@lunora/browser 1.0.0-alpha.21 → 1.0.0-alpha.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -230,10 +230,21 @@ interface LunoraBrowserOptions {
230
230
  * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
231
231
  * resolved A/AAAA record is a private/internal address — closing the gap
232
232
  * where a public hostname resolves to a private IP after the string guard
233
- * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
234
- * (the browser re-resolves independently). If the DoH lookup itself fails, it
235
- * falls back to the string guard rather than allowing a resolved private IP.
236
- * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
233
+ * passes.
234
+ *
235
+ * **On by default when no {@link LunoraBrowserOptions.allowedHosts} is set**,
236
+ * because a `scrape`/`screenshot` action that forwards a client-supplied URL
237
+ * is the common shape and the string guard alone lets
238
+ * `http://127.0.0.1.nip.io:8787/…` through to an internal service. It costs
239
+ * one DNS round-trip per navigation and is TOCTOU-imperfect (the browser
240
+ * re-resolves independently), and if the DoH lookup itself fails it falls
241
+ * back to the string guard rather than allowing a resolved private IP.
242
+ *
243
+ * Configuring `allowedHosts` turns it OFF by default: an exact-origin
244
+ * allowlist is the stronger guard and may deliberately name an internal host
245
+ * (reachable over a Tunnel / private-network binding) that a resolved-address
246
+ * check would refuse. Set this explicitly to `true` to run both, or to
247
+ * `false` for trusted, non-caller-supplied URLs where the round-trip matters.
237
248
  */
238
249
  resolveDns?: boolean;
239
250
  /**
package/dist/index.d.ts CHANGED
@@ -230,10 +230,21 @@ interface LunoraBrowserOptions {
230
230
  * (`https://cloudflare-dns.com/dns-query`) and refuses to navigate if any
231
231
  * resolved A/AAAA record is a private/internal address — closing the gap
232
232
  * where a public hostname resolves to a private IP after the string guard
233
- * passes. Off by default: it adds a DNS round-trip and is TOCTOU-imperfect
234
- * (the browser re-resolves independently). If the DoH lookup itself fails, it
235
- * falls back to the string guard rather than allowing a resolved private IP.
236
- * For a hard guarantee prefer {@link LunoraBrowserOptions.allowedHosts}.
233
+ * passes.
234
+ *
235
+ * **On by default when no {@link LunoraBrowserOptions.allowedHosts} is set**,
236
+ * because a `scrape`/`screenshot` action that forwards a client-supplied URL
237
+ * is the common shape and the string guard alone lets
238
+ * `http://127.0.0.1.nip.io:8787/…` through to an internal service. It costs
239
+ * one DNS round-trip per navigation and is TOCTOU-imperfect (the browser
240
+ * re-resolves independently), and if the DoH lookup itself fails it falls
241
+ * back to the string guard rather than allowing a resolved private IP.
242
+ *
243
+ * Configuring `allowedHosts` turns it OFF by default: an exact-origin
244
+ * allowlist is the stronger guard and may deliberately name an internal host
245
+ * (reachable over a Tunnel / private-network binding) that a resolved-address
246
+ * check would refuse. Set this explicitly to `true` to run both, or to
247
+ * `false` for trusted, non-caller-supplied URLs where the round-trip matters.
237
248
  */
238
249
  resolveDns?: boolean;
239
250
  /**
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{createBrowser as o}from"./packem_shared/createBrowser-Dn01AHCX.mjs";export{o as createBrowser};
1
+ import{createBrowser as o}from"./packem_shared/createBrowser-elmnaxue.mjs";export{o as createBrowser};
@@ -0,0 +1 @@
1
+ import{LunoraError as c}from"@lunora/errors";const P=/^\d{1,3}$/u,x=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,H=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,_=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,F=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,O=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,C=/^\[|\]$/gu,q=/\.$/u,m=t=>{const e=t.split(".");if(e.length!==4)return;const n=e.map(r=>P.test(r)?Number(r):-1);if(!n.some(r=>r<0||r>255))return[n[0],n[1],n[2],n[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,R=(t,e)=>{const n=Number.parseInt(t??"",16),r=Number.parseInt(e??"",16);return!Number.isFinite(n)||!Number.isFinite(r)?!0:y([Math.floor(n/256),n%256,Math.floor(r/256),r%256])},M=t=>{const e=t.toLowerCase(),n=x.exec(e);if(n)return R(n[1],n[2]);const r=H.exec(e);if(r){const a=m(r[1]??"");return a===void 0||y(a)}const i=_.exec(e);if(i){const a=m(i[1]??"");return a===void 0||y(a)}const o=F.exec(e);return o?R(o[1],o[2]):O.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"),w=t=>t.replaceAll(C,"").replace(q,"").toLowerCase(),U=t=>{const e=w(t);if(e.includes(":"))return M(e);const n=m(e);return n===void 0?Q(e):y(n)},j="https://cloudflare-dns.com/dns-query",b=1,N=28,z=2e3,V=(t,e)=>{if(e===b){const n=m(t);return n===void 0||y(n)}return M(t.toLowerCase())},k=async(t,e,n)=>{try{const r=await fetch(`${j}?name=${encodeURIComponent(t)}&type=${String(e)}`,{headers:{accept:"application/dns-json"},signal:AbortSignal.timeout(n)});return r.ok?(await r.json()).Answer??[]:void 0}catch{return}},G=async(t,e=z)=>{const n=w(t);if(n.includes(":")||m(n)!==void 0)return{kind:"unknown"};const[r,i]=await Promise.all([k(n,b,e),k(n,N,e)]);if(r===void 0&&i===void 0)return{kind:"unknown"};for(const o of[...r??[],...i??[]])if((o.type===b||o.type===N)&&V(o.data,o.type))return{address:o.data,kind:"private"};return{kind:"public"}},B=3e4,J=12e4,K=3840,X=4320,A=5e3,T=async(t,e=A)=>{const n=w(new URL(t).hostname),r=await G(n,e);if(r.kind==="private")throw new c("FORBIDDEN",`@lunora/browser: url host "${n}" resolves to a private/internal address (${r.address}); refusing to navigate (DNS-rebinding guard)`)},S=(t,e,n)=>{if(typeof t!="string"||t.length===0)throw new c("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let r;try{r=new URL(t)}catch{throw new c("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(r.protocol!=="http:"&&r.protocol!=="https:")throw new c("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${r.protocol}")`);if(r.username!==""||r.password!=="")throw new c("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(n&&n.length>0){const i=w(r.hostname);if(!n.some(o=>w(o)===i))throw new c("FORBIDDEN",`@lunora/browser: url host "${r.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&U(r.hostname))throw new c("FORBIDDEN",`@lunora/browser: url host "${r.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return r.toString()},W=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,Y=t=>({height:W(t.height,X),width:W(t.width,K)}),Z=(t,e)=>{const n=t??e??B,r=Number.isFinite(n)?n:B;return Math.min(Math.max(1,Math.floor(r)),J)},tt=async(t,e)=>{let n;try{return await Promise.race([t(),new Promise((r,i)=>{n=setTimeout(()=>{i(new c("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{n!==void 0&&clearTimeout(n)}},D=async t=>{try{await t.close()}catch{}},nt=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new c("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},n=(o,a)=>{if(!o)throw new c("INTERNAL",`@lunora/browser: \`${a}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${a} }).`);return o},r=async(o,a)=>{const s=await e()(t.binding,a===void 0?void 0:{keep_alive:a*1e3});if(a!==void 0)return await o(s);try{return await o(s)}finally{await D(s)}},i=async(o,a,s,u)=>{const f=t.allowPrivateTargets??!1,v=S(o,f,t.allowedHosts),g=Z(a.timeoutMs,t.timeoutMs),$=t.resolveDns??(t.allowedHosts?.length??0)===0,E=Math.min(g,A);!f&&$&&await T(v,E);const I=async h=>{S(h,f,t.allowedHosts),!f&&$&&await T(h,E)},L=h=>{let l;try{l=new URL(h)}catch{return!1}if(l.protocol!=="http:"&&l.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const d=w(l.hostname);if(!t.allowedHosts.some(p=>w(p)===d))return!0}return U(l.hostname)};return r(async h=>{const l=await(await h.newContext()).newPage();return l.route&&(!f||(t.allowedHosts?.length??0)>0)&&await l.route("**/*",async d=>{const p=d.request();if(!(p.isNavigationRequest?.()??!0)){if(L(p.url())){await d.abort("blockedbyclient");return}await d.continue();return}try{await I(p.url())}catch{await d.abort("blockedbyclient");return}await d.continue()}),u&&l.setViewportSize&&await l.setViewportSize(Y(u)),tt(async()=>(await l.goto(v,{timeout:g,waitUntil:a.waitUntil??"load"}),s(l)),g)})};return{connect:async(o,a,s={})=>{const u=await n(t.connect,"connect")(t.binding,o);if(s.close!==!0)return await a(u);try{return await a(u)}finally{await D(u)}},content:async(o,a={})=>i(o,a,async s=>s.content()),launch:async(o,a={})=>r(o,a.keepAlive),pdf:async(o,a={})=>i(o,a,async s=>s.pdf({format:a.format,printBackground:a.printBackground??!1}),a.viewport),scrape:async(o,a,s={})=>i(o,s,async u=>u.evaluate(a)),screenshot:async(o,a={})=>i(o,a,async s=>s.screenshot({fullPage:a.fullPage??!1,type:a.type??"png"}),a.viewport),sessions:async()=>await n(t.sessions,"sessions")(t.binding)}};export{nt as createBrowser};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/browser",
3
- "version": "1.0.0-alpha.21",
3
+ "version": "1.0.0-alpha.22",
4
4
  "description": "Cloudflare Browser Rendering for Lunora: ctx.browser screenshots, PDF, and scraping in actions",
5
5
  "keywords": [
6
6
  "browser-rendering",
@@ -1 +0,0 @@
1
- import{LunoraError as c}from"@lunora/errors";const P=/^\d{1,3}$/u,k=/^::ffff:([\da-f]{1,4}):([\da-f]{1,4})$/u,F=/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u,H=/^::(\d{1,3}(?:\.\d{1,3}){3})$/u,_=/^::([\da-f]{1,4}):([\da-f]{1,4})$/u,O=/^64:ff9b::[\da-f]{1,4}:[\da-f]{1,4}$/u,q=/^\[|\]$/gu,C=/\.$/u,y=t=>{const e=t.split(".");if(e.length!==4)return;const r=e.map(n=>P.test(n)?Number(n):-1);if(!r.some(n=>n<0||n>255))return[r[0],r[1],r[2],r[3]]},m=([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,N=(t,e)=>{const r=Number.parseInt(t??"",16),n=Number.parseInt(e??"",16);return!Number.isFinite(r)||!Number.isFinite(n)?!0:m([Math.floor(r/256),r%256,Math.floor(n/256),n%256])},A=t=>{const e=t.toLowerCase(),r=k.exec(e);if(r)return N(r[1],r[2]);const n=F.exec(e);if(n){const a=y(n[1]??"");return a===void 0||m(a)}const i=H.exec(e);if(i){const a=y(i[1]??"");return a===void 0||m(a)}const o=_.exec(e);return o?N(o[1],o[2]):O.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"),d=t=>t.replaceAll(q,"").replace(C,"").toLowerCase(),I=t=>{const e=d(t);if(e.includes(":"))return A(e);const r=y(e);return r===void 0?Q(e):m(r)},B=3e4,V=12e4,j=3840,z=4320,G="https://cloudflare-dns.com/dns-query",v=5e3,b=1,T=28,J=(t,e)=>{if(e===b){const r=y(t);return r===void 0||m(r)}return A(t.toLowerCase())},W=async(t,e,r=v)=>{try{const n=await fetch(`${G}?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}},S=async(t,e=v)=>{const r=d(new URL(t).hostname);if(r.includes(":")||y(r)!==void 0)return;const[n,i]=await Promise.all([W(r,b,e),W(r,T,e)]);if(!(n===void 0&&i===void 0)){for(const o of[...n??[],...i??[]])if((o.type===b||o.type===T)&&J(o.data,o.type))throw new c("FORBIDDEN",`@lunora/browser: url host "${r}" resolves to a private/internal address (${o.data}); refusing to navigate (DNS-rebinding guard)`)}},D=(t,e,r)=>{if(typeof t!="string"||t.length===0)throw new c("BAD_REQUEST","@lunora/browser: url must be a non-empty string");let n;try{n=new URL(t)}catch{throw new c("BAD_REQUEST",`@lunora/browser: url must be an absolute http(s) URL (got "${t}")`)}if(n.protocol!=="http:"&&n.protocol!=="https:")throw new c("BAD_REQUEST",`@lunora/browser: url protocol must be http(s) (got "${n.protocol}")`);if(n.username!==""||n.password!=="")throw new c("BAD_REQUEST","@lunora/browser: url must not embed credentials (strip the `user:pass@` userinfo)");if(r&&r.length>0){const i=d(n.hostname);if(!r.some(o=>d(o)===i))throw new c("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is not in the configured allowedHosts allowlist`)}if(!e&&I(n.hostname))throw new c("FORBIDDEN",`@lunora/browser: url host "${n.hostname}" is a private/internal address; pass createBrowser({ …, allowPrivateTargets: true }) to allow it`);return n.toString()},U=(t,e)=>Number.isFinite(t)?Math.min(Math.max(1,Math.floor(t)),e):e,K=t=>({height:U(t.height,z),width:U(t.width,j)}),X=(t,e)=>{const r=t??e??B,n=Number.isFinite(r)?r:B;return Math.min(Math.max(1,Math.floor(n)),V)},Y=async(t,e)=>{let r;try{return await Promise.race([t(),new Promise((n,i)=>{r=setTimeout(()=>{i(new c("BROWSER_TIMEOUT",`@lunora/browser: navigation + operation exceeded the ${String(e)}ms timeout budget`,{status:504}))},e)})])}finally{r!==void 0&&clearTimeout(r)}},M=async t=>{try{await t.close()}catch{}},tt=t=>{if(!t.binding)throw new TypeError("@lunora/browser: `binding` is required (env.BROWSER)");const e=()=>{if(!t.launch)throw new c("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=(o,a)=>{if(!o)throw new c("INTERNAL",`@lunora/browser: \`${a}\` is not available — install the \`@cloudflare/playwright\` peer dependency. The generated worker wires it for you; outside codegen pass it via createBrowser({ binding, ${a} }).`);return o},n=async(o,a)=>{const s=await e()(t.binding,a===void 0?void 0:{keep_alive:a*1e3});if(a!==void 0)return await o(s);try{return await o(s)}finally{await M(s)}},i=async(o,a,s,u)=>{const f=t.allowPrivateTargets??!1,$=D(o,f,t.allowedHosts),g=X(a.timeoutMs,t.timeoutMs),E=t.resolveDns??!1,R=Math.min(g,v);!f&&E&&await S($,R);const L=async h=>{D(h,f,t.allowedHosts),!f&&E&&await S(h,R)},x=h=>{let l;try{l=new URL(h)}catch{return!1}if(l.protocol!=="http:"&&l.protocol!=="https:")return!1;if(t.allowedHosts&&t.allowedHosts.length>0){const w=d(l.hostname);if(!t.allowedHosts.some(p=>d(p)===w))return!0}return I(l.hostname)};return n(async h=>{const l=await(await h.newContext()).newPage();return l.route&&(!f||(t.allowedHosts?.length??0)>0)&&await l.route("**/*",async w=>{const p=w.request();if(!(p.isNavigationRequest?.()??!0)){if(x(p.url())){await w.abort("blockedbyclient");return}await w.continue();return}try{await L(p.url())}catch{await w.abort("blockedbyclient");return}await w.continue()}),u&&l.setViewportSize&&await l.setViewportSize(K(u)),Y(async()=>(await l.goto($,{timeout:g,waitUntil:a.waitUntil??"load"}),s(l)),g)})};return{connect:async(o,a,s={})=>{const u=await r(t.connect,"connect")(t.binding,o);if(s.close!==!0)return await a(u);try{return await a(u)}finally{await M(u)}},content:async(o,a={})=>i(o,a,async s=>s.content()),launch:async(o,a={})=>n(o,a.keepAlive),pdf:async(o,a={})=>i(o,a,async s=>s.pdf({format:a.format,printBackground:a.printBackground??!1}),a.viewport),scrape:async(o,a,s={})=>i(o,s,async u=>u.evaluate(a)),screenshot:async(o,a={})=>i(o,a,async s=>s.screenshot({fullPage:a.fullPage??!1,type:a.type??"png"}),a.viewport),sessions:async()=>await r(t.sessions,"sessions")(t.binding)}};export{tt as createBrowser};