From edf124e3df76d6cbec51d116fc8c3ba252716611 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 24 Jul 2026 08:35:58 -0400 Subject: [PATCH] feat: add getOrgFullScanCached with 202 polling for cached full scans getOrgFullScanCached reads a full scan's artifacts into memory, serving pre-computed results from the immutable scan store via ?cached=true (the default). A cache hit returns 200 with the ndjson artifacts; a cache miss returns 202 Accepted while the server computes them, so the method polls with exponential backoff until a 200 arrives or the poll budget is spent. Unlike the streaming getOrgFullScan it buffers and returns the parsed artifacts instead of piping to a stream, so it never pipes a non-existent stream on a 202. The streaming getOrgFullScan is left untouched. --- src/index.ts | 180 ++++++++++++++++++++++++++++++++++ test/full-scan-cached.test.ts | 109 ++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 test/full-scan-cached.test.ts diff --git a/src/index.ts b/src/index.ts index 43b6b049b..cc81c6ce3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,6 +184,42 @@ export type UploadManifestFilesError = { cause: string | undefined } +export type GetOrgFullScanCachedOptions = { + // Read pre-computed results from the immutable scan store via `?cached=true`. + // Defaults to true. When false the endpoint recomputes on demand and this + // method returns the first 200 without any polling. + cached?: boolean | undefined + // Initial delay between polls after a 202. Doubles each poll up to + // DEFAULT_CACHED_SCAN_POLL_MAX_MS. Defaults to + // DEFAULT_CACHED_SCAN_POLL_INITIAL_MS. + pollIntervalMs?: number | undefined + // Maximum wall-clock time to keep polling a 202 before returning a timeout + // error. Defaults to DEFAULT_CACHED_SCAN_POLL_TIMEOUT_MS. + maxPollMs?: number | undefined +} + +export type GetOrgFullScanCachedResult = + | { + success: true + status: 200 + data: SocketArtifact[] + } + | { + success: false + status: number + error: string + cause?: string | undefined + url?: string | undefined + } + +// HTTP 202 Accepted: the cached scan is still being computed; poll again. +const HTTP_STATUS_ACCEPTED = 202 + +// Backoff schedule for polling a cached full scan that returns 202 Accepted. +const DEFAULT_CACHED_SCAN_POLL_INITIAL_MS = 1000 +const DEFAULT_CACHED_SCAN_POLL_MAX_MS = 10_000 +const DEFAULT_CACHED_SCAN_POLL_TIMEOUT_MS = 10 * 60 * 1000 + const DEFAULT_USER_AGENT = createUserAgentFromPkgJson(rootPkgJson) // Public security policy. @@ -852,6 +888,73 @@ function isResponseOk(response: IncomingMessage): boolean { ) } +async function getResponseText(response: IncomingMessage): Promise { + const chunks = [] + let size = 0 + const MAX = 50 * 1024 * 1024 + for await (const chunk of response) { + size += chunk.length + if (size > MAX) { + throw new Error('Response body too large') + } + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf8') +} + +// Parse a newline-delimited JSON body (one artifact per line) into an array. +// Blank lines are skipped; a line that is not valid JSON throws. +function parseNdjsonArtifacts(text: string): SocketArtifact[] { + const artifacts: SocketArtifact[] = [] + const lines = text.split('\n') + for (let i = 0, { length } = lines; i < length; i += 1) { + const line = lines[i]!.trim() + if (!line) { + continue + } + artifacts.push(JSON.parse(line) as SocketArtifact) + } + return artifacts +} + +// Read the `{ status, id }` payload the API sends with a 202 Accepted. Returns +// undefined when the body is absent or not JSON — polling continues on the +// status code alone, so a missing body never breaks the loop. +async function readProcessingBody( + response: IncomingMessage +): Promise< + { id?: string | undefined; status?: string | undefined } | undefined +> { + let text: string + try { + text = await getResponseText(response) + } catch { + return undefined + } + if (!text) { + return undefined + } + try { + const parsed = JSON.parse(text) + if (isObjectObject(parsed)) { + const { id, status } = parsed as Record + return { + id: typeof id === 'string' ? id : undefined, + status: typeof status === 'string' ? status : undefined + } + } + } catch { + // Non-JSON 202 body: nothing to surface, keep polling on status. + } + return undefined +} + +function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms) + }) +} + function promiseWithResolvers(): ReturnType< typeof Promise.withResolvers > { @@ -1499,6 +1602,83 @@ export class SocketSdk { } } + // Read a full scan's artifacts into memory, serving pre-computed results from + // the immutable scan store via `?cached=true` (the default). A cache hit + // returns 200 with the ndjson artifacts; a cache miss returns 202 Accepted + // while the server computes them in the background, so this polls with + // exponential backoff until a 200 arrives (or the poll budget is exhausted). + // + // Unlike getOrgFullScan this never pipes to a stream — it buffers and returns + // the parsed artifacts, so it is the right entry point for callers that need + // the whole scan (e.g. to format or diff it) rather than to tee bytes to a + // file or stdout. getOrgFullScan remains the streaming path. + async getOrgFullScanCached( + orgSlug: string, + fullScanId: string, + options?: GetOrgFullScanCachedOptions | undefined + ): Promise { + const { + cached = true, + maxPollMs = DEFAULT_CACHED_SCAN_POLL_TIMEOUT_MS, + pollIntervalMs = DEFAULT_CACHED_SCAN_POLL_INITIAL_MS + } = { __proto__: null, ...options } as GetOrgFullScanCachedOptions + // Omit the param when disabled: an absent `cached` reads as false + // server-side, so there is no reason to send cached=false on the wire. + const search = queryToSearchParams(cached ? { cached: true } : undefined) + const qs = search.toString() + const urlPath = `orgs/${encodeURIComponent(orgSlug)}/full-scans/${encodeURIComponent(fullScanId)}${qs ? `?${qs}` : ''}` + try { + const deadline = Date.now() + maxPollMs + let attempt = 0 + let delayMs = pollIntervalMs + // getResponse() treats 202 as ok (it is 2xx) and throws a ResponseError + // on any non-2xx, so 4xx/5xx surface to the catch below and a 202 flows + // through the poll loop. + for (;;) { + // eslint-disable-next-line no-await-in-loop + const response = await createGetRequest( + this.#baseUrl, + urlPath, + this.#reqOptions, + this.#hooks + ) + if (response.statusCode !== HTTP_STATUS_ACCEPTED) { + // eslint-disable-next-line no-await-in-loop + const text = await getResponseText(response) + return { + success: true, + status: 200, + data: parseNdjsonArtifacts(text) + } + } + // Cache miss: results still computing. Drain the body for its + // { status, id } payload, then wait and poll again — unless polling is + // disabled or the next poll would land past the wall-clock budget. + attempt += 1 + // eslint-disable-next-line no-await-in-loop + const processing = await readProcessingBody(response) + const scanId = processing?.id || fullScanId + debugLog( + `Socket API full scan ${scanId} ${processing?.status ?? 'processing'} (poll attempt ${attempt})` + ) + if (!cached || Date.now() + delayMs > deadline) { + return { + success: false, + status: HTTP_STATUS_ACCEPTED, + error: 'Cached full scan not ready', + cause: `The Socket API is still computing cached results for scan ${scanId} after ${Math.round(maxPollMs / 1000)}s (${attempt} polls). Retry later, or call with cached:false to live-compute.`, + url: `${this.#baseUrl}${urlPath}` + } + } + // eslint-disable-next-line no-await-in-loop + await sleep(delayMs) + delayMs = Math.min(delayMs * 2, DEFAULT_CACHED_SCAN_POLL_MAX_MS) + } + } catch (e) { + return await this.#handleApiError<'getOrgFullScan'>(e) + } + } + async getOrgFullScanList( orgSlug: string, queryParams?: QueryParams | undefined diff --git a/test/full-scan-cached.test.ts b/test/full-scan-cached.test.ts new file mode 100644 index 000000000..8a466d7fd --- /dev/null +++ b/test/full-scan-cached.test.ts @@ -0,0 +1,109 @@ +import nock from 'nock' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { SocketSdk } from '../dist/index.js' + +process.on('unhandledRejection', cause => { + throw new Error('Unhandled rejection', { cause }) +}) + +const NDJSON = + '{"type":"npm","name":"lodash","version":"4.17.21"}\n' + + '{"type":"npm","name":"react","version":"18.2.0"}\n' + +describe('SocketSdk#getOrgFullScanCached', () => { + beforeEach(() => { + nock.cleanAll() + nock.disableNetConnect() + }) + + afterEach(() => { + if (!nock.isDone()) { + throw new Error(`pending nock mocks: ${nock.pendingMocks()}`) + } + }) + + it('returns parsed artifacts on a 200 cache hit', async () => { + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/scan-1') + .query({ cached: 'true' }) + .reply(200, NDJSON) + + const client = new SocketSdk('apiKey') + const res = await client.getOrgFullScanCached('test-org', 'scan-1') + + expect(res).toEqual({ + success: true, + status: 200, + data: [ + { type: 'npm', name: 'lodash', version: '4.17.21' }, + { type: 'npm', name: 'react', version: '18.2.0' } + ] + }) + }) + + it('polls on 202 until the cached result is ready', async () => { + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/scan-2') + .query({ cached: 'true' }) + .reply(202, { status: 'processing', id: 'scan-2' }) + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/scan-2') + .query({ cached: 'true' }) + .reply(200, NDJSON) + + const client = new SocketSdk('apiKey') + const res = await client.getOrgFullScanCached('test-org', 'scan-2', { + pollIntervalMs: 1 + }) + + expect(res.success).toBe(true) + expect(res).toMatchObject({ status: 200 }) + expect((res as { data: unknown[] }).data).toHaveLength(2) + }) + + it('returns a not-ready error when polling exceeds the budget', async () => { + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/scan-3') + .query({ cached: 'true' }) + .reply(202, { status: 'processing', id: 'scan-3' }) + + const client = new SocketSdk('apiKey') + const res = await client.getOrgFullScanCached('test-org', 'scan-3', { + pollIntervalMs: 5, + maxPollMs: 1 + }) + + expect(res.success).toBe(false) + expect(res).toMatchObject({ + status: 202, + error: 'Cached full scan not ready' + }) + }) + + it('omits the cached param and does not poll when cached is false', async () => { + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/scan-4') + .reply(200, NDJSON) + + const client = new SocketSdk('apiKey') + const res = await client.getOrgFullScanCached('test-org', 'scan-4', { + cached: false + }) + + expect(res).toMatchObject({ success: true, status: 200 }) + }) + + it('surfaces a 404 as an error result', async () => { + nock('https://api.socket.dev') + .get('/v0/orgs/test-org/full-scans/missing') + .query({ cached: 'true' }) + .reply(404, { error: { message: 'Not found' } }) + + const client = new SocketSdk('apiKey') + const res = await client.getOrgFullScanCached('test-org', 'missing') + + expect(res.success).toBe(false) + expect(res).toMatchObject({ status: 404 }) + }) +})