@lunora/browser 1.0.0-alpha.6 → 1.0.0-alpha.8

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/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
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * It is intentionally opaque: callers never touch the binding directly, they
10
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.
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
13
16
  */
14
17
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
18
+ readonly fetch: (...args: never[]) => unknown;
16
19
  }
17
20
  /**
18
21
  * Minimal projection of a Playwright `Route` (the argument the `page.route`
@@ -35,6 +38,7 @@ interface RouteLike {
35
38
  * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
36
39
  * Declared structurally so a test can inject a plain stub instead of a real
37
40
  * headless page (which needs workerd + the Browser Rendering binding).
41
+ * @experimental
38
42
  */
39
43
  interface PageLike {
40
44
  /** Return the page's serialized HTML after the navigation settles. */
@@ -66,6 +70,7 @@ interface PageLike {
66
70
  /**
67
71
  * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
68
72
  * declared structurally for the same test-double reason as {@link PageLike}.
73
+ * @experimental
69
74
  */
70
75
  interface BrowserContextLike {
71
76
  newPage: () => Promise<PageLike>;
@@ -74,6 +79,7 @@ interface BrowserContextLike {
74
79
  * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
75
80
  * Only `newContext`/`close` are used; declared structurally for the same
76
81
  * test-double reason as {@link PageLike}.
82
+ * @experimental
77
83
  */
78
84
  interface BrowserLike {
79
85
  close: () => Promise<void>;
@@ -86,9 +92,13 @@ interface BrowserLike {
86
92
  * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
87
93
  * dep out of the bundle for apps that never screenshot, and lets tests pass a
88
94
  * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
95
+ * @experimental
89
96
  */
90
97
  type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
91
- /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
98
+ /**
99
+ * Options shared by the page-driving helpers ({@link Browser.screenshot} etc.).
100
+ * @experimental
101
+ */
92
102
  interface NavigateOptions {
93
103
  /**
94
104
  * Hard timeout in milliseconds for the navigation + operation. Clamped to a
@@ -102,7 +112,10 @@ interface NavigateOptions {
102
112
  */
103
113
  waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
104
114
  }
105
- /** Options for {@link Browser.screenshot}. */
115
+ /**
116
+ * Options for {@link Browser.screenshot}.
117
+ * @experimental
118
+ */
106
119
  interface ScreenshotOptions extends NavigateOptions {
107
120
  /** Capture the full scrollable page rather than just the viewport. */
108
121
  fullPage?: boolean;
@@ -117,7 +130,10 @@ interface ScreenshotOptions extends NavigateOptions {
117
130
  width: number;
118
131
  };
119
132
  }
120
- /** Options for {@link Browser.pdf}. */
133
+ /**
134
+ * Options for {@link Browser.pdf}.
135
+ * @experimental
136
+ */
121
137
  interface PdfOptions extends NavigateOptions {
122
138
  /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
123
139
  format?: string;
@@ -132,6 +148,10 @@ interface PdfOptions extends NavigateOptions {
132
148
  width: number;
133
149
  };
134
150
  }
151
+ /**
152
+ * `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
153
+ * @experimental
154
+ */
135
155
  interface LunoraBrowserOptions {
136
156
  /**
137
157
  * Strict host allowlist. When set (non-empty), a navigation URL is refused
@@ -191,6 +211,7 @@ interface LunoraBrowserOptions {
191
211
  * browser, opens a context + page, navigates, performs the op, and always
192
212
  * closes the browser in a `finally` (a leaked session is billed and
193
213
  * rate-limited).
214
+ * @experimental
194
215
  */
195
216
  interface Browser {
196
217
  /** Serialized HTML of `url` after navigation settles. */
@@ -213,5 +234,9 @@ interface Browser {
213
234
  /** Render `url` to an image buffer (PNG by default). */
214
235
  screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
215
236
  }
237
+ /**
238
+ * `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
239
+ * @experimental
240
+ */
216
241
  declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
217
242
  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
@@ -8,11 +8,14 @@
8
8
  *
9
9
  * It is intentionally opaque: callers never touch the binding directly, they
10
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.
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
13
16
  */
14
17
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
18
+ readonly fetch: (...args: never[]) => unknown;
16
19
  }
17
20
  /**
18
21
  * Minimal projection of a Playwright `Route` (the argument the `page.route`
@@ -35,6 +38,7 @@ interface RouteLike {
35
38
  * Minimal projection of a Playwright `Page` — just the methods the helpers drive.
36
39
  * Declared structurally so a test can inject a plain stub instead of a real
37
40
  * headless page (which needs workerd + the Browser Rendering binding).
41
+ * @experimental
38
42
  */
39
43
  interface PageLike {
40
44
  /** Return the page's serialized HTML after the navigation settles. */
@@ -66,6 +70,7 @@ interface PageLike {
66
70
  /**
67
71
  * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
68
72
  * declared structurally for the same test-double reason as {@link PageLike}.
73
+ * @experimental
69
74
  */
70
75
  interface BrowserContextLike {
71
76
  newPage: () => Promise<PageLike>;
@@ -74,6 +79,7 @@ interface BrowserContextLike {
74
79
  * Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
75
80
  * Only `newContext`/`close` are used; declared structurally for the same
76
81
  * test-double reason as {@link PageLike}.
82
+ * @experimental
77
83
  */
78
84
  interface BrowserLike {
79
85
  close: () => Promise<void>;
@@ -86,9 +92,13 @@ interface BrowserLike {
86
92
  * `@cloudflare/playwright` at module top — that keeps the heavy optional peer
87
93
  * dep out of the bundle for apps that never screenshot, and lets tests pass a
88
94
  * fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
95
+ * @experimental
89
96
  */
90
97
  type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
91
- /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
98
+ /**
99
+ * Options shared by the page-driving helpers ({@link Browser.screenshot} etc.).
100
+ * @experimental
101
+ */
92
102
  interface NavigateOptions {
93
103
  /**
94
104
  * Hard timeout in milliseconds for the navigation + operation. Clamped to a
@@ -102,7 +112,10 @@ interface NavigateOptions {
102
112
  */
103
113
  waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
104
114
  }
105
- /** Options for {@link Browser.screenshot}. */
115
+ /**
116
+ * Options for {@link Browser.screenshot}.
117
+ * @experimental
118
+ */
106
119
  interface ScreenshotOptions extends NavigateOptions {
107
120
  /** Capture the full scrollable page rather than just the viewport. */
108
121
  fullPage?: boolean;
@@ -117,7 +130,10 @@ interface ScreenshotOptions extends NavigateOptions {
117
130
  width: number;
118
131
  };
119
132
  }
120
- /** Options for {@link Browser.pdf}. */
133
+ /**
134
+ * Options for {@link Browser.pdf}.
135
+ * @experimental
136
+ */
121
137
  interface PdfOptions extends NavigateOptions {
122
138
  /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
123
139
  format?: string;
@@ -132,6 +148,10 @@ interface PdfOptions extends NavigateOptions {
132
148
  width: number;
133
149
  };
134
150
  }
151
+ /**
152
+ * `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
153
+ * @experimental
154
+ */
135
155
  interface LunoraBrowserOptions {
136
156
  /**
137
157
  * Strict host allowlist. When set (non-empty), a navigation URL is refused
@@ -191,6 +211,7 @@ interface LunoraBrowserOptions {
191
211
  * browser, opens a context + page, navigates, performs the op, and always
192
212
  * closes the browser in a `finally` (a leaked session is billed and
193
213
  * rate-limited).
214
+ * @experimental
194
215
  */
195
216
  interface Browser {
196
217
  /** Serialized HTML of `url` after navigation settles. */
@@ -213,5 +234,9 @@ interface Browser {
213
234
  /** Render `url` to an image buffer (PNG by default). */
214
235
  screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
215
236
  }
237
+ /**
238
+ * `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
239
+ * @experimental
240
+ */
216
241
  declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
217
242
  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-BaV8oQ-4.mjs';
1
+ export { createBrowser } from './packem_shared/createBrowser-CZmfQ38r.mjs';
@@ -129,19 +129,19 @@ const isPrivateTarget = (parsed) => {
129
129
  };
130
130
  const validateUrl = (url, allowPrivateTargets, allowedHosts) => {
131
131
  if (typeof url !== "string" || url.length === 0) {
132
- throw new TypeError("@lunora/browser: url must be a non-empty string");
132
+ throw new LunoraError("BAD_REQUEST", "@lunora/browser: url must be a non-empty string");
133
133
  }
134
134
  let parsed;
135
135
  try {
136
136
  parsed = new URL(url);
137
137
  } catch {
138
- throw new TypeError(`@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
138
+ throw new LunoraError("BAD_REQUEST", `@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
139
139
  }
140
140
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
141
- throw new TypeError(`@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
141
+ throw new LunoraError("BAD_REQUEST", `@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
142
142
  }
143
143
  if (parsed.username !== "" || parsed.password !== "") {
144
- throw new LunoraError("INTERNAL", "@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
144
+ throw new LunoraError("BAD_REQUEST", "@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
145
145
  }
146
146
  if (allowedHosts && allowedHosts.length > 0) {
147
147
  const host = normalizeHost(parsed.hostname);
@@ -151,7 +151,7 @@ const validateUrl = (url, allowPrivateTargets, allowedHosts) => {
151
151
  }
152
152
  if (!allowPrivateTargets && isPrivateTarget(parsed)) {
153
153
  throw new LunoraError(
154
- "INTERNAL",
154
+ "FORBIDDEN",
155
155
  `@lunora/browser: url host "${parsed.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`
156
156
  );
157
157
  }
@@ -174,6 +174,27 @@ const resolveTimeout = (callTimeout, factoryTimeout) => {
174
174
  const safe = Number.isFinite(requested) ? requested : DEFAULT_TIMEOUT_MS;
175
175
  return Math.min(Math.max(1, Math.floor(safe)), MAX_TIMEOUT_MS);
176
176
  };
177
+ const withDeadline = async (operation, timeoutMs) => {
178
+ let timer;
179
+ try {
180
+ return await Promise.race([
181
+ operation(),
182
+ new Promise((_resolve, reject) => {
183
+ timer = setTimeout(() => {
184
+ reject(
185
+ new LunoraError("BROWSER_TIMEOUT", `@lunora/browser: navigation + operation exceeded the ${String(timeoutMs)}ms timeout budget`, {
186
+ status: 504
187
+ })
188
+ );
189
+ }, timeoutMs);
190
+ })
191
+ ]);
192
+ } finally {
193
+ if (timer !== void 0) {
194
+ clearTimeout(timer);
195
+ }
196
+ }
197
+ };
177
198
  const createBrowser = (options) => {
178
199
  if (!options.binding) {
179
200
  throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");
@@ -209,18 +230,40 @@ const createBrowser = (options) => {
209
230
  }
210
231
  const assertNavigationAllowed = async (requestUrl) => {
211
232
  validateUrl(requestUrl, allowPrivateTargets, options.allowedHosts);
212
- if (resolveDns) {
233
+ if (!allowPrivateTargets && resolveDns) {
213
234
  await assertResolvedHostIsPublic(requestUrl, dohTimeout);
214
235
  }
215
236
  };
237
+ const isBlockedSubresource = (rawUrl) => {
238
+ let parsed;
239
+ try {
240
+ parsed = new URL(rawUrl);
241
+ } catch {
242
+ return false;
243
+ }
244
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
245
+ return false;
246
+ }
247
+ if (options.allowedHosts && options.allowedHosts.length > 0) {
248
+ const host = normalizeHost(parsed.hostname);
249
+ if (!options.allowedHosts.some((entry) => normalizeHost(entry) === host)) {
250
+ return true;
251
+ }
252
+ }
253
+ return isPrivateTarget(parsed);
254
+ };
216
255
  return withBrowser(async (browser) => {
217
256
  const context = await browser.newContext();
218
257
  const page = await context.newPage();
219
- if (!allowPrivateTargets && page.route) {
258
+ if (page.route && (!allowPrivateTargets || (options.allowedHosts?.length ?? 0) > 0)) {
220
259
  await page.route("**/*", async (route) => {
221
260
  const request = route.request();
222
261
  const isNavigation = request.isNavigationRequest?.() ?? true;
223
262
  if (!isNavigation) {
263
+ if (isBlockedSubresource(request.url())) {
264
+ await route.abort("blockedbyclient");
265
+ return;
266
+ }
224
267
  await route.continue();
225
268
  return;
226
269
  }
@@ -236,8 +279,10 @@ const createBrowser = (options) => {
236
279
  if (viewport && page.setViewportSize) {
237
280
  await page.setViewportSize(clampViewport(viewport));
238
281
  }
239
- await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
240
- return use(page);
282
+ return withDeadline(async () => {
283
+ await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
284
+ return use(page);
285
+ }, timeout);
241
286
  });
242
287
  };
243
288
  const screenshot = async (url, screenshotOptions = {}) => withPage(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.3"
49
+ "@lunora/errors": "1.0.0-alpha.5"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@cloudflare/playwright": ">=1.0.0"