@lunora/browser 1.0.0-alpha.2 → 1.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md CHANGED
@@ -103,3 +103,9 @@ Unless required by applicable law or agreed to in writing, software distributed
103
103
  under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
104
  CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
105
  specific language governing permissions and limitations under the License.
106
+
107
+ <!-- DEPENDENCIES -->
108
+ <!-- /DEPENDENCIES -->
109
+
110
+ <!-- TYPE_DEPENDENCIES -->
111
+ <!-- /TYPE_DEPENDENCIES -->
package/README.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  <!-- END_PACKAGE_OG_IMAGE_PLACEHOLDER -->
12
12
 
13
+ > **Experimental** — this package is outside the Lunora 1.0 stability promise: its API may change in any release, without a major version bump.
14
+
13
15
  <br />
14
16
 
15
17
  <div align="center">
package/dist/index.d.mts CHANGED
@@ -1,24 +1,45 @@
1
1
  /**
2
- * Structural projection of the Cloudflare **Browser Rendering** binding
3
- * (`env.BROWSER`). The binding is a `Fetcher` under the hood — `@cloudflare/playwright`
4
- * drives it via `launch(env.BROWSER)`. Declared locally (an empty structural
5
- * marker) so unit tests can pass a plain-object double and the real binding
6
- * satisfies the same shape without importing `@cloudflare/workers-types` into
7
- * the public surface. See https://developers.cloudflare.com/browser-rendering/.
8
- *
9
- * It is intentionally opaque: callers never touch the binding directly, they
10
- * hand it to {@link LunoraBrowserOptions.binding} and the Playwright layer
11
- * consumes it. Typed as a non-empty marker so an arbitrary value (e.g. `{}`)
12
- * doesn't silently type-check where a binding is required.
13
- */
2
+ * Structural projection of the Cloudflare **Browser Rendering** binding
3
+ * (`env.BROWSER`). The binding is a `Fetcher` under the hood — `@cloudflare/playwright`
4
+ * drives it via `launch(env.BROWSER)`. Declared locally (an empty structural
5
+ * marker) so unit tests can pass a plain-object double and the real binding
6
+ * satisfies the same shape without importing `@cloudflare/workers-types` into
7
+ * the public surface. See https://developers.cloudflare.com/browser-rendering/.
8
+ *
9
+ * It is intentionally opaque: callers never touch the binding directly, they
10
+ * hand it to {@link LunoraBrowserOptions.binding} and the Playwright layer
11
+ * consumes it. `fetch` is REQUIRED (the real binding is a `Fetcher`, so it
12
+ * always has one) so the marker actually excludes an arbitrary value like `{}` —
13
+ * a bare object fails to type-check where a binding is required, catching the
14
+ * misuse at the call site instead of deferring to an opaque launch error.
15
+ * @experimental
16
+ */
14
17
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
18
+ readonly fetch: (...args: never[]) => unknown;
16
19
  }
17
20
  /**
18
- * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
19
- * Declared structurally so a test can inject a plain stub instead of a real
20
- * headless page (which needs workerd + the Browser Rendering binding).
21
- */
21
+ * Minimal projection of a Playwright `Route` (the argument the `page.route`
22
+ * handler receives). Only the members the SSRF redirect guard drives are
23
+ * declared: inspect the intercepted request's URL / navigation-ness, then either
24
+ * let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
25
+ */
26
+ interface RouteLike {
27
+ /** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
28
+ abort: (errorCode?: string) => Promise<void>;
29
+ /** Allow the intercepted request to proceed. */
30
+ continue: () => Promise<void>;
31
+ /** The intercepted request: its URL and (when available) whether it is a top-level navigation. */
32
+ request: () => {
33
+ isNavigationRequest?: () => boolean;
34
+ url: () => string;
35
+ };
36
+ }
37
+ /**
38
+ * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
39
+ * Declared structurally so a test can inject a plain stub instead of a real
40
+ * headless page (which needs workerd + the Browser Rendering binding).
41
+ * @experimental
42
+ */
22
43
  interface PageLike {
23
44
  /** Return the page's serialized HTML after the navigation settles. */
24
45
  content: () => Promise<string>;
@@ -31,6 +52,13 @@ interface PageLike {
31
52
  }) => Promise<unknown>;
32
53
  /** Render the page to a PDF buffer. */
33
54
  pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
55
+ /**
56
+ * Register a request interceptor (Playwright `page.route`). Optional: a fake
57
+ * or older page double without it still works — the SSRF redirect guard only
58
+ * activates when interception is available, and the initial-URL guard applies
59
+ * regardless. `pattern` follows Playwright's glob/URL matcher.
60
+ */
61
+ route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
34
62
  /** Render the page to a PNG/JPEG buffer. */
35
63
  screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
64
  /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
@@ -40,132 +68,253 @@ interface PageLike {
40
68
  }) => Promise<void>;
41
69
  }
42
70
  /**
43
- * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
44
- * declared structurally for the same test-double reason as {@link PageLike}.
45
- */
71
+ * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
72
+ * declared structurally for the same test-double reason as {@link PageLike}.
73
+ * @experimental
74
+ */
46
75
  interface BrowserContextLike {
47
76
  newPage: () => Promise<PageLike>;
48
77
  }
49
78
  /**
50
- * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
51
- * Only `newContext`/`close` are used; declared structurally for the same
52
- * test-double reason as {@link PageLike}.
53
- */
79
+ * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
80
+ * Only `newContext`/`close` are used; declared structurally for the same
81
+ * test-double reason as {@link PageLike}.
82
+ * @experimental
83
+ */
54
84
  interface BrowserLike {
55
85
  close: () => Promise<void>;
56
86
  newContext: () => Promise<BrowserContextLike>;
87
+ /**
88
+ * The Browser Rendering session this browser is attached to, when the
89
+ * runtime exposes it.
90
+ *
91
+ * Optional because this is a structural projection, not a re-declaration of
92
+ * the upstream Playwright type — but without it there is no way to learn
93
+ * the id of a session you just held open with `launch(fn, { keepAlive })`,
94
+ * which makes {@link Browser.connect} unreachable except by guessing from
95
+ * {@link Browser.sessions}.
96
+ */
97
+ sessionId?: () => string | undefined;
57
98
  }
