@open-mercato/web-research-browser 0.9.1-develop.7547.1.ade91a3f6b → 0.9.1-develop.7552.1.4acf199f7a

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.
@@ -1,2 +1,2 @@
1
- [build:web-research-browser] found 7 entry points
1
+ [build:web-research-browser] found 8 entry points
2
2
  [build:web-research-browser] built successfully
package/dist/dispatch.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { assertPublicUrl } from "@open-mercato/web-research";
2
+ import { startEgressProxy } from "./egressProxy.js";
2
3
  import {
3
4
  DEFAULT_USER_AGENT,
5
+ RESOLVER_LOCKDOWN_ARGS,
4
6
  isSandboxDisabled,
5
7
  resolveLaunchArgs,
6
8
  importChromium,
@@ -9,33 +11,64 @@ import {
9
11
  } from "./playwright.js";
10
12
  import { SidecarError } from "./protocol.js";
11
13
  const LEASE_IDLE_TIMEOUT_MS = 5 * 6e4;
14
+ const FILTER_SUBRESOURCES_ENV = "OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES";
12
15
  const DEFAULT_GOTO_TIMEOUT_MS = 3e4;
13
16
  const MAX_GOTO_TIMEOUT_MS = 12e4;
14
17
  function createState(overrides = {}) {
15
18
  return {
16
19
  browser: null,
17
20
  leases: /* @__PURE__ */ new Map(),
18
- filterSubresources: process.env.OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES === "1",
21
+ filterSubresources: true,
19
22
  warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}
20
23
  `),
21
24
  now: () => Date.now(),
22
25
  ...overrides
23
26
  };
24
27
  }
25
- async function ensureBrowser(state) {
26
- if (state.browser) return state.browser;
27
- const chromium = await importChromium();
28
+ function lookupOption(state) {
29
+ return state.lookup ? { lookup: state.lookup } : {};
30
+ }
31
+ async function launchBrowser(state) {
32
+ const browserType = await (state.loadBrowserType ?? importChromium)();
28
33
  if (isSandboxDisabled()) {
29
34
  state.warn(
30
35
  "SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 \u2014 a renderer compromise reaches this host"
31
36
  );
32
37
  }
33
- state.browser = await launchChromium(
34
- chromium,
35
- { headless: true, args: resolveLaunchArgs() },
36
- { onProgress: state.warn, ...state.installRunner ? { install: state.installRunner } : {} }
37
- );
38
- return state.browser;
38
+ if (process.env[FILTER_SUBRESOURCES_ENV] !== void 0) {
39
+ state.warn(`subresource filtering is now always on; ${FILTER_SUBRESOURCES_ENV} is ignored`);
40
+ }
41
+ const proxy = await startEgressProxy({
42
+ ...lookupOption(state),
43
+ ...state.connect ? { connect: state.connect } : {}
44
+ });
45
+ let browser;
46
+ try {
47
+ browser = await launchChromium(
48
+ browserType,
49
+ {
50
+ headless: true,
51
+ args: [...resolveLaunchArgs(), ...RESOLVER_LOCKDOWN_ARGS],
52
+ proxy: { server: proxy.url, bypass: "<-loopback>" }
53
+ },
54
+ { onProgress: state.warn, ...state.installRunner ? { install: state.installRunner } : {} }
55
+ );
56
+ } catch (error) {
57
+ await proxy.close();
58
+ throw error;
59
+ }
60
+ state.egressProxy = proxy;
61
+ state.browser = browser;
62
+ return browser;
63
+ }
64
+ async function ensureBrowser(state) {
65
+ if (state.browser) return state.browser;
66
+ if (!state.launching) {
67
+ state.launching = launchBrowser(state).finally(() => {
68
+ state.launching = null;
69
+ });
70
+ }
71
+ return state.launching;
39
72
  }
40
73
  async function openLease(state, leaseId, userAgent) {
41
74
  await reapIdleLeases(state);
@@ -45,8 +78,8 @@ async function openLease(state, leaseId, userAgent) {
45
78
  return existing;
46
79
  }
47
80
  const browser = await ensureBrowser(state);
48
- const context = await browser.newContext({ userAgent, locale: "en-US" });
49
- await installNavigationGuard(context, { filterSubresources: state.filterSubresources, warn: state.warn });
81
+ const context = await browser.newContext({ userAgent, locale: "en-US", serviceWorkers: "block" });
82
+ await installNavigationGuard(context, { warn: state.warn, ...lookupOption(state) });
50
83
  const page = await context.newPage();
51
84
  const lease = { context, page, touchedAt: state.now() };
52
85
  state.leases.set(leaseId, lease);
@@ -70,15 +103,19 @@ async function closeLease(state, leaseId) {
70
103
  }
71
104
  }
72
105
  async function teardown(state) {
106
+ if (state.launching) await state.launching.catch(() => null);
73
107
  for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId);
74
108
  const browser = state.browser;
109
+ const proxy = state.egressProxy;
75
110
  state.browser = null;
111
+ state.egressProxy = null;
76
112
  if (browser) {
77
113
  try {
78
114
  await browser.close();
79
115
  } catch {
80
116
  }
81
117
  }
118
+ if (proxy) await proxy.close();
82
119
  }
83
120
  function stringParam(request, key) {
84
121
  const value = request.params?.[key];
@@ -95,7 +132,7 @@ async function dispatchInner(state, request) {
95
132
  const url = stringParam(request, "url");
96
133
  const leaseId = stringParam(request, "leaseId");
97
134
  try {
98
- await assertPublicUrl(url, "browser render", { failClosed: true });
135
+ await assertPublicUrl(url, "browser render", { failClosed: true, ...lookupOption(state) });
99
136
  } catch (error) {
100
137
  return {
101
138
  id: request.id,
@@ -119,8 +156,18 @@ async function dispatchInner(state, request) {
119
156
  error: { message: error instanceof Error ? error.message : String(error), kind: "navigation" }
120
157
  };
121
158
  }
159
+ const finalUrl = lease.page.url();
160
+ try {
161
+ await assertPublicUrl(finalUrl, "browser final URL", { failClosed: true, ...lookupOption(state) });
162
+ } catch (error) {
163
+ return {
164
+ id: request.id,
165
+ ok: false,
166
+ error: { message: error instanceof Error ? error.message : String(error), kind: "navigation" }
167
+ };
168
+ }
122
169
  const html = await lease.page.content();
123
- return { id: request.id, ok: true, result: { url: lease.page.url(), html } };
170
+ return { id: request.id, ok: true, result: { url: finalUrl, html } };
124
171
  }
125
172
  case "release":
126
173
  await closeLease(state, stringParam(request, "leaseId"));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/dispatch.ts"],
4
- "sourcesContent": ["import { assertPublicUrl } from '@open-mercato/web-research'\nimport {\n DEFAULT_USER_AGENT,\n isSandboxDisabled,\n resolveLaunchArgs,\n importChromium,\n installNavigationGuard,\n launchChromium,\n type InstallRunner,\n type BrowserHandle,\n type ContextHandle,\n type PageHandle,\n} from './playwright'\nimport { SidecarError, type SidecarReply, type SidecarRequest } from './protocol'\n\nexport type Lease = { context: ContextHandle; page: PageHandle; touchedAt: number }\n\n/**\n * How long an untouched lease may live.\n *\n * `release` normally closes a context, but the caller can die between `render`\n * and `release` \u2014 a killed parent, a dropped socket \u2014 and every orphan is a live\n * BrowserContext holding memory for as long as the sidecar runs.\n */\nexport const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000\n\nexport type SidecarState = {\n browser: BrowserHandle | null\n leases: Map<string, Lease>\n filterSubresources: boolean\n warn: (message: string) => void\n /** Injectable so a lease-expiry test does not have to wait five minutes. */\n now: () => number\n /** Injectable so tests never shell out to `npx playwright install`. */\n installRunner?: InstallRunner\n}\n\nexport const DEFAULT_GOTO_TIMEOUT_MS = 30_000\nconst MAX_GOTO_TIMEOUT_MS = 120_000\n\nexport function createState(overrides: Partial<SidecarState> = {}): SidecarState {\n return {\n browser: null,\n leases: new Map(),\n filterSubresources: process.env.OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES === '1',\n warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\\n`),\n now: () => Date.now(),\n ...overrides,\n }\n}\n\nasync function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {\n if (state.browser) return state.browser\n const chromium = await importChromium()\n if (isSandboxDisabled()) {\n // Said once per browser, not once per render, but said: this process renders\n // pages chosen by a search engine or a model with the one mitigation\n // Chromium offers against a hostile renderer turned off.\n state.warn(\n 'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 \u2014 a renderer compromise reaches this host',\n )\n }\n state.browser = await launchChromium(\n chromium,\n { headless: true, args: resolveLaunchArgs() },\n { onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },\n )\n return state.browser\n}\n\n/**\n * One BrowserContext per lease. Contexts are Playwright's real isolation boundary\n * (cookies, storage, cache) and cost ~10ms, so two concurrent searches never share\n * a cookie jar the way a single shared page would.\n */\nasync function openLease(state: SidecarState, leaseId: string, userAgent: string): Promise<Lease> {\n await reapIdleLeases(state)\n const existing = state.leases.get(leaseId)\n if (existing) {\n existing.touchedAt = state.now()\n return existing\n }\n const browser = await ensureBrowser(state)\n const context = await browser.newContext({ userAgent, locale: 'en-US' })\n await installNavigationGuard(context, { filterSubresources: state.filterSubresources, warn: state.warn })\n const page = (await context.newPage()) as unknown as PageHandle\n const lease: Lease = { context, page, touchedAt: state.now() }\n state.leases.set(leaseId, lease)\n return lease\n}\n\n/** Closes leases nobody released. Swept on open, so an idle sidecar stays idle. */\nexport async function reapIdleLeases(state: SidecarState): Promise<void> {\n const cutoff = state.now() - LEASE_IDLE_TIMEOUT_MS\n for (const [leaseId, lease] of [...state.leases]) {\n if (lease.touchedAt > cutoff) continue\n state.warn(`reaping lease ${leaseId} idle for over ${LEASE_IDLE_TIMEOUT_MS}ms`)\n await closeLease(state, leaseId)\n }\n}\n\nasync function closeLease(state: SidecarState, leaseId: string): Promise<void> {\n const lease = state.leases.get(leaseId)\n if (!lease) return\n state.leases.delete(leaseId)\n try {\n await lease.context.close()\n } catch {\n // A context that already died takes nothing with it.\n }\n}\n\nexport async function teardown(state: SidecarState): Promise<void> {\n for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)\n const browser = state.browser\n state.browser = null\n if (browser) {\n try {\n await browser.close()\n } catch {\n // Best effort; the parent escalates to SIGKILL if we hang.\n }\n }\n}\n\nfunction stringParam(request: SidecarRequest, key: string): string {\n const value = request.params?.[key]\n if (typeof value !== 'string' || value.length === 0) {\n throw new SidecarError(`missing \"${key}\" parameter`, 'runtime')\n }\n return value\n}\n\nasync function dispatchInner(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {\n switch (request.method) {\n case 'ping':\n return { id: request.id, ok: true, result: { ready: true } }\n\n case 'render': {\n const url = stringParam(request, 'url')\n const leaseId = stringParam(request, 'leaseId')\n // Re-checked here, in a distinct process from the caller, and BEFORE\n // Playwright is imported \u2014 a blocked URL never launches a browser.\n try {\n await assertPublicUrl(url, 'browser render', { failClosed: true })\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },\n }\n }\n\n const userAgent = typeof request.params?.userAgent === 'string' ? request.params.userAgent : DEFAULT_USER_AGENT\n const timeout = Math.min(\n typeof request.params?.timeoutMs === 'number' ? request.params.timeoutMs : DEFAULT_GOTO_TIMEOUT_MS,\n MAX_GOTO_TIMEOUT_MS,\n )\n const waitUntil = typeof request.params?.waitUntil === 'string' ? request.params.waitUntil : 'domcontentloaded'\n\n const lease = await openLease(state, leaseId, userAgent)\n try {\n await lease.page.goto(url, { waitUntil, timeout })\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },\n }\n }\n const html = await lease.page.content()\n return { id: request.id, ok: true, result: { url: lease.page.url(), html } }\n }\n\n case 'release':\n await closeLease(state, stringParam(request, 'leaseId'))\n return { id: request.id, ok: true }\n\n case 'close':\n await teardown(state)\n return { id: request.id, ok: true }\n\n default:\n return {\n id: request.id,\n ok: false,\n error: { message: `unknown method: ${request.method}`, kind: 'runtime' },\n }\n }\n}\n\nexport async function dispatch(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {\n try {\n return await dispatchInner(state, request)\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: {\n message: error instanceof Error ? error.message : String(error),\n kind: error instanceof SidecarError ? error.kind : 'unknown',\n },\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { assertPublicUrl, type LookupFn } from '@open-mercato/web-research'\nimport { startEgressProxy, type EgressConnect, type EgressProxy } from './egressProxy'\nimport {\n DEFAULT_USER_AGENT,\n RESOLVER_LOCKDOWN_ARGS,\n isSandboxDisabled,\n resolveLaunchArgs,\n importChromium,\n installNavigationGuard,\n launchChromium,\n type InstallRunner,\n type BrowserHandle,\n type BrowserType,\n type ContextHandle,\n type PageHandle,\n} from './playwright'\nimport { SidecarError, type SidecarReply, type SidecarRequest } from './protocol'\n\nexport type Lease = { context: ContextHandle; page: PageHandle; touchedAt: number }\n\n/**\n * How long an untouched lease may live.\n *\n * `release` normally closes a context, but the caller can die between `render`\n * and `release` \u2014 a killed parent, a dropped socket \u2014 and every orphan is a live\n * BrowserContext holding memory for as long as the sidecar runs.\n */\nexport const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000\n\nexport type SidecarState = {\n browser: BrowserHandle | null\n leases: Map<string, Lease>\n /** @deprecated Subresource filtering is always on; this field is ignored. */\n filterSubresources: boolean\n warn: (message: string) => void\n /** Injectable so a lease-expiry test does not have to wait five minutes. */\n now: () => number\n /** Injectable so tests never shell out to `npx playwright install`. */\n installRunner?: InstallRunner\n /** Injectable DNS for the URL checks and the egress proxy, so tests never resolve real names. */\n lookup?: LookupFn\n /** Injectable dial for the egress proxy, so tests can stand in for public hosts. */\n connect?: EgressConnect\n /** Injectable so tests never import Playwright. */\n loadBrowserType?: () => Promise<BrowserType>\n egressProxy?: EgressProxy | null\n launching?: Promise<BrowserHandle> | null\n}\n\nconst FILTER_SUBRESOURCES_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'\n\nexport const DEFAULT_GOTO_TIMEOUT_MS = 30_000\nconst MAX_GOTO_TIMEOUT_MS = 120_000\n\nexport function createState(overrides: Partial<SidecarState> = {}): SidecarState {\n return {\n browser: null,\n leases: new Map(),\n filterSubresources: true,\n warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\\n`),\n now: () => Date.now(),\n ...overrides,\n }\n}\n\nfunction lookupOption(state: SidecarState): { lookup?: LookupFn } {\n return state.lookup ? { lookup: state.lookup } : {}\n}\n\nasync function launchBrowser(state: SidecarState): Promise<BrowserHandle> {\n const browserType = await (state.loadBrowserType ?? importChromium)()\n if (isSandboxDisabled()) {\n // Said once per browser, not once per render, but said: this process renders\n // pages chosen by a search engine or a model with the one mitigation\n // Chromium offers against a hostile renderer turned off.\n state.warn(\n 'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 \u2014 a renderer compromise reaches this host',\n )\n }\n if (process.env[FILTER_SUBRESOURCES_ENV] !== undefined) {\n state.warn(`subresource filtering is now always on; ${FILTER_SUBRESOURCES_ENV} is ignored`)\n }\n const proxy = await startEgressProxy({\n ...lookupOption(state),\n ...(state.connect ? { connect: state.connect } : {}),\n })\n let browser: BrowserHandle\n try {\n browser = await launchChromium(\n browserType,\n {\n headless: true,\n args: [...resolveLaunchArgs(), ...RESOLVER_LOCKDOWN_ARGS],\n proxy: { server: proxy.url, bypass: '<-loopback>' },\n },\n { onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },\n )\n } catch (error) {\n await proxy.close()\n throw error\n }\n state.egressProxy = proxy\n state.browser = browser\n return browser\n}\n\n/**\n * Single-flight: two leases opening at once share one launch, so neither a\n * browser nor its egress proxy is orphaned outside `teardown`.\n */\nasync function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {\n if (state.browser) return state.browser\n if (!state.launching) {\n state.launching = launchBrowser(state).finally(() => {\n state.launching = null\n })\n }\n return state.launching\n}\n\n/**\n * One BrowserContext per lease. Contexts are Playwright's real isolation boundary\n * (cookies, storage, cache) and cost ~10ms, so two concurrent searches never share\n * a cookie jar the way a single shared page would.\n */\nasync function openLease(state: SidecarState, leaseId: string, userAgent: string): Promise<Lease> {\n await reapIdleLeases(state)\n const existing = state.leases.get(leaseId)\n if (existing) {\n existing.touchedAt = state.now()\n return existing\n }\n const browser = await ensureBrowser(state)\n const context = await browser.newContext({ userAgent, locale: 'en-US', serviceWorkers: 'block' })\n await installNavigationGuard(context, { warn: state.warn, ...lookupOption(state) })\n const page = (await context.newPage()) as unknown as PageHandle\n const lease: Lease = { context, page, touchedAt: state.now() }\n state.leases.set(leaseId, lease)\n return lease\n}\n\n/** Closes leases nobody released. Swept on open, so an idle sidecar stays idle. */\nexport async function reapIdleLeases(state: SidecarState): Promise<void> {\n const cutoff = state.now() - LEASE_IDLE_TIMEOUT_MS\n for (const [leaseId, lease] of [...state.leases]) {\n if (lease.touchedAt > cutoff) continue\n state.warn(`reaping lease ${leaseId} idle for over ${LEASE_IDLE_TIMEOUT_MS}ms`)\n await closeLease(state, leaseId)\n }\n}\n\nasync function closeLease(state: SidecarState, leaseId: string): Promise<void> {\n const lease = state.leases.get(leaseId)\n if (!lease) return\n state.leases.delete(leaseId)\n try {\n await lease.context.close()\n } catch {\n // A context that already died takes nothing with it.\n }\n}\n\nexport async function teardown(state: SidecarState): Promise<void> {\n if (state.launching) await state.launching.catch(() => null)\n for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)\n const browser = state.browser\n const proxy = state.egressProxy\n state.browser = null\n state.egressProxy = null\n if (browser) {\n try {\n await browser.close()\n } catch {\n // Best effort; the parent escalates to SIGKILL if we hang.\n }\n }\n if (proxy) await proxy.close()\n}\n\nfunction stringParam(request: SidecarRequest, key: string): string {\n const value = request.params?.[key]\n if (typeof value !== 'string' || value.length === 0) {\n throw new SidecarError(`missing \"${key}\" parameter`, 'runtime')\n }\n return value\n}\n\nasync function dispatchInner(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {\n switch (request.method) {\n case 'ping':\n return { id: request.id, ok: true, result: { ready: true } }\n\n case 'render': {\n const url = stringParam(request, 'url')\n const leaseId = stringParam(request, 'leaseId')\n // Re-checked here, in a distinct process from the caller, and BEFORE\n // Playwright is imported \u2014 a blocked URL never launches a browser.\n try {\n await assertPublicUrl(url, 'browser render', { failClosed: true, ...lookupOption(state) })\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },\n }\n }\n\n const userAgent = typeof request.params?.userAgent === 'string' ? request.params.userAgent : DEFAULT_USER_AGENT\n const timeout = Math.min(\n typeof request.params?.timeoutMs === 'number' ? request.params.timeoutMs : DEFAULT_GOTO_TIMEOUT_MS,\n MAX_GOTO_TIMEOUT_MS,\n )\n const waitUntil = typeof request.params?.waitUntil === 'string' ? request.params.waitUntil : 'domcontentloaded'\n\n const lease = await openLease(state, leaseId, userAgent)\n try {\n await lease.page.goto(url, { waitUntil, timeout })\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },\n }\n }\n const finalUrl = lease.page.url()\n try {\n await assertPublicUrl(finalUrl, 'browser final URL', { failClosed: true, ...lookupOption(state) })\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },\n }\n }\n const html = await lease.page.content()\n return { id: request.id, ok: true, result: { url: finalUrl, html } }\n }\n\n case 'release':\n await closeLease(state, stringParam(request, 'leaseId'))\n return { id: request.id, ok: true }\n\n case 'close':\n await teardown(state)\n return { id: request.id, ok: true }\n\n default:\n return {\n id: request.id,\n ok: false,\n error: { message: `unknown method: ${request.method}`, kind: 'runtime' },\n }\n }\n}\n\nexport async function dispatch(state: SidecarState, request: SidecarRequest): Promise<SidecarReply> {\n try {\n return await dispatchInner(state, request)\n } catch (error) {\n return {\n id: request.id,\n ok: false,\n error: {\n message: error instanceof Error ? error.message : String(error),\n kind: error instanceof SidecarError ? error.kind : 'unknown',\n },\n }\n }\n}\n"],
5
+ "mappings": "AAAA,SAAS,uBAAsC;AAC/C,SAAS,wBAA8D;AACvE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAMK;AACP,SAAS,oBAA4D;AAW9D,MAAM,wBAAwB,IAAI;AAsBzC,MAAM,0BAA0B;AAEzB,MAAM,0BAA0B;AACvC,MAAM,sBAAsB;AAErB,SAAS,YAAY,YAAmC,CAAC,GAAiB;AAC/E,SAAO;AAAA,IACL,SAAS;AAAA,IACT,QAAQ,oBAAI,IAAI;AAAA,IAChB,oBAAoB;AAAA,IACpB,MAAM,CAAC,YAAY,QAAQ,OAAO,MAAM,4BAA4B,OAAO;AAAA,CAAI;AAAA,IAC/E,KAAK,MAAM,KAAK,IAAI;AAAA,IACpB,GAAG;AAAA,EACL;AACF;AAEA,SAAS,aAAa,OAA4C;AAChE,SAAO,MAAM,SAAS,EAAE,QAAQ,MAAM,OAAO,IAAI,CAAC;AACpD;AAEA,eAAe,cAAc,OAA6C;AACxE,QAAM,cAAc,OAAO,MAAM,mBAAmB,gBAAgB;AACpE,MAAI,kBAAkB,GAAG;AAIvB,UAAM;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AACA,MAAI,QAAQ,IAAI,uBAAuB,MAAM,QAAW;AACtD,UAAM,KAAK,2CAA2C,uBAAuB,aAAa;AAAA,EAC5F;AACA,QAAM,QAAQ,MAAM,iBAAiB;AAAA,IACnC,GAAG,aAAa,KAAK;AAAA,IACrB,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC;AAAA,EACpD,CAAC;AACD,MAAI;AACJ,MAAI;AACF,cAAU,MAAM;AAAA,MACd;AAAA,MACA;AAAA,QACE,UAAU;AAAA,QACV,MAAM,CAAC,GAAG,kBAAkB,GAAG,GAAG,sBAAsB;AAAA,QACxD,OAAO,EAAE,QAAQ,MAAM,KAAK,QAAQ,cAAc;AAAA,MACpD;AAAA,MACA,EAAE,YAAY,MAAM,MAAM,GAAI,MAAM,gBAAgB,EAAE,SAAS,MAAM,cAAc,IAAI,CAAC,EAAG;AAAA,IAC7F;AAAA,EACF,SAAS,OAAO;AACd,UAAM,MAAM,MAAM;AAClB,UAAM;AAAA,EACR;AACA,QAAM,cAAc;AACpB,QAAM,UAAU;AAChB,SAAO;AACT;AAMA,eAAe,cAAc,OAA6C;AACxE,MAAI,MAAM,QAAS,QAAO,MAAM;AAChC,MAAI,CAAC,MAAM,WAAW;AACpB,UAAM,YAAY,cAAc,KAAK,EAAE,QAAQ,MAAM;AACnD,YAAM,YAAY;AAAA,IACpB,CAAC;AAAA,EACH;AACA,SAAO,MAAM;AACf;AAOA,eAAe,UAAU,OAAqB,SAAiB,WAAmC;AAChG,QAAM,eAAe,KAAK;AAC1B,QAAM,WAAW,MAAM,OAAO,IAAI,OAAO;AACzC,MAAI,UAAU;AACZ,aAAS,YAAY,MAAM,IAAI;AAC/B,WAAO;AAAA,EACT;AACA,QAAM,UAAU,MAAM,cAAc,KAAK;AACzC,QAAM,UAAU,MAAM,QAAQ,WAAW,EAAE,WAAW,QAAQ,SAAS,gBAAgB,QAAQ,CAAC;AAChG,QAAM,uBAAuB,SAAS,EAAE,MAAM,MAAM,MAAM,GAAG,aAAa,KAAK,EAAE,CAAC;AAClF,QAAM,OAAQ,MAAM,QAAQ,QAAQ;AACpC,QAAM,QAAe,EAAE,SAAS,MAAM,WAAW,MAAM,IAAI,EAAE;AAC7D,QAAM,OAAO,IAAI,SAAS,KAAK;AAC/B,SAAO;AACT;AAGA,eAAsB,eAAe,OAAoC;AACvE,QAAM,SAAS,MAAM,IAAI,IAAI;AAC7B,aAAW,CAAC,SAAS,KAAK,KAAK,CAAC,GAAG,MAAM,MAAM,GAAG;AAChD,QAAI,MAAM,YAAY,OAAQ;AAC9B,UAAM,KAAK,iBAAiB,OAAO,kBAAkB,qBAAqB,IAAI;AAC9E,UAAM,WAAW,OAAO,OAAO;AAAA,EACjC;AACF;AAEA,eAAe,WAAW,OAAqB,SAAgC;AAC7E,QAAM,QAAQ,MAAM,OAAO,IAAI,OAAO;AACtC,MAAI,CAAC,MAAO;AACZ,QAAM,OAAO,OAAO,OAAO;AAC3B,MAAI;AACF,UAAM,MAAM,QAAQ,MAAM;AAAA,EAC5B,QAAQ;AAAA,EAER;AACF;AAEA,eAAsB,SAAS,OAAoC;AACjE,MAAI,MAAM,UAAW,OAAM,MAAM,UAAU,MAAM,MAAM,IAAI;AAC3D,aAAW,WAAW,CAAC,GAAG,MAAM,OAAO,KAAK,CAAC,EAAG,OAAM,WAAW,OAAO,OAAO;AAC/E,QAAM,UAAU,MAAM;AACtB,QAAM,QAAQ,MAAM;AACpB,QAAM,UAAU;AAChB,QAAM,cAAc;AACpB,MAAI,SAAS;AACX,QAAI;AACF,YAAM,QAAQ,MAAM;AAAA,IACtB,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI,MAAO,OAAM,MAAM,MAAM;AAC/B;AAEA,SAAS,YAAY,SAAyB,KAAqB;AACjE,QAAM,QAAQ,QAAQ,SAAS,GAAG;AAClC,MAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GAAG;AACnD,UAAM,IAAI,aAAa,YAAY,GAAG,eAAe,SAAS;AAAA,EAChE;AACA,SAAO;AACT;AAEA,eAAe,cAAc,OAAqB,SAAgD;AAChG,UAAQ,QAAQ,QAAQ;AAAA,IACtB,KAAK;AACH,aAAO,EAAE,IAAI,QAAQ,IAAI,IAAI,MAAM,QAAQ,EAAE,OAAO,KAAK,EAAE;AAAA,IAE7D,KAAK,UAAU;AACb,YAAM,MAAM,YAAY,SAAS,KAAK;AACtC,YAAM,UAAU,YAAY,SAAS,SAAS;AAG9C,UAAI;AACF,cAAM,gBAAgB,KAAK,kBAAkB,EAAE,YAAY,MAAM,GAAG,aAAa,KAAK,EAAE,CAAC;AAAA,MAC3F,SAAS,OAAO;AACd,eAAO;AAAA,UACL,IAAI,QAAQ;AAAA,UACZ,IAAI;AAAA,UACJ,OAAO,EAAE,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAAG,MAAM,aAAa;AAAA,QAC/F;AAAA,MACF;AAEA,YAAM,YAAY,OAAO,QAAQ,QAAQ,cAAc,WAAW,QAAQ,OAAO,YAAY;AAC7F,YAAM,UAAU,KAAK;AAAA,QACnB,OAAO,QAAQ,QAAQ,cAAc,WAAW,QAAQ,OAAO,YAAY;AAAA,QAC3E;AAAA,MACF;AACA,YAAM,YAAY,OAAO,QAAQ,QAAQ,cAAc,WAAW,QAAQ,OAAO,YAAY;AAE7F,YAAM,QAAQ,MAAM,UAAU,OAAO,SAAS,SAAS;AACvD,UAAI;AACF,cAAM,MAAM,KAAK,KAAK,KAAK,EAAE,WAAW,QAAQ,CAAC;AAAA,MACnD,SAAS,OAAO;AACd,eAAO;AAAA,UACL,IAAI,QAAQ;AAAA,UACZ,IAAI;AAAA,UACJ,OAAO,EAAE,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAAG,MAAM,aAAa;AAAA,QAC/F;AAAA,MACF;AACA,YAAM,WAAW,MAAM,KAAK,IAAI;AAChC,UAAI;AACF,cAAM,gBAAgB,UAAU,qBAAqB,EAAE,YAAY,MAAM,GAAG,aAAa,KAAK,EAAE,CAAC;AAAA,MACnG,SAAS,OAAO;AACd,eAAO;AAAA,UACL,IAAI,QAAQ;AAAA,UACZ,IAAI;AAAA,UACJ,OAAO,EAAE,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAAG,MAAM,aAAa;AAAA,QAC/F;AAAA,MACF;AACA,YAAM,OAAO,MAAM,MAAM,KAAK,QAAQ;AACtC,aAAO,EAAE,IAAI,QAAQ,IAAI,IAAI,MAAM,QAAQ,EAAE,KAAK,UAAU,KAAK,EAAE;AAAA,IACrE;AAAA,IAEA,KAAK;AACH,YAAM,WAAW,OAAO,YAAY,SAAS,SAAS,CAAC;AACvD,aAAO,EAAE,IAAI,QAAQ,IAAI,IAAI,KAAK;AAAA,IAEpC,KAAK;AACH,YAAM,SAAS,KAAK;AACpB,aAAO,EAAE,IAAI,QAAQ,IAAI,IAAI,KAAK;AAAA,IAEpC;AACE,aAAO;AAAA,QACL,IAAI,QAAQ;AAAA,QACZ,IAAI;AAAA,QACJ,OAAO,EAAE,SAAS,mBAAmB,QAAQ,MAAM,IAAI,MAAM,UAAU;AAAA,MACzE;AAAA,EACJ;AACF;AAEA,eAAsB,SAAS,OAAqB,SAAgD;AAClG,MAAI;AACF,WAAO,MAAM,cAAc,OAAO,OAAO;AAAA,EAC3C,SAAS,OAAO;AACd,WAAO;AAAA,MACL,IAAI,QAAQ;AAAA,MACZ,IAAI;AAAA,MACJ,OAAO;AAAA,QACL,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,QAC9D,MAAM,iBAAiB,eAAe,MAAM,OAAO;AAAA,MACrD;AAAA,IACF;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,291 @@
1
+ import {
2
+ createServer,
3
+ request as sendHttp
4
+ } from "node:http";
5
+ import { connect as netConnect } from "node:net";
6
+ import { assertPublicUrl, createPinnedLookup } from "@open-mercato/web-research";
7
+ const EGRESS_VET_TIMEOUT_MS = 1e4;
8
+ const EGRESS_DIAL_TIMEOUT_MS = 1e4;
9
+ const EGRESS_IDLE_TIMEOUT_MS = 12e4;
10
+ const AUTHORITY_PATTERN = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+):(\d{1,5})$/;
11
+ const HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
12
+ "connection",
13
+ "keep-alive",
14
+ "proxy-authenticate",
15
+ "proxy-authorization",
16
+ "proxy-connection",
17
+ "te",
18
+ "trailer",
19
+ "transfer-encoding",
20
+ "upgrade",
21
+ "host"
22
+ ]);
23
+ const STATUS_TEXT = {
24
+ 400: "Bad Request",
25
+ 403: "Forbidden",
26
+ 502: "Bad Gateway",
27
+ 504: "Gateway Timeout"
28
+ };
29
+ const pinnedConnect = ({ host, port, addresses }) => netConnect({ host, port, lookup: createPinnedLookup(addresses), autoSelectFamily: true });
30
+ function parseConnectAuthority(raw) {
31
+ const match = AUTHORITY_PATTERN.exec(raw);
32
+ if (!match) return null;
33
+ const port = Number(match[2]);
34
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
35
+ return { host: match[1], port };
36
+ }
37
+ function parseAbsoluteForm(raw) {
38
+ if (!/^http:\/\//i.test(raw)) return null;
39
+ try {
40
+ const url = new URL(raw);
41
+ if (url.protocol !== "http:" || url.hostname === "") return null;
42
+ return url;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ function forwardableHeaders(headers) {
48
+ const listed = new Set(
49
+ String(headers.connection ?? "").split(",").map((token) => token.trim().toLowerCase()).filter((token) => token.length > 0)
50
+ );
51
+ const result = {};
52
+ for (const [name, value] of Object.entries(headers)) {
53
+ if (value === void 0) continue;
54
+ const key = name.toLowerCase();
55
+ if (HOP_BY_HOP_HEADERS.has(key) || key.startsWith("proxy-") || listed.has(key)) continue;
56
+ result[key] = value;
57
+ }
58
+ return result;
59
+ }
60
+ function dialHost(vetted) {
61
+ return vetted.url.hostname.replace(/^\[|\]$/g, "");
62
+ }
63
+ function refuseSocket(socket, status) {
64
+ if (socket.destroyed || !socket.writable) {
65
+ socket.destroy();
66
+ return;
67
+ }
68
+ socket.end(
69
+ `HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? "Error"}\r
70
+ Connection: close\r
71
+ Content-Length: 0\r
72
+ \r
73
+ `,
74
+ () => socket.destroy()
75
+ );
76
+ }
77
+ function refuseResponse(res, status, reason) {
78
+ if (res.headersSent || res.destroyed) {
79
+ res.destroy();
80
+ return;
81
+ }
82
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", connection: "close" });
83
+ res.end(`om-web-research egress proxy: ${reason}
84
+ `);
85
+ }
86
+ async function startEgressProxy(options = {}) {
87
+ const connect = options.connect ?? pinnedConnect;
88
+ const vetTimeoutMs = options.vetTimeoutMs ?? EGRESS_VET_TIMEOUT_MS;
89
+ const dialTimeoutMs = options.dialTimeoutMs ?? EGRESS_DIAL_TIMEOUT_MS;
90
+ const idleTimeoutMs = options.idleTimeoutMs ?? EGRESS_IDLE_TIMEOUT_MS;
91
+ const sockets = /* @__PURE__ */ new Set();
92
+ let closed = false;
93
+ const track = (socket) => {
94
+ sockets.add(socket);
95
+ socket.once("close", () => sockets.delete(socket));
96
+ };
97
+ const vet = (rawUrl) => new Promise((resolve) => {
98
+ const timer = setTimeout(() => resolve("timeout"), vetTimeoutMs);
99
+ assertPublicUrl(rawUrl, "browser egress", {
100
+ failClosed: true,
101
+ ...options.lookup ? { lookup: options.lookup } : {}
102
+ }).then(
103
+ (vetted) => {
104
+ clearTimeout(timer);
105
+ resolve(vetted);
106
+ },
107
+ () => {
108
+ clearTimeout(timer);
109
+ resolve("blocked");
110
+ }
111
+ );
112
+ });
113
+ const dial = (target) => new Promise((resolve, reject) => {
114
+ let socket;
115
+ try {
116
+ socket = connect(target);
117
+ } catch (error) {
118
+ reject(error);
119
+ return;
120
+ }
121
+ track(socket);
122
+ socket.on("error", () => socket.destroy());
123
+ const timer = setTimeout(() => {
124
+ socket.destroy();
125
+ reject(new Error(`dial to ${target.host}:${target.port} timed out`));
126
+ }, dialTimeoutMs);
127
+ socket.once("close", () => {
128
+ clearTimeout(timer);
129
+ reject(new Error(`dial to ${target.host}:${target.port} failed`));
130
+ });
131
+ socket.once("connect", () => {
132
+ clearTimeout(timer);
133
+ if (closed) {
134
+ socket.destroy();
135
+ return;
136
+ }
137
+ resolve(socket);
138
+ });
139
+ });
140
+ const handleConnect = async (req, client, head) => {
141
+ track(client);
142
+ client.on("error", () => client.destroy());
143
+ let clientLeft = false;
144
+ const markLeft = () => {
145
+ clientLeft = true;
146
+ };
147
+ client.once("end", markLeft);
148
+ client.once("close", markLeft);
149
+ const authority = parseConnectAuthority(req.url ?? "");
150
+ if (!authority) return refuseSocket(client, 400);
151
+ const vetted = await vet(`http://${authority.host}:${authority.port}/`);
152
+ if (vetted === "timeout") return refuseSocket(client, 504);
153
+ if (vetted === "blocked") return refuseSocket(client, 403);
154
+ if (closed || clientLeft) {
155
+ client.destroy();
156
+ return;
157
+ }
158
+ let upstream;
159
+ try {
160
+ upstream = await dial({ host: dialHost(vetted), port: authority.port, addresses: vetted.addresses });
161
+ } catch {
162
+ return refuseSocket(client, 502);
163
+ }
164
+ if (clientLeft) {
165
+ upstream.destroy();
166
+ client.destroy();
167
+ return;
168
+ }
169
+ upstream.setTimeout(idleTimeoutMs, () => {
170
+ upstream.destroy();
171
+ client.destroy();
172
+ });
173
+ upstream.once("close", () => client.destroy());
174
+ client.once("close", () => upstream.destroy());
175
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
176
+ if (head.length > 0) upstream.write(head);
177
+ client.pipe(upstream);
178
+ upstream.pipe(client);
179
+ };
180
+ const handleRequest = async (req, res) => {
181
+ req.on("error", () => res.destroy());
182
+ res.on("error", () => res.destroy());
183
+ const target = parseAbsoluteForm(req.url ?? "");
184
+ if (!target) return refuseResponse(res, 400, "only absolute-form http:// requests are proxied");
185
+ if (req.headers.upgrade !== void 0) return refuseResponse(res, 400, "upgrade requests must use CONNECT");
186
+ const vetted = await vet(target.href);
187
+ if (vetted === "timeout") return refuseResponse(res, 504, "destination could not be vetted in time");
188
+ if (vetted === "blocked") return refuseResponse(res, 403, "destination is not a public address");
189
+ if (closed || res.destroyed) return;
190
+ let upstreamSocket;
191
+ try {
192
+ upstreamSocket = await dial({
193
+ host: dialHost(vetted),
194
+ port: Number(vetted.url.port || 80),
195
+ addresses: vetted.addresses
196
+ });
197
+ } catch {
198
+ return refuseResponse(res, 502, "destination unreachable");
199
+ }
200
+ if (res.destroyed) {
201
+ upstreamSocket.destroy();
202
+ return;
203
+ }
204
+ upstreamSocket.setTimeout(idleTimeoutMs, () => upstreamSocket.destroy());
205
+ const headers = forwardableHeaders(req.headers);
206
+ headers.host = vetted.url.host;
207
+ headers.connection = "close";
208
+ const forwardResponse = (upstreamResponse) => {
209
+ upstreamResponse.on("error", () => res.destroy());
210
+ const status = upstreamResponse.statusCode ?? 0;
211
+ if (status < 200 || status > 999) {
212
+ upstreamResponse.destroy();
213
+ refuseResponse(res, 502, "upstream sent an invalid status");
214
+ return;
215
+ }
216
+ try {
217
+ res.writeHead(status, forwardableHeaders(upstreamResponse.headers));
218
+ } catch {
219
+ upstreamResponse.destroy();
220
+ res.destroy();
221
+ return;
222
+ }
223
+ upstreamResponse.pipe(res);
224
+ };
225
+ let upstreamRequest;
226
+ try {
227
+ upstreamRequest = sendHttp(
228
+ {
229
+ method: req.method,
230
+ path: `${vetted.url.pathname}${vetted.url.search}`,
231
+ headers,
232
+ setHost: false,
233
+ createConnection: () => upstreamSocket
234
+ },
235
+ forwardResponse
236
+ );
237
+ } catch {
238
+ upstreamSocket.destroy();
239
+ return refuseResponse(res, 502, "upstream request could not be built");
240
+ }
241
+ upstreamRequest.on("error", () => refuseResponse(res, 502, "upstream failed"));
242
+ upstreamRequest.on("upgrade", (_response, socket) => {
243
+ socket.destroy();
244
+ refuseResponse(res, 502, "upstream attempted a protocol upgrade");
245
+ });
246
+ res.once("close", () => upstreamRequest.destroy());
247
+ req.pipe(upstreamRequest);
248
+ };
249
+ const server = createServer();
250
+ server.timeout = idleTimeoutMs;
251
+ server.on("connection", (socket) => track(socket));
252
+ server.on("request", (req, res) => {
253
+ void handleRequest(req, res).catch(() => refuseResponse(res, 502, "proxy failure"));
254
+ });
255
+ server.on("connect", (req, socket, head) => {
256
+ void handleConnect(req, socket, head).catch(() => socket.destroy());
257
+ });
258
+ server.on("upgrade", (_req, socket) => {
259
+ socket.on("error", () => socket.destroy());
260
+ refuseSocket(socket, 400);
261
+ });
262
+ server.on("clientError", (_error, socket) => {
263
+ refuseSocket(socket, 400);
264
+ });
265
+ await new Promise((resolve, reject) => {
266
+ server.once("error", reject);
267
+ server.listen(0, "127.0.0.1", () => {
268
+ server.removeListener("error", reject);
269
+ resolve();
270
+ });
271
+ });
272
+ server.on("error", () => void 0);
273
+ const address = server.address();
274
+ if (!address || typeof address === "string") {
275
+ server.close();
276
+ throw new Error("[internal] egress proxy did not bind a TCP port");
277
+ }
278
+ return {
279
+ url: `http://127.0.0.1:${address.port}`,
280
+ close: () => new Promise((resolve) => {
281
+ closed = true;
282
+ server.close(() => resolve());
283
+ for (const socket of sockets) socket.destroy();
284
+ })
285
+ };
286
+ }
287
+ export {
288
+ pinnedConnect,
289
+ startEgressProxy
290
+ };
291
+ //# sourceMappingURL=egressProxy.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/egressProxy.ts"],
4
+ "sourcesContent": ["import {\n createServer,\n request as sendHttp,\n type ClientRequest,\n type IncomingHttpHeaders,\n type IncomingMessage,\n type OutgoingHttpHeaders,\n type ServerResponse,\n} from 'node:http'\nimport { connect as netConnect, type Socket } from 'node:net'\nimport type { Duplex } from 'node:stream'\nimport { assertPublicUrl, createPinnedLookup, type LookupFn, type VettedUrl } from '@open-mercato/web-research'\n\nexport type EgressDialTarget = {\n readonly host: string\n readonly port: number\n /** The addresses `assertPublicUrl` vetted for this connection; the only ones a dial may use. */\n readonly addresses: readonly string[]\n}\n\nexport type EgressConnect = (target: EgressDialTarget) => Socket\n\nexport type EgressProxyOptions = {\n readonly lookup?: LookupFn\n readonly connect?: EgressConnect\n readonly vetTimeoutMs?: number\n readonly dialTimeoutMs?: number\n readonly idleTimeoutMs?: number\n}\n\nexport type EgressProxy = {\n readonly url: string\n close(): Promise<void>\n}\n\nconst EGRESS_VET_TIMEOUT_MS = 10_000\nconst EGRESS_DIAL_TIMEOUT_MS = 10_000\nconst EGRESS_IDLE_TIMEOUT_MS = 120_000\n\nconst AUTHORITY_PATTERN = /^(\\[[0-9A-Fa-f:.]+\\]|[A-Za-z0-9._-]+):(\\d{1,5})$/\n\nconst HOP_BY_HOP_HEADERS = new Set([\n 'connection',\n 'keep-alive',\n 'proxy-authenticate',\n 'proxy-authorization',\n 'proxy-connection',\n 'te',\n 'trailer',\n 'transfer-encoding',\n 'upgrade',\n 'host',\n])\n\nconst STATUS_TEXT: Record<number, string> = {\n 400: 'Bad Request',\n 403: 'Forbidden',\n 502: 'Bad Gateway',\n 504: 'Gateway Timeout',\n}\n\n/** Dials only the vetted addresses: the hostname is never handed to DNS. */\nexport const pinnedConnect: EgressConnect = ({ host, port, addresses }) =>\n netConnect({ host, port, lookup: createPinnedLookup(addresses), autoSelectFamily: true })\n\nfunction parseConnectAuthority(raw: string): { host: string; port: number } | null {\n const match = AUTHORITY_PATTERN.exec(raw)\n if (!match) return null\n const port = Number(match[2])\n if (!Number.isInteger(port) || port < 1 || port > 65535) return null\n return { host: match[1], port }\n}\n\nfunction parseAbsoluteForm(raw: string): URL | null {\n if (!/^http:\\/\\//i.test(raw)) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' || url.hostname === '') return null\n return url\n } catch {\n return null\n }\n}\n\nfunction forwardableHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders {\n const listed = new Set(\n String(headers.connection ?? '')\n .split(',')\n .map((token) => token.trim().toLowerCase())\n .filter((token) => token.length > 0),\n )\n const result: OutgoingHttpHeaders = {}\n for (const [name, value] of Object.entries(headers)) {\n if (value === undefined) continue\n const key = name.toLowerCase()\n if (HOP_BY_HOP_HEADERS.has(key) || key.startsWith('proxy-') || listed.has(key)) continue\n result[key] = value\n }\n return result\n}\n\nfunction dialHost(vetted: VettedUrl): string {\n return vetted.url.hostname.replace(/^\\[|\\]$/g, '')\n}\n\nfunction refuseSocket(socket: Duplex, status: number): void {\n if (socket.destroyed || !socket.writable) {\n socket.destroy()\n return\n }\n socket.end(`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Error'}\\r\\nConnection: close\\r\\nContent-Length: 0\\r\\n\\r\\n`, () =>\n socket.destroy(),\n )\n}\n\nfunction refuseResponse(res: ServerResponse, status: number, reason: string): void {\n if (res.headersSent || res.destroyed) {\n res.destroy()\n return\n }\n res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' })\n res.end(`om-web-research egress proxy: ${reason}\\n`)\n}\n\n/**\n * Loopback HTTP proxy that every connection of the sidecar's Chromium goes through.\n *\n * Each CONNECT tunnel and each absolute-form request is vetted with\n * `assertPublicUrl` on its own, then dialled only to the addresses that check\n * returned, so redirect hops, WebSockets, workers and a host whose DNS answer\n * changes between connections are all held to the same rule. Nothing is cached.\n */\nexport async function startEgressProxy(options: EgressProxyOptions = {}): Promise<EgressProxy> {\n const connect = options.connect ?? pinnedConnect\n const vetTimeoutMs = options.vetTimeoutMs ?? EGRESS_VET_TIMEOUT_MS\n const dialTimeoutMs = options.dialTimeoutMs ?? EGRESS_DIAL_TIMEOUT_MS\n const idleTimeoutMs = options.idleTimeoutMs ?? EGRESS_IDLE_TIMEOUT_MS\n const sockets = new Set<Duplex>()\n let closed = false\n\n const track = (socket: Duplex): void => {\n sockets.add(socket)\n socket.once('close', () => sockets.delete(socket))\n }\n\n const vet = (rawUrl: string): Promise<VettedUrl | 'blocked' | 'timeout'> =>\n new Promise((resolve) => {\n const timer = setTimeout(() => resolve('timeout'), vetTimeoutMs)\n assertPublicUrl(rawUrl, 'browser egress', {\n failClosed: true,\n ...(options.lookup ? { lookup: options.lookup } : {}),\n }).then(\n (vetted) => {\n clearTimeout(timer)\n resolve(vetted)\n },\n () => {\n clearTimeout(timer)\n resolve('blocked')\n },\n )\n })\n\n const dial = (target: EgressDialTarget): Promise<Socket> =>\n new Promise((resolve, reject) => {\n let socket: Socket\n try {\n socket = connect(target)\n } catch (error) {\n reject(error)\n return\n }\n track(socket)\n socket.on('error', () => socket.destroy())\n const timer = setTimeout(() => {\n socket.destroy()\n reject(new Error(`dial to ${target.host}:${target.port} timed out`))\n }, dialTimeoutMs)\n socket.once('close', () => {\n clearTimeout(timer)\n reject(new Error(`dial to ${target.host}:${target.port} failed`))\n })\n socket.once('connect', () => {\n clearTimeout(timer)\n if (closed) {\n socket.destroy()\n return\n }\n resolve(socket)\n })\n })\n\n const handleConnect = async (req: IncomingMessage, client: Duplex, head: Buffer): Promise<void> => {\n track(client)\n client.on('error', () => client.destroy())\n let clientLeft = false\n const markLeft = (): void => {\n clientLeft = true\n }\n client.once('end', markLeft)\n client.once('close', markLeft)\n const authority = parseConnectAuthority(req.url ?? '')\n if (!authority) return refuseSocket(client, 400)\n const vetted = await vet(`http://${authority.host}:${authority.port}/`)\n if (vetted === 'timeout') return refuseSocket(client, 504)\n if (vetted === 'blocked') return refuseSocket(client, 403)\n if (closed || clientLeft) {\n client.destroy()\n return\n }\n let upstream: Socket\n try {\n upstream = await dial({ host: dialHost(vetted), port: authority.port, addresses: vetted.addresses })\n } catch {\n return refuseSocket(client, 502)\n }\n if (clientLeft) {\n upstream.destroy()\n client.destroy()\n return\n }\n upstream.setTimeout(idleTimeoutMs, () => {\n upstream.destroy()\n client.destroy()\n })\n upstream.once('close', () => client.destroy())\n client.once('close', () => upstream.destroy())\n client.write('HTTP/1.1 200 Connection Established\\r\\n\\r\\n')\n if (head.length > 0) upstream.write(head)\n client.pipe(upstream)\n upstream.pipe(client)\n }\n\n const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n req.on('error', () => res.destroy())\n res.on('error', () => res.destroy())\n const target = parseAbsoluteForm(req.url ?? '')\n if (!target) return refuseResponse(res, 400, 'only absolute-form http:// requests are proxied')\n if (req.headers.upgrade !== undefined) return refuseResponse(res, 400, 'upgrade requests must use CONNECT')\n const vetted = await vet(target.href)\n if (vetted === 'timeout') return refuseResponse(res, 504, 'destination could not be vetted in time')\n if (vetted === 'blocked') return refuseResponse(res, 403, 'destination is not a public address')\n if (closed || res.destroyed) return\n let upstreamSocket: Socket\n try {\n upstreamSocket = await dial({\n host: dialHost(vetted),\n port: Number(vetted.url.port || 80),\n addresses: vetted.addresses,\n })\n } catch {\n return refuseResponse(res, 502, 'destination unreachable')\n }\n if (res.destroyed) {\n upstreamSocket.destroy()\n return\n }\n upstreamSocket.setTimeout(idleTimeoutMs, () => upstreamSocket.destroy())\n\n const headers = forwardableHeaders(req.headers)\n headers.host = vetted.url.host\n headers.connection = 'close'\n const forwardResponse = (upstreamResponse: IncomingMessage): void => {\n upstreamResponse.on('error', () => res.destroy())\n const status = upstreamResponse.statusCode ?? 0\n if (status < 200 || status > 999) {\n upstreamResponse.destroy()\n refuseResponse(res, 502, 'upstream sent an invalid status')\n return\n }\n try {\n res.writeHead(status, forwardableHeaders(upstreamResponse.headers))\n } catch {\n upstreamResponse.destroy()\n res.destroy()\n return\n }\n upstreamResponse.pipe(res)\n }\n let upstreamRequest: ClientRequest\n try {\n upstreamRequest = sendHttp(\n {\n method: req.method,\n path: `${vetted.url.pathname}${vetted.url.search}`,\n headers,\n setHost: false,\n createConnection: () => upstreamSocket,\n },\n forwardResponse,\n )\n } catch {\n upstreamSocket.destroy()\n return refuseResponse(res, 502, 'upstream request could not be built')\n }\n upstreamRequest.on('error', () => refuseResponse(res, 502, 'upstream failed'))\n upstreamRequest.on('upgrade', (_response, socket) => {\n socket.destroy()\n refuseResponse(res, 502, 'upstream attempted a protocol upgrade')\n })\n res.once('close', () => upstreamRequest.destroy())\n req.pipe(upstreamRequest)\n }\n\n const server = createServer()\n server.timeout = idleTimeoutMs\n server.on('connection', (socket) => track(socket))\n server.on('request', (req, res) => {\n void handleRequest(req, res).catch(() => refuseResponse(res, 502, 'proxy failure'))\n })\n server.on('connect', (req, socket, head) => {\n void handleConnect(req, socket, head).catch(() => socket.destroy())\n })\n server.on('upgrade', (_req, socket) => {\n socket.on('error', () => socket.destroy())\n refuseSocket(socket, 400)\n })\n server.on('clientError', (_error, socket) => {\n refuseSocket(socket, 400)\n })\n\n await new Promise<void>((resolve, reject) => {\n server.once('error', reject)\n server.listen(0, '127.0.0.1', () => {\n server.removeListener('error', reject)\n resolve()\n })\n })\n server.on('error', () => undefined)\n const address = server.address()\n if (!address || typeof address === 'string') {\n server.close()\n throw new Error('[internal] egress proxy did not bind a TCP port')\n }\n\n return {\n url: `http://127.0.0.1:${address.port}`,\n close: () =>\n new Promise<void>((resolve) => {\n closed = true\n server.close(() => resolve())\n for (const socket of sockets) socket.destroy()\n }),\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -75,10 +75,8 @@ async function launchChromium(browserType, options, deps = {}) {
75
75
  const MAX_VERDICT_CACHE = 2048;
76
76
  async function installNavigationGuard(context, options = {}) {
77
77
  if (typeof context.route !== "function") {
78
- options.warn?.("context.route() unavailable \u2014 in-page navigation SSRF guard NOT installed");
79
- return;
78
+ throw new SidecarError("context.route() is unavailable, so the request guard cannot be installed", "init");
80
79
  }
81
- const filterSubresources = options.filterSubresources ?? false;
82
80
  const verdicts = /* @__PURE__ */ new Map();
83
81
  const remember = (host, blocked) => {
84
82
  if (verdicts.size >= MAX_VERDICT_CACHE) {
@@ -90,20 +88,20 @@ async function installNavigationGuard(context, options = {}) {
90
88
  await context.route("**/*", async (route) => {
91
89
  const request = route.request();
92
90
  const isNavigation = request.isNavigationRequest();
93
- if (!isNavigation && !filterSubresources) return route.continue();
94
91
  const url = request.url();
95
92
  let host;
96
93
  try {
97
94
  host = new URL(url).host;
98
95
  } catch {
99
- return isNavigation ? route.abort("blockedbyclient") : route.continue();
96
+ return route.abort("blockedbyclient");
100
97
  }
101
98
  const cached = isNavigation ? void 0 : verdicts.get(host);
102
99
  if (cached === true) return route.abort("blockedbyclient");
103
100
  if (cached === false) return route.continue();
104
101
  try {
105
102
  await assertPublicUrl(url, isNavigation ? "browser navigation" : "browser subresource", {
106
- failClosed: true
103
+ failClosed: true,
104
+ ...options.lookup ? { lookup: options.lookup } : {}
107
105
  });
108
106
  remember(host, false);
109
107
  } catch {
@@ -113,9 +111,12 @@ async function installNavigationGuard(context, options = {}) {
113
111
  return route.continue();
114
112
  });
115
113
  }
114
+ const RESOLVER_LOCKDOWN_ARGS = ["--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1"];
116
115
  const LAUNCH_ARGS = [
117
116
  "--disable-blink-features=AutomationControlled",
118
- "--disable-dev-shm-usage"
117
+ "--disable-dev-shm-usage",
118
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp",
119
+ "--disable-quic"
119
120
  ];
120
121
  const SANDBOX_OPT_OUT_ENV = "OM_WEB_RESEARCH_BROWSER_NO_SANDBOX";
121
122
  function resolveLaunchArgs(env = process.env) {
@@ -132,6 +133,7 @@ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Appl
132
133
  export {
133
134
  DEFAULT_USER_AGENT,
134
135
  LAUNCH_ARGS,
136
+ RESOLVER_LOCKDOWN_ARGS,
135
137
  SANDBOX_OPT_OUT_ENV,
136
138
  importChromium,
137
139
  installNavigationGuard,