Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -852,6 +888,73 @@ function isResponseOk(response: IncomingMessage): boolean {
)
}

async function getResponseText(response: IncomingMessage): Promise<string> {
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<string, unknown>
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<void> {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}

function promiseWithResolvers<T>(): ReturnType<
typeof Promise.withResolvers<T>
> {
Expand Down Expand Up @@ -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<GetOrgFullScanCachedResult> {
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
Expand Down
109 changes: 109 additions & 0 deletions test/full-scan-cached.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})