58
99
  /**
59
- * Structural projection of `@cloudflare/playwright`'s `launch` export
60
- * (`import { launch } from "@cloudflare/playwright"`). Injected via
61
- * {@link LunoraBrowserOptions.launch} so the factory never imports
62
- * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
63
- * dep out of the bundle for apps that never screenshot, and lets tests pass a
64
- * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
65
- */
100
+ * Structural projection of `@cloudflare/playwright`'s `launch` export
101
+ * (`import { launch } from "@cloudflare/playwright"`). Injected via
102
+ * {@link LunoraBrowserOptions.launch} so the factory never imports
103
+ * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
104
+ * dep out of the bundle for apps that never screenshot, and lets tests pass a
105
+ * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
106
+ * @experimental
107
+ */
66
108
  type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
67
- /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
109
+ /**
110
+ * One live Browser Rendering session, as `@cloudflare/playwright`'s `sessions()`
111
+ * reports it. `connectionId` is set while another worker holds the session — you
112
+ * can only {@link Browser.connect} to a free one.
113
+ * @experimental
114
+ */
115
+ interface BrowserSession {
116
+ connectionId?: string;
117
+ sessionId: string;
118
+ startTime?: number;
119
+ }
120
+ /**
121
+ * Structural projection of `@cloudflare/playwright`'s `connect` export —
122
+ * re-attaches to an existing session rather than starting a new browser.
123
+ * Injected like {@link BrowserLaunchLike} so the peer dep stays optional.
124
+ * @experimental
125
+ */
126
+ type BrowserConnectLike = (binding: BrowserBindingLike, sessionId: string) => Promise<BrowserLike>;
127
+ /**
128
+ * Structural projection of `@cloudflare/playwright`'s `sessions` export — lists
129
+ * the account's live Browser Rendering sessions for this binding.
130
+ * @experimental
131
+ */
132
+ type BrowserSessionsLike = (binding: BrowserBindingLike) => Promise<ReadonlyArray<BrowserSession>>;
133
+ /**
134
+ * Options shared by the page-driving helpers ({@link Browser.screenshot} etc.).
135
+ * @experimental
136
+ */
68
137
  interface NavigateOptions {
69
138
  /**
70
- * Hard timeout in milliseconds for the navigation + operation. Clamped to a
71
- * sane ceiling so a hung/hostile page can't pin the worker. Default 30000.
72
- */
139
+ * Hard timeout in milliseconds for the navigation + operation. Clamped to a
140
+ * sane ceiling so a hung/hostile page can't pin the worker. Default 30000.
141
+ */
73
142
  timeoutMs?: number;
74
143
  /**
75
- * Playwright navigation wait condition. Playwright's set differs from
76
- * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
77
- * Default `load`.
78
- */
144
+ * Playwright navigation wait condition. Playwright's set differs from
145
+ * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
146
+ * Default `load`.
147
+ */
79
148
  waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
80
149
  }
81
- /** Options for {@link Browser.screenshot}. */
150
+ /**
151
+ * Options for {@link Browser.screenshot}.
152
+ * @experimental
153
+ */
82
154
  interface ScreenshotOptions extends NavigateOptions {
83
155
  /** Capture the full scrollable page rather than just the viewport. */
84
156
  fullPage?: boolean;
85
157
  /** Image encoding. Default `png`. */
86
158
  type?: "jpeg" | "png";
87
159
  /**
88
- * Viewport size. Each dimension is hard-capped (see the factory's
89
- * `MAX_VIEWPORT_*`) so a caller can't request a multi-million-pixel render.
90
- */
160
+ * Viewport size. Each dimension is hard-capped (see the factory's
161
+ * `MAX_VIEWPORT_*`) so a caller can't request a multi-million-pixel render.
162
+ */
91
163
  viewport?: {
92
164
  height: number;
93
165
  width: number;
94
166
  };
95
167
  }
96
- /** Options for {@link Browser.pdf}. */
168
+ /**
169
+ * Options for {@link Browser.pdf}.
170
+ * @experimental
171
+ */
97
172
  interface PdfOptions extends NavigateOptions {
98
173
  /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
99
174
  format?: string;
100
175
  /** Print background graphics. Default `false`. */
101
176
  printBackground?: boolean;
102
177
  /**
103
- * Viewport used while laying out the page before printing. Hard-capped like
104
- * {@link ScreenshotOptions.viewport}.
105
- */
178
+ * Viewport used while laying out the page before printing. Hard-capped like
179
+ * {@link ScreenshotOptions.viewport}.
180
+ */
106
181
  viewport?: {
107
182
  height: number;
108
183
  width: number;
109
184
  };
110
185
  }
186
+ /**
187
+ * `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
188
+ * @experimental
189
+ */
111
190
  interface LunoraBrowserOptions {
112
191
  /**
113
- * Opt out of the SSRF guard that, by default, refuses to navigate to a
114
- * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
115
- * `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
116
- * link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
117
- * `false` (the default) unless every caller-supplied URL is trusted e.g.
118
- * you deliberately drive the browser at an internal service reachable through
119
- * a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
120
- * the SSRF surface, so never combine it with caller-controlled URLs.
121
- */
192
+ * Strict host allowlist. When set (non-empty), a navigation URL is refused
193
+ * unless its hostname exactly matches one of these entries (case-insensitive,
194
+ * trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
195
+ * that fully closes DNS rebinding: a public hostname that resolves to a
196
+ * private/metadata IP can still be pinned out if it isn't on the list. Set it
197
+ * whenever you pass client-controlled URLs to the browser. Leave it unset (the
198
+ * default) to keep the previous behavior (only the string-based SSRF guard).
199
+ */
200
+ allowedHosts?: string[];
201
+ /**
202
+ * Opt out of the SSRF guard that, by default, refuses to navigate to a
203
+ * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
204
+ * `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
205
+ * link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
206
+ * `false` (the default) unless every caller-supplied URL is trusted — e.g.
207
+ * you deliberately drive the browser at an internal service reachable through
208
+ * a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
209
+ * the SSRF surface, so never combine it with caller-controlled URLs.
210
+ */
122
211
  allowPrivateTargets?: boolean;
123
212
  /** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
124
213
  binding: BrowserBindingLike;
125
214
  /**
126
- * The `@cloudflare/playwright` `launch` function. Injected rather than
127
- * imported at module top so the optional peer dep stays out of the bundle
128
- * for non-browser apps and tests can pass a double. The generated worker
129
- * passes the real function; omitting it makes the helper throw on first use
130
- * with a clear "install `@cloudflare/playwright`" error.
131
- */
215
+ * The `@cloudflare/playwright` `connect` function, injected like
216
+ * {@link LunoraBrowserOptions.launch}. Required for {@link Browser.connect}.
217
+ */
218
+ connect?: BrowserConnectLike;
219
+ /**
220
+ * The `@cloudflare/playwright` `launch` function. Injected rather than
221
+ * imported at module top so the optional peer dep stays out of the bundle
222
+ * for non-browser apps and tests can pass a double. The generated worker
223
+ * passes the real function; omitting it makes the helper throw on first use
224
+ * with a clear "install `@cloudflare/playwright`" error.
225
+ */
132
226
  launch?: BrowserLaunchLike;
133
227
  /**
134
- * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
135
- * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
136
- */
228
+ * Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
229
+ * is `false`), the factory resolves the URL's hostname over Cloudflare DoH
230
+ * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
231
+ * resolved A/AAAA record is a private/internal address — closing the gap
232
+ * where a public hostname resolves to a private IP after the string guard
233
+ * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
234
+ * (the browser re-resolves independently). If the DoH lookup itself fails, it
235
+ * falls back to the string guard rather than allowing a resolved private IP.
236
+ * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
237
+ */
238
+ resolveDns?: boolean;
239
+ /**
240
+ * The `@cloudflare/playwright` `sessions` function, injected like
241
+ * {@link LunoraBrowserOptions.launch}. Required for {@link Browser.sessions}.
242
+ */
243
+ sessions?: BrowserSessionsLike;
244
+ /**
245
+ * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
246
+ * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
247
+ */
137
248
  timeoutMs?: number;
138
249
  }
