@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.
@@ -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
+ }