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

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/dist/index.d.mts CHANGED
@@ -8,11 +8,13 @@
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.
13
15
  */
14
16
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
17
+ readonly fetch: (...args: never[]) => unknown;
16
18
  }
17
19
  /**
18
20
  * Minimal projection of a Playwright `Route` (the argument the `page.route`
package/dist/index.d.ts CHANGED
@@ -8,11 +8,13 @@
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.
13
15
  */
14
16
  interface BrowserBindingLike {
15
- readonly fetch?: (...args: never[]) => unknown;
17
+ readonly fetch: (...args: never[]) => unknown;
16
18
  }
17
19
  /**
18
20
  * Minimal projection of a Playwright `Route` (the argument the `page.route`
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.7",
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.4"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "@cloudflare/playwright": ">=1.0.0"