Skip to content

Commit 800e362

Browse files
committed
feat(scan): read cached full-scan results with 202 polling in scan view
Backports the cached scan-view behavior from main (e5fb60b, f6741d2) to the v1.x line. socket scan view (default and --json non-stream) now reads pre-computed results from the immutable scan store: fetchScan hits ...full-scans/{id}?cached=true, polls on 202 ({status:'processing'}) with exponential backoff and a 10 minute deadline, then parses the 200 ndjson. The --stream --json live-stream path is unchanged. Uses the direct API via a new queryApiSafeTextWithStatus helper (which surfaces the HTTP status so the 202 poll loop can see it) — no SDK dependency, so no @socketsecurity/sdk release is required.
1 parent ee09686 commit 800e362

3 files changed

Lines changed: 182 additions & 31 deletions

File tree

src/commands/scan/fetch-scan.mts

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,78 @@
11
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
22

3-
import { queryApiSafeText } from '../../utils/api.mts'
3+
import { queryApiSafeTextWithStatus } from '../../utils/api.mts'
44

55
import type { CResult } from '../../types.mts'
66
import type { SocketArtifact } from '../../utils/alert/artifact.mts'
77

8+
// HTTP 202 Accepted: cached results are still being computed; poll again.
9+
const HTTP_STATUS_ACCEPTED = 202
10+
11+
export const CACHED_POLL_INITIAL_DELAY_MS = 1000
12+
export const CACHED_POLL_MAX_DELAY_MS = 10_000
13+
export const CACHED_POLL_TIMEOUT_MS = 10 * 60 * 1000
14+
15+
function sleep(ms: number): Promise<void> {
16+
return new Promise(resolve => {
17+
setTimeout(resolve, ms)
18+
})
19+
}
20+
821
export async function fetchScan(
922
orgSlug: string,
1023
scanId: string,
1124
): Promise<CResult<SocketArtifact[]>> {
12-
const result = await queryApiSafeText(
13-
`orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}`,
14-
'a scan',
15-
)
16-
17-
if (!result.ok) {
18-
return result
25+
// Serve pre-computed results from the immutable store (`?cached=true`): a
26+
// 200 carries the ndjson body, a 202 means the server enqueued a background
27+
// job to compute them — poll with backoff until the results are ready, so
28+
// callers only ever observe the final scan.
29+
const path = `orgs/${orgSlug}/full-scans/${encodeURIComponent(scanId)}?cached=true`
30+
const deadline = Date.now() + CACHED_POLL_TIMEOUT_MS
31+
let delayMs = CACHED_POLL_INITIAL_DELAY_MS
32+
for (;;) {
33+
// eslint-disable-next-line no-await-in-loop
34+
const result = await queryApiSafeTextWithStatus(path, 'a scan')
35+
if (!result.ok) {
36+
return result
37+
}
38+
if (result.data.status !== HTTP_STATUS_ACCEPTED) {
39+
return parseArtifactsNdjson(result.data.text)
40+
}
41+
if (Date.now() >= deadline) {
42+
return {
43+
ok: false,
44+
message: 'Scan results not ready',
45+
cause: `The Socket API is still computing cached results for scan ${scanId} after ${CACHED_POLL_TIMEOUT_MS / 60_000} minutes (path: ${path}). Retry in a few minutes — the server keeps computing in the background.`,
46+
}
47+
}
48+
// eslint-disable-next-line no-await-in-loop
49+
await sleep(delayMs)
50+
delayMs = Math.min(delayMs * 2, CACHED_POLL_MAX_DELAY_MS)
1951
}
52+
}
2053

21-
const jsonsString = result.data
22-
23-
// This is nd-json; each line is a json object
54+
export function parseArtifactsNdjson(
55+
jsonsString: string,
56+
): CResult<SocketArtifact[]> {
57+
// This is nd-json; each line is a json object.
2458
const lines = jsonsString.split('\n').filter(Boolean)
25-
let ok = true
26-
const data = lines.map(line => {
59+
const data: SocketArtifact[] = []
60+
61+
for (let i = 0, { length } = lines; i < length; i += 1) {
62+
const line = lines[i]!
2763
try {
28-
return JSON.parse(line)
64+
data.push(JSON.parse(line))
2965
} catch (e) {
30-
ok = false
3166
debugFn('error', 'Failed to parse scan result line as JSON')
3267
debugDir('error', { error: e, line })
33-
return undefined
68+
return {
69+
ok: false,
70+
message: 'Invalid Socket API response',
71+
cause:
72+
'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.',
73+
}
3474
}
35-
}) as unknown as SocketArtifact[]
36-
37-
if (ok) {
38-
return { ok: true, data }
3975
}
4076

41-
return {
42-
ok: false,
43-
message: 'Invalid Socket API response',
44-
cause:
45-
'The Socket API responded with at least one line that was not valid JSON. Please report if this persists.',
46-
}
77+
return { ok: true, data }
4778
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
process.env['SOCKET_CLI_API_TOKEN'] = 'test-token'
2+
process.env['SOCKET_CLI_API_BASE_URL'] = 'https://api.socket.dev/v0/'
3+
4+
import nock from 'nock'
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+
7+
import { fetchScan, parseArtifactsNdjson } from './fetch-scan.mts'
8+
9+
// Drives the real direct-API path through nock (an external HTTP double) rather
10+
// than stubbing owned modules. cached scan reads hit ?cached=true and poll on
11+
// 202 until a 200 arrives.
12+
const BASE_HOST = 'https://api.socket.dev'
13+
14+
const NDJSON =
15+
'{"type":"npm","name":"lodash","version":"4.17.21"}\n' +
16+
'{"type":"npm","name":"react","version":"18.2.0"}\n'
17+
18+
describe('fetchScan', () => {
19+
beforeEach(() => {
20+
nock.cleanAll()
21+
nock.disableNetConnect()
22+
})
23+
24+
afterEach(() => {
25+
nock.cleanAll()
26+
nock.enableNetConnect()
27+
})
28+
29+
it('returns cached artifacts on a 200 cache hit', async () => {
30+
nock(BASE_HOST)
31+
.get('/v0/orgs/test-org/full-scans/scan-1')
32+
.query({ cached: 'true' })
33+
.reply(200, NDJSON)
34+
35+
const result = await fetchScan('test-org', 'scan-1')
36+
37+
expect(result.ok).toBe(true)
38+
expect((result as { data: unknown[] }).data).toEqual([
39+
{ type: 'npm', name: 'lodash', version: '4.17.21' },
40+
{ type: 'npm', name: 'react', version: '18.2.0' },
41+
])
42+
})
43+
44+
it('polls on 202 until the cached result is ready', async () => {
45+
nock(BASE_HOST)
46+
.get('/v0/orgs/test-org/full-scans/scan-2')
47+
.query({ cached: 'true' })
48+
.reply(202, { status: 'processing', id: 'scan-2' })
49+
nock(BASE_HOST)
50+
.get('/v0/orgs/test-org/full-scans/scan-2')
51+
.query({ cached: 'true' })
52+
.reply(200, NDJSON)
53+
54+
const result = await fetchScan('test-org', 'scan-2')
55+
56+
expect(result.ok).toBe(true)
57+
expect((result as { data: unknown[] }).data).toHaveLength(2)
58+
})
59+
60+
it('maps a 404 to a failed CResult', async () => {
61+
nock(BASE_HOST)
62+
.get('/v0/orgs/test-org/full-scans/missing')
63+
.query({ cached: 'true' })
64+
.reply(404, { error: { message: 'Not found' } })
65+
66+
const result = await fetchScan('test-org', 'missing')
67+
68+
expect(result.ok).toBe(false)
69+
expect(result).toMatchObject({
70+
message: 'Socket API error',
71+
data: { code: 404 },
72+
})
73+
})
74+
})
75+
76+
describe('parseArtifactsNdjson', () => {
77+
it('parses one artifact per line, skipping blanks', () => {
78+
const result = parseArtifactsNdjson(NDJSON)
79+
expect(result).toEqual({
80+
ok: true,
81+
data: [
82+
{ type: 'npm', name: 'lodash', version: '4.17.21' },
83+
{ type: 'npm', name: 'react', version: '18.2.0' },
84+
],
85+
})
86+
})
87+
88+
it('returns an error when a line is not valid JSON', () => {
89+
const result = parseArtifactsNdjson('{"ok":true}\nnot-json\n')
90+
expect(result.ok).toBe(false)
91+
expect(result).toMatchObject({ message: 'Invalid Socket API response' })
92+
})
93+
})

src/utils/api.mts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,14 +461,22 @@ async function queryApi(path: string, apiToken: string) {
461461
return result
462462
}
463463

464+
export type ApiTextResult = {
465+
status: number
466+
text: string
467+
}
468+
464469
/**
465-
* Query Socket API endpoint and return text response with error handling.
470+
* Query a Socket API endpoint and return the HTTP status alongside the text
471+
* body, with error handling. Unlike queryApiSafeText this surfaces the status
472+
* on success (including 2xx statuses like 202 Accepted), so callers can drive
473+
* status-dependent flows such as the cached-scan 202 poll loop.
466474
*/
467-
export async function queryApiSafeText(
475+
export async function queryApiSafeTextWithStatus(
468476
path: string,
469477
description?: string | undefined,
470478
commandPath?: string | undefined,
471-
): Promise<CResult<string>> {
479+
): Promise<CResult<ApiTextResult>> {
472480
const apiToken = getDefaultApiToken()
473481
if (!apiToken) {
474482
return {
@@ -546,10 +554,13 @@ export async function queryApiSafeText(
546554
}
547555

548556
try {
549-
const data = await result.text()
557+
const text = await result.text()
550558
return {
551559
ok: true,
552-
data,
560+
data: {
561+
status: result.status,
562+
text,
563+
},
553564
}
554565
} catch (e) {
555566
debugFn('error', 'Failed to read API response text')
@@ -563,6 +574,22 @@ export async function queryApiSafeText(
563574
}
564575
}
565576

577+
/**
578+
* Query Socket API endpoint and return text response with error handling.
579+
*/
580+
export async function queryApiSafeText(
581+
path: string,
582+
description?: string | undefined,
583+
commandPath?: string | undefined,
584+
): Promise<CResult<string>> {
585+
const result = await queryApiSafeTextWithStatus(
586+
path,
587+
description,
588+
commandPath,
589+
)
590+
return result.ok ? { ok: true, data: result.data.text } : result
591+
}
592+
566593
/**
567594
* Query Socket API endpoint and return parsed JSON response.
568595
*/

0 commit comments

Comments
 (0)