@lunora/browser 1.0.0-alpha.11 → 1.0.0-alpha.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- export { createBrowser } from './packem_shared/createBrowser-CZmfQ38r.mjs';
1
+ import{createBrowser as o}from"./packem_shared/createBrowser-7KtY5lSV.mjs";export{o as createBrowser};
@@ -0,0 +1 @@
1
+ import{LunoraError as l}from"@lunora/errors";const L=/^\d{1,3}$/u,P=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,A=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,F=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,H=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,_=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,k=/^\[|\]$/gu,O=/\.$/u,f=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(o=>L.test(o)?Number(o):-1);if(!r.some(o=>o<0||o>255))return[r[0],r[1],r[2],r[3]]},p=([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,R=(t,e)=>{const r=Number.parseInt(t??"",16),o=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(o)?!0:p([Math.floor(r/256),r%256,Math.floor(o/256),o%256])},S=t=>{const e=t.toLowerCase(),r=P.exec(e);if(r)return R(r[1],r[2]);const o=A.exec(e);if(o){const s=f(o[1]??"");return s===void 0||p(s)}const n=F.exec(e);if(n){const s=f(n[1]??"");return s===void 0||p(s)}const a=H.exec(e);return a?R(a[1],a[2]):_.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(k,"").replace(O,"").toLowerCase(),U=t=>{const e=h(t);if(e.includes(":"))return S(e);const r=f(e);return r===void 0?q(e):p(r)},B=3e4,C=12e4,Q=3840,V=4320,j="https://cloudflare-dns.com/dns-query",b=5e3,g=1,W=28,z=(t,e)=>{if(e===g){const r=f(t);return r===void 0||p(r)}return S(t.toLowerCase())},N=async(t,e,r=b)=>{try{const o=await fetch(`${j}?name=${encodeURIComponent(t)}&type=${String(e)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(r)});return o.ok?(await o.json()).Answer??[]:void 0}catch{return}},T=async(t,e=b)=>{const r=h(new URL(t).hostname);if(r.includes(":")||f(r)!==void 0)return;const[o,n]=await Promise.all([N(r,g,e),N(r,W,e)]);if(!(o===void 0&&n===void 0)){for(const a of[...o??[],...n??[]])if((a.type===g||a.type===W)&&z(a.data,a.type))throw new l("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${a.data}); refusing to navigate (DNS-rebinding guard)`)}},D=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new l("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let o;try{o=new URL(t)}catch{throw new l("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(o.protocol!=="http:"&&o.protocol!=="https:")throw new l("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${o.protocol}")`);if(o.username!==""||o.password!=="")throw new l("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const n=h(o.hostname);if(!r.some(a=>h(a)===n))throw new l("FORBIDDEN",`@lunora/browser: url host "${o.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&U(o.hostname))throw new l("FORBIDDEN",`@lunora/browser: url host "${o.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return o.toString()},M=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,G=t=>({height:M(t.height,V),width:M(t.width,Q)}),J=(t,e)=>{const r=t??e??B,o=Number.isFinite(r)?r:B;return Math.min(Math.max(1,Math.floor(o)),C)},K=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((o,n)=>{r=setTimeout(()=>{n(new l("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},Y=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new l("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=async n=>{const a=await e()(t.binding);try{return await n(a)}finally{try{await a.close()}catch{}}},o=async(n,a,s,m)=>{const w=t.allowPrivateTargets??!1,v=D(n,w,t.allowedHosts),y=J(a.timeoutMs,t.timeoutMs),$=t.resolveDns??!1,E=Math.min(y,b);!w&&$&&await T(v,E);const x=async c=>{D(c,w,t.allowedHosts),!w&&$&&await T(c,E)},I=c=>{let i;try{i=new URL(c)}catch{return!1}if(i.protocol!=="http:"&&i.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const u=h(i.hostname);if(!t.allowedHosts.some(d=>h(d)===u))return!0}return U(i.hostname)};return r(async c=>{const i=await(await c.newContext()).newPage();return i.route&&(!w||(t.allowedHosts?.length??0)>0)&&await i.route("**/*",async u=>{const d=u.request();if(!(d.isNavigationRequest?.()??!0)){if(I(d.url())){await u.abort("blockedbyclient");return}await u.continue();return}try{await x(d.url())}catch{await u.abort("blockedbyclient");return}await u.continue()}),m&&i.setViewportSize&&await i.setViewportSize(G(m)),K(async()=>(await i.goto(v,{timeout:y,waitUntil:a.waitUntil??"load"}),s(i)),y)})};return{content:async(n,a={})=>o(n,a,async s=>s.content()),launch:async n=>r(n),pdf:async(n,a={})=>o(n,a,async s=>s.pdf({format:a.format,printBackground:a.printBackground??!1}),a.viewport),scrape:async(n,a,s={})=>o(n,s,async m=>m.evaluate(a)),screenshot:async(n,a={})=>o(n,a,async s=>s.screenshot({fullPage:a.fullPage??!1,type:a.type??"png"}),a.viewport)}};export{Y as createBrowser};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.12",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -1,318 +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 LunoraError("BAD_REQUEST", "@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 LunoraError("BAD_REQUEST", `@lunora/browser: url must be an absolute http(s) URL (got "${url}")`);
139
- }
140
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
141
- throw new LunoraError("BAD_REQUEST", `@lunora/browser: url protocol must be http(s) (got "${parsed.protocol}")`);
142
- }
143
- if (parsed.username !== "" || parsed.password !== "") {
144
- throw new LunoraError("BAD_REQUEST", "@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
- "FORBIDDEN",
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 withDeadline = async (operation, timeoutMs) => {
178
- let timer;
179
- try {
180
- return await Promise.race([
181
- operation(),
182
- new Promise((_resolve, reject) => {
183
- timer = setTimeout(() => {
184
- reject(
185
- new LunoraError("BROWSER_TIMEOUT", `@lunora/browser: navigation + operation exceeded the ${String(timeoutMs)}ms timeout budget`, {
186
- status: 504
187
- })
188
- );
189
- }, timeoutMs);
190
- })
191
- ]);
192
- } finally {
193
- if (timer !== void 0) {
194
- clearTimeout(timer);
195
- }
196
- }
197
- };
198
- const createBrowser = (options) => {
199
- if (!options.binding) {
200
- throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");
201
- }
202
- const getLaunch = () => {
203
- if (!options.launch) {
204
- throw new LunoraError(
205
- "INTERNAL",
206
- '@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").'
207
- );
208
- }
209
- return options.launch;
210
- };
211
- const withBrowser = async (use) => {
212
- const browser = await getLaunch()(options.binding);
213
- try {
214
- return await use(browser);
215
- } finally {
216
- try {
217
- await browser.close();
218
- } catch {
219
- }
220
- }
221
- };
222
- const withPage = async (url, navigate, use, viewport) => {
223
- const allowPrivateTargets = options.allowPrivateTargets ?? false;
224
- const target = validateUrl(url, allowPrivateTargets, options.allowedHosts);
225
- const timeout = resolveTimeout(navigate.timeoutMs, options.timeoutMs);
226
- const resolveDns = options.resolveDns ?? false;
227
- const dohTimeout = Math.min(timeout, DOH_TIMEOUT_MS);
228
- if (!allowPrivateTargets && resolveDns) {
229
- await assertResolvedHostIsPublic(target, dohTimeout);
230
- }
231
- const assertNavigationAllowed = async (requestUrl) => {
232
- validateUrl(requestUrl, allowPrivateTargets, options.allowedHosts);
233
- if (!allowPrivateTargets && resolveDns) {
234
- await assertResolvedHostIsPublic(requestUrl, dohTimeout);
235
- }
236
- };
237
- const isBlockedSubresource = (rawUrl) => {
238
- let parsed;
239
- try {
240
- parsed = new URL(rawUrl);
241
- } catch {
242
- return false;
243
- }
244
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
245
- return false;
246
- }
247
- if (options.allowedHosts && options.allowedHosts.length > 0) {
248
- const host = normalizeHost(parsed.hostname);
249
- if (!options.allowedHosts.some((entry) => normalizeHost(entry) === host)) {
250
- return true;
251
- }
252
- }
253
- return isPrivateTarget(parsed);
254
- };
255
- return withBrowser(async (browser) => {
256
- const context = await browser.newContext();
257
- const page = await context.newPage();
258
- if (page.route && (!allowPrivateTargets || (options.allowedHosts?.length ?? 0) > 0)) {
259
- await page.route("**/*", async (route) => {
260
- const request = route.request();
261
- const isNavigation = request.isNavigationRequest?.() ?? true;
262
- if (!isNavigation) {
263
- if (isBlockedSubresource(request.url())) {
264
- await route.abort("blockedbyclient");
265
- return;
266
- }
267
- await route.continue();
268
- return;
269
- }
270
- try {
271
- await assertNavigationAllowed(request.url());
272
- } catch {
273
- await route.abort("blockedbyclient");
274
- return;
275
- }
276
- await route.continue();
277
- });
278
- }
279
- if (viewport && page.setViewportSize) {
280
- await page.setViewportSize(clampViewport(viewport));
281
- }
282
- return withDeadline(async () => {
283
- await page.goto(target, { timeout, waitUntil: navigate.waitUntil ?? "load" });
284
- return use(page);
285
- }, timeout);
286
- });
287
- };
288
- const screenshot = async (url, screenshotOptions = {}) => withPage(
289
- url,
290
- screenshotOptions,
291
- async (page) => page.screenshot({
292
- fullPage: screenshotOptions.fullPage ?? false,
293
- type: screenshotOptions.type ?? "png"
294
- }),
295
- screenshotOptions.viewport
296
- );
297
- const pdf = async (url, pdfOptions = {}) => withPage(
298
- url,
299
- pdfOptions,
300
- async (page) => page.pdf({
301
- format: pdfOptions.format,
302
- printBackground: pdfOptions.printBackground ?? false
303
- }),
304
- pdfOptions.viewport
305
- );
306
- const content = async (url, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.content());
307
- const scrape = async (url, function_, navigateOptions = {}) => withPage(url, navigateOptions, async (page) => page.evaluate(function_));
308
- const launch = async (function_) => withBrowser(function_);
309
- return {
310
- content,
311
- launch,
312
- pdf,
313
- scrape,
314
- screenshot
315
- };
316
- };
317
-
318
- export { createBrowser };