@lunora/browser 1.0.0-alpha.4 → 1.0.0-alpha.40
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 +6 -0
- package/README.md +6 -2
- package/dist/index.d.mts +238 -114
- package/dist/index.d.ts +238 -114
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/createBrowser-CN6x72HL.mjs +1 -0
- package/package.json +2 -2
- package/dist/packem_shared/createBrowser-BaV8oQ-4.mjs +0 -273
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">
|
|
@@ -105,9 +107,11 @@ If you deliberately drive the browser at an internal service reachable through a
|
|
|
105
107
|
const browser = createBrowser({ binding: env.BROWSER, launch, allowPrivateTargets: true });
|
|
106
108
|
```
|
|
107
109
|
|
|
108
|
-
Only set `allowPrivateTargets` when every URL is trusted — it re-opens the SSRF surface.
|
|
110
|
+
Only set `allowPrivateTargets` when every URL is trusted — it re-opens the SSRF surface.
|
|
111
|
+
|
|
112
|
+
DNS rebinding is covered too: whenever `allowedHosts` is unset, the host is resolved over DoH and refused if it maps to a private address, before the browser launches and again on every redirect hop. Setting `allowedHosts` turns that re-check off, because an exact-host allowlist already closes rebinding and may deliberately name an internal host reachable over a Tunnel; pass `resolveDns: true` to force both.
|
|
109
113
|
|
|
110
|
-
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/
|
|
114
|
+
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs/packages/browser)**.
|
|
111
115
|
|
|
112
116
|
## Related
|
|
113
117
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
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.
|
|
12
|
-
*
|
|
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
|
|
18
|
+
readonly fetch: typeof fetch;
|
|
16
19
|
}
|
|
17
20
|
/**
|
|
18
|
-
* Minimal projection of a Playwright `Route` (the argument the `page.route`
|
|
19
|
-
* handler receives). Only the members the SSRF redirect guard drives are
|
|
20
|
-
* declared: inspect the intercepted request's URL / navigation-ness, then either
|
|
21
|
-
* let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
|
|
22
|
-
*/
|
|
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
|
+
*/
|
|
23
26
|
interface RouteLike {
|
|
24
27
|
/** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
|
|
25
28
|
abort: (errorCode?: string) => Promise<void>;
|
|
@@ -32,28 +35,29 @@ interface RouteLike {
|
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
37
|
/**
|
|
35
|
-
* Minimal projection of a Playwright `Page` — just the methods the helpers drive.
|
|
36
|
-
* Declared structurally so a test can inject a plain stub instead of a real
|
|
37
|
-
* headless page (which needs workerd + the Browser Rendering binding).
|
|
38
|
-
|
|
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
|
+
*/
|
|
39
43
|
interface PageLike {
|
|
40
44
|
/** Return the page's serialized HTML after the navigation settles. */
|
|
41
45
|
content: () => Promise<string>;
|
|
42
46
|
/** Run a function in the page context and return its (serializable) result. */
|
|
43
|
-
evaluate: <T>(function_: (
|
|
47
|
+
evaluate: <T>(function_: () => T) => Promise<T>;
|
|
44
48
|
/** Navigate to a URL; resolves once the configured wait condition is met. */
|
|
45
49
|
goto: (url: string, options?: {
|
|
46
50
|
timeout?: number;
|
|
47
|
-
waitUntil?:
|
|
51
|
+
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
|
48
52
|
}) => Promise<unknown>;
|
|
49
53
|
/** Render the page to a PDF buffer. */
|
|
50
54
|
pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
|
|
51
55
|
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
*/
|
|
57
61
|
route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
|
|
58
62
|
/** Render the page to a PNG/JPEG buffer. */
|
|
59
63
|
screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
|
|
@@ -64,154 +68,274 @@ interface PageLike {
|
|
|
64
68
|
}) => Promise<void>;
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
67
|
-
* Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
|
|
68
|
-
* declared structurally for the same test-double reason as {@link PageLike}.
|
|
69
|
-
|
|
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
|
+
*/
|
|
70
75
|
interface BrowserContextLike {
|
|
71
76
|
newPage: () => Promise<PageLike>;
|
|
72
77
|
}
|
|
73
78
|
/**
|
|
74
|
-
* Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
|
|
75
|
-
* Only `newContext`/`close` are used; declared structurally for the same
|
|
76
|
-
* test-double reason as {@link PageLike}.
|
|
77
|
-
|
|
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
|
+
*/
|
|
78
84
|
interface BrowserLike {
|
|
79
85
|
close: () => Promise<void>;
|
|
80
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;
|
|
81
98
|
}
|
|
82
99
|
/**
|
|
83
|
-
* Structural projection of `@cloudflare/playwright`'s `launch` export
|
|
84
|
-
* (`import { launch } from "@cloudflare/playwright"`). Injected via
|
|
85
|
-
* {@link LunoraBrowserOptions.launch} so the factory never imports
|
|
86
|
-
* `@cloudflare/playwright` at module top — that keeps the heavy optional peer
|
|
87
|
-
* dep out of the bundle for apps that never screenshot, and lets tests pass a
|
|
88
|
-
* fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
|
|
89
|
-
|
|
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
|
+
*/
|
|
90
108
|
type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
|
|
91
|
-
/**
|
|
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
|
+
*/
|
|
92
137
|
interface NavigateOptions {
|
|
93
138
|
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
*/
|
|
97
142
|
timeoutMs?: number;
|
|
98
143
|
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
* Playwright navigation wait condition. Playwright's set differs from
|
|
145
|
+
* Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
|
|
146
|
+
* Default `load`.
|
|
147
|
+
*/
|
|
103
148
|
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
|
104
149
|
}
|
|
105
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Options for {@link Browser.screenshot}.
|
|
152
|
+
* @experimental
|
|
153
|
+
*/
|
|
106
154
|
interface ScreenshotOptions extends NavigateOptions {
|
|
107
155
|
/** Capture the full scrollable page rather than just the viewport. */
|
|
108
156
|
fullPage?: boolean;
|
|
109
157
|
/** Image encoding. Default `png`. */
|
|
110
158
|
type?: "jpeg" | "png";
|
|
111
159
|
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
*/
|
|
115
163
|
viewport?: {
|
|
116
164
|
height: number;
|
|
117
165
|
width: number;
|
|
118
166
|
};
|
|
119
167
|
}
|
|
120
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Options for {@link Browser.pdf}.
|
|
170
|
+
* @experimental
|
|
171
|
+
*/
|
|
121
172
|
interface PdfOptions extends NavigateOptions {
|
|
122
173
|
/** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
|
|
123
174
|
format?: string;
|
|
124
175
|
/** Print background graphics. Default `false`. */
|
|
125
176
|
printBackground?: boolean;
|
|
126
177
|
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
178
|
+
* Viewport used while laying out the page before printing. Hard-capped like
|
|
179
|
+
* {@link ScreenshotOptions.viewport}.
|
|
180
|
+
*/
|
|
130
181
|
viewport?: {
|
|
131
182
|
height: number;
|
|
132
183
|
width: number;
|
|
133
184
|
};
|
|
134
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
|
|
188
|
+
* @experimental
|
|
189
|
+
*/
|
|
135
190
|
interface LunoraBrowserOptions {
|
|
136
191
|
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
198
|
+
*
|
|
199
|
+
* Leaving it unset (the default) is NOT unguarded: it turns
|
|
200
|
+
* {@link LunoraBrowserOptions.resolveDns} on, so every host is resolved over
|
|
201
|
+
* DoH and refused if it maps to a private address. Setting an allowlist turns
|
|
202
|
+
* that re-check off (the allowlist is the stronger guard, and may deliberately
|
|
203
|
+
* name an internal host); `resolveDns: true` forces both.
|
|
204
|
+
*/
|
|
145
205
|
allowedHosts?: string[];
|
|
146
206
|
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
207
|
+
* Opt out of the SSRF guard that, by default, refuses to navigate to a
|
|
208
|
+
* private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
|
|
209
|
+
* `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
|
|
210
|
+
* link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
|
|
211
|
+
* `false` (the default) unless every caller-supplied URL is trusted — e.g.
|
|
212
|
+
* you deliberately drive the browser at an internal service reachable through
|
|
213
|
+
* a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
|
|
214
|
+
* the SSRF surface, so never combine it with caller-controlled URLs.
|
|
215
|
+
*/
|
|
156
216
|
allowPrivateTargets?: boolean;
|
|
157
217
|
/** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
|
|
158
218
|
binding: BrowserBindingLike;
|
|
159
219
|
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
220
|
+
* The `@cloudflare/playwright` `connect` function, injected like
|
|
221
|
+
* {@link LunoraBrowserOptions.launch}. Required for {@link Browser.connect}.
|
|
222
|
+
*/
|
|
223
|
+
connect?: BrowserConnectLike;
|
|
224
|
+
/**
|
|
225
|
+
* The `@cloudflare/playwright` `launch` function. Injected rather than
|
|
226
|
+
* imported at module top so the optional peer dep stays out of the bundle
|
|
227
|
+
* for non-browser apps and tests can pass a double. The generated worker
|
|
228
|
+
* passes the real function; omitting it makes the helper throw on first use
|
|
229
|
+
* with a clear "install `@cloudflare/playwright`" error.
|
|
230
|
+
*/
|
|
166
231
|
launch?: BrowserLaunchLike;
|
|
167
232
|
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
233
|
+
* Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
|
|
234
|
+
* is `false`), the factory resolves the URL's hostname over Cloudflare DoH
|
|
235
|
+
* (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
|
|
236
|
+
* resolved A/AAAA record is a private/internal address — closing the gap
|
|
237
|
+
* where a public hostname resolves to a private IP after the string guard
|
|
238
|
+
* passes.
|
|
239
|
+
*
|
|
240
|
+
* **On by default when no {@link LunoraBrowserOptions.allowedHosts} is set**,
|
|
241
|
+
* because a `scrape`/`screenshot` action that forwards a client-supplied URL
|
|
242
|
+
* is the common shape and the string guard alone lets
|
|
243
|
+
* `http://127.0.0.1.nip.io:8787/…` through to an internal service. It costs
|
|
244
|
+
* one DNS round-trip per navigation and is TOCTOU-imperfect (the browser
|
|
245
|
+
* re-resolves independently), and if the DoH lookup itself fails it falls
|
|
246
|
+
* back to the string guard rather than allowing a resolved private IP.
|
|
247
|
+
*
|
|
248
|
+
* Configuring `allowedHosts` turns it OFF by default: an exact-origin
|
|
249
|
+
* allowlist is the stronger guard and may deliberately name an internal host
|
|
250
|
+
* (reachable over a Tunnel / private-network binding) that a resolved-address
|
|
251
|
+
* check would refuse. Set this explicitly to `true` to run both, or to
|
|
252
|
+
* `false` for trusted, non-caller-supplied URLs where the round-trip matters.
|
|
253
|
+
*/
|
|
178
254
|
resolveDns?: boolean;
|
|
179
255
|
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
256
|
+
* The `@cloudflare/playwright` `sessions` function, injected like
|
|
257
|
+
* {@link LunoraBrowserOptions.launch}. Required for {@link Browser.sessions}.
|
|
258
|
+
*/
|
|
259
|
+
sessions?: BrowserSessionsLike;
|
|
260
|
+
/**
|
|
261
|
+
* Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
|
|
262
|
+
* given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
|
|
263
|
+
*/
|
|
183
264
|
timeoutMs?: number;
|
|
184
265
|
}
|
|
185
266
|
/**
|
|
186
|
-
* The `ctx.browser` surface — Cloudflare Browser Rendering driven through
|
|
187
|
-
* `@cloudflare/playwright`. **Action-only**: every method performs
|
|
188
|
-
* non-deterministic network I/O (it navigates a real headless browser to a
|
|
189
|
-
* URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
|
|
190
|
-
* `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
|
|
191
|
-
* browser, opens a context + page, navigates, performs the op, and always
|
|
192
|
-
* closes the browser in a `finally` (a leaked session is billed and
|
|
193
|
-
* rate-limited).
|
|
194
|
-
|
|
267
|
+
* The `ctx.browser` surface — Cloudflare Browser Rendering driven through
|
|
268
|
+
* `@cloudflare/playwright`. **Action-only**: every method performs
|
|
269
|
+
* non-deterministic network I/O (it navigates a real headless browser to a
|
|
270
|
+
* URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
|
|
271
|
+
* `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
|
|
272
|
+
* browser, opens a context + page, navigates, performs the op, and always
|
|
273
|
+
* closes the browser in a `finally` (a leaked session is billed and
|
|
274
|
+
* rate-limited).
|
|
275
|
+
* @experimental
|
|
276
|
+
*/
|
|
195
277
|
interface Browser {
|
|
278
|
+
/**
|
|
279
|
+
* Re-attach to an existing session and hand the browser to `fn`.
|
|
280
|
+
*
|
|
281
|
+
* Get the id either by reading it inside the call that opened the session
|
|
282
|
+
* (`launch(async (browser) => browser.sessionId?.(), { keepAlive: 600 })`)
|
|
283
|
+
* and persisting it, or by picking a free one out of
|
|
284
|
+
* {@link Browser.sessions} — an entry with a `connectionId` is already held
|
|
285
|
+
* by another worker.
|
|
286
|
+
*
|
|
287
|
+
* The session is deliberately **left open** afterwards — closing it is the
|
|
288
|
+
* whole thing you are avoiding. Close it when the flow is done by passing
|
|
289
|
+
* `close: true`, or let `keepAlive` lapse.
|
|
290
|
+
*
|
|
291
|
+
* This is what makes agent-style browsing possible: a model calls
|
|
292
|
+
* `navigate`, then `click`, then `extract` as three separate action
|
|
293
|
+
* invocations, and the page has to survive between them. With only the
|
|
294
|
+
* per-call lifecycle each step got a fresh browser, so `click` ran against
|
|
295
|
+
* a blank page — silently, which is the worst shape for that bug.
|
|
296
|
+
*/
|
|
297
|
+
connect: <T>(sessionId: string, function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
298
|
+
close?: boolean;
|
|
299
|
+
}) => Promise<T>;
|
|
196
300
|
/** Serialized HTML of `url` after navigation settles. */
|
|
197
301
|
content: (url: string, options?: NavigateOptions) => Promise<string>;
|
|
198
302
|
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
303
|
+
* Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
|
|
304
|
+
* `fn` (e.g. for multi-page flows or APIs not surfaced here).
|
|
305
|
+
*
|
|
306
|
+
* The browser is **always closed** when `fn` resolves or throws — unless
|
|
307
|
+
* `keepAlive` is a number of seconds **between 10 and 600**, which holds the
|
|
308
|
+
* session open for that long so a later {@link Browser.connect} can
|
|
309
|
+
* re-attach. `0`, a negative value and `NaN` all mean "do not keep alive"
|
|
310
|
+
* and take the always-close path (a held session is billed, so the
|
|
311
|
+
* ambiguous values fall to the safe side); a positive value outside the
|
|
312
|
+
* 10–600s window Browser Rendering accepts throws `BAD_REQUEST` rather than
|
|
313
|
+
* being sent on for the provider to refuse. Do not retain references to the
|
|
314
|
+
* browser past the callback either way.
|
|
315
|
+
*/
|
|
316
|
+
launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
317
|
+
keepAlive?: number;
|
|
318
|
+
}) => Promise<T>;
|
|
205
319
|
/** Render `url` to a PDF buffer. */
|
|
206
320
|
pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
|
|
207
321
|
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
322
|
+
* Navigate to `url`, run `fn` inside the page context, and return its
|
|
323
|
+
* (serializable) result. `fn` runs in the browser, not the worker — it
|
|
324
|
+
* cannot close over worker-side variables.
|
|
325
|
+
*/
|
|
212
326
|
scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
|
|
213
327
|
/** Render `url` to an image buffer (PNG by default). */
|
|
214
328
|
screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
|
|
329
|
+
/**
|
|
330
|
+
* List the live Browser Rendering sessions for this binding, so a caller can
|
|
331
|
+
* pick a free one to {@link Browser.connect} to. An entry with a
|
|
332
|
+
* `connectionId` is already held by another worker.
|
|
333
|
+
*/
|
|
334
|
+
sessions: () => Promise<ReadonlyArray<BrowserSession>>;
|
|
215
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
|
|
338
|
+
* @experimental
|
|
339
|
+
*/
|
|
216
340
|
declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
|
|
217
341
|
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,25 +1,28 @@
|
|
|
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.
|
|
12
|
-
*
|
|
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
|
|
18
|
+
readonly fetch: typeof fetch;
|
|
16
19
|
}
|
|
17
20
|
/**
|
|
18
|
-
* Minimal projection of a Playwright `Route` (the argument the `page.route`
|
|
19
|
-
* handler receives). Only the members the SSRF redirect guard drives are
|
|
20
|
-
* declared: inspect the intercepted request's URL / navigation-ness, then either
|
|
21
|
-
* let it proceed ({@link RouteLike.continue}) or reject it ({@link RouteLike.abort}).
|
|
22
|
-
*/
|
|
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
|
+
*/
|
|
23
26
|
interface RouteLike {
|
|
24
27
|
/** Reject the intercepted request (fail-closed); `errorCode` is a Playwright abort reason. */
|
|
25
28
|
abort: (errorCode?: string) => Promise<void>;
|
|
@@ -32,28 +35,29 @@ interface RouteLike {
|
|
|
32
35
|
};
|
|
33
36
|
}
|
|
34
37
|
/**
|
|
35
|
-
* Minimal projection of a Playwright `Page` — just the methods the helpers drive.
|
|
36
|
-
* Declared structurally so a test can inject a plain stub instead of a real
|
|
37
|
-
* headless page (which needs workerd + the Browser Rendering binding).
|
|
38
|
-
|
|
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
|
+
*/
|
|
39
43
|
interface PageLike {
|
|
40
44
|
/** Return the page's serialized HTML after the navigation settles. */
|
|
41
45
|
content: () => Promise<string>;
|
|
42
46
|
/** Run a function in the page context and return its (serializable) result. */
|
|
43
|
-
evaluate: <T>(function_: (
|
|
47
|
+
evaluate: <T>(function_: () => T) => Promise<T>;
|
|
44
48
|
/** Navigate to a URL; resolves once the configured wait condition is met. */
|
|
45
49
|
goto: (url: string, options?: {
|
|
46
50
|
timeout?: number;
|
|
47
|
-
waitUntil?:
|
|
51
|
+
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
|
48
52
|
}) => Promise<unknown>;
|
|
49
53
|
/** Render the page to a PDF buffer. */
|
|
50
54
|
pdf: (options?: Record<string, unknown>) => Promise<Uint8Array>;
|
|
51
55
|
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
*/
|
|
57
61
|
route?: (pattern: string, handler: (route: RouteLike) => unknown) => Promise<void>;
|
|
58
62
|
/** Render the page to a PNG/JPEG buffer. */
|
|
59
63
|
screenshot: (options?: Record<string, unknown>) => Promise<Uint8Array>;
|
|
@@ -64,154 +68,274 @@ interface PageLike {
|
|
|
64
68
|
}) => Promise<void>;
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
67
|
-
* Minimal projection of a Playwright `BrowserContext`. Only `newPage` is used;
|
|
68
|
-
* declared structurally for the same test-double reason as {@link PageLike}.
|
|
69
|
-
|
|
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
|
+
*/
|
|
70
75
|
interface BrowserContextLike {
|
|
71
76
|
newPage: () => Promise<PageLike>;
|
|
72
77
|
}
|
|
73
78
|
/**
|
|
74
|
-
* Minimal projection of a Playwright `Browser` (the value `launch` resolves to).
|
|
75
|
-
* Only `newContext`/`close` are used; declared structurally for the same
|
|
76
|
-
* test-double reason as {@link PageLike}.
|
|
77
|
-
|
|
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
|
+
*/
|
|
78
84
|
interface BrowserLike {
|
|
79
85
|
close: () => Promise<void>;
|
|
80
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;
|
|
81
98
|
}
|
|
82
99
|
/**
|
|
83
|
-
* Structural projection of `@cloudflare/playwright`'s `launch` export
|
|
84
|
-
* (`import { launch } from "@cloudflare/playwright"`). Injected via
|
|
85
|
-
* {@link LunoraBrowserOptions.launch} so the factory never imports
|
|
86
|
-
* `@cloudflare/playwright` at module top — that keeps the heavy optional peer
|
|
87
|
-
* dep out of the bundle for apps that never screenshot, and lets tests pass a
|
|
88
|
-
* fake. Calling it with the Browser Rendering binding resolves a {@link BrowserLike}.
|
|
89
|
-
|
|
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
|
+
*/
|
|
90
108
|
type BrowserLaunchLike = (binding: BrowserBindingLike, options?: Record<string, unknown>) => Promise<BrowserLike>;
|
|
91
|
-
/**
|
|
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
|
+
*/
|
|
92
137
|
interface NavigateOptions {
|
|
93
138
|
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
*/
|
|
97
142
|
timeoutMs?: number;
|
|
98
143
|
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
* Playwright navigation wait condition. Playwright's set differs from
|
|
145
|
+
* Puppeteer's: `load`, `domcontentloaded`, `networkidle`, `commit`.
|
|
146
|
+
* Default `load`.
|
|
147
|
+
*/
|
|
103
148
|
waitUntil?: "commit" | "domcontentloaded" | "load" | "networkidle";
|
|
104
149
|
}
|
|
105
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Options for {@link Browser.screenshot}.
|
|
152
|
+
* @experimental
|
|
153
|
+
*/
|
|
106
154
|
interface ScreenshotOptions extends NavigateOptions {
|
|
107
155
|
/** Capture the full scrollable page rather than just the viewport. */
|
|
108
156
|
fullPage?: boolean;
|
|
109
157
|
/** Image encoding. Default `png`. */
|
|
110
158
|
type?: "jpeg" | "png";
|
|
111
159
|
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
*/
|
|
115
163
|
viewport?: {
|
|
116
164
|
height: number;
|
|
117
165
|
width: number;
|
|
118
166
|
};
|
|
119
167
|
}
|
|
120
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Options for {@link Browser.pdf}.
|
|
170
|
+
* @experimental
|
|
171
|
+
*/
|
|
121
172
|
interface PdfOptions extends NavigateOptions {
|
|
122
173
|
/** Paper format (`A4`, `Letter`, …) forwarded to Playwright. */
|
|
123
174
|
format?: string;
|
|
124
175
|
/** Print background graphics. Default `false`. */
|
|
125
176
|
printBackground?: boolean;
|
|
126
177
|
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
178
|
+
* Viewport used while laying out the page before printing. Hard-capped like
|
|
179
|
+
* {@link ScreenshotOptions.viewport}.
|
|
180
|
+
*/
|
|
130
181
|
viewport?: {
|
|
131
182
|
height: number;
|
|
132
183
|
width: number;
|
|
133
184
|
};
|
|
134
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* `LunoraBrowserOptions` is part of the experimental `@lunora/browser` API and may change without a major version bump.
|
|
188
|
+
* @experimental
|
|
189
|
+
*/
|
|
135
190
|
interface LunoraBrowserOptions {
|
|
136
191
|
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
198
|
+
*
|
|
199
|
+
* Leaving it unset (the default) is NOT unguarded: it turns
|
|
200
|
+
* {@link LunoraBrowserOptions.resolveDns} on, so every host is resolved over
|
|
201
|
+
* DoH and refused if it maps to a private address. Setting an allowlist turns
|
|
202
|
+
* that re-check off (the allowlist is the stronger guard, and may deliberately
|
|
203
|
+
* name an internal host); `resolveDns: true` forces both.
|
|
204
|
+
*/
|
|
145
205
|
allowedHosts?: string[];
|
|
146
206
|
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
207
|
+
* Opt out of the SSRF guard that, by default, refuses to navigate to a
|
|
208
|
+
* private / internal / loopback / link-local host (RFC1918, `127.0.0.0/8`,
|
|
209
|
+
* `169.254.0.0/16` incl. the cloud-metadata address, CGNAT, IPv6 ULA/
|
|
210
|
+
* link-local, and `localhost` / `*.internal` / `*.local` literals). Leave it
|
|
211
|
+
* `false` (the default) unless every caller-supplied URL is trusted — e.g.
|
|
212
|
+
* you deliberately drive the browser at an internal service reachable through
|
|
213
|
+
* a Cloudflare Tunnel / private-network binding. Setting it `true` re-opens
|
|
214
|
+
* the SSRF surface, so never combine it with caller-controlled URLs.
|
|
215
|
+
*/
|
|
156
216
|
allowPrivateTargets?: boolean;
|
|
157
217
|
/** The Cloudflare Browser Rendering binding (`env.BROWSER`). Required. */
|
|
158
218
|
binding: BrowserBindingLike;
|
|
159
219
|
/**
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
220
|
+
* The `@cloudflare/playwright` `connect` function, injected like
|
|
221
|
+
* {@link LunoraBrowserOptions.launch}. Required for {@link Browser.connect}.
|
|
222
|
+
*/
|
|
223
|
+
connect?: BrowserConnectLike;
|
|
224
|
+
/**
|
|
225
|
+
* The `@cloudflare/playwright` `launch` function. Injected rather than
|
|
226
|
+
* imported at module top so the optional peer dep stays out of the bundle
|
|
227
|
+
* for non-browser apps and tests can pass a double. The generated worker
|
|
228
|
+
* passes the real function; omitting it makes the helper throw on first use
|
|
229
|
+
* with a clear "install `@cloudflare/playwright`" error.
|
|
230
|
+
*/
|
|
166
231
|
launch?: BrowserLaunchLike;
|
|
167
232
|
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
233
|
+
* Best-effort DNS-rebinding re-check. When `true` (and `allowPrivateTargets`
|
|
234
|
+
* is `false`), the factory resolves the URL's hostname over Cloudflare DoH
|
|
235
|
+
* (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
|
|
236
|
+
* resolved A/AAAA record is a private/internal address — closing the gap
|
|
237
|
+
* where a public hostname resolves to a private IP after the string guard
|
|
238
|
+
* passes.
|
|
239
|
+
*
|
|
240
|
+
* **On by default when no {@link LunoraBrowserOptions.allowedHosts} is set**,
|
|
241
|
+
* because a `scrape`/`screenshot` action that forwards a client-supplied URL
|
|
242
|
+
* is the common shape and the string guard alone lets
|
|
243
|
+
* `http://127.0.0.1.nip.io:8787/…` through to an internal service. It costs
|
|
244
|
+
* one DNS round-trip per navigation and is TOCTOU-imperfect (the browser
|
|
245
|
+
* re-resolves independently), and if the DoH lookup itself fails it falls
|
|
246
|
+
* back to the string guard rather than allowing a resolved private IP.
|
|
247
|
+
*
|
|
248
|
+
* Configuring `allowedHosts` turns it OFF by default: an exact-origin
|
|
249
|
+
* allowlist is the stronger guard and may deliberately name an internal host
|
|
250
|
+
* (reachable over a Tunnel / private-network binding) that a resolved-address
|
|
251
|
+
* check would refuse. Set this explicitly to `true` to run both, or to
|
|
252
|
+
* `false` for trusted, non-caller-supplied URLs where the round-trip matters.
|
|
253
|
+
*/
|
|
178
254
|
resolveDns?: boolean;
|
|
179
255
|
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
256
|
+
* The `@cloudflare/playwright` `sessions` function, injected like
|
|
257
|
+
* {@link LunoraBrowserOptions.launch}. Required for {@link Browser.sessions}.
|
|
258
|
+
*/
|
|
259
|
+
sessions?: BrowserSessionsLike;
|
|
260
|
+
/**
|
|
261
|
+
* Default navigation timeout (ms) applied when a per-call `timeoutMs` is not
|
|
262
|
+
* given. Clamped to the factory's `MAX_TIMEOUT_MS`. Default 30000.
|
|
263
|
+
*/
|
|
183
264
|
timeoutMs?: number;
|
|
184
265
|
}
|
|
185
266
|
/**
|
|
186
|
-
* The `ctx.browser` surface — Cloudflare Browser Rendering driven through
|
|
187
|
-
* `@cloudflare/playwright`. **Action-only**: every method performs
|
|
188
|
-
* non-deterministic network I/O (it navigates a real headless browser to a
|
|
189
|
-
* URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
|
|
190
|
-
* `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
|
|
191
|
-
* browser, opens a context + page, navigates, performs the op, and always
|
|
192
|
-
* closes the browser in a `finally` (a leaked session is billed and
|
|
193
|
-
* rate-limited).
|
|
194
|
-
|
|
267
|
+
* The `ctx.browser` surface — Cloudflare Browser Rendering driven through
|
|
268
|
+
* `@cloudflare/playwright`. **Action-only**: every method performs
|
|
269
|
+
* non-deterministic network I/O (it navigates a real headless browser to a
|
|
270
|
+
* URL), so codegen wires it onto `ActionCtx` exclusively — never `QueryCtx`/
|
|
271
|
+
* `MutationCtx` — exactly like `ctx.ai` / `ctx.fetch`. Each helper launches a
|
|
272
|
+
* browser, opens a context + page, navigates, performs the op, and always
|
|
273
|
+
* closes the browser in a `finally` (a leaked session is billed and
|
|
274
|
+
* rate-limited).
|
|
275
|
+
* @experimental
|
|
276
|
+
*/
|
|
195
277
|
interface Browser {
|
|
278
|
+
/**
|
|
279
|
+
* Re-attach to an existing session and hand the browser to `fn`.
|
|
280
|
+
*
|
|
281
|
+
* Get the id either by reading it inside the call that opened the session
|
|
282
|
+
* (`launch(async (browser) => browser.sessionId?.(), { keepAlive: 600 })`)
|
|
283
|
+
* and persisting it, or by picking a free one out of
|
|
284
|
+
* {@link Browser.sessions} — an entry with a `connectionId` is already held
|
|
285
|
+
* by another worker.
|
|
286
|
+
*
|
|
287
|
+
* The session is deliberately **left open** afterwards — closing it is the
|
|
288
|
+
* whole thing you are avoiding. Close it when the flow is done by passing
|
|
289
|
+
* `close: true`, or let `keepAlive` lapse.
|
|
290
|
+
*
|
|
291
|
+
* This is what makes agent-style browsing possible: a model calls
|
|
292
|
+
* `navigate`, then `click`, then `extract` as three separate action
|
|
293
|
+
* invocations, and the page has to survive between them. With only the
|
|
294
|
+
* per-call lifecycle each step got a fresh browser, so `click` ran against
|
|
295
|
+
* a blank page — silently, which is the worst shape for that bug.
|
|
296
|
+
*/
|
|
297
|
+
connect: <T>(sessionId: string, function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
298
|
+
close?: boolean;
|
|
299
|
+
}) => Promise<T>;
|
|
196
300
|
/** Serialized HTML of `url` after navigation settles. */
|
|
197
301
|
content: (url: string, options?: NavigateOptions) => Promise<string>;
|
|
198
302
|
/**
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
303
|
+
* Low-level escape hatch: launch a raw Playwright `Browser` and hand it to
|
|
304
|
+
* `fn` (e.g. for multi-page flows or APIs not surfaced here).
|
|
305
|
+
*
|
|
306
|
+
* The browser is **always closed** when `fn` resolves or throws — unless
|
|
307
|
+
* `keepAlive` is a number of seconds **between 10 and 600**, which holds the
|
|
308
|
+
* session open for that long so a later {@link Browser.connect} can
|
|
309
|
+
* re-attach. `0`, a negative value and `NaN` all mean "do not keep alive"
|
|
310
|
+
* and take the always-close path (a held session is billed, so the
|
|
311
|
+
* ambiguous values fall to the safe side); a positive value outside the
|
|
312
|
+
* 10–600s window Browser Rendering accepts throws `BAD_REQUEST` rather than
|
|
313
|
+
* being sent on for the provider to refuse. Do not retain references to the
|
|
314
|
+
* browser past the callback either way.
|
|
315
|
+
*/
|
|
316
|
+
launch: <T>(function_: (browser: BrowserLike) => Promise<T>, options?: {
|
|
317
|
+
keepAlive?: number;
|
|
318
|
+
}) => Promise<T>;
|
|
205
319
|
/** Render `url` to a PDF buffer. */
|
|
206
320
|
pdf: (url: string, options?: PdfOptions) => Promise<Uint8Array>;
|
|
207
321
|
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
322
|
+
* Navigate to `url`, run `fn` inside the page context, and return its
|
|
323
|
+
* (serializable) result. `fn` runs in the browser, not the worker — it
|
|
324
|
+
* cannot close over worker-side variables.
|
|
325
|
+
*/
|
|
212
326
|
scrape: <T>(url: string, function_: (...args: never[]) => T, options?: NavigateOptions) => Promise<T>;
|
|
213
327
|
/** Render `url` to an image buffer (PNG by default). */
|
|
214
328
|
screenshot: (url: string, options?: ScreenshotOptions) => Promise<Uint8Array>;
|
|
329
|
+
/**
|
|
330
|
+
* List the live Browser Rendering sessions for this binding, so a caller can
|
|
331
|
+
* pick a free one to {@link Browser.connect} to. An entry with a
|
|
332
|
+
* `connectionId` is already held by another worker.
|
|
333
|
+
*/
|
|
334
|
+
sessions: () => Promise<ReadonlyArray<BrowserSession>>;
|
|
215
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* `createBrowser` is part of the experimental `@lunora/browser` API and may change without a major version bump.
|
|
338
|
+
* @experimental
|
|
339
|
+
*/
|
|
216
340
|
declare const createBrowser: (options: LunoraBrowserOptions) => Browser;
|
|
217
341
|
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
|
-
|
|
1
|
+
import{createBrowser as o}from"./packem_shared/createBrowser-CN6x72HL.mjs";export{o as createBrowser};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{LunoraError as u}from"@lunora/errors";const x=/^\d{1,3}$/u,C=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,F=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,X=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,Q=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,z=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,G=/^\[|\]$/gu,j=/\.$/u,v=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(n=>x.test(n)?Number(n):-1);if(!r.some(n=>n<0||n>255))return[r[0],r[1],r[2],r[3]]},y=([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,D=(t,e)=>{const r=Number.parseInt(t??"",16),n=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(n)?!0:y([Math.floor(r/256),r%256,Math.floor(n/256),n%256])},L=t=>{const e=t.toLowerCase(),r=C.exec(e);if(r)return D(r[1],r[2]);const n=F.exec(e);if(n){const f=v(n[1]??"");return f===void 0||y(f)}const i=X.exec(e);if(i){const f=v(i[1]??"");return f===void 0||y(f)}const c=Q.exec(e);return c?D(c[1],c[2]):z.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"),h=t=>t.replaceAll(G,"").replace(j,"").toLowerCase(),O=t=>{const e=h(t);if(e.includes(":"))return L(e);const r=v(e);return r===void 0?q(e):y(r)},Y="https://cloudflare-dns.com/dns-query",T=1,N=28,J=2e3,Z=(t,e)=>{if(e===T){const r=v(t);return r===void 0||y(r)}return L(t.toLowerCase())},S=async(t,e,r)=>{try{const n=await fetch(`${Y}?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}},K=async(t,e=J)=>{const r=h(t);if(r.includes(":")||v(r)!==void 0)return{kind:"unknown"};const[n,i]=await Promise.all([S(r,T,e),S(r,N,e)]);if(n===void 0&&i===void 0)return{kind:"unknown"};for(const c of[...n??[],...i??[]])if((c.type===T||c.type===N)&&Z(c.data,c.type))return{address:c.data,kind:"private"};return{kind:"public"}},A=3e4,tt=12e4,et=3840,rt=4320,R=10,M=600,U=5e3,$=async(t,e=U)=>{const r=h(new URL(t).hostname),n=await K(r,e);if(n.kind==="private")throw new u("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${n.address}); refusing to navigate (DNS-rebinding guard)`)},B=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new u("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let n;try{n=new URL(t)}catch{throw new u("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new u("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${n.protocol}")`);if(n.username!==""||n.password!=="")throw new u("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const i=h(n.hostname);if(!r.some(c=>h(c)===i))throw new u("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&O(n.hostname))throw new u("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return n.toString()},H=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,nt=t=>({height:H(t.height,rt),width:H(t.width,et)}),ot=(t,e)=>{const r=t??e??A,n=Number.isFinite(r)?r:A;return Math.min(Math.max(1,Math.floor(n)),tt)},st=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((n,i)=>{r=setTimeout(()=>{i(new u("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},k=async t=>{try{await t.close()}catch{}},ht=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new u("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=(s,o)=>{if(!s)throw new u("INTERNAL",`@lunora/browser: \`${o}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${o} }).`);return s},n=async(s,o)=>{const a=o!==void 0&&Number.isFinite(o)&&o>0?o:void 0;if(a!==void 0&&(a<R||a>M))throw new u("BAD_REQUEST",`@lunora/browser: keepAlive must be between ${String(R)} and ${String(M)} seconds (Browser Rendering accepts keep_alive from 10s to 10min; got ${String(o)})`);const l=await e()(t.binding,a===void 0?void 0:{keep_alive:a*1e3});if(a!==void 0)return await s(l);try{return await s(l)}finally{await k(l)}},i=async(s,o,a,l)=>{const p=t.allowPrivateTargets??!1,_=B(s,p,t.allowedHosts),E=ot(o.timeoutMs,t.timeoutMs),I=t.resolveDns??(t.allowedHosts?.length??0)===0,P=Math.min(E,U);!p&&I&&await $(_,P);const W=async m=>{B(m,p,t.allowedHosts),!p&&I&&await $(m,P)},V=m=>{let g;try{g=new URL(m)}catch{return!1}if(g.protocol!=="http:"&&g.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const d=h(g.hostname);if(!t.allowedHosts.some(w=>h(w)===d))return!0}return O(g.hostname)};return n(async m=>{const d=await(await m.newContext()).newPage();return d.route&&(!p||(t.allowedHosts?.length??0)>0)&&await d.route("**/*",async w=>{const b=w.request();if(!(b.isNavigationRequest?.()??!0)){if(V(b.url())){await w.abort("blockedbyclient");return}await w.continue();return}try{await W(b.url())}catch{await w.abort("blockedbyclient");return}await w.continue()}),l&&d.setViewportSize&&await d.setViewportSize(nt(l)),st(async()=>(await d.goto(_,{timeout:E,waitUntil:o.waitUntil??"load"}),a(d)),E)})};return{connect:async(s,o,a={})=>{const l=await r(t.connect,"connect")(t.binding,s);if(a.close!==!0)return await o(l);try{return await o(l)}finally{await k(l)}},content:async(s,o={})=>i(s,o,async a=>a.content()),launch:async(s,o={})=>n(s,o.keepAlive),pdf:async(s,o={})=>i(s,o,async a=>a.pdf({format:o.format,printBackground:o.printBackground??!1}),o.viewport),scrape:async(s,o,a={})=>i(s,a,async l=>l.evaluate(o)),screenshot:async(s,o={})=>i(s,o,async a=>a.screenshot({fullPage:o.fullPage??!1,type:o.type??"png"}),o.viewport),sessions:async()=>await r(t.sessions,"sessions")(t.binding)}};export{ht as createBrowser};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/browser",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.40",
|
|
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.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.31"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@cloudflare/playwright": ">=1.0.0"
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import { LunoraError } from '@lunora/errors';
|
|
2
|
-
|
|
3
|
-
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
4
|
-
const MAX_TIMEOUT_MS = 12e4;
|
|
5
|
-
const MAX_VIEWPORT_WIDTH = 3840;
|
|
6
|
-
const MAX_VIEWPORT_HEIGHT = 4320;
|
|
7
|
-
const IPV4_OCTET = /^\d{1,3}$/;
|
|
8
|
-
const IPV6_MAPPED_HEX = /^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/;
|
|
9
|
-
const IPV6_MAPPED_DOTTED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/;
|
|
10
|
-
const IPV6_COMPATIBLE_DOTTED = /^::(\d{1,3}(?:\.\d{1,3}){3})$/;
|
|
11
|
-
const IPV6_COMPATIBLE_HEX = /^::([\da-f]{1,4}):([\da-f]{1,4})$/;
|
|
12
|
-
const IPV6_NAT64_HEX = /^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/;
|
|
13
|
-
const IPV6_BRACKETS = /^\[|\]$/g;
|
|
14
|
-
const TRAILING_DOT = /\.$/;
|
|
15
|
-
const parseIpv4 = (host) => {
|
|
16
|
-
const parts = host.split(".");
|
|
17
|
-
if (parts.length !== 4) {
|
|
18
|
-
return void 0;
|
|
19
|
-
}
|
|
20
|
-
const octets = parts.map((part) => IPV4_OCTET.test(part) ? Number(part) : -1);
|
|
21
|
-
if (octets.some((octet) => octet < 0 || octet > 255)) {
|
|
22
|
-
return void 0;
|
|
23
|
-
}
|
|
24
|
-
const result = [octets[0], octets[1], octets[2], octets[3]];
|
|
25
|
-
return result;
|
|
26
|
-
};
|
|
27
|
-
const isPrivateIpv4 = ([a, b]) => a === 0 || // 0.0.0.0/8 "this host"
|
|
28
|
-
a === 10 || // 10.0.0.0/8 private
|
|
29
|
-
a === 127 || // 127.0.0.0/8 loopback
|
|
30
|
-
a === 100 && b >= 64 && b <= 127 || // 100.64.0.0/10 CGNAT
|
|
31
|
-
a === 169 && b === 254 || // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
|
|
32
|
-
a === 172 && b >= 16 && b <= 31 || // 172.16.0.0/12 private
|
|
33
|
-
a === 192 && b === 168 || // 192.168.0.0/16 private
|
|
34
|
-
a >= 224;
|
|
35
|
-
const isPrivateEmbeddedIpv4 = (highGroup, lowGroup) => {
|
|
36
|
-
const high = Number.parseInt(highGroup ?? "", 16);
|
|
37
|
-
const low = Number.parseInt(lowGroup ?? "", 16);
|
|
38
|
-
if (!Number.isFinite(high) || !Number.isFinite(low)) {
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
return isPrivateIpv4([Math.floor(high / 256), high % 256, Math.floor(low / 256), low % 256]);
|
|
42
|
-
};
|
|
43
|
-
const isPrivateIpv6 = (host) => {
|
|
44
|
-
const ip = host.toLowerCase();
|
|
45
|
-
const mappedHex = IPV6_MAPPED_HEX.exec(ip);
|
|
46
|
-
if (mappedHex) {
|
|
47
|
-
return isPrivateEmbeddedIpv4(mappedHex[1], mappedHex[2]);
|
|
48
|
-
}
|
|
49
|
-
const mappedDotted = IPV6_MAPPED_DOTTED.exec(ip);
|
|
50
|
-
if (mappedDotted) {
|
|
51
|
-
const v4 = parseIpv4(mappedDotted[1] ?? "");
|
|
52
|
-
return v4 === void 0 || isPrivateIpv4(v4);
|
|
53
|
-
}
|
|
54
|
-
const compatDotted = IPV6_COMPATIBLE_DOTTED.exec(ip);
|
|
55
|
-
if (compatDotted) {
|
|
56
|
-
const v4 = parseIpv4(compatDotted[1] ?? "");
|
|
57
|
-
return v4 === void 0 || isPrivateIpv4(v4);
|
|
58
|
-
}
|
|
59
|
-
const compatHex = IPV6_COMPATIBLE_HEX.exec(ip);
|
|
60
|
-
if (compatHex) {
|
|
61
|
-
return isPrivateEmbeddedIpv4(compatHex[1], compatHex[2]);
|
|
62
|
-
}
|
|
63
|
-
if (IPV6_NAT64_HEX.test(ip)) {
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
return ip === "::" || // unspecified
|
|
67
|
-
ip === "::1" || // loopback
|
|
68
|
-
ip.startsWith("fc") || // fc00::/7 unique-local
|
|
69
|
-
ip.startsWith("fd") || // fc00::/7 unique-local
|
|
70
|
-
ip.startsWith("fe8") || // fe80::/10 link-local
|
|
71
|
-
ip.startsWith("fe9") || ip.startsWith("fea") || ip.startsWith("feb");
|
|
72
|
-
};
|
|
73
|
-
const DOH_ENDPOINT = "https://cloudflare-dns.com/dns-query";
|
|
74
|
-
const DOH_TIMEOUT_MS = 5e3;
|
|
75
|
-
const DNS_TYPE_A = 1;
|
|
76
|
-
const DNS_TYPE_AAAA = 28;
|
|
77
|
-
const normalizeHost = (host) => host.replaceAll(IPV6_BRACKETS, "").replace(TRAILING_DOT, "").toLowerCase();
|
|
78
|
-
const isPrivateResolvedIp = (data, type) => {
|
|
79
|
-
if (type === DNS_TYPE_A) {
|
|
80
|
-
const v4 = parseIpv4(data);
|
|
81
|
-
return v4 === void 0 || isPrivateIpv4(v4);
|
|
82
|
-
}
|
|
83
|
-
return isPrivateIpv6(data.toLowerCase());
|
|
84
|
-
};
|
|
85
|
-
const dohLookup = async (hostname, type, timeoutMs = DOH_TIMEOUT_MS) => {
|
|
86
|
-
try {
|
|
87
|
-
const response = await fetch(`${DOH_ENDPOINT}?name=${encodeURIComponent(hostname)}&type=${String(type)}`, {
|
|
88
|
-
headers: { accept: "application/dns-json" },
|
|
89
|
-
// Bound the lookup so a stalled resolver can't hang the worker; an
|
|
90
|
-
// abort surfaces as a rejection caught below → `undefined` → the
|
|
91
|
-
// caller falls back to the (already-passed) string guard.
|
|
92
|
-
signal: AbortSignal.timeout(timeoutMs)
|
|
93
|
-
});
|
|
94
|
-
if (!response.ok) {
|
|
95
|
-
return void 0;
|
|
96
|
-
}
|
|
97
|
-
const body = await response.json();
|
|
98
|
-
return body.Answer ?? [];
|
|
99
|
-
} catch {
|
|
100
|
-
return void 0;
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
const assertResolvedHostIsPublic = async (target, timeoutMs = DOH_TIMEOUT_MS) => {
|
|
104
|
-
const host = normalizeHost(new URL(target).hostname);
|
|
105
|
-
if (host.includes(":") || parseIpv4(host) !== void 0) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
const [aRecords, aaaaRecords] = await Promise.all([dohLookup(host, DNS_TYPE_A, timeoutMs), dohLookup(host, DNS_TYPE_AAAA, timeoutMs)]);
|
|
109
|
-
if (aRecords === void 0 && aaaaRecords === void 0) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
for (const answer of [...aRecords ?? [], ...aaaaRecords ?? []]) {
|
|
113
|
-
if ((answer.type === DNS_TYPE_A || answer.type === DNS_TYPE_AAAA) && isPrivateResolvedIp(answer.data, answer.type)) {
|
|
114
|
-
throw new LunoraError(
|
|
115
|
-
"FORBIDDEN",
|
|
116
|
-
`@lunora/browser: url host "${host}" resolves to a private/internal address (${answer.data}); refusing to navigate (DNS-rebinding guard)`
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
const isPrivateHostname = (host) => host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal") || host.endsWith(".home.arpa");
|
|
122
|
-
const isPrivateTarget = (parsed) => {
|
|
123
|
-
const host = parsed.hostname.replaceAll(IPV6_BRACKETS, "").replace(TRAILING_DOT, "");
|
|
124
|
-
if (host.includes(":")) {
|
|
125
|
-
return isPrivateIpv6(host);
|
|
126
|
-
}
|
|
127
|
-
const v4 = parseIpv4(host);
|
|
128
|
-
return v4 === void 0 ? isPrivateHostname(host.toLowerCase()) : isPrivateIpv4(v4);
|
|
129
|
-
};
|
|
130
|
-
const validateUrl = (url, allowPrivateTargets, allowedHosts) => {
|
|
131
|
-
if (typeof url !== "string" || url.length === 0) {
|
|
132
|
-
throw new TypeError("@lunora/browser: url must be a non-empty string");
|
|
133
|
-
}
|
|
134
|
-
let parsed;
|
|
135
|
-
try {
|
|
136
|
-
parsed = new URL(url);
|
|
137
|
-
} catch {
|
|
138
|
-
throw new TypeError(`@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
|
|
139
|
-
}
|
|
140
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
141
|
-
throw new TypeError(`@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
|
|
142
|
-
}
|
|
143
|
-
if (parsed.username !== "" || parsed.password !== "") {
|
|
144
|
-
throw new LunoraError("INTERNAL", "@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");
|
|
145
|
-
}
|
|
146
|
-
if (allowedHosts && allowedHosts.length > 0) {
|
|
147
|
-
const host = normalizeHost(parsed.hostname);
|
|
148
|
-
if (!allowedHosts.some((entry) => normalizeHost(entry) === host)) {
|
|
149
|
-
throw new LunoraError("FORBIDDEN", `@lunora/browser: url host "${parsed.hostname}" is not in the configured allowedHosts allowlist`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (!allowPrivateTargets && isPrivateTarget(parsed)) {
|
|
153
|
-
throw new LunoraError(
|
|
154
|
-
"INTERNAL",
|
|
155
|
-
`@lunora/browser: url host "${parsed.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
return parsed.toString();
|
|
159
|
-
};
|
|
160
|
-
const clampDimension = (value, max) => {
|
|
161
|
-
if (!Number.isFinite(value)) {
|
|
162
|
-
return max;
|
|
163
|
-
}
|
|
164
|
-
return Math.min(Math.max(1, Math.floor(value)), max);
|
|
165
|
-
};
|
|
166
|
-
const clampViewport = (viewport) => {
|
|
167
|
-
return {
|
|
168
|
-
height: clampDimension(viewport.height, MAX_VIEWPORT_HEIGHT),
|
|
169
|
-
width: clampDimension(viewport.width, MAX_VIEWPORT_WIDTH)
|
|
170
|
-
};
|
|
171
|
-
};
|
|
172
|
-
const resolveTimeout = (callTimeout, factoryTimeout) => {
|
|
173
|
-
const requested = callTimeout ?? factoryTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
174
|
-
const safe = Number.isFinite(requested) ? requested : DEFAULT_TIMEOUT_MS;
|
|
175
|
-
return Math.min(Math.max(1, Math.floor(safe)), MAX_TIMEOUT_MS);
|
|
176
|
-
};
|
|
177
|
-
const createBrowser = (options) => {
|
|
178
|
-
if (!options.binding) {
|
|
179
|
-
throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");
|
|
180
|
-
}
|
|
181
|
-
const getLaunch = () => {
|
|
182
|
-
if (!options.launch) {
|
|
183
|
-
throw new LunoraError(
|
|
184
|
-
"INTERNAL",
|
|
185
|
-
'@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").'
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
return options.launch;
|
|
189
|
-
};
|
|
190
|
-
const withBrowser = async (use) => {
|
|
191
|
-
const browser = await getLaunch()(options.binding);
|
|
192
|
-
try {
|
|
193
|
-
return await use(browser);
|
|
194
|
-
} finally {
|
|
195
|
-
try {
|
|
196
|
-
await browser.close();
|
|
197
|
-
} catch {
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
};
|
|
201
|
-
const withPage = async (url, navigate, use, viewport) => {
|
|
202
|
-
const allowPrivateTargets = options.allowPrivateTargets ?? false;
|
|
203
|
-
const target = validateUrl(url, allowPrivateTargets, options.allowedHosts);
|
|
204
|
-
const timeout = resolveTimeout(navigate.timeoutMs, options.timeoutMs);
|
|
205
|
-
const resolveDns = options.resolveDns ?? false;
|
|
206
|
-
const dohTimeout = Math.min(timeout, DOH_TIMEOUT_MS);
|
|
207
|
-
if (!allowPrivateTargets && resolveDns) {
|
|
208
|
-
await assertResolvedHostIsPublic(target, dohTimeout);
|
|
209
|
-
}
|
|
210
|
-
const assertNavigationAllowed = async (requestUrl) => {
|
|
211
|
-
validateUrl(requestUrl, allowPrivateTargets, options.allowedHosts);
|
|
212
|
-
if (resolveDns) {
|
|
213
|
-
await assertResolvedHostIsPublic(requestUrl, dohTimeout);
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
return withBrowser(async (browser) => {
|
|
217
|
-
const context = await browser.newContext();
|
|
218
|
-
const page = await context.newPage();
|
|
219
|
-
if (!allowPrivateTargets && page.route) {
|
|
220
|
-
await page.route("**/*", async (route) => {
|
|
221
|
-
const request = route.request();
|
|
222
|
-
const isNavigation = request.isNavigationRequest?.() ?? true;
|
|
223
|
-
if (!isNavigation) {
|
|
224
|
-
await route.continue();
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
try {
|
|
228
|
-
await assertNavigationAllowed(request.url());
|
|
229
|
-
} catch {
|
|
230
|
-
await route.abort("blockedbyclient");
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
await route.continue();
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
if (viewport && page.setViewportSize) {
|
|
237
|
-
await page.setViewportSize(clampViewport(viewport));
|
|
238
|
-
}
|
|
239
|
-
await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
|
|
240
|
-
return use(page);
|
|
241
|
-
});
|
|
242
|
-
};
|
|
243
|
-
const screenshot = async (url, screenshotOptions = {}) => withPage(
|
|
244
|
-
url,
|
|
245
|
-
screenshotOptions,
|
|
246
|
-
async (page) => page.screenshot({
|
|
247
|
-
fullPage: screenshotOptions.fullPage ?? false,
|
|
248
|
-
type: screenshotOptions.type ?? "png"
|
|
249
|
-
}),
|
|
250
|
-
screenshotOptions.viewport
|
|
251
|
-
);
|
|
252
|
-
const pdf = async (url, pdfOptions = {}) => withPage(
|
|
253
|
-
url,
|
|
254
|
-
pdfOptions,
|
|
255
|
-
async (page) => page.pdf({
|
|
256
|
-
format: pdfOptions.format,
|
|
257
|
-
printBackground: pdfOptions.printBackground ?? false
|
|
258
|
-
}),
|
|
259
|
-
pdfOptions.viewport
|
|
260
|
-
);
|
|
261
|
-
const content = async (url, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.content());
|
|
262
|
-
const scrape = async (url, function_, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.evaluate(function_));
|
|
263
|
-
const launch = async (function_) => withBrowser(function_);
|
|
264
|
-
return {
|
|
265
|
-
content,
|
|
266
|
-
launch,
|
|
267
|
-
pdf,
|
|
268
|
-
scrape,
|
|
269
|
-
screenshot
|
|
270
|
-
};
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
export { createBrowser };
|