@lunora/browser 0.0.0 → 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
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
+ */
14
+ interface BrowserBindingLike {
15
+ readonly fetch?: (...args: never[]) => unknown;
16
+ }
17
+ /**
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
+ */
22
+ interface PageLike {
23
+ /** Return the page's serialized HTML after the navigation settles. */
24
+ content: () => Promise<string>;
25
+ /** Run a function in the page context and return its (serializable) result. */
26
+ evaluate: <T>(function_: (...args: never[]) => T) => Promise<T>;
27
+ /** Navigate to a URL; resolves once the configured wait condition is met. */
28
+ goto: (url: string, options?: {
29
+ timeout?: number;
30
+ waitUntil?: string;
31
+ }) => Promise<unknown>;
32
+ /** Render the page to a PDF buffer. */
33
+ pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
34
+ /** Render the page to a PNG/JPEG buffer. */
35
+ screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
+ /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
37
+ setViewportSize?: (viewport: {
38
+ height: number;
39
+ width: number;
40
+ }) => Promise<void>;
41
+ }
42
+ /**
43
+ * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
44
+ * declared structurally for the same test-double reason as {@link PageLike}.
45
+ */
46
+ interface BrowserContextLike {
47
+ newPage: () => Promise<PageLike>;
48
+ }
49
+ /**
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
+ */
54
+ interface BrowserLike {
55
+ close: () => Promise<void>;
56
+ newContext: () => Promise<BrowserContextLike>;
57
+ }
58
+ /**
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
+ */
66
+ type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
67
+ /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
68
+ interface NavigateOptions {
69
+ /**
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
+ */
73
+ timeoutMs?: number;
74
+ /**
75
+ * Playwright navigation wait condition. Playwright's set differs from
76
+ * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
77
+ * Default `load`.
78
+ */
79
+ waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
80
+ }
81
+ /** Options for {@link Browser.screenshot}. */
82
+ interface ScreenshotOptions extends NavigateOptions {
83
+ /** Capture the full scrollable page rather than just the viewport. */
84
+ fullPage?: boolean;
85
+ /** Image encoding. Default `png`. */
86
+ type?: "jpeg" | "png";
87
+ /**
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
+ */
91
+ viewport?: {
92
+ height: number;
93
+ width: number;
94
+ };
95
+ }
96
+ /** Options for {@link Browser.pdf}. */
97
+ interface PdfOptions extends NavigateOptions {
98
+ /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
99
+ format?: string;
100
+ /** Print background graphics. Default `false`. */
101
+ printBackground?: boolean;
102
+ /**
103
+ * Viewport used while laying out the page before printing. Hard-capped like
104
+ * {@link ScreenshotOptions.viewport}.
105
+ */
106
+ viewport?: {
107
+ height: number;
108
+ width: number;
109
+ };
110
+ }
111
+ interface LunoraBrowserOptions {
112
+ /**
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
+ */
122
+ allowPrivateTargets?: boolean;
123
+ /** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
124
+ binding: BrowserBindingLike;
125
+ /**
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
+ */
132
+ launch?: BrowserLaunchLike;
133
+ /**
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
+ */
137
+ timeoutMs?: number;
138
+ }
139
+ /**
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
+ */
149
+ interface Browser {
150
+ /** Serialized HTML of `url` after navigation settles. */
151
+ content: (url: string, options?: NavigateOptions) => Promise<string>;
152
+ /**
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>;
159
+ /** Render `url` to a PDF buffer. */
160
+ pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
161
+ /**
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
+ */
166
+ scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
167
+ /** Render `url` to an image buffer (PNG by default). */
168
+ screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
169
+ }
170
+ declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
171
+ export { type Browser, type BrowserBindingLike, type BrowserContextLike, type BrowserLaunchLike, type BrowserLike, type LunoraBrowserOptions, type NavigateOptions, type PageLike, type PdfOptions, type ScreenshotOptions, createBrowser };
@@ -0,0 +1,171 @@
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
+ */
14
+ interface BrowserBindingLike {
15
+ readonly fetch?: (...args: never[]) => unknown;
16
+ }
17
+ /**
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
+ */
22
+ interface PageLike {
23
+ /** Return the page's serialized HTML after the navigation settles. */
24
+ content: () => Promise<string>;
25
+ /** Run a function in the page context and return its (serializable) result. */
26
+ evaluate: <T>(function_: (...args: never[]) => T) => Promise<T>;
27
+ /** Navigate to a URL; resolves once the configured wait condition is met. */
28
+ goto: (url: string, options?: {
29
+ timeout?: number;
30
+ waitUntil?: string;
31
+ }) => Promise<unknown>;
32
+ /** Render the page to a PDF buffer. */
33
+ pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
34
+ /** Render the page to a PNG/JPEG buffer. */
35
+ screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
36
+ /** Constrain the page viewport (a hard cap so a hostile page can't pin the worker). */
37
+ setViewportSize?: (viewport: {
38
+ height: number;
39
+ width: number;
40
+ }) => Promise<void>;
41
+ }
42
+ /**
43
+ * Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
44
+ * declared structurally for the same test-double reason as {@link PageLike}.
45
+ */
46
+ interface BrowserContextLike {
47
+ newPage: () => Promise<PageLike>;
48
+ }
49
+ /**
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
+ */
54
+ interface BrowserLike {
55
+ close: () => Promise<void>;
56
+ newContext: () => Promise<BrowserContextLike>;
57
+ }
58
+ /**
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
+ */
66
+ type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
67
+ /** Options shared by the page-driving helpers ({@link Browser.screenshot} etc.). */
68
+ interface NavigateOptions {
69
+ /**
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
+ */
73
+ timeoutMs?: number;
74
+ /**
75
+ * Playwright navigation wait condition. Playwright's set differs from
76
+ * Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
77
+ * Default `load`.
78
+ */
79
+ waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
80
+ }
81
+ /** Options for {@link Browser.screenshot}. */
82
+ interface ScreenshotOptions extends NavigateOptions {
83
+ /** Capture the full scrollable page rather than just the viewport. */
84
+ fullPage?: boolean;
85
+ /** Image encoding. Default `png`. */
86
+ type?: "jpeg" | "png";
87
+ /**
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
+ */
91
+ viewport?: {
92
+ height: number;
93
+ width: number;
94
+ };
95
+ }
96
+ /** Options for {@link Browser.pdf}. */
97
+ interface PdfOptions extends NavigateOptions {
98
+ /** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
99
+ format?: string;
100
+ /** Print background graphics. Default `false`. */
101
+ printBackground?: boolean;
102
+ /**
103
+ * Viewport used while laying out the page before printing. Hard-capped like
104
+ * {@link ScreenshotOptions.viewport}.
105
+ */
106
+ viewport?: {
107
+ height: number;
108
+ width: number;
109
+ };
110
+ }
111
+ interface LunoraBrowserOptions {
112
+ /**
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
+ */
122
+ allowPrivateTargets?: boolean;
123
+ /** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
124
+ binding: BrowserBindingLike;
125
+ /**
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
+ */
132
+ launch?: BrowserLaunchLike;
133
+ /**
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
+ */
137
+ timeoutMs?: number;
138
+ }
139
+ /**
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
+ */
149
+ interface Browser {
150
+ /** Serialized HTML of `url` after navigation settles. */
151
+ content: (url: string, options?: NavigateOptions) => Promise<string>;
152
+ /**
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>;
159
+ /** Render `url` to a PDF buffer. */
160
+ pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
161
+ /**
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
+ */
166
+ scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
167
+ /** Render `url` to an image buffer (PNG by default). */
168
+ screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
169
+ }
170
+ declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
171
+ 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 ADDED
@@ -0,0 +1 @@
1
+ export { createBrowser } from './packem_shared/createBrowser-6RQ-H1k9.mjs';
@@ -0,0 +1,185 @@
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 };
package/package.json CHANGED
@@ -1,31 +1,59 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "0.0.0",
3
+ "version": "1.0.0-alpha.1",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
- "license": "FSL-1.1-Apache-2.0",
5
+ "keywords": [
6
+ "browser-rendering",
7
+ "cloudflare",
8
+ "lunora",
9
+ "pdf",
10
+ "playwright",
11
+ "scrape",
12
+ "screenshot",
13
+ "workers"
14
+ ],
6
15
  "homepage": "https://lunora.sh",
