@open-mercato/web-research-browser 0.7.1-develop.7194.1.ab4fc81f82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +10 -0
- package/dist/adapter.js +162 -0
- package/dist/adapter.js.map +7 -0
- package/dist/client.js +189 -0
- package/dist/client.js.map +7 -0
- package/dist/dispatch.js +161 -0
- package/dist/dispatch.js.map +7 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +7 -0
- package/dist/playwright.js +143 -0
- package/dist/playwright.js.map +7 -0
- package/dist/protocol.js +28 -0
- package/dist/protocol.js.map +7 -0
- package/dist/sidecar.js +79 -0
- package/dist/sidecar.js.map +7 -0
- package/jest.config.cjs +31 -0
- package/package.json +64 -0
- package/src/__tests__/browser.test.ts +242 -0
- package/src/__tests__/install.test.ts +72 -0
- package/src/__tests__/leases.test.ts +50 -0
- package/src/__tests__/sandbox.test.ts +67 -0
- package/src/adapter.ts +196 -0
- package/src/client.ts +245 -0
- package/src/dispatch.ts +205 -0
- package/src/index.ts +10 -0
- package/src/playwright.ts +265 -0
- package/src/protocol.ts +51 -0
- package/src/sidecar.ts +108 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { launchChromium, type BrowserHandle, type BrowserType } from '../playwright'
|
|
2
|
+
|
|
3
|
+
const MISSING_BINARY = "Executable doesn't exist at /root/.cache/ms-playwright/chromium-1140/chrome-linux/chrome"
|
|
4
|
+
|
|
5
|
+
const fakeBrowser = { newContext: async () => ({}) as never, close: async () => {} } as BrowserHandle
|
|
6
|
+
|
|
7
|
+
function browserThatFails(times: number, message: string): BrowserType & { launches: number } {
|
|
8
|
+
const type = {
|
|
9
|
+
launches: 0,
|
|
10
|
+
async launch() {
|
|
11
|
+
type.launches += 1
|
|
12
|
+
if (type.launches <= times) throw new Error(message)
|
|
13
|
+
return fakeBrowser
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
return type as BrowserType & { launches: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('launchChromium', () => {
|
|
20
|
+
it('launches directly when the binary is present', async () => {
|
|
21
|
+
const type = browserThatFails(0, MISSING_BINARY)
|
|
22
|
+
const install = jest.fn(async () => {})
|
|
23
|
+
|
|
24
|
+
await expect(launchChromium(type, { headless: true }, { install })).resolves.toBe(fakeBrowser)
|
|
25
|
+
expect(install).not.toHaveBeenCalled()
|
|
26
|
+
expect(type.launches).toBe(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Installing the npm package does not fetch the ~150MB browser, so an operator
|
|
30
|
+
// who enables the tier would otherwise hit a dead-end launch failure.
|
|
31
|
+
it('downloads the binary once and retries the launch', async () => {
|
|
32
|
+
const type = browserThatFails(1, MISSING_BINARY)
|
|
33
|
+
const install = jest.fn(async () => {})
|
|
34
|
+
const progress: string[] = []
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
launchChromium(type, { headless: true }, { install, onProgress: (line) => progress.push(line) }),
|
|
38
|
+
).resolves.toBe(fakeBrowser)
|
|
39
|
+
|
|
40
|
+
expect(install).toHaveBeenCalledTimes(1)
|
|
41
|
+
expect(type.launches).toBe(2)
|
|
42
|
+
expect(progress.join(' ')).toContain('one-time')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('does not retry a launch failure that is not a missing binary', async () => {
|
|
46
|
+
const type = browserThatFails(1, 'Target page crashed')
|
|
47
|
+
const install = jest.fn(async () => {})
|
|
48
|
+
|
|
49
|
+
await expect(launchChromium(type, { headless: true }, { install })).rejects.toThrow('Target page crashed')
|
|
50
|
+
expect(install).not.toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reports an actionable error when the download itself fails', async () => {
|
|
54
|
+
const type = browserThatFails(1, MISSING_BINARY)
|
|
55
|
+
const install = jest.fn(async () => {
|
|
56
|
+
throw new Error('ENOTFOUND registry.npmjs.org')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await expect(launchChromium(type, { headless: true }, { install })).rejects.toThrow(
|
|
60
|
+
/auto-install failed.*ENOTFOUND.*manually/is,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('gives up after one retry rather than looping', async () => {
|
|
65
|
+
const type = browserThatFails(5, MISSING_BINARY)
|
|
66
|
+
const install = jest.fn(async () => {})
|
|
67
|
+
|
|
68
|
+
await expect(launchChromium(type, { headless: true }, { install })).rejects.toThrow(/Executable doesn't exist/)
|
|
69
|
+
expect(install).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(type.launches).toBe(2)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { LEASE_IDLE_TIMEOUT_MS, createState, reapIdleLeases, type Lease } from '../dispatch'
|
|
2
|
+
|
|
3
|
+
function fakeLease(touchedAt: number, onClose: () => void): Lease {
|
|
4
|
+
return {
|
|
5
|
+
touchedAt,
|
|
6
|
+
context: {
|
|
7
|
+
newPage: async () => ({}),
|
|
8
|
+
close: async () => {
|
|
9
|
+
onClose()
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
page: {
|
|
13
|
+
goto: async () => undefined,
|
|
14
|
+
content: async () => '',
|
|
15
|
+
url: () => 'https://example.com',
|
|
16
|
+
close: async () => undefined,
|
|
17
|
+
},
|
|
18
|
+
} as unknown as Lease
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('lease reaping', () => {
|
|
22
|
+
it('closes a lease nobody released', async () => {
|
|
23
|
+
// `release` normally closes the context, but a caller can die between render
|
|
24
|
+
// and release — and every orphan is a live BrowserContext holding memory for
|
|
25
|
+
// as long as the sidecar runs.
|
|
26
|
+
let closed = 0
|
|
27
|
+
const clock = { value: 1_000_000 }
|
|
28
|
+
const state = createState({ now: () => clock.value, warn: () => {} })
|
|
29
|
+
state.leases.set('orphan', fakeLease(clock.value, () => (closed += 1)))
|
|
30
|
+
|
|
31
|
+
clock.value += LEASE_IDLE_TIMEOUT_MS + 1
|
|
32
|
+
await reapIdleLeases(state)
|
|
33
|
+
|
|
34
|
+
expect(closed).toBe(1)
|
|
35
|
+
expect(state.leases.size).toBe(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('leaves a lease that is still in use alone', async () => {
|
|
39
|
+
let closed = 0
|
|
40
|
+
const clock = { value: 1_000_000 }
|
|
41
|
+
const state = createState({ now: () => clock.value, warn: () => {} })
|
|
42
|
+
state.leases.set('active', fakeLease(clock.value, () => (closed += 1)))
|
|
43
|
+
|
|
44
|
+
clock.value += LEASE_IDLE_TIMEOUT_MS - 1_000
|
|
45
|
+
await reapIdleLeases(state)
|
|
46
|
+
|
|
47
|
+
expect(closed).toBe(0)
|
|
48
|
+
expect(state.leases.size).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LAUNCH_ARGS,
|
|
3
|
+
SANDBOX_OPT_OUT_ENV,
|
|
4
|
+
isSandboxDisabled,
|
|
5
|
+
isSandboxFailure,
|
|
6
|
+
launchChromium,
|
|
7
|
+
resolveLaunchArgs,
|
|
8
|
+
type BrowserType,
|
|
9
|
+
} from '../playwright'
|
|
10
|
+
import { SidecarError } from '../protocol'
|
|
11
|
+
|
|
12
|
+
describe('Chromium sandbox posture', () => {
|
|
13
|
+
it('keeps the sandbox on by default', () => {
|
|
14
|
+
// This adapter renders pages chosen by a search engine or a model, so the
|
|
15
|
+
// renderer parses hostile input by definition. `--no-sandbox` turns a
|
|
16
|
+
// renderer compromise into code running as the app's own user.
|
|
17
|
+
expect(LAUNCH_ARGS).not.toContain('--no-sandbox')
|
|
18
|
+
expect(resolveLaunchArgs({})).not.toContain('--no-sandbox')
|
|
19
|
+
expect(isSandboxDisabled({})).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('disables it only on an explicit opt-out', () => {
|
|
23
|
+
const env = { [SANDBOX_OPT_OUT_ENV]: '1' } as NodeJS.ProcessEnv
|
|
24
|
+
expect(resolveLaunchArgs(env)).toContain('--no-sandbox')
|
|
25
|
+
expect(isSandboxDisabled(env)).toBe(true)
|
|
26
|
+
// Anything other than the exact opt-out keeps the sandbox.
|
|
27
|
+
expect(resolveLaunchArgs({ [SANDBOX_OPT_OUT_ENV]: 'true' } as NodeJS.ProcessEnv)).not.toContain(
|
|
28
|
+
'--no-sandbox',
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('recognises Chromium refusing to run unsandboxed', () => {
|
|
33
|
+
expect(isSandboxFailure(new Error('Running as root without --no-sandbox is not supported.'))).toBe(true)
|
|
34
|
+
expect(isSandboxFailure(new Error('No usable sandbox! Update your kernel'))).toBe(true)
|
|
35
|
+
expect(isSandboxFailure(new Error('Executable doesn\'t exist at /root/.cache/ms-playwright'))).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('refuses to fall back to an unsandboxed browser on its own', async () => {
|
|
39
|
+
const browserType = {
|
|
40
|
+
launch: async () => {
|
|
41
|
+
throw new Error('Running as root without --no-sandbox is not supported.')
|
|
42
|
+
},
|
|
43
|
+
} as unknown as BrowserType
|
|
44
|
+
|
|
45
|
+
const error = await launchChromium(browserType, { headless: true }).catch((caught: unknown) => caught)
|
|
46
|
+
|
|
47
|
+
// A silent retry without the sandbox would make the security posture depend
|
|
48
|
+
// on container configuration with nobody told which one they got.
|
|
49
|
+
expect(error).toBeInstanceOf(SidecarError)
|
|
50
|
+
expect(String((error as Error).message)).toContain(SANDBOX_OPT_OUT_ENV)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('still auto-installs a missing browser binary', async () => {
|
|
54
|
+
let launches = 0
|
|
55
|
+
const browserType = {
|
|
56
|
+
launch: async () => {
|
|
57
|
+
launches += 1
|
|
58
|
+
if (launches === 1) throw new Error("Executable doesn't exist at /ms-playwright/chromium/chrome")
|
|
59
|
+
return { newContext: async () => ({}), close: async () => {} }
|
|
60
|
+
},
|
|
61
|
+
} as unknown as BrowserType
|
|
62
|
+
|
|
63
|
+
await launchChromium(browserType, { headless: true }, { install: async () => {} })
|
|
64
|
+
|
|
65
|
+
expect(launches).toBe(2)
|
|
66
|
+
})
|
|
67
|
+
})
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
CONTRACT_VERSION,
|
|
4
|
+
READY,
|
|
5
|
+
blocked,
|
|
6
|
+
classifyPage,
|
|
7
|
+
defineAdapterModule,
|
|
8
|
+
extractMainContent,
|
|
9
|
+
extractTitle,
|
|
10
|
+
htmlToText,
|
|
11
|
+
notReady,
|
|
12
|
+
searchOk,
|
|
13
|
+
type FetchOutcome,
|
|
14
|
+
type SearchAdapter,
|
|
15
|
+
type SearchOutcome,
|
|
16
|
+
} from '@open-mercato/web-research'
|
|
17
|
+
import { SERP_PROFILES, extractSerpResults, type SerpEngineId } from '@open-mercato/web-research-serp'
|
|
18
|
+
import { z } from 'zod'
|
|
19
|
+
import { createSidecar, type Sidecar, type SidecarOptions } from './client'
|
|
20
|
+
import { SidecarError } from './protocol'
|
|
21
|
+
|
|
22
|
+
export const browserOptionsSchema = z.object({
|
|
23
|
+
engines: z.array(z.enum(['ddg-html', 'ddg-lite'])).min(1).optional(),
|
|
24
|
+
userAgent: z.string().min(1).optional(),
|
|
25
|
+
timeoutMs: z.number().int().min(1000).max(120_000).optional(),
|
|
26
|
+
sidecar: z.custom<SidecarOptions>((value) => typeof value === 'object' && value !== null).optional(),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export type BrowserOptions = z.infer<typeof browserOptionsSchema>
|
|
30
|
+
|
|
31
|
+
const DEFAULT_ENGINES: readonly SerpEngineId[] = ['ddg-html']
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
33
|
+
|
|
34
|
+
type RenderResult = { url: string; html: string }
|
|
35
|
+
|
|
36
|
+
function isRenderResult(value: unknown): value is RenderResult {
|
|
37
|
+
return (
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
value !== null &&
|
|
40
|
+
typeof (value as { html?: unknown }).html === 'string' &&
|
|
41
|
+
typeof (value as { url?: unknown }).url === 'string'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createBrowserAdapter(options: BrowserOptions): SearchAdapter {
|
|
46
|
+
const engines = options.engines ?? DEFAULT_ENGINES
|
|
47
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
48
|
+
let sidecar: Sidecar | null = null
|
|
49
|
+
// Remembered so `readiness()` stops promising a tier that cannot start. It is
|
|
50
|
+
// sync and I/O-free by contract, so it cannot probe — but it can report what
|
|
51
|
+
// the last attempt already proved, instead of letting the scheduler pay the
|
|
52
|
+
// escalation cost again to rediscover it.
|
|
53
|
+
let startupFailure: string | null = null
|
|
54
|
+
|
|
55
|
+
const client = (): Sidecar => {
|
|
56
|
+
sidecar ??= createSidecar(options.sidecar ?? {})
|
|
57
|
+
return sidecar
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every render takes its own lease, so concurrent work never shares a cookie jar.
|
|
62
|
+
*
|
|
63
|
+
* The budget is the smaller of the configured timeout and whatever the engine
|
|
64
|
+
* has left: a render that outlives `deadlineAt` keeps a browser context open on
|
|
65
|
+
* a page nobody is waiting for any more.
|
|
66
|
+
*/
|
|
67
|
+
const render = async (url: string, deadlineAt: number, signal: AbortSignal): Promise<RenderResult> => {
|
|
68
|
+
const leaseId = randomUUID()
|
|
69
|
+
const budget = Math.max(1_000, Math.min(timeoutMs, deadlineAt - Date.now()))
|
|
70
|
+
const active = client()
|
|
71
|
+
try {
|
|
72
|
+
const result = await active.call(
|
|
73
|
+
'render',
|
|
74
|
+
{ url, leaseId, timeoutMs: budget, ...(options.userAgent ? { userAgent: options.userAgent } : {}) },
|
|
75
|
+
signal,
|
|
76
|
+
)
|
|
77
|
+
if (!isRenderResult(result)) throw new Error('sidecar returned a malformed render result')
|
|
78
|
+
startupFailure = null
|
|
79
|
+
return result
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof SidecarError && (error.kind === 'init' || error.kind === 'needs-install')) {
|
|
82
|
+
startupFailure = error.message
|
|
83
|
+
}
|
|
84
|
+
throw error
|
|
85
|
+
} finally {
|
|
86
|
+
await active.call('release', { leaseId }).catch(() => undefined)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
id: 'browser',
|
|
92
|
+
kind: 'browser',
|
|
93
|
+
capabilities: {
|
|
94
|
+
search: true,
|
|
95
|
+
fetch: true,
|
|
96
|
+
snippets: true,
|
|
97
|
+
content: true,
|
|
98
|
+
freshness: true,
|
|
99
|
+
siteFilter: true,
|
|
100
|
+
// Roughly two orders of magnitude more expensive than an HTTP read, which
|
|
101
|
+
// is why the engine only reaches for this after a source reports blocked.
|
|
102
|
+
cost: 'metered',
|
|
103
|
+
},
|
|
104
|
+
// Participation is governed solely by the policy row. The scheduler already
|
|
105
|
+
// keeps `kind: 'browser'` out of normal waves, so an enabled browser adapter
|
|
106
|
+
// is reached only for escalation — a second per-adapter switch just made the
|
|
107
|
+
// UI show two toggles meaning different things.
|
|
108
|
+
/** `heavy` — spawns and pings the sidecar process. */
|
|
109
|
+
probeCost: 'heavy' as const,
|
|
110
|
+
readiness: () => (startupFailure === null ? READY : notReady(startupFailure)),
|
|
111
|
+
|
|
112
|
+
async search(request, context): Promise<SearchOutcome> {
|
|
113
|
+
let lastReason = 'no engine produced results'
|
|
114
|
+
|
|
115
|
+
for (const engine of engines) {
|
|
116
|
+
if (context.signal.aborted) break
|
|
117
|
+
const profile = SERP_PROFILES[engine]
|
|
118
|
+
context.report('started', `rendering ${profile.label} in a browser`)
|
|
119
|
+
try {
|
|
120
|
+
const rendered = await render(profile.buildUrl(request), context.deadlineAt, context.signal)
|
|
121
|
+
const results = extractSerpResults(rendered.html, profile.selector, profile.baseUrl, request.limit)
|
|
122
|
+
if (results.length > 0) {
|
|
123
|
+
context.report('ok', `${profile.label} returned ${results.length} result(s)`, {
|
|
124
|
+
resultCount: results.length,
|
|
125
|
+
})
|
|
126
|
+
return searchOk(results)
|
|
127
|
+
}
|
|
128
|
+
const verdict = classifyPage({
|
|
129
|
+
status: 200,
|
|
130
|
+
contentType: 'text/html',
|
|
131
|
+
html: rendered.html,
|
|
132
|
+
text: '',
|
|
133
|
+
})
|
|
134
|
+
lastReason =
|
|
135
|
+
verdict.verdict === 'blocked'
|
|
136
|
+
? `${profile.label} served a challenge page even in a browser (${verdict.reason ?? 'unknown'})`
|
|
137
|
+
: `${profile.label} rendered but produced no parseable results`
|
|
138
|
+
} catch (error) {
|
|
139
|
+
lastReason = error instanceof Error ? error.message : String(error)
|
|
140
|
+
}
|
|
141
|
+
context.report('blocked', lastReason)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return blocked(lastReason)
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async fetch(request, context): Promise<FetchOutcome> {
|
|
148
|
+
try {
|
|
149
|
+
const rendered = await render(request.url, context.deadlineAt, context.signal)
|
|
150
|
+
const text = extractMainContent(rendered.html) || htmlToText(rendered.html)
|
|
151
|
+
return {
|
|
152
|
+
status: 'ok',
|
|
153
|
+
page: {
|
|
154
|
+
url: rendered.url,
|
|
155
|
+
title: extractTitle(rendered.html),
|
|
156
|
+
text,
|
|
157
|
+
contentType: 'text/html',
|
|
158
|
+
status: 200,
|
|
159
|
+
truncated: false,
|
|
160
|
+
renderedWith: 'browser',
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return {
|
|
165
|
+
status: 'error',
|
|
166
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
167
|
+
retriable: false,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async healthCheck() {
|
|
173
|
+
try {
|
|
174
|
+
await client().call('ping', {})
|
|
175
|
+
startupFailure = null
|
|
176
|
+
return { ok: true }
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async dispose() {
|
|
183
|
+
const active = sidecar
|
|
184
|
+
sidecar = null
|
|
185
|
+
if (active) await active.close()
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const browserAdapterModule = defineAdapterModule({
|
|
191
|
+
id: 'browser',
|
|
192
|
+
kind: 'browser',
|
|
193
|
+
contractVersion: CONTRACT_VERSION,
|
|
194
|
+
optionsSchema: browserOptionsSchema,
|
|
195
|
+
createAdapter: createBrowserAdapter,
|
|
196
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { SidecarError, decodeLine, encodeLine, isReply, type SidecarReply } from './protocol'
|
|
6
|
+
|
|
7
|
+
export type SidecarStream = {
|
|
8
|
+
stdin: { write(chunk: string): unknown; on?(event: 'error', handler: (error: NodeJS.ErrnoException) => void): unknown }
|
|
9
|
+
stdout: { setEncoding?(encoding: string): unknown; on(event: 'data', handler: (chunk: string) => void): unknown }
|
|
10
|
+
stderr: { setEncoding?(encoding: string): unknown; on(event: 'data', handler: (chunk: string) => void): unknown }
|
|
11
|
+
once(event: 'exit', handler: (code: number | null) => void): unknown
|
|
12
|
+
kill(signal?: NodeJS.Signals): unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SpawnFn = (scriptPath: string) => SidecarStream
|
|
16
|
+
|
|
17
|
+
export type SidecarOptions = {
|
|
18
|
+
readonly sidecarPath?: string
|
|
19
|
+
readonly spawnFn?: SpawnFn
|
|
20
|
+
readonly callTimeoutMs?: number
|
|
21
|
+
readonly closeTimeoutMs?: number
|
|
22
|
+
readonly maxPending?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_CALL_TIMEOUT_MS = 150_000
|
|
26
|
+
/**
|
|
27
|
+
* Deliberately short and separate from the call timeout: shutdown must not block
|
|
28
|
+
* for minutes on a wedged child before SIGTERM is even attempted.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000
|
|
31
|
+
const DEFAULT_MAX_PENDING = 128
|
|
32
|
+
const SIGKILL_GRACE_MS = 2_000
|
|
33
|
+
/** A full-page screenshot or a large DOM fits comfortably; a runaway does not. */
|
|
34
|
+
const MAX_STDOUT_BUFFER = 64 * 1024 * 1024
|
|
35
|
+
const STDERR_RING = 24
|
|
36
|
+
|
|
37
|
+
function defaultSidecarPath(): string {
|
|
38
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), 'sidecar.js')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultSpawn(scriptPath: string): SidecarStream {
|
|
42
|
+
const child: ChildProcessWithoutNullStreams = spawn(process.execPath, [scriptPath], {
|
|
43
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
44
|
+
})
|
|
45
|
+
return child as unknown as SidecarStream
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type Pending = {
|
|
49
|
+
resolve: (value: unknown) => void
|
|
50
|
+
reject: (error: Error) => void
|
|
51
|
+
timer: NodeJS.Timeout
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Sidecar {
|
|
55
|
+
private child: SidecarStream | null = null
|
|
56
|
+
private startError: Error | null = null
|
|
57
|
+
private buffer = ''
|
|
58
|
+
private recentStderr: string[] = []
|
|
59
|
+
private readonly pending = new Map<string, Pending>()
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly sidecarPath: string,
|
|
63
|
+
private readonly spawnFn: SpawnFn,
|
|
64
|
+
private readonly callTimeoutMs: number,
|
|
65
|
+
private readonly closeTimeoutMs: number,
|
|
66
|
+
private readonly maxPending: number,
|
|
67
|
+
) {}
|
|
68
|
+
|
|
69
|
+
private ensure(): void {
|
|
70
|
+
if (this.child) return
|
|
71
|
+
if (this.startError) throw this.startError
|
|
72
|
+
try {
|
|
73
|
+
this.child = this.spawnFn(this.sidecarPath)
|
|
74
|
+
} catch (error) {
|
|
75
|
+
this.startError = error instanceof Error ? error : new Error(String(error))
|
|
76
|
+
throw this.startError
|
|
77
|
+
}
|
|
78
|
+
// Reset per-process state on every spawn. A child that died mid-line would
|
|
79
|
+
// otherwise leave a JSON fragment that corrupts the next spawn's first
|
|
80
|
+
// reply — which then parses as garbage and hangs that call until timeout.
|
|
81
|
+
this.buffer = ''
|
|
82
|
+
this.recentStderr = []
|
|
83
|
+
|
|
84
|
+
this.child.stdout.setEncoding?.('utf8')
|
|
85
|
+
this.child.stdout.on('data', (chunk: string) => {
|
|
86
|
+
this.buffer += chunk
|
|
87
|
+
let newline = this.buffer.indexOf('\n')
|
|
88
|
+
while (newline !== -1) {
|
|
89
|
+
const line = this.buffer.slice(0, newline)
|
|
90
|
+
this.buffer = this.buffer.slice(newline + 1)
|
|
91
|
+
if (line.trim()) this.handleLine(line)
|
|
92
|
+
newline = this.buffer.indexOf('\n')
|
|
93
|
+
}
|
|
94
|
+
if (this.buffer.length > MAX_STDOUT_BUFFER) {
|
|
95
|
+
this.recentStderr.push(`dropped ${this.buffer.length} bytes of un-delimited sidecar output`)
|
|
96
|
+
this.buffer = ''
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// A dead child leaves its stdin pipe broken, and Node reports that as an
|
|
101
|
+
// asynchronous 'error' event (EPIPE) rather than as a throw from write() —
|
|
102
|
+
// so the try/catch around the write in `call` never sees it. Unhandled, a
|
|
103
|
+
// stream 'error' escalates past the adapter and the call throws instead of
|
|
104
|
+
// resolving to an outcome. Recording it keeps the event handled; the 'exit'
|
|
105
|
+
// handler below is what rejects the calls still in flight.
|
|
106
|
+
this.child.stdin.on?.('error', (error: NodeJS.ErrnoException) => {
|
|
107
|
+
this.recentStderr.push(`sidecar stdin ${error.code ?? 'error'}: ${error.message}`)
|
|
108
|
+
if (this.recentStderr.length > STDERR_RING) this.recentStderr.shift()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
this.child.stderr.setEncoding?.('utf8')
|
|
112
|
+
this.child.stderr.on('data', (chunk: string) => {
|
|
113
|
+
for (const line of chunk.split('\n')) {
|
|
114
|
+
if (!line.trim()) continue
|
|
115
|
+
this.recentStderr.push(line)
|
|
116
|
+
if (this.recentStderr.length > STDERR_RING) this.recentStderr.shift()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.child.once('exit', (code) => {
|
|
121
|
+
const tail = this.recentStderr.slice(-8).join('\n').trim()
|
|
122
|
+
const error = new SidecarError(
|
|
123
|
+
`browser sidecar exited unexpectedly (code=${code ?? 'null'})${tail ? `:\n${tail}` : ''}`,
|
|
124
|
+
'init',
|
|
125
|
+
)
|
|
126
|
+
for (const [, entry] of this.pending) {
|
|
127
|
+
clearTimeout(entry.timer)
|
|
128
|
+
entry.reject(error)
|
|
129
|
+
}
|
|
130
|
+
this.pending.clear()
|
|
131
|
+
this.child = null
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private handleLine(line: string): void {
|
|
136
|
+
const parsed = decodeLine(line)
|
|
137
|
+
if (!isReply(parsed)) return
|
|
138
|
+
const entry = this.pending.get(parsed.id)
|
|
139
|
+
if (!entry) return
|
|
140
|
+
this.pending.delete(parsed.id)
|
|
141
|
+
clearTimeout(entry.timer)
|
|
142
|
+
if (parsed.ok) {
|
|
143
|
+
entry.resolve(parsed.result)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
entry.reject(new SidecarError(parsed.error.message, parsed.error.kind))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async call(method: string, params: Record<string, unknown> = {}, signal?: AbortSignal): Promise<unknown> {
|
|
150
|
+
this.ensure()
|
|
151
|
+
const child = this.child
|
|
152
|
+
if (!child) throw new SidecarError('browser sidecar is not running', 'init')
|
|
153
|
+
if (this.pending.size >= this.maxPending) {
|
|
154
|
+
throw new SidecarError(`browser sidecar busy (${this.pending.size} in flight)`, 'runtime')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const id = randomUUID()
|
|
158
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
this.pending.delete(id)
|
|
161
|
+
signal?.removeEventListener('abort', onAbort)
|
|
162
|
+
reject(new SidecarError(`browser sidecar call "${method}" timed out`, 'timeout'))
|
|
163
|
+
}, this.callTimeoutMs)
|
|
164
|
+
timer.unref?.()
|
|
165
|
+
|
|
166
|
+
// Abort rejects THIS call only. The sidecar is shared, so killing it would
|
|
167
|
+
// take healthy concurrent leases down with it; a late reply is ignored
|
|
168
|
+
// because its id is no longer pending.
|
|
169
|
+
const onAbort = (): void => {
|
|
170
|
+
if (!this.pending.delete(id)) return
|
|
171
|
+
clearTimeout(timer)
|
|
172
|
+
reject(new SidecarError(`browser sidecar call "${method}" was aborted`, 'timeout'))
|
|
173
|
+
}
|
|
174
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
175
|
+
|
|
176
|
+
this.pending.set(id, {
|
|
177
|
+
resolve: (value) => {
|
|
178
|
+
signal?.removeEventListener('abort', onAbort)
|
|
179
|
+
resolve(value)
|
|
180
|
+
},
|
|
181
|
+
reject: (error) => {
|
|
182
|
+
signal?.removeEventListener('abort', onAbort)
|
|
183
|
+
reject(error)
|
|
184
|
+
},
|
|
185
|
+
timer,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
child.stdin.write(encodeLine({ id, method, params }))
|
|
190
|
+
} catch (error) {
|
|
191
|
+
this.pending.delete(id)
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
signal?.removeEventListener('abort', onAbort)
|
|
194
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async close(): Promise<void> {
|
|
200
|
+
const child = this.child
|
|
201
|
+
if (!child) return
|
|
202
|
+
try {
|
|
203
|
+
const closeSignal = AbortSignal.timeout(this.closeTimeoutMs)
|
|
204
|
+
await this.call('close', {}, closeSignal)
|
|
205
|
+
} catch {
|
|
206
|
+
// Wedged or already gone; escalate below.
|
|
207
|
+
}
|
|
208
|
+
await new Promise<void>((resolve) => {
|
|
209
|
+
let settled = false
|
|
210
|
+
const done = (): void => {
|
|
211
|
+
if (settled) return
|
|
212
|
+
settled = true
|
|
213
|
+
clearTimeout(timer)
|
|
214
|
+
resolve()
|
|
215
|
+
}
|
|
216
|
+
child.once('exit', done)
|
|
217
|
+
// Armed before the kill so a synchronous exit still finds the timer set.
|
|
218
|
+
const timer = setTimeout(() => {
|
|
219
|
+
try {
|
|
220
|
+
child.kill('SIGKILL')
|
|
221
|
+
} catch {
|
|
222
|
+
// Already gone.
|
|
223
|
+
}
|
|
224
|
+
done()
|
|
225
|
+
}, SIGKILL_GRACE_MS)
|
|
226
|
+
timer.unref?.()
|
|
227
|
+
try {
|
|
228
|
+
child.kill('SIGTERM')
|
|
229
|
+
} catch {
|
|
230
|
+
done()
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
this.child = null
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function createSidecar(options: SidecarOptions = {}): Sidecar {
|
|
238
|
+
return new Sidecar(
|
|
239
|
+
options.sidecarPath ?? defaultSidecarPath(),
|
|
240
|
+
options.spawnFn ?? defaultSpawn,
|
|
241
|
+
options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
|
|
242
|
+
options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS,
|
|
243
|
+
options.maxPending ?? DEFAULT_MAX_PENDING,
|
|
244
|
+
)
|
|
245
|
+
}
|