@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,205 @@
1
+ import { assertPublicUrl } from '@open-mercato/web-research'
2
+ import {
3
+ DEFAULT_USER_AGENT,
4
+ isSandboxDisabled,
5
+ resolveLaunchArgs,
6
+ importChromium,
7
+ installNavigationGuard,
8
+ launchChromium,
9
+ type InstallRunner,
10
+ type BrowserHandle,
11
+ type ContextHandle,
12
+ type PageHandle,
13
+ } from './playwright'
14
+ import { SidecarError, type SidecarReply, type SidecarRequest } from './protocol'
15
+
16
+ export type Lease = { context: ContextHandle; page: PageHandle; touchedAt: number }
17
+
18
+ /**
19
+ * How long an untouched lease may live.
20
+ *
21
+ * `release` normally closes a context, but the caller can die between `render`
22
+ * and `release` — a killed parent, a dropped socket — and every orphan is a live
23
+ * BrowserContext holding memory for as long as the sidecar runs.
24
+ */
25
+ export const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000
26
+
27
+ export type SidecarState = {
28
+ browser: BrowserHandle | null
29
+ leases: Map<string, Lease>
30
+ filterSubresources: boolean
31
+ warn: (message: string) => void
32
+ /** Injectable so a lease-expiry test does not have to wait five minutes. */
33
+ now: () => number
34
+ /** Injectable so tests never shell out to `npx playwright install`. */
35
+ installRunner?: InstallRunner
36
+ }
37
+
38
+ export const DEFAULT_GOTO_TIMEOUT_MS = 30_000
39
+ const MAX_GOTO_TIMEOUT_MS = 120_000
40
+
41
+ export function createState(overrides: Partial<SidecarState> = {}): SidecarState {
42
+ return {
43
+ browser: null,
44
+ leases: new Map(),
45
+ filterSubresources: process.env.OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES === '1',
46
+ warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\n`),
47
+ now: () => Date.now(),
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
53
+ if (state.browser) return state.browser
54
+ const chromium = await importChromium()
55
+ if (isSandboxDisabled()) {
56
+ // Said once per browser, not once per render, but said: this process renders
57
+ // pages chosen by a search engine or a model with the one mitigation
58
+ // Chromium offers against a hostile renderer turned off.
59
+ state.warn(
60
+ 'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 — a renderer compromise reaches this host',
61
+ )
62
+ }
63
+ state.browser = await launchChromium(
64
+ chromium,
65
+ { headless: true, args: resolveLaunchArgs() },
66
+ { onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },
67
+ )
68
+ return state.browser
69
+ }
70
+
71
+ /**
72
+ * One BrowserContext per lease. Contexts are Playwright's real isolation boundary
73
+ * (cookies, storage, cache) and cost ~10ms, so two concurrent searches never share
74
+ * a cookie jar the way a single shared page would.
75
+ */
76
+ async function openLease(state: SidecarState, leaseId: string, userAgent: string): Promise<Lease> {
77
+ await reapIdleLeases(state)
78
+ const existing = state.leases.get(leaseId)
79
+ if (existing) {
80
+ existing.touchedAt = state.now()
81
+ return existing
82
+ }
83
+ const browser = await ensureBrowser(state)
84
+ const context = await browser.newContext({ userAgent, locale: 'en-US' })
85
+ await installNavigationGuard(context, { filterSubresources: state.filterSubresources, warn: state.warn })
86
+ const page = (await context.newPage()) as unknown as PageHandle
87
+ const lease: Lease = { context, page, touchedAt: state.now() }
88
+ state.leases.set(leaseId, lease)
89
+ return lease
90
+ }
91
+
92
+ /** Closes leases nobody released. Swept on open, so an idle sidecar stays idle. */
93
+ export async function reapIdleLeases(state: SidecarState): Promise<void> {
94
+ const cutoff = state.now() - LEASE_IDLE_TIMEOUT_MS
95
+ for (const [leaseId, lease] of [...state.leases]) {
96
+ if (lease.touchedAt > cutoff) continue
97
+ state.warn(`reaping lease ${leaseId} idle for over ${LEASE_IDLE_TIMEOUT_MS}ms`)
98
+ await closeLease(state, leaseId)
99
+ }
100
+ }
101
+
102
+ async function closeLease(state: SidecarState, leaseId: string): Promise<void> {
103
+ const lease = state.leases.get(leaseId)
104
+ if (!lease) return
105
+ state.leases.delete(leaseId)
106
+ try {
107
+ await lease.context.close()
108
+ } catch {
109
+ // A context that already died takes nothing with it.
110
+ }
111
+ }
112
+
113
+ export async function teardown(state: SidecarState): Promise<void> {
114
+ for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)
115
+ const browser = state.browser
116
+ state.browser = null
117
+ if (browser) {
118
+ try {
119
+ await browser.close()
120
+ } catch {
121
+ // Best effort; the parent escalates to SIGKILL if we hang.
122
+ }
123
+ }
124
+ }
125
+
126
+ function stringParam(request: SidecarRequest, key: string): string {
127
+ const value = request.params?.[key]
128
+ if (typeof value !== 'string' || value.length === 0) {
129
+ throw new SidecarError(`missing "${key}" parameter`, 'runtime')
130
+ }
131
+ return value
132
+ }
133
+
134
+ async function dispatchInner(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {
135
+ switch (request.method) {
136
+ case 'ping':
137
+ return { id: request.id, ok: true, result: { ready: true } }
138
+
139
+ case 'render': {
140
+ const url = stringParam(request, 'url')
141
+ const leaseId = stringParam(request, 'leaseId')
142
+ // Re-checked here, in a distinct process from the caller, and BEFORE
143
+ // Playwright is imported — a blocked URL never launches a browser.
144
+ try {
145
+ await assertPublicUrl(url, 'browser render', { failClosed: true })
146
+ } catch (error) {
147
+ return {
148
+ id: request.id,
149
+ ok: false,
150
+ error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
151
+ }
152
+ }
153
+
154
+ const userAgent = typeof request.params?.userAgent === 'string' ? request.params.userAgent : DEFAULT_USER_AGENT
155
+ const timeout = Math.min(
156
+ typeof request.params?.timeoutMs === 'number' ? request.params.timeoutMs : DEFAULT_GOTO_TIMEOUT_MS,
157
+ MAX_GOTO_TIMEOUT_MS,
158
+ )
159
+ const waitUntil = typeof request.params?.waitUntil === 'string' ? request.params.waitUntil : 'domcontentloaded'
160
+
161
+ const lease = await openLease(state, leaseId, userAgent)
162
+ try {
163
+ await lease.page.goto(url, { waitUntil, timeout })
164
+ } catch (error) {
165
+ return {
166
+ id: request.id,
167
+ ok: false,
168
+ error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
169
+ }
170
+ }
171
+ const html = await lease.page.content()
172
+ return { id: request.id, ok: true, result: { url: lease.page.url(), html } }
173
+ }
174
+
175
+ case 'release':
176
+ await closeLease(state, stringParam(request, 'leaseId'))
177
+ return { id: request.id, ok: true }
178
+
179
+ case 'close':
180
+ await teardown(state)
181
+ return { id: request.id, ok: true }
182
+
183
+ default:
184
+ return {
185
+ id: request.id,
186
+ ok: false,
187
+ error: { message: `unknown method: ${request.method}`, kind: 'runtime' },
188
+ }
189
+ }
190
+ }
191
+
192
+ export async function dispatch(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {
193
+ try {
194
+ return await dispatchInner(state, request)
195
+ } catch (error) {
196
+ return {
197
+ id: request.id,
198
+ ok: false,
199
+ error: {
200
+ message: error instanceof Error ? error.message : String(error),
201
+ kind: error instanceof SidecarError ? error.kind : 'unknown',
202
+ },
203
+ }
204
+ }
205
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export {
2
+ browserAdapterModule,
3
+ browserOptionsSchema,
4
+ createBrowserAdapter,
5
+ type BrowserOptions,
6
+ } from './adapter'
7
+ export { Sidecar, createSidecar, type SidecarOptions, type SidecarStream, type SpawnFn } from './client'
8
+ export { SidecarError, type SidecarErrorKind, type SidecarReply, type SidecarRequest } from './protocol'
9
+
10
+ export { browserAdapterModule as default } from './adapter'
@@ -0,0 +1,265 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { assertPublicUrl } from '@open-mercato/web-research'
3
+ import { SidecarError } from './protocol'
4
+
5
+ /**
6
+ * Structural projections of the Playwright surface we touch.
7
+ *
8
+ * Playwright is a real dependency of this package — installing the adapter gets
9
+ * you the tooling it needs — but it is still reached through a dynamic import
10
+ * inside the sidecar, so the app and MCP processes never load it. The types stay
11
+ * structural for the same reason: nothing here should pull the SDK into a bundle
12
+ * that only ever spawns the child.
13
+ */
14
+ export type PageHandle = {
15
+ goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>
16
+ content(): Promise<string>
17
+ url(): string
18
+ close(): Promise<void>
19
+ }
20
+
21
+ export type ContextHandle = {
22
+ newPage(): Promise<unknown>
23
+ close(): Promise<void>
24
+ route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>
25
+ }
26
+
27
+ export type RouteHandle = {
28
+ request(): { url(): string; isNavigationRequest(): boolean }
29
+ abort(reason?: string): Promise<void>
30
+ continue(): Promise<void>
31
+ }
32
+
33
+ export type BrowserHandle = {
34
+ newContext(options?: Record<string, unknown>): Promise<ContextHandle>
35
+ close(): Promise<void>
36
+ }
37
+
38
+ export type BrowserType = {
39
+ launch(options?: { headless?: boolean; args?: string[] }): Promise<BrowserHandle>
40
+ }
41
+
42
+ function isModuleNotFound(message: string): boolean {
43
+ return (
44
+ /cannot find (package|module) ['"]?playwright/i.test(message) ||
45
+ /ERR_MODULE_NOT_FOUND/i.test(message) ||
46
+ /failed to resolve ['"]?playwright/i.test(message)
47
+ )
48
+ }
49
+
50
+ export async function importChromium(): Promise<BrowserType> {
51
+ try {
52
+ const playwright = (await import('playwright')) as unknown as { chromium: BrowserType }
53
+ return playwright.chromium
54
+ } catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error)
56
+ // Playwright is a dependency of this package, so a module-not-found here
57
+ // means a broken install (hoisting, a pruned image layer), not a missing opt-in.
58
+ throw new SidecarError(
59
+ `Playwright could not be loaded even though it is a dependency of this package — reinstall dependencies. Underlying: ${message}`,
60
+ isModuleNotFound(message) ? 'needs-install' : 'init',
61
+ )
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Playwright's launch error when the npm package is installed but the browser
67
+ * binary was never downloaded. The wording has been stable across 1.x, and it is
68
+ * the only signal Playwright gives for this case.
69
+ */
70
+ function isMissingBrowserError(error: unknown): boolean {
71
+ return error instanceof Error && /Executable doesn'?t exist at/i.test(error.message)
72
+ }
73
+
74
+ /** Generous, but bounded — an unbounded install hangs the sidecar forever. */
75
+ const INSTALL_TIMEOUT_MS = 10 * 60_000
76
+
77
+ export type InstallRunner = (onProgress: (line: string) => void) => Promise<void>
78
+
79
+ const defaultInstallRunner: InstallRunner = async (onProgress) =>
80
+ new Promise((resolve, reject) => {
81
+ const child = spawn('npx', ['playwright', 'install', 'chromium'], { stdio: ['ignore', 'pipe', 'pipe'] })
82
+ let tail = ''
83
+ const collect = (chunk: Buffer): void => {
84
+ tail = `${tail}${chunk.toString('utf8')}`.slice(-4000)
85
+ }
86
+ child.stdout?.on('data', collect)
87
+ child.stderr?.on('data', collect)
88
+
89
+ const timer = setTimeout(() => {
90
+ child.kill('SIGKILL')
91
+ reject(new Error(`\`npx playwright install chromium\` exceeded ${INSTALL_TIMEOUT_MS}ms`))
92
+ }, INSTALL_TIMEOUT_MS)
93
+
94
+ child.once('error', (error) => {
95
+ clearTimeout(timer)
96
+ reject(error)
97
+ })
98
+ child.once('close', (code) => {
99
+ clearTimeout(timer)
100
+ if (code === 0) {
101
+ onProgress('chromium install complete')
102
+ resolve()
103
+ return
104
+ }
105
+ reject(new Error(`\`npx playwright install chromium\` failed (exit ${code}): ${tail.trim()}`))
106
+ })
107
+ })
108
+
109
+ /**
110
+ * Launches Chromium, downloading the binary once if it is missing.
111
+ *
112
+ * Installing the npm package does not fetch the ~150MB browser, so an operator
113
+ * who enables this tier would otherwise hit a launch failure with no obvious
114
+ * remedy. One retry only — a second failure is a real fault, not a cold cache.
115
+ */
116
+ export async function launchChromium(
117
+ browserType: BrowserType,
118
+ options: { headless?: boolean; args?: string[] },
119
+ deps: { install?: InstallRunner; onProgress?: (line: string) => void } = {},
120
+ ): Promise<BrowserHandle> {
121
+ try {
122
+ return await browserType.launch(options)
123
+ } catch (error) {
124
+ // Deliberately not retried without the sandbox. Falling back silently would
125
+ // mean the security posture depends on whether a container happened to be
126
+ // configured, with nobody told which one they got.
127
+ if (isSandboxFailure(error)) {
128
+ throw new SidecarError(
129
+ `Chromium could not start its sandbox, and this adapter renders untrusted pages so it will not run without one. ` +
130
+ `Fix the container (run as a non-root user, or grant SYS_ADMIN, or enable user namespaces), ` +
131
+ `or accept the risk explicitly with ${SANDBOX_OPT_OUT_ENV}=1. Underlying: ${error instanceof Error ? error.message : String(error)}`,
132
+ 'init',
133
+ )
134
+ }
135
+ if (!isMissingBrowserError(error)) throw error
136
+ const onProgress = deps.onProgress ?? (() => {})
137
+ onProgress('chromium binary missing, running `npx playwright install chromium` (one-time, ~150MB)')
138
+ try {
139
+ await (deps.install ?? defaultInstallRunner)(onProgress)
140
+ } catch (installError) {
141
+ const detail = installError instanceof Error ? installError.message : String(installError)
142
+ throw new SidecarError(
143
+ `Playwright browser auto-install failed: ${detail}. Run \`npx playwright install chromium\` manually.`,
144
+ 'init',
145
+ )
146
+ }
147
+ return await browserType.launch(options)
148
+ }
149
+ }
150
+
151
+ const MAX_VERDICT_CACHE = 2048
152
+
153
+ /**
154
+ * Blocks private-origin navigations for the life of the context.
155
+ *
156
+ * The parent and the sidecar both vet the URL before a `goto`, but a page reached
157
+ * through a legitimate public navigation can redirect or script-navigate itself
158
+ * to a metadata endpoint, and those hops never pass through the RPC layer.
159
+ *
160
+ * Fails closed: Chromium resolves names with its own resolver, so a hostname
161
+ * `node:dns` cannot vet must not be allowed through on the assumption it is dead.
162
+ */
163
+ export async function installNavigationGuard(
164
+ context: ContextHandle,
165
+ options: { filterSubresources?: boolean; warn?: (message: string) => void } = {},
166
+ ): Promise<void> {
167
+ if (typeof context.route !== 'function') {
168
+ options.warn?.('context.route() unavailable — in-page navigation SSRF guard NOT installed')
169
+ return
170
+ }
171
+ const filterSubresources = options.filterSubresources ?? false
172
+ const verdicts = new Map<string, boolean>()
173
+ const remember = (host: string, blocked: boolean): void => {
174
+ if (verdicts.size >= MAX_VERDICT_CACHE) {
175
+ const oldest = verdicts.keys().next().value
176
+ if (oldest !== undefined) verdicts.delete(oldest)
177
+ }
178
+ verdicts.set(host, blocked)
179
+ }
180
+
181
+ await context.route('**/*', async (route) => {
182
+ const request = route.request()
183
+ const isNavigation = request.isNavigationRequest()
184
+ if (!isNavigation && !filterSubresources) return route.continue()
185
+
186
+ const url = request.url()
187
+ let host: string
188
+ try {
189
+ host = new URL(url).host
190
+ } catch {
191
+ return isNavigation ? route.abort('blockedbyclient') : route.continue()
192
+ }
193
+
194
+ const cached = isNavigation ? undefined : verdicts.get(host)
195
+ if (cached === true) return route.abort('blockedbyclient')
196
+ if (cached === false) return route.continue()
197
+
198
+ try {
199
+ await assertPublicUrl(url, isNavigation ? 'browser navigation' : 'browser subresource', {
200
+ failClosed: true,
201
+ })
202
+ remember(host, false)
203
+ } catch {
204
+ remember(host, true)
205
+ return route.abort('blockedbyclient')
206
+ }
207
+ return route.continue()
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Launch flags aimed at not announcing automation. Playwright's defaults set
213
+ * `navigator.webdriver` and a headless UA, which the SERP endpoints this adapter
214
+ * exists to reach specifically look for.
215
+ *
216
+ * `--disable-dev-shm-usage` is a sizing workaround, not a security control: it
217
+ * makes Chromium write shared memory to /tmp instead of a container's small
218
+ * /dev/shm. The sandbox is deliberately NOT disabled here — see
219
+ * {@link resolveLaunchArgs}.
220
+ */
221
+ export const LAUNCH_ARGS = [
222
+ '--disable-blink-features=AutomationControlled',
223
+ '--disable-dev-shm-usage',
224
+ ]
225
+
226
+ export const SANDBOX_OPT_OUT_ENV = 'OM_WEB_RESEARCH_BROWSER_NO_SANDBOX'
227
+
228
+ /**
229
+ * Launch flags for this process, sandbox included unless an operator opted out.
230
+ *
231
+ * This adapter exists to render pages chosen by a search engine or a model, so
232
+ * the renderer is by definition parsing hostile input. `--no-sandbox` turns a
233
+ * renderer compromise into code running as whatever user the app runs as, which
234
+ * is the one mitigation Chromium provides for exactly this threat.
235
+ *
236
+ * It used to be on unconditionally because the sandbox needs privileges a
237
+ * root-in-Docker container does not grant by default. That is a container
238
+ * problem with container fixes (run as a non-root user, or grant `SYS_ADMIN`, or
239
+ * enable user namespaces); silently shipping every deployment the insecure
240
+ * variant to spare those is the wrong trade. The opt-out stays available for
241
+ * environments that genuinely cannot, and it is loud.
242
+ */
243
+ export function resolveLaunchArgs(env: NodeJS.ProcessEnv = process.env): string[] {
244
+ return env[SANDBOX_OPT_OUT_ENV] === '1' ? [...LAUNCH_ARGS, '--no-sandbox'] : [...LAUNCH_ARGS]
245
+ }
246
+
247
+ export function isSandboxDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
248
+ return env[SANDBOX_OPT_OUT_ENV] === '1'
249
+ }
250
+
251
+ /**
252
+ * Chromium's refusal when it cannot set up its sandbox. The wording differs
253
+ * between the root check and the namespace failure, so both shapes are matched.
254
+ */
255
+ export function isSandboxFailure(error: unknown): boolean {
256
+ if (!(error instanceof Error)) return false
257
+ return (
258
+ /running as root without --no-sandbox/i.test(error.message) ||
259
+ /No usable sandbox/i.test(error.message) ||
260
+ /clone helper|namespace sandbox|SUID sandbox/i.test(error.message)
261
+ )
262
+ }
263
+
264
+ export const DEFAULT_USER_AGENT =
265
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
@@ -0,0 +1,51 @@
1
+ export type SidecarErrorKind = 'init' | 'needs-install' | 'navigation' | 'runtime' | 'timeout' | 'unknown'
2
+
3
+ export type SidecarRequest = {
4
+ readonly id: string
5
+ readonly method: string
6
+ readonly params?: Record<string, unknown>
7
+ }
8
+
9
+ export type SidecarReply =
10
+ | { readonly id: string; readonly ok: true; readonly result?: unknown }
11
+ | {
12
+ readonly id: string
13
+ readonly ok: false
14
+ readonly error: { readonly message: string; readonly kind: SidecarErrorKind }
15
+ }
16
+
17
+ export class SidecarError extends Error {
18
+ readonly kind: SidecarErrorKind
19
+
20
+ constructor(message: string, kind: SidecarErrorKind = 'runtime') {
21
+ super(message)
22
+ this.name = 'SidecarError'
23
+ this.kind = kind
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Line-delimited JSON, one object per line, correlated by `id`. Deliberately not
29
+ * JSON-RPC 2.0: the surface is four methods over a pipe we own both ends of, and
30
+ * an `ok` discriminant is easier to narrow than presence-of-`error`.
31
+ */
32
+ export function encodeLine(value: SidecarRequest | SidecarReply): string {
33
+ return `${JSON.stringify(value)}\n`
34
+ }
35
+
36
+ export function decodeLine(line: string): unknown {
37
+ try {
38
+ return JSON.parse(line)
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ export function isReply(value: unknown): value is SidecarReply {
45
+ return (
46
+ typeof value === 'object' &&
47
+ value !== null &&
48
+ typeof (value as { id?: unknown }).id === 'string' &&
49
+ typeof (value as { ok?: unknown }).ok === 'boolean'
50
+ )
51
+ }
package/src/sidecar.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Playwright sidecar entry point.
3
+ *
4
+ * Runs as a child process so Playwright never enters the app or MCP bundle and a
5
+ * crashed browser cannot take the server down. Speaks line-delimited JSON on
6
+ * stdio; every request is serialized per lease so a `goto` cannot race a
7
+ * `release` on the same context.
8
+ */
9
+
10
+ import { createState, dispatch, teardown, type SidecarState } from './dispatch'
11
+ import { decodeLine, encodeLine, type SidecarReply, type SidecarRequest } from './protocol'
12
+
13
+ const state: SidecarState = createState()
14
+
15
+ function write(reply: SidecarReply): void {
16
+ try {
17
+ process.stdout.write(encodeLine(reply))
18
+ } catch {
19
+ // stdout is gone; the parent will time the call out and reap us.
20
+ }
21
+ }
22
+
23
+ /** Per-lease chains: work on different contexts proceeds concurrently. */
24
+ const chains = new Map<string, Promise<void>>()
25
+
26
+ function chainKey(request: SidecarRequest): string {
27
+ const leaseId = request.params?.leaseId
28
+ return typeof leaseId === 'string' ? leaseId : '__global__'
29
+ }
30
+
31
+ export function enqueue(line: string): void {
32
+ const parsed = decodeLine(line)
33
+ if (typeof parsed !== 'object' || parsed === null) return
34
+ const request = parsed as SidecarRequest
35
+ if (typeof request.id !== 'string' || typeof request.method !== 'string') return
36
+
37
+ const key = chainKey(request)
38
+ const previous = chains.get(key) ?? Promise.resolve()
39
+ const next: Promise<void> = previous
40
+ .then(async () => {
41
+ write(await dispatch(state, request))
42
+ })
43
+ .catch((error: unknown) => {
44
+ write({
45
+ id: request.id,
46
+ ok: false,
47
+ error: { message: error instanceof Error ? error.message : String(error), kind: 'unknown' },
48
+ })
49
+ })
50
+ .finally(() => {
51
+ // Every render takes a fresh lease id, so without this the map grows one
52
+ // settled promise per request for the life of the process. Only the tail is
53
+ // dropped; a later request on the same lease owns the entry by then.
54
+ if (chains.get(key) === next) chains.delete(key)
55
+ })
56
+ chains.set(key, next)
57
+ }
58
+
59
+ async function shutdownAndExit(code: number): Promise<void> {
60
+ try {
61
+ await teardown(state)
62
+ } catch {
63
+ // Exiting regardless.
64
+ }
65
+ process.exit(code)
66
+ }
67
+
68
+ /**
69
+ * A SIGKILL'd parent produces no stdin EOF, so without this poll a Chromium can
70
+ * outlive the server that spawned it and leak memory until the box is restarted.
71
+ */
72
+ function startParentWatchdog(): void {
73
+ const parentPid = process.ppid
74
+ if (!parentPid || parentPid <= 1) return
75
+ const interval = setInterval(() => {
76
+ try {
77
+ process.kill(parentPid, 0)
78
+ } catch (error) {
79
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
80
+ clearInterval(interval)
81
+ void shutdownAndExit(0)
82
+ }
83
+ // EPERM means it exists but we may not signal it — still alive.
84
+ }
85
+ }, 2000)
86
+ interval.unref?.()
87
+ }
88
+
89
+ export function main(): void {
90
+ let buffer = ''
91
+ process.stdin.setEncoding('utf8')
92
+ process.stdin.on('data', (chunk: string) => {
93
+ buffer += chunk
94
+ let newline = buffer.indexOf('\n')
95
+ while (newline !== -1) {
96
+ const line = buffer.slice(0, newline)
97
+ buffer = buffer.slice(newline + 1)
98
+ if (line.trim()) enqueue(line)
99
+ newline = buffer.indexOf('\n')
100
+ }
101
+ })
102
+ process.stdin.on('end', () => void shutdownAndExit(0))
103
+ process.stdout.on('error', () => void shutdownAndExit(0))
104
+ startParentWatchdog()
105
+ }
106
+
107
+ // Guarded so tests can import the module without starting the stdio loop.
108
+ if (process.env.OM_WEB_RESEARCH_SIDECAR_TEST !== '1') main()
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { watch } from '../../scripts/watch.mjs'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)