16
+ "bugs": "https://github.com/anolilab/lunora/issues",
17
+ "license": "FSL-1.1-Apache-2.0",
18
+ "author": {
19
+ "name": "Daniel Bannert",
20
+ "email": "d.bannert@anolilab.de"
21
+ },
7
22
  "repository": {
8
23
  "type": "git",
9
24
  "url": "git+https://github.com/anolilab/lunora.git",
10
25
  "directory": "packages/browser"
11
26
  },
12
- "bugs": {
13
- "url": "https://github.com/anolilab/lunora/issues"
14
- },
15
- "keywords": [
16
- "lunora",
17
- "cloudflare",
18
- "workers",
19
- "browser-rendering",
20
- "playwright",
21
- "screenshot",
22
- "pdf",
23
- "scrape"
27
+ "files": [
28
+ "dist",
29
+ "__assets__",
30
+ "README.md",
31
+ "LICENSE.md"
24
32
  ],
33
+ "type": "module",
34
+ "sideEffects": false,
35
+ "main": "./dist/index.mjs",
36
+ "module": "./dist/index.mjs",
37
+ "types": "./dist/index.d.ts",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.mjs"
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
25
45
  "publishConfig": {
26
46
  "access": "public"
27
47
  },
28
- "files": [
29
- "README.md"
30
- ]
48
+ "peerDependencies": {
49
+ "@cloudflare/playwright": ">=1.0.0"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@cloudflare/playwright": {
53
+ "optional": true
54
+ }
55
+ },
56
+ "engines": {
57
+ "node": "^22.15.0 || >=24.11.0"
58
+ }
31
59
  }