139
250
  /**
140
- * The `ctx.browser` surface — Cloudflare Browser Rendering driven through
141
- * `@cloudflare/playwright`. **Action-only**: every method performs
142
- * non-deterministic network I/O (it navigates a real headless browser to a
143
- * URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
144
- * `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
145
- * browser, opens a context + page, navigates, performs the op, and always
146
- * closes the browser in a `finally` (a leaked session is billed and
147
- * rate-limited).
148
- */
251
+ * The `ctx.browser` surface — Cloudflare Browser Rendering driven through
252
+ * `@cloudflare/playwright`. **Action-only**: every method performs
253
+ * non-deterministic network I/O (it navigates a real headless browser to a
254
+ * URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
255
+ * `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
256
+ * browser, opens a context + page, navigates, performs the op, and always
257
+ * closes the browser in a `finally` (a leaked session is billed and
258
+ * rate-limited).
259
+ * @experimental
260
+ */
149
261
  interface Browser {
262
+ /**
263
+ * Re-attach to an existing session and hand the browser to `fn`.
264
+ *
265
+ * Get the id either by reading it inside the call that opened the session
266
+ * (`launch(async (browser) => browser.sessionId?.(), { keepAlive: 600 })`)
267
+ * and persisting it, or by picking a free one out of
268
+ * {@link Browser.sessions} — an entry with a `connectionId` is already held
269
+ * by another worker.
270
+ *
271
+ * The session is deliberately **left open** afterwards — closing it is the
272
+ * whole thing you are avoiding. Close it when the flow is done by passing
273
+ * `close: true`, or let `keepAlive` lapse.
274
+ *
275
+ * This is what makes agent-style browsing possible: a model calls
276
+ * `navigate`, then `click`, then `extract` as three separate action
277
+ * invocations, and the page has to survive between them. With only the
278
+ * per-call lifecycle each step got a fresh browser, so `click` ran against
279
+ * a blank page — silently, which is the worst shape for that bug.
280
+ */
281
+ connect: <T>(sessionId: string, function_: (browser: BrowserLike) => Promise<T>, options?: {
282
+ close?: boolean;
283
+ }) => Promise<T>;
150
284
  /** Serialized HTML of `url` after navigation settles. */
151
285
  content: (url: string, options?: NavigateOptions) => Promise<string>;
152
286
  /**
153
- * Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
154
- * `fn` (e.g. for multi-page flows or APIs not surfaced here). The browser is
155
- * **always closed** when `fn` resolves or throws — do not retain references
156
- * to it past the callback.
157
- */
158
- launch: <T>(function_: (browser: BrowserLike) => Promise<T>) => Promise<T>;
287
+ * Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
288
+ * `fn` (e.g. for multi-page flows or APIs not surfaced here).
289
+ *
290
+ * The browser is **always closed** when `fn` resolves or throws — unless
291
+ * `keepAlive` is set, which holds the session open for that many seconds so
292
+ * a later {@link Browser.connect} can re-attach. Do not retain references to
293
+ * the browser past the callback either way.
294
+ */
295
+ launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
296
+ keepAlive?: number;
297
+ }) => Promise<T>;
159
298
  /** Render `url` to a PDF buffer. */
160
299
  pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
161
300
  /**
162
- * Navigate to `url`, run `fn` inside the page context, and return its
163
- * (serializable) result. `fn` runs in the browser, not the worker — it
164
- * cannot close over worker-side variables.
165
- */
301
+ * Navigate to `url`, run `fn` inside the page context, and return its
302
+ * (serializable) result. `fn` runs in the browser, not the worker — it
303
+ * cannot close over worker-side variables.
304
+ */
166
305
  scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
167
306
  /** Render `url` to an image buffer (PNG by default). */
168
307
  screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
308
+ /**
309
+ * List the live Browser Rendering sessions for this binding, so a caller can
310
+ * pick a free one to {@link Browser.connect} to. An entry with a
311
+ * `connectionId` is already held by another worker.
312
+ */
313
+ sessions: () => Promise<ReadonlyArray<BrowserSession>>;
169
314
  }
315
+ /**
316
+ * `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
317
+ * @experimental
318
+ */
170
319
  declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
171
320
  export { type Browser, type BrowserBindingLike, type BrowserContextLike, type BrowserLaunchLike, type BrowserLike, type LunoraBrowserOptions, type NavigateOptions, type PageLike, type PdfOptions, type ScreenshotOptions, createBrowser };
package/dist/index.d.ts CHANGED
@@ -1,24 +1,45 @@
1
1
  /**
2
- * Structural projection of the Cloudflare **Browser Rendering** binding
3
- * (`env.BROWSER`). The binding is a `Fetcher` under the hood — `@cloudflare/playwright`
4
- * drives it via `launch(env.BROWSER)`. Declared locally (an empty structural
5
- * marker) so unit tests can pass a plain-object double and the real binding
6
- * satisfies the same shape without importing `@cloudflare/workers-types` into
7
- * the public surface. See https://developers.cloudflare.com/browser-rendering/.
8
- *
9
- * It is intentionally opaque: callers never touch the binding directly, they
10
- * hand it to {@link LunoraBrowserOptions.binding} and the Playwright layer
11
- * consumes it. Typed as a non-empty marker so an arbitrary value (e.g. `{}`)
12
- * doesn't silently type-check where a binding is required.
13
- */
2
+ * Structural projection of the Cloudflare **Browser Rendering** binding
3
+ * (`env.BROWSER`). The binding is a `Fetcher` under the hood — `@cloudflare/playwright`
4
+ * drives it via `launch(env.BROWSER)`. Declared locally (an empty structural
5
+ * marker) so unit tests can pass a plain-object double and the real binding
6
+ * satisfies the same shape without importing `@cloudflare/workers-types` into
7
+ * the public surface. See https://developers.cloudflare.com/browser-rendering/.
8
+ *
9
+ * It is intentionally opaque: callers never touch the binding directly, they
10
+ * hand it to {@link LunoraBrowserOptions.binding} and the Playwright layer
11
+ * consumes it. `fetch` is REQUIRED (the real binding is a `Fetcher`, so it
12
+ * always has one) so the marker actually excludes an arbitrary value like `{}` —
13
+ * a bare object fails to type-check where a binding is required, catching the
14
+ * misuse at the call site instead of deferring to an opaque launch error.
15
+ * @experimental
16
+ */
14
17
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
18
+ readonly fetch: (...args: never[]) => unknown;
16
19
  }
17
20
  /**
18
- * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
19
- * Declared structurally so a test can inject a plain stub instead of a real
20
- * headless page (which needs workerd + the Browser Rendering binding).
21
- */
21
+ * Minimal projection of a Playwright `Route` (the argument the `page.route`
22
+ * handler receives). Only the members the SSRF redirect guard drives are
23
+ * declared: inspect the intercepted request's URL / navigation-ness, then either
24
+ * let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
25
+ */
26
+ interface RouteLike {
27
+ /** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
28
+ abort: (errorCode?: string) => Promise<void>;
29
+ /** Allow the intercepted request to proceed. */
30
+ continue: () => Promise<void>;
31
+ /** The intercepted request: its URL and (when available) whether it is a top-level navigation. */
32
+ request: () => {
33
+ isNavigationRequest?: () => boolean;
34
+ url: () => string;
35
+ };
36
+ }
37
+ /**
38
+ * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
39
+ * Declared structurally so a test can inject a plain stub instead of a real
40
+ * headless page (which needs workerd + the Browser Rendering binding).
41
+ * @experimental
42
+ */
22
43
  interface PageLike {
23
44
  /** Return the page's serialized HTML after the navigation settles. */
24
45
  content: () => Promise<string>;
@@ -31,6 +52,13 @@ interface PageLike {
31
52
  }) => Promise<unknown>;
32
53
  /** Render the page to a PDF buffer. */
33
54
  pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
55
+ /**
56
+ * Register a request interceptor (Playwright `page.route`). Optional: a fake
57
+ * or older page double without it still works — the SSRF redirect guard only
58
+ * activates when interception is available, and the initial-URL guard applies
59
+ * regardless. `pattern` follows Playwright's glob/URL matcher.
60
+ */
61
+ route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
34
62
  /** Render the page to a PNG/JPEG buffer. */
35
63
  screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
64
  /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
@@ -40,132 +68,253 @@ interface PageLike {
40
68
  }) => Promise<void>;
41
69
  }
42
70
  /**
43
- * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
44
- * declared structurally for the same test-double reason as {@link PageLike}.
45
- */
71
+ * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
72
+ * declared structurally for the same test-double reason as {@link PageLike}.
73
+ * @experimental
74
+ */
46
75
  interface BrowserContextLike {
47
76
  newPage: () => Promise<PageLike>;
48
77
  }
49
78
  /**
50
- * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
51
- * Only `newContext`/`close` are used; declared structurally for the same
52
- * test-double reason as {@link PageLike}.
53
- */
79
+ * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
80
+ * Only `newContext`/`close` are used; declared structurally for the same
81
+ * test-double reason as {@link PageLike}.
82
+ * @experimental
83
+ */
54
84
  interface BrowserLike {
55
85
  close: () => Promise<void>;
56
86
  newContext: () => Promise<BrowserContextLike>;
87
+ /**
88
+ * The Browser Rendering session this browser is attached to, when the
89
+ * runtime exposes it.
90
+ *
91
+ * Optional because this is a structural projection, not a re-declaration of
92
+ * the upstream Playwright type — but without it there is no way to learn
93
+ * the id of a session you just held open with `launch(fn, { keepAlive })`,
94
+ * which makes {@link Browser.connect} unreachable except by guessing from
95
+ * {@link Browser.sessions}.
96
+ */
97
+ sessionId?: () => string | undefined;
57
98
  }
58
99
  /**
59
- * Structural projection of `@cloudflare/playwright`'s `launch` export
60
- * (`import { launch } from "@cloudflare/playwright"`). Injected via
61
- * {@link LunoraBrowserOptions.launch} so the factory never imports
62
- * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
63
- * dep out of the bundle for apps that never screenshot, and lets tests pass a
64
- * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
65
- */
100
+ * Structural projection of `@cloudflare/playwright`'s `launch` export
101
+ * (`import { launch } from "@cloudflare/playwright"`). Injected via
102
+ * {@link LunoraBrowserOptions.launch} so the factory never imports
103
+ * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
104
+ * dep out of the bundle for apps that never screenshot, and lets tests pass a
105
+ * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
106
+ * @experimental
107
+ */
66
108
  type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
67
- /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
109
+ /**
110
+ * One live Browser Rendering session, as `@cloudflare/playwright`'s `sessions()`
111
+ * reports it. `connectionId` is set while another worker holds the session — you
112
+ * can only {@link Browser.connect} to a free one.
113
+ * @experimental
114
+ */
115
+ interface BrowserSession {
116
+ connectionId?: string;
117
+ sessionId: string;
118
+ startTime?: number;
119
+ }
120
+ /**
121
+ * Structural projection of `@cloudflare/playwright`'s `connect` export —
122
+ * re-attaches to an existing session rather than starting a new browser.
123
+ * Injected like {@link BrowserLaunchLike} so the peer dep stays optional.
124
+ * @experimental
125
+ */
126
+ type BrowserConnectLike = (binding: BrowserBindingLike, sessionId: string) => Promise<BrowserLike>;
127
+ /**
128
+ * Structural projection of `@cloudflare/playwright`'s `sessions` export — lists
129
+ * the account's live Browser Rendering sessions for this binding.
130
+ * @experimental
131
+ */
132
+ type BrowserSessionsLike = (binding: BrowserBindingLike) => Promise<ReadonlyArray<BrowserSession>>;
133
+ /**
134
+ * Options shared by the page-driving helpers ({@link Browser.screenshot} etc.).
135
+ * @experimental
136
+ */
68
137
  interface NavigateOptions {
69
138
  /**
70
- * Hard timeout in milliseconds for the navigation + operation. Clamped to a
71
- * sane ceiling so a hung/hostile page can't pin the worker. Default 30000.
72
- */
139
+ * Hard timeout in milliseconds for the navigation + operation. Clamped to a
140
+ * sane ceiling so a hung/hostile page can't pin the worker. Default 30000.
141
+ */
73
142
  timeoutMs?: number;
74
143
  /**
75
- * Playwright navigation wait condition. Playwright's set differs from
76
- * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
77
- * Default `load`.
78
- */
144
+ * Playwright navigation wait condition. Playwright's set differs from
145
+ * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
146
+ * Default `load`.
147
+ */
79
148
  waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
80
149
  }
81
- /** Options for {@link Browser.screenshot}. */
150
+ /**
151
+ * Options for {@link Browser.screenshot}.
152
+ * @experimental
153
+ */
82
154
  interface ScreenshotOptions extends NavigateOptions {
83
155
  /** Capture the full scrollable page rather than just the viewport. */
84
156
  fullPage?: boolean;
85
157
  /** Image encoding. Default `png`. */
86
158
  type?: "jpeg" | "png";
87
159
  /**
88
- * Viewport size. Each dimension is hard-capped (see the factory's
89
- * `MAX_VIEWPORT_*`) so a caller can't request a multi-million-pixel render.
90
- */
160
+ * Viewport size. Each dimension is hard-capped (see the factory's
161
+ * `MAX_VIEWPORT_*`) so a caller can't request a multi-million-pixel render.
162
+ */
91
163
  viewport?: {
92
164
  height: number;
93
165
  width: number;
94
166
  };
95
167
  }
96
- /** Options for {@link Browser.pdf}. */
168
+ /**
169
+ * Options for {@link Browser.pdf}.
170
+ * @experimental
171
+ */
97
172
  interface PdfOptions extends NavigateOptions {
98
173
  /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
99
174
  format?: string;
100
175
  /** Print background graphics. Default `false`. */
101
176
  printBackground?: boolean;
102
177
  /**
103
- * Viewport used while laying out the page before printing. Hard-capped like
104
- * {@link ScreenshotOptions.viewport}.
105
- */
178
+ * Viewport used while laying out the page before printing. Hard-capped like
179
+ * {@link ScreenshotOptions.viewport}.
180
+ */
106
181
  viewport?: {
107
182
  height: number;
108
183
  width: number;
109
184
  };
110
185
  }
186
+ /**
187
+ * `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
188
+ * @experimental
189
+ */
111
190
  interface LunoraBrowserOptions {
112
191
  /**
113
- * Opt out of the SSRF guard that, by default, refuses to navigate to a
114
- * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
115
- * `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
116
- * link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
117
- * `false` (the default) unless every caller-supplied URL is trusted e.g.
118
- * you deliberately drive the browser at an internal service reachable through
119
- * a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
120
- * the SSRF surface, so never combine it with caller-controlled URLs.
121
- */
192
+ * Strict host allowlist. When set (non-empty), a navigation URL is refused
193
+ * unless its hostname exactly matches one of these entries (case-insensitive,
194
+ * trailing-dot-normalized, IPv6 brackets stripped). This is the only guard
195
+ * that fully closes DNS rebinding: a public hostname that resolves to a
196
+ * private/metadata IP can still be pinned out if it isn't on the list. Set it
197
+ * whenever you pass client-controlled URLs to the browser. Leave it unset (the
198
+ * default) to keep the previous behavior (only the string-based SSRF guard).
199
+ */
200
+ allowedHosts?: string[];
201
+ /**
202
+ * Opt out of the SSRF guard that, by default, refuses to navigate to a
203
+ * private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
204
+ * `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
205
+ * link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
206
+ * `false` (the default) unless every caller-supplied URL is trusted — e.g.
207
+ * you deliberately drive the browser at an internal service reachable through
208
+ * a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
209
+ * the SSRF surface, so never combine it with caller-controlled URLs.
210
+ */
122
211
  allowPrivateTargets?: boolean;
123
212
  /** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
124
213
  binding: BrowserBindingLike;
125
214
  /**
126
- * The `@cloudflare/playwright` `launch` function. Injected rather than
127
- * imported at module top so the optional peer dep stays out of the bundle
128
- * for non-browser apps and tests can pass a double. The generated worker
129
- * passes the real function; omitting it makes the helper throw on first use
130
- * with a clear "install `@cloudflare/playwright`" error.
131
- */
215
+ * The `@cloudflare/playwright` `connect` function, injected like
216
+ * {@link LunoraBrowserOptions.launch}. Required for {@link Browser.connect}.
217
+ */
218
+ connect?: BrowserConnectLike;
219
+ /**
220
+ * The `@cloudflare/playwright` `launch` function. Injected rather than
221
+ * imported at module top so the optional peer dep stays out of the bundle
222
+ * for non-browser apps and tests can pass a double. The generated worker
223
+ * passes the real function; omitting it makes the helper throw on first use
224
+ * with a clear "install `@cloudflare/playwright`" error.
225
+ */
132
226
  launch?: BrowserLaunchLike;
133
227
  /**
134
- * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
135
- * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
136
- */
228
+ * Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
229
+ * is `false`), the factory resolves the URL's hostname over Cloudflare DoH
230
+ * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
231
+ * resolved A/AAAA record is a private/internal address — closing the gap
232
+ * where a public hostname resolves to a private IP after the string guard
233
+ * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
234
+ * (the browser re-resolves independently). If the DoH lookup itself fails, it
235
+ * falls back to the string guard rather than allowing a resolved private IP.
236
+ * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
237
+ */
238
+ resolveDns?: boolean;
239
+ /**
240
+ * The `@cloudflare/playwright` `sessions` function, injected like
241
+ * {@link LunoraBrowserOptions.launch}. Required for {@link Browser.sessions}.
242
+ */
243
+ sessions?: BrowserSessionsLike;
244
+ /**
245
+ * Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
246
+ * given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
247
+ */
137
248
  timeoutMs?: number;
138
249
  }
139
250
  /**
140
- * The `ctx.browser` surface — Cloudflare Browser Rendering driven through
141
- * `@cloudflare/playwright`. **Action-only**: every method performs
142
- * non-deterministic network I/O (it navigates a real headless browser to a
143
- * URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
144
- * `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
145
- * browser, opens a context + page, navigates, performs the op, and always
146
- * closes the browser in a `finally` (a leaked session is billed and
147
- * rate-limited).
148
- */
251
+ * The `ctx.browser` surface — Cloudflare Browser Rendering driven through
252
+ * `@cloudflare/playwright`. **Action-only**: every method performs
253
+ * non-deterministic network I/O (it navigates a real headless browser to a
254
+ * URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
255
+ * `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
256
+ * browser, opens a context + page, navigates, performs the op, and always
257
+ * closes the browser in a `finally` (a leaked session is billed and
258
+ * rate-limited).
259
+ * @experimental
260
+ */
149
261
  interface Browser {
262
+ /**
263
+ * Re-attach to an existing session and hand the browser to `fn`.
264
+ *
265
+ * Get the id either by reading it inside the call that opened the session
266
+ * (`launch(async (browser) => browser.sessionId?.(), { keepAlive: 600 })`)
267
+ * and persisting it, or by picking a free one out of
268
+ * {@link Browser.sessions} — an entry with a `connectionId` is already held
269
+ * by another worker.
270
+ *
271
+ * The session is deliberately **left open** afterwards — closing it is the
272
+ * whole thing you are avoiding. Close it when the flow is done by passing
273
+ * `close: true`, or let `keepAlive` lapse.
274
+ *
275
+ * This is what makes agent-style browsing possible: a model calls
276
+ * `navigate`, then `click`, then `extract` as three separate action
277
+ * invocations, and the page has to survive between them. With only the
278
+ * per-call lifecycle each step got a fresh browser, so `click` ran against
279
+ * a blank page — silently, which is the worst shape for that bug.
280
+ */
281
+ connect: <T>(sessionId: string, function_: (browser: BrowserLike) => Promise<T>, options?: {
282
+ close?: boolean;
283
+ }) => Promise<T>;
150
284
  /** Serialized HTML of `url` after navigation settles. */
151
285
  content: (url: string, options?: NavigateOptions) => Promise<string>;
152
286
  /**
153
- * Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
154
- * `fn` (e.g. for multi-page flows or APIs not surfaced here). The browser is
155
- * **always closed** when `fn` resolves or throws — do not retain references
156
- * to it past the callback.
157
- */
158
- launch: <T>(function_: (browser: BrowserLike) => Promise<T>) => Promise<T>;
287
+ * Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
288
+ * `fn` (e.g. for multi-page flows or APIs not surfaced here).
289
+ *
290
+ * The browser is **always closed** when `fn` resolves or throws — unless
291
+ * `keepAlive` is set, which holds the session open for that many seconds so
292
+ * a later {@link Browser.connect} can re-attach. Do not retain references to
293
+ * the browser past the callback either way.
294
+ */
295
+ launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
296
+ keepAlive?: number;
297
+ }) => Promise<T>;
159
298
  /** Render `url` to a PDF buffer. */
160
299
  pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
161
300
  /**
162
- * Navigate to `url`, run `fn` inside the page context, and return its
163
- * (serializable) result. `fn` runs in the browser, not the worker — it
164
- * cannot close over worker-side variables.
165
- */
301
+ * Navigate to `url`, run `fn` inside the page context, and return its
302
+ * (serializable) result. `fn` runs in the browser, not the worker — it
303
+ * cannot close over worker-side variables.
304
+ */
166
305
  scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
167
306
  /** Render `url` to an image buffer (PNG by default). */
168
307
  screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
308
+ /**
309
+ * List the live Browser Rendering sessions for this binding, so a caller can
310
+ * pick a free one to {@link Browser.connect} to. An entry with a
311
+ * `connectionId` is already held by another worker.
312
+ */
313
+ sessions: () => Promise<ReadonlyArray<BrowserSession>>;
169
314
  }
315
+ /**
316
+ * `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
317
+ * @experimental
318
+ */
170
319
  declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
171
320
  export { type Browser, type BrowserBindingLike, type BrowserContextLike, type BrowserLaunchLike, type BrowserLike, type LunoraBrowserOptions, type NavigateOptions, type PageLike, type PdfOptions, type ScreenshotOptions, createBrowser };
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { createBrowser } from './packem_shared/createBrowser-6RQ-H1k9.mjs';
1
+ import{createBrowser as o}from"./packem_shared/createBrowser-Dn01AHCX.mjs";export{o as createBrowser};
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";const P=/^\d{1,3}$/u,k=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,F=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,H=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,_=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,O=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,q=/^\[|\]$/gu,C=/\.$/u,y=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(n=>P.test(n)?Number(n):-1);if(!r.some(n=>n<0||n>255))return[r[0],r[1],r[2],r[3]]},m=([t,e])=>t===0||t===10||t===127||t===100&&e>=64&&e<=127||t===169&&e===254||t===172&&e>=16&&e<=31||t===192&&e===168||t>=224,N=(t,e)=>{const r=Number.parseInt(t??"",16),n=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(n)?!0:m([Math.floor(r/256),r%256,Math.floor(n/256),n%256])},A=t=>{const e=t.toLowerCase(),r=k.exec(e);if(r)return N(r[1],r[2]);const n=F.exec(e);if(n){const a=y(n[1]??"");return a===void 0||m(a)}const i=H.exec(e);if(i){const a=y(i[1]??"");return a===void 0||m(a)}const o=_.exec(e);return o?N(o[1],o[2]):O.test(e)||e.startsWith("2002:")||e.startsWith("2001:0:")?!0:e==="::"||e==="::1"||e.startsWith("fc")||e.startsWith("fd")||e.startsWith("fe8")||e.startsWith("fe9")||e.startsWith("fea")||e.startsWith("feb")},Q=t=>t==="localhost"||t.endsWith(".localhost")||t.endsWith(".local")||t.endsWith(".internal")||t.endsWith(".home.arpa"),d=t=>t.replaceAll(q,"").replace(C,"").toLowerCase(),I=t=>{const e=d(t);if(e.includes(":"))return A(e);const r=y(e);return r===void 0?Q(e):m(r)},B=3e4,V=12e4,j=3840,z=4320,G="https://cloudflare-dns.com/dns-query",v=5e3,b=1,T=28,J=(t,e)=>{if(e===b){const r=y(t);return r===void 0||m(r)}return A(t.toLowerCase())},W=async(t,e,r=v)=>{try{const n=await fetch(`${G}?name=${encodeURIComponent(t)}&type=${String(e)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});return n.ok?(await n.json()).Answer??[]:void 0}catch{return}},S=async(t,e=v)=>{const r=d(new URL(t).hostname);if(r.includes(":")||y(r)!==void 0)return;const[n,i]=await Promise.all([W(r,b,e),W(r,T,e)]);if(!(n===void 0&&i===void 0)){for(const o of[...n??[],...i??[]])if((o.type===b||o.type===T)&&J(o.data,o.type))throw new c("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${o.data}); refusing to navigate (DNS-rebinding guard)`)}},D=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new c("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let n;try{n=new URL(t)}catch{throw new c("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new c("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${n.protocol}")`);if(n.username!==""||n.password!=="")throw new c("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const i=d(n.hostname);if(!r.some(o=>d(o)===i))throw new c("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&I(n.hostname))throw new c("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return n.toString()},U=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,K=t=>({height:U(t.height,z),width:U(t.width,j)}),X=(t,e)=>{const r=t??e??B,n=Number.isFinite(r)?r:B;return Math.min(Math.max(1,Math.floor(n)),V)},Y=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((n,i)=>{r=setTimeout(()=>{i(new c("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},M=async t=>{try{await t.close()}catch{}},tt=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new c("INTERNAL",'@lunora/browser: `launch` is not available — install the `@cloudflare/playwright` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, launch }) (import { launch } from "@cloudflare/playwright").');return t.launch},r=(o,a)=>{if(!o)throw new c("INTERNAL",`@lunora/browser: \`${a}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${a} }).`);return o},n=async(o,a)=>{const s=await e()(t.binding,a===void 0?void 0:{keep_alive:a*1e3});if(a!==void 0)return await o(s);try{return await o(s)}finally{await M(s)}},i=async(o,a,s,u)=>{const f=t.allowPrivateTargets??!1,$=D(o,f,t.allowedHosts),g=X(a.timeoutMs,t.timeoutMs),E=t.resolveDns??!1,R=Math.min(g,v);!f&&E&&await S($,R);const L=async h=>{D(h,f,t.allowedHosts),!f&&E&&await S(h,R)},x=h=>{let l;try{l=new URL(h)}catch{return!1}if(l.protocol!=="http:"&&l.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const w=d(l.hostname);if(!t.allowedHosts.some(p=>d(p)===w))return!0}return I(l.hostname)};return n(async h=>{const l=await(await h.newContext()).newPage();return l.route&&(!f||(t.allowedHosts?.length??0)>0)&&await l.route("**/*",async w=>{const p=w.request();if(!(p.isNavigationRequest?.()??!0)){if(x(p.url())){await w.abort("blockedbyclient");return}await w.continue();return}try{await L(p.url())}catch{await w.abort("blockedbyclient");return}await w.continue()}),u&&l.setViewportSize&&await l.setViewportSize(K(u)),Y(async()=>(await l.goto($,{timeout:g,waitUntil:a.waitUntil??"load"}),s(l)),g)})};return{connect:async(o,a,s={})=>{const u=await r(t.connect,"connect")(t.binding,o);if(s.close!==!0)return await a(u);try{return await a(u)}finally{await M(u)}},content:async(o,a={})=>i(o,a,async s=>s.content()),launch:async(o,a={})=>n(o,a.keepAlive),pdf:async(o,a={})=>i(o,a,async s=>s.pdf({format:a.format,printBackground:a.printBackground??!1}),a.viewport),scrape:async(o,a,s={})=>i(o,s,async u=>u.evaluate(a)),screenshot:async(o,a={})=>i(o,a,async s=>s.screenshot({fullPage:a.fullPage??!1,type:a.type??"png"}),a.viewport),sessions:async()=>await r(t.sessions,"sessions")(t.binding)}};export{tt as createBrowser};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.20",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -45,6 +45,9 @@
45
45
  "publishConfig": {
46
46
  "access": "public"
47
47
  },
48
+ "dependencies": {
49
+ "@lunora/errors": "1.0.0-alpha.15"
50
+ },
48
51
  "peerDependencies": {
49
52
  "@cloudflare/playwright": ">=1.0.0"
50
53
  },
@@ -1,185 +0,0 @@
1
- const DEFAULT_TIMEOUT_MS = 3e4;
2
- const MAX_TIMEOUT_MS = 12e4;
3
- const MAX_VIEWPORT_WIDTH = 3840;
4
- const MAX_VIEWPORT_HEIGHT = 4320;
5
- const IPV4_OCTET = /^\d{1,3}$/;
6
- const IPV6_MAPPED_HEX = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/;
7
- const IPV6_MAPPED_DOTTED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/;
8
- const IPV6_COMPATIBLE_DOTTED = /^::(\d{1,3}(?:\.\d{1,3}){3})$/;
9
- const IPV6_COMPATIBLE_HEX = /^::([\da-f]{1,4}):([\da-f]{1,4})$/;
10
- const IPV6_NAT64_HEX = /^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/;
11
- const IPV6_BRACKETS = /^\[|\]$/g;
12
- const parseIpv4 = (host) => {
13
- const parts = host.split(".");
14
- if (parts.length !== 4) {
15
- return void 0;
16
- }
17
- const octets = parts.map((part) => IPV4_OCTET.test(part) ? Number(part) : -1);
18
- if (octets.some((octet) => octet < 0 || octet > 255)) {
19
- return void 0;
20
- }
21
- const result = [octets[0], octets[1], octets[2], octets[3]];
22
- return result;
23
- };
24
- const isPrivateIpv4 = ([a, b]) => a === 0 || // 0.0.0.0/8 "this host"
25
- a === 10 || // 10.0.0.0/8 private
26
- a === 127 || // 127.0.0.0/8 loopback
27
- a === 100 && b >= 64 && b <= 127 || // 100.64.0.0/10 CGNAT
28
- a === 169 && b === 254 || // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
29
- a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
30
- a === 192 && b === 168 || // 192.168.0.0/16 private
31
- a >= 224;
32
- const isPrivateEmbeddedIpv4 = (highGroup, lowGroup) => {
33
- const high = Number.parseInt(highGroup ?? "", 16);
34
- const low = Number.parseInt(lowGroup ?? "", 16);
35
- if (!Number.isFinite(high) || !Number.isFinite(low)) {
36
- return true;
37
- }
38
- return isPrivateIpv4([Math.floor(high / 256), high % 256, Math.floor(low / 256), low % 256]);
39
- };
40
- const isPrivateIpv6 = (host) => {
41
- const ip = host.toLowerCase();
42
- const mappedHex = IPV6_MAPPED_HEX.exec(ip);
43
- if (mappedHex) {
44
- return isPrivateEmbeddedIpv4(mappedHex[1], mappedHex[2]);
45
- }
46
- const mappedDotted = IPV6_MAPPED_DOTTED.exec(ip);
47
- if (mappedDotted) {
48
- const v4 = parseIpv4(mappedDotted[1] ?? "");
49
- return v4 === void 0 || isPrivateIpv4(v4);
50
- }
51
- const compatDotted = IPV6_COMPATIBLE_DOTTED.exec(ip);
52
- if (compatDotted) {
53
- const v4 = parseIpv4(compatDotted[1] ?? "");
54
- return v4 === void 0 || isPrivateIpv4(v4);
55
- }
56
- const compatHex = IPV6_COMPATIBLE_HEX.exec(ip);
57
- if (compatHex) {
58
- return isPrivateEmbeddedIpv4(compatHex[1], compatHex[2]);
59
- }
60
- if (IPV6_NAT64_HEX.test(ip)) {
61
- return true;
62
- }
63
- return ip === "::" || // unspecified
64
- ip === "::1" || // loopback
65
- ip.startsWith("fc") || // fc00::/7 unique-local
66
- ip.startsWith("fd") || // fc00::/7 unique-local
67
- ip.startsWith("fe8") || // fe80::/10 link-local
68
- ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb");
69
- };
70
- const isPrivateHostname = (host) => host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".home.arpa");
71
- const isPrivateTarget = (parsed) => {
72
- const host = parsed.hostname.replaceAll(IPV6_BRACKETS, "");
73
- if (host.includes(":")) {
74
- return isPrivateIpv6(host);
75
- }
76
- const v4 = parseIpv4(host);
77
- return v4 === void 0 ? isPrivateHostname(host.toLowerCase()) : isPrivateIpv4(v4);
78
- };
79
- const validateUrl = (url, allowPrivateTargets) => {
80
- if (typeof url !== "string" || url.length === 0) {
81
- throw new Error("@lunora/browser: url must be a non-empty string");
82
- }
83
- let parsed;
84
- try {
85
- parsed = new URL(url);
86
- } catch {
87
- throw new Error(`@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
88
- }
89
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
90
- throw new Error(`@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
91
- }
92
- if (parsed.username !== "" || parsed.password !== "") {
93
- throw new Error("@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
94
- }
95
- if (!allowPrivateTargets && isPrivateTarget(parsed)) {
96
- throw new Error(
97
- `@lunora/browser: url host "${parsed.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`
98
- );
99
- }
100
- return parsed.toString();
101
- };
102
- const clampDimension = (value, max) => {
103
- if (!Number.isFinite(value)) {
104
- return max;
105
- }
106
- return Math.min(Math.max(1, Math.floor(value)), max);
107
- };
108
- const clampViewport = (viewport) => {
109
- return {
110
- height: clampDimension(viewport.height, MAX_VIEWPORT_HEIGHT),
111
- width: clampDimension(viewport.width, MAX_VIEWPORT_WIDTH)
112
- };
113
- };
114
- const resolveTimeout = (callTimeout, factoryTimeout) => {
115
- const requested = callTimeout ?? factoryTimeout ?? DEFAULT_TIMEOUT_MS;
116
- const safe = Number.isFinite(requested) ? requested : DEFAULT_TIMEOUT_MS;
117
- return Math.min(Math.max(1, Math.floor(safe)), MAX_TIMEOUT_MS);
118
- };
119
- const createBrowser = (options) => {
120
- if (!options.binding) {
121
- throw new Error("@lunora/browser: `binding` is required (env.BROWSER)");
122
- }
123
- const getLaunch = () => {
124
- if (!options.launch) {
125
- throw new Error(
126
- '@lunora/browser: `launch` is not available — install the `@cloudflare/playwright` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, launch }) (import { launch } from "@cloudflare/playwright").'
127
- );
128
- }
129
- return options.launch;
130
- };
131
- const withBrowser = async (use) => {
132
- const browser = await getLaunch()(options.binding);
133
- try {
134
- return await use(browser);
135
- } finally {
136
- try {
137
- await browser.close();
138
- } catch {
139
- }
140
- }
141
- };
142
- const withPage = async (url, navigate, use, viewport) => {
143
- const target = validateUrl(url, options.allowPrivateTargets ?? false);
144
- const timeout = resolveTimeout(navigate.timeoutMs, options.timeoutMs);
145
- return withBrowser(async (browser) => {
146
- const context = await browser.newContext();
147
- const page = await context.newPage();
148
- if (viewport && page.setViewportSize) {
149
- await page.setViewportSize(clampViewport(viewport));
150
- }
151
- await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
152
- return use(page);
153
- });
154
- };
155
- const screenshot = async (url, screenshotOptions = {}) => withPage(
156
- url,
157
- screenshotOptions,
158
- async (page) => page.screenshot({
159
- fullPage: screenshotOptions.fullPage ?? false,
160
- type: screenshotOptions.type ?? "png"
161
- }),
162
- screenshotOptions.viewport
163
- );
164
- const pdf = async (url, pdfOptions = {}) => withPage(
165
- url,
166
- pdfOptions,
167
- async (page) => page.pdf({
168
- format: pdfOptions.format,
169
- printBackground: pdfOptions.printBackground ?? false
170
- }),
171
- pdfOptions.viewport
172
- );
173
- const content = async (url, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.content());
174
- const scrape = async (url, function_, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.evaluate(function_));
175
- const launch = async (function_) => withBrowser(function_);
176
- return {
177
- content,
178
- launch,
179
- pdf,
180
- scrape,
181
- screenshot
182
- };
183
- };
184
-
185
- export { createBrowser };