diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf6f7f7..7b17653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,12 @@ jobs: with: python-version: '3.12' - run: python -m pip install pypdf==6.19.0 pypdfium2==5.14.0 fonttools==4.65.0 pillow==12.3.0 + - name: Verify the packed Workers entry in local workerd + run: | + npm run verify:cloudflare + python tools/verify-cloudflare.py + npm run verify:cloudflare:starter + python tools/verify-cloudflare-starter.py - run: python -m unittest discover -s test -p 'test_*.py' - run: npm run verify:text - run: python tools/verify-components.py @@ -115,6 +121,8 @@ jobs: output/hidden-text/ output/engine-paint/ output/component-verification/ + output/cloudflare-verification/ + output/cloudflare-starter-verification/ retention-days: 7 installed: @@ -169,6 +177,10 @@ jobs: node tools/render-engine-paint.mjs python tools/verify-engine-paint.py python tools/verify-components.py + npm run verify:cloudflare + python tools/verify-cloudflare.py + npm run verify:cloudflare:starter + python tools/verify-cloudflare-starter.py - uses: actions/upload-artifact@v4 if: always() && matrix.os == 'windows-latest' && matrix.node == '22' with: @@ -182,6 +194,8 @@ jobs: output/hidden-text/ output/engine-paint/ output/component-verification/ + output/cloudflare-verification/ + output/cloudflare-starter-verification/ retention-days: 7 - uses: actions/upload-artifact@v4 if: always() diff --git a/README.md b/README.md index 4874704..4797257 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ -# Fullbleed for Node.js and browsers +# Fullbleed for Node.js, browsers, and Cloudflare Workers -Generate PDFs from reusable function components, JSX, or static HTML and CSS in Node.js or a browser worker. The package includes the +Generate PDFs from static HTML/CSS, reusable function components, or JSX in Node.js, a browser worker, or Cloudflare Workers. The package includes the Fullbleed Rust engine compiled to WebAssembly and four font faces, so installing it does not require Python, Rust, a browser, or system fonts. MIT licensed. -Import `fullbleed` for Node.js or `fullbleed/browser` for a web application. +Import `fullbleed` for Node.js, `fullbleed/browser` for a web application, or +[`fullbleed/cloudflare`](docs/cloudflare.md) inside a Cloudflare Worker. Use [`fullbleed/ui`](docs/components.md) to compose documents without writing HTML strings. Named components, typed props, ordinary conditions and arrays, reusable -styles, and an optional JSX/TSX runtime work with either renderer. No React +styles, and an optional JSX/TSX runtime work with each renderer. No React runtime or build step is required for ordinary JavaScript functions. Try a complete project online: [JavaScript + Vite](https://docs.fullbleed.dev/assets/browser-starter/edit-online.html) @@ -21,7 +22,7 @@ Using Vue? The [Vue and TypeScript starter](examples/vue) includes editable templates, automatic previews, downloads, and a reusable `usePdfPreview` composable. This is an optional integration around the published **Fullbleed 2.5.29** engine. -The package has its own version, **0.7.1**. Node applications can use a shared +The package has its own version, **0.8.0**. Node applications can use a shared render queue to bound active renders and waiting requests, include queue time in deadlines, cancel waiting jobs, and stop rendering cleanly during shutdown. The browser entry renders in a Web Worker, and the Node API supports worker and @@ -31,7 +32,12 @@ process isolation. See the [browser guide](docs/browser.md), [font-family checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-families.md), and [embedded-font checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-subsets.md) for the retained fixtures. -Version 0.7.1 brings the stable engine's SVG stylesheet and inset-shadow fixes to +Version 0.8.0 adds the optional Workers entry with bundled engine/font imports, +shared input validation, and component authoring. It computes inside the Worker +isolate, with CPU limits owned by Cloudflare. The [Workers guide](docs/cloudflare.md) +describes its local-runtime verification and deployment requirements. + +Version 0.7.1 brought the stable engine's SVG stylesheet and inset-shadow fixes to Node.js and browser workers. The [paint checks](docs/engine-2.5.29.md) cover stylesheet selectors, inherited SVG colors and opacity, and clipped, blurred, translated and negative-spread inset shadows. The component and static HTML APIs @@ -73,7 +79,7 @@ Use Node.js 22 or newer. Install the [npm package](https://www.npmjs.com/package npm install fullbleed ``` -For a version-pinned installation, use `npm install --save-exact fullbleed@0.7.1`. +For a version-pinned installation, use `npm install --save-exact fullbleed@0.8.0`. Package archives and retained PDF/PNG evidence are attached to each [GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases). The [installation verification records](https://github.com/fullbleed-engine/fullbleed-node/tree/main/verification) diff --git a/docs/browser.md b/docs/browser.md index 7b8610d..d5412e8 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -1,7 +1,7 @@ # Generate PDFs in a browser -The `fullbleed/browser` entry in Fullbleed 0.5.0 renders static HTML/CSS in a -dedicated Web Worker. It uses the same pinned 2.5.22 engine and bundled fonts as +The `fullbleed/browser` entry in Fullbleed 0.8.0 renders static HTML/CSS in a +dedicated Web Worker. It uses the same pinned 2.5.29 engine and bundled fonts as the Node entry. The result is a `Uint8Array`, suitable for a PDF `Blob`, plus optional PNG previews. No PDF server or account is required. @@ -21,7 +21,7 @@ Install the package in your web project, then copy its browser files into the directory your application serves as static files: ```sh -npm install --save-exact fullbleed@0.5.0 +npm install --save-exact fullbleed@0.8.0 npx fullbleed-browser-assets public/fullbleed ``` diff --git a/docs/cloudflare.md b/docs/cloudflare.md new file mode 100644 index 0000000..91f860b --- /dev/null +++ b/docs/cloudflare.md @@ -0,0 +1,154 @@ +# Generate PDFs in Cloudflare Workers + +Import `renderPdf` from `fullbleed/cloudflare` inside a Worker. Wrangler bundles +the package's pinned WebAssembly engine and four font faces with your application. +The renderer uses an in-memory filesystem and produces PDF bytes plus optional +PNG previews. HTML/CSS and document data stay in the Worker handling the request. + +This entry is qualified with the local `workerd` runtime. Hosted CPU, memory, +startup behavior, and free-plan suitability require testing in your deployment. +See [Cloudflare's current limits](https://developers.cloudflare.com/workers/platform/limits/). + +For a complete JSON-to-PDF endpoint, use the +[invoice starter](../examples/cloudflare/). It includes an editable Northstar +component template, request limits, integer-cent totals, download headers and +local tests for normal, long, escaped and invalid invoices. + +## Add it to a Worker + +Use Node.js 22 or newer for your development tools: + +```sh +npm install --save-exact fullbleed@0.8.0 +npm install --save-dev --save-exact wrangler@4.151.3 +``` + +Add a font module rule to your Wrangler configuration. The `.wasm` import uses +Wrangler's built-in compiled-module rule. + +```json +{ + "name": "invoice-worker", + "main": "src/index.js", + "compatibility_date": "2026-10-20", + "workers_dev": false, + "preview_urls": false, + "rules": [{ "type": "Data", "globs": ["**/*.ttf"], "fallthrough": false }] +} +``` + +Save this as `src/index.js`: + +```js +import { renderPdf } from 'fullbleed/cloudflare'; + +export default { + async fetch() { + const result = await renderPdf({ + html: '

Invoice NS-1042

Design and delivery: $1,870.00

', + css: '@page { size: A4; margin: 20mm } h1 { color: #173b35 }', + maxPages: 5, + }); + return new Response(result.pdf, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': 'attachment; filename="invoice.pdf"', + 'Cache-Control': 'no-store', + }, + }); + }, +}; +``` + +Run `npx wrangler dev --local` and open the displayed localhost URL. No Cloudflare +account is needed for this local check. `workers_dev: false` and +`preview_urls: false` keep public preview URLs disabled if you later deploy; +configure service bindings or your application's authenticated route deliberately. + +## Components and styles + +The static component API works in the same Worker. A document artifact supplies +its HTML and CSS to the renderer: + +```js +import { renderPdf } from 'fullbleed/cloudflare'; +import { Document, Heading, Paragraph } from 'fullbleed/ui'; + +const doc = Document({ + title: 'Monthly statement', + styles: { + '@page': { size: 'A4', margin: '20mm' }, + '.balance': { color: '#173b35', fontSize: '24pt' }, + }, +}, Heading({ level: 1 }, 'Monthly statement'), + Paragraph({ className: 'balance' }, '$1,870.00')); + +const result = await doc.renderPdf(renderPdf, { maxPages: 5 }); +``` + +`className` adds an HTML class. The matching rule belongs in `styles` or the +renderer’s `css` string. Use `doc.renderPdf(renderPdf)` or pass both +`doc.toHtml()` and `doc.toCss()` for the separate HTML/CSS form. +`doc.toHtml({ includeStyles: true })` produces a complete HTML document with +its stylesheet embedded. This also renders with `css: ''` and +`defaultStyles: false`; the Workers checks compare both forms' PDF and preview +bytes. Remote stylesheets are not fetched. + +## Options and execution + +`renderPdf(options)` returns a Promise for `{ pdf, previews, pages, +missingGlyphs, engineVersion }`. PDF and PNG values are `Uint8Array` instances +backed by ordinary `ArrayBuffer` objects. + +| Option | Workers behavior | +| --- | --- | +| `html`, `css` | Static source; at most 1,000,000 UTF-8 bytes combined. | +| `defaultStyles` | Defaults to `true`, adding the Inter body rule; `false` preserves your CSS. | +| `fonts` | Additional TrueType font byte arrays, using family names in CSS. | +| `assets` | Byte arrays under relative names; refer to `assets/name.svg` in HTML/CSS. | +| `fonts` + `assets` | At most 8 MiB combined; accepted caller buffers are snapshotted. | +| `maxPages` | Defaults to 20; exceeding the limit rejects the result. | +| `previewDpi` | Defaults to 0; 36–300 enables one PNG per page. Account for memory. | +| `allowMissingGlyphs` | Defaults to `false`; explicit opt-in retains the missing-glyph count. | + +The computation is synchronous inside the isolate and returns a settled Promise. +Each call uses a fresh engine instance. Calls on the same isolate do not +interleave engine execution; this does not guarantee that previous allocations +have already been garbage-collected. + +`timeoutMs`, `signal`, and `isolation` are rejected. The SDK cannot interrupt +synchronous computation from within the same isolate. Cloudflare owns CPU and +memory enforcement; a platform termination may bypass application error handling. +Set workload-appropriate platform limits, bound accepted data and concurrency in +your application, and measure representative documents in your deployed Worker. +Page and input limits are not CPU or memory guarantees. + +Catch `FullbleedError` for input validation, page limits, missing glyphs, and +engine failures. A rejected call returns no partial PDF. A later call creates a +fresh instance. Document scripts do not run, and document URLs do not trigger +network fetching. Supply images and custom fonts as bytes. + +This is the static print engine, not browser layout or a live-page renderer. +The entry exposes ordinary PDF generation and previews, not PDF/A, PDF/UA, +PDF/X, or VDP profiles. + +## Verify the installed package + +```sh +npm ci --ignore-scripts +npm run build +npm test +npm run check:types +npm run verify:pack +npm run verify:cloudflare +python tools/verify-cloudflare.py +``` + +The Workers check uses the isolated tarball installation. It compares PDF and +PNG bytes with Node for Northstar, Common Ground, a custom font/SVG fixture, and +component-authored documents. It exercises input budgets, unsupported controls, +asset paths, page limits, missing glyphs, repeated calls, and failure recovery. +Runtime fetching and asynchronous Wasm compilation are disabled during the check. +An independent PDF reader checks pages, expected text, and the custom font face; +PNG files are decoded separately. Evidence is retained under +`output/cloudflare-verification/`. Local checks establish local behavior only. diff --git a/engine/Cargo.lock b/engine/Cargo.lock index f5367ae..899cb23 100644 --- a/engine/Cargo.lock +++ b/engine/Cargo.lock @@ -15,7 +15,7 @@ dependencies = [ [[package]] name = "fullbleed-node-adapter" -version = "0.7.1" +version = "0.8.0" dependencies = [ "fullbleed", ] diff --git a/engine/Cargo.toml b/engine/Cargo.toml index 0a4214d..df521b6 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fullbleed-node-adapter" -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT" publish = false diff --git a/examples/cloudflare/.gitignore b/examples/cloudflare/.gitignore new file mode 100644 index 0000000..54434c7 --- /dev/null +++ b/examples/cloudflare/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +dist/ +output/ +.dev.vars +.env* diff --git a/examples/cloudflare/LICENSE b/examples/cloudflare/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/cloudflare/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fullbleed Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/cloudflare/README.md b/examples/cloudflare/README.md new file mode 100644 index 0000000..d90ac27 --- /dev/null +++ b/examples/cloudflare/README.md @@ -0,0 +1,67 @@ +# A Fullbleed invoice Worker + +Send structured invoice JSON and receive a styled PDF attachment. This starter +uses `fullbleed/cloudflare` with a fixed, editable component template, bundled +fonts, integer-cent totals, and flowing tables. It stores no invoices or PDFs. +Northstar Studio and the sample customer are fictional. + +## Run locally + +Use Node.js 22 or newer: + +```sh +npm install +npm test +npm run build +npm run dev +``` + +In a second terminal: + +```sh +curl --fail-with-body http://127.0.0.1:8787/invoice \ + -H "Content-Type: application/json" \ + --data-binary @sample.json --output invoice.pdf +npm run test:worker +``` + +In Windows PowerShell, use `curl.exe` and place the command on one line. +`test:worker` submits synthetic invoices and retains PDFs under `output/`. +Set `FULLBLEED_WORKER_URL` to change the local test address. + +## Change the design + +- `src/document.mjs`: text, function components, fields, and table rows. +- `src/styles.mjs`: page size, margins, colors, typography, and class rules. +- `src/handler.mjs`: request schema, response headers, and validation limits. +- `src/index.mjs`: the Workers adapter import. + +`className` connects a component to a rule in the document's `StyleSheet`. +`Invoice({ data }).renderPdf(renderPdf)` passes both generated HTML and CSS. +For a browser HTML export, use `.toHtml({ includeStyles: true })`. You can also +replace the component template with your own HTML/CSS and call `renderPdf`. + +The sample accepts 1–30 rows, 32 KiB of JSON, dates in `YYYY-MM-DD` format, +nonnegative integer-cent rates, integer quantities, and totals up to $999,999.99. +PDFs have a 10-page limit. Change these bounds alongside representative layout +tests. The template uses USD and does not calculate tax. Invoice identifiers are +restricted to safe filename characters. Text is escaped by the component API. + +## Connect it to an application + +The configuration disables public Workers and preview URLs and includes no +route. The local endpoint has no authentication. Before providing an external +route, add your application's authentication, authorization and rate limits; +or call it through a private Worker service binding. Keep real invoices out of +logs. `npm run build` bundles locally and does not deploy anything. + +Rendering runs synchronously inside an isolate and returns a settled Promise. +The SDK does not expose cancellation or a timeout for Workers. Page and input +limits do not bound CPU time or memory. Local workerd checks are retained in the +SDK repository; hosted CPU, memory, startup and free-plan suitability have not +been qualified. Measure your real documents against +[Cloudflare's current limits](https://developers.cloudflare.com/workers/platform/limits/) +before choosing deployment settings. + +See the [adapter guide](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/cloudflare.md) +for custom assets, fonts, options and the supported execution model. diff --git a/examples/cloudflare/package.json b/examples/cloudflare/package.json new file mode 100644 index 0000000..e01cdaa --- /dev/null +++ b/examples/cloudflare/package.json @@ -0,0 +1,16 @@ +{ + "name": "fullbleed-cloudflare-invoice", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "MIT", + "engines": { "node": ">=22" }, + "scripts": { + "dev": "wrangler dev --local --ip 127.0.0.1 --port 8787 --inspector-port 0", + "build": "wrangler deploy --dry-run --outdir dist", + "test": "node --test test/request.test.mjs", + "test:worker": "node test/worker.mjs" + }, + "dependencies": { "fullbleed": "0.8.0" }, + "devDependencies": { "wrangler": "4.151.3" } +} diff --git a/examples/cloudflare/sample.json b/examples/cloudflare/sample.json new file mode 100644 index 0000000..6370866 --- /dev/null +++ b/examples/cloudflare/sample.json @@ -0,0 +1,31 @@ +{ + "number": "NS-1042", + "customer": "Maple & Finch", + "period": "September 2026", + "issued": "2026-10-01", + "due": "2026-10-15", + "paid": false, + "items": [ + { + "name": "Discovery & direction", + "detail": "Stakeholder workshops and a clear creative brief.", + "quantity": 12, + "rate_cents": 15000, + "progress": 100 + }, + { + "name": "Identity system", + "detail": "Typography, color, and the pieces that bring it together.", + "quantity": 24, + "rate_cents": 15000, + "progress": 100 + }, + { + "name": "Digital experience", + "detail": "A considered home for a growing brand.", + "quantity": 18, + "rate_cents": 15000, + "progress": 75 + } + ] +} diff --git a/examples/cloudflare/src/document.mjs b/examples/cloudflare/src/document.mjs new file mode 100644 index 0000000..7a77a33 --- /dev/null +++ b/examples/cloudflare/src/document.mjs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +import { component, Document, Box, Text, Paragraph, Strong, Header, Footer, Heading, + LineBreak, Section, LayoutGrid, Table, TableCaption, THead, TBody, Tr, Th, Td } from 'fullbleed/ui'; +import { invoiceStyles } from './styles.mjs'; + +export function money(cents) { + if (!Number.isSafeInteger(cents)) throw new TypeError('Amounts must be safe integer cents'); + const value = Math.abs(cents); + return (cents < 0 ? '-' : '') + '$' + Math.floor(value / 100).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + + '.' + String(value % 100).padStart(2, '0'); +} +export const Field = component(({ label, value }) => + Box(Text({ className: 'label' }, label), Paragraph({ className: 'value' }, value))); +export const WorkRow = component(({ item }) => Tr( + Td({ className: 'work' }, Paragraph({ className: 'item-name' }, item.name), + Paragraph({ className: 'detail' }, item.detail), + Box({ className: 'progress-track', 'aria-hidden': true }, + Box({ className: 'progress-fill', style: { width: `${item.progress}%` } }))), + Td({ className: 'number hours' }, item.quantity), + Td({ className: 'number rate' }, money(item.rate_cents)), + Td({ className: 'number amount' }, money(item.quantity * item.rate_cents)))); +export const WorkTable = component(({ items }) => Table( + TableCaption('The work, in detail'), + THead(Tr( + Th({ className: 'work', scope: 'col' }, 'Project'), + Th({ className: 'number hours', scope: 'col' }, 'Hours'), + Th({ className: 'number rate', scope: 'col' }, 'Rate'), + Th({ className: 'number amount', scope: 'col' }, 'Amount'))), + TBody(items.map(item => WorkRow({ item }))))); +export const Total = component(({ items, due, paid }) => Section({ className: 'total', 'aria-label': 'Invoice total' }, + Text({ className: 'label' }, paid ? 'Paid in full' : 'Amount due · USD'), + Paragraph({ className: 'total-amount' }, money(items.reduce((sum, item) => sum + item.quantity * item.rate_cents, 0))), + Paragraph({ className: 'total-note' }, paid ? 'Thank you.' : `Due ${due}`))); +export const PaymentNote = component(({ paid }) => Box( + Paragraph({ className: 'note' }, Strong('Onward, together.'), + 'Please include the invoice number with your payment. We’re grateful to be part of what comes next.'), + paid && Paragraph({ style: { fontSize: '8pt', marginTop: '6pt' } }, 'This invoice has been settled.'))); +export const Invoice = component(({ data }) => Document({ title: 'Northstar Studio / Invoice', lang: 'en', styles: invoiceStyles }, + Header({ className: 'brand' }, Paragraph({ className: 'wordmark' }, 'NORTHSTAR / STUDIO'), + Paragraph({ className: 'brand-note' }, 'Independent design.\nThoughtful by nature.')), + Section({ className: 'intro' }, Paragraph({ className: 'eyebrow' }, `Invoice ${data.number} · ${data.period}`), + Heading({ level: 1 }, 'Good work.', LineBreak(), 'Clear numbers.'), + Paragraph({ className: 'lede' }, 'A shared vision, made tangible. Here’s the work we brought to life together.')), + LayoutGrid({ className: 'meta', 'aria-label': 'Invoice details' }, Field({ label: 'Prepared for', value: data.customer }), + Field({ label: 'Issued', value: data.issued }), Field({ label: 'Payment due', value: data.due })), + WorkTable({ items: data.items }), + LayoutGrid({ className: 'summary', 'aria-label': 'Payment summary' }, PaymentNote({ paid: data.paid }), Total(data)), + Footer({ className: 'footer' }, 'NORTHSTAR STUDIO / FICTIONAL DEMONSTRATION / GENERATED WITH FULLBLEED'))); diff --git a/examples/cloudflare/src/handler.mjs b/examples/cloudflare/src/handler.mjs new file mode 100644 index 0000000..6ec8d9c --- /dev/null +++ b/examples/cloudflare/src/handler.mjs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: MIT +import { Invoice } from './document.mjs'; + +const BODY_LIMIT = 32 * 1024; +const MAX_CENTS = 99_999_999; +class RequestError extends Error { + constructor(status, code, message) { super(message); Object.assign(this, { status, code }); } +} +const invalid = message => { throw new RequestError(400, 'INVALID_INVOICE', message); }; +const json = (data, status = 200, headers = {}) => Response.json(data, { + status, headers: { 'Cache-Control': 'no-store', ...headers }, +}); +function fields(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) invalid(`${label} must be an object.`); + for (const key of Object.keys(value)) if (!keys.includes(key)) invalid(`${label} has an unknown field.`); +} +function text(value, max, label) { + if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/u.test(value)) { + invalid(`${label} must contain 1 to ${max} characters without control characters.`); + } + return value.trim(); +} +function integer(value, min, max, label) { + if (!Number.isSafeInteger(value) || value < min || value > max) invalid(`${label} must be an integer from ${min} to ${max}.`); + return value; +} +function date(value, label) { + if (typeof value !== 'string' || !/^20\d{2}-\d{2}-\d{2}$/.test(value)) invalid(`${label} must be a date in YYYY-MM-DD format (2000–2099).`); + const stamp = new Date(`${value}T00:00:00.000Z`); + if (!Number.isFinite(stamp.getTime()) || stamp.toISOString().slice(0, 10) !== value) invalid(`${label} is not a calendar date.`); + return stamp.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric', timeZone: 'UTC' }); +} +export function validateInvoice(data) { + fields(data, ['number', 'customer', 'period', 'issued', 'due', 'paid', 'items'], 'Invoice'); + const number = text(data.number, 32, 'number'); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(number)) invalid('number must use letters, digits, underscores or hyphens.'); + const customer = text(data.customer, 80, 'customer'); + const period = text(data.period, 32, 'period'); + const issued = date(data.issued, 'issued'); + const due = date(data.due, 'due'); + if (data.due < data.issued) invalid('due must be on or after issued.'); + if (typeof data.paid !== 'boolean') invalid('paid must be a boolean.'); + if (!Array.isArray(data.items) || data.items.length < 1 || data.items.length > 30) invalid('items must contain 1 to 30 rows.'); + let total = 0; + const items = data.items.map((item, i) => { + const label = `items[${i}]`; + fields(item, ['name', 'detail', 'quantity', 'rate_cents', 'progress'], label); + const name = text(item.name, 60, `${label}.name`); + const detail = text(item.detail, 160, `${label}.detail`); + const quantity = integer(item.quantity, 1, 999, `${label}.quantity`); + const rate_cents = integer(item.rate_cents, 0, MAX_CENTS, `${label}.rate_cents`); + const progress = integer(item.progress, 0, 100, `${label}.progress`); + total += quantity * rate_cents; + if (total > MAX_CENTS) invalid('The invoice total must not exceed $999,999.99.'); + return { name, detail, quantity, rate_cents, progress }; + }); + return { number, customer, period, issued, due, paid: data.paid, items }; +} +async function readJson(request) { + if ((request.headers.get('Content-Type') ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') { + throw new RequestError(415, 'JSON_REQUIRED', 'Send application/json.'); + } + // Enforce the limit on actual streamed bytes, including requests without Content-Length. + const reader = request.body?.getReader(); + if (!reader) throw new RequestError(400, 'INVALID_JSON', 'Send a JSON invoice.'); + let size = 0; + const chunks = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > BODY_LIMIT) { + await reader.cancel().catch(() => {}); + throw new RequestError(413, 'BODY_TOO_LARGE', 'JSON must not exceed 32 KiB.'); + } + chunks.push(value); + } + } finally { reader.releaseLock(); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); } + catch { throw new RequestError(400, 'INVALID_JSON', 'Send valid UTF-8 JSON.'); } +} +export function invoiceHandler(renderPdf) { + return { + async fetch(request) { + const path = new URL(request.url).pathname; + if (path === '/health' && request.method === 'GET') return json({ ok: true }); + if (path !== '/invoice') return json({ error: 'NOT_FOUND', message: 'POST JSON to /invoice.' }, 404); + if (request.method !== 'POST') return json({ error: 'METHOD_NOT_ALLOWED', message: 'Use POST.' }, 405, { Allow: 'POST' }); + try { + const data = validateInvoice(await readJson(request)); + const result = await Invoice({ data }).renderPdf(renderPdf, { maxPages: 10, defaultStyles: false }); + return new Response(result.pdf, { headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="invoice-${data.number}.pdf"`, + 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', + } }); + } catch (error) { + if (error instanceof RequestError) return json({ error: error.code, message: error.message }, error.status); + if (error?.code === 'MISSING_GLYPHS') return json({ error: 'MISSING_GLYPHS', message: 'This invoice needs an additional font. Register suitable font bytes in the Worker.' }, 422); + if (error?.code === 'PAGE_LIMIT') return json({ error: 'PAGE_LIMIT', message: 'This invoice exceeds the 10-page limit.' }, 422); + // Do not echo renderer errors or log invoice/customer data. + return json({ error: 'RENDER_FAILED', message: 'The invoice could not be rendered.' }, 500); + } + }, + }; +} diff --git a/examples/cloudflare/src/index.mjs b/examples/cloudflare/src/index.mjs new file mode 100644 index 0000000..897b536 --- /dev/null +++ b/examples/cloudflare/src/index.mjs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT +import { renderPdf } from 'fullbleed/cloudflare'; +import { invoiceHandler } from './handler.mjs'; + +export default invoiceHandler(renderPdf); diff --git a/examples/cloudflare/src/styles.mjs b/examples/cloudflare/src/styles.mjs new file mode 100644 index 0000000..22e3177 --- /dev/null +++ b/examples/cloudflare/src/styles.mjs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +import { StyleSheet } from "fullbleed/ui"; + +export const invoiceStyles = new StyleSheet({ + "@page": {"size": "A4", "margin": "17mm 18mm"}, + "body": {"margin": "0", "fontFamily": "Inter", "fontSize": "10pt", "lineHeight": "1.5", "color": "#233c36", "background": "#fff"}, + "*": {"boxSizing": "border-box"}, + "h1, h2, p": {"margin": "0"}, + ".brand": {"display": "grid", "gridTemplateColumns": "1fr 1fr", "paddingBottom": "16pt", "borderBottom": "2pt solid #233c36"}, + ".wordmark": {"fontSize": "15pt", "fontWeight": "800", "letterSpacing": "-0.5pt"}, + ".brand-note": {"textAlign": "right", "fontSize": "8pt", "color": "#63756e"}, + ".intro": {"paddingTop": "26pt", "paddingBottom": "24pt"}, + ".eyebrow": {"color": "#a0442e", "fontSize": "8pt", "fontWeight": "700", "letterSpacing": "1.5pt", "textTransform": "uppercase"}, + "h1": {"fontSize": "42pt", "fontWeight": "700", "lineHeight": "1.08", "letterSpacing": "-2pt", "margin": "9pt 0 13pt"}, + ".lede": {"width": "80%", "fontSize": "11pt", "color": "#63756e"}, + ".meta": {"display": "grid", "gridTemplateColumns": "2fr 1fr 1fr", "gap": "14pt", "borderTop": "1pt solid #d3ded8", "paddingTop": "13pt", "marginBottom": "26pt"}, + ".label": {"display": "block", "fontSize": "7pt", "letterSpacing": "1pt", "textTransform": "uppercase", "color": "#63756e", "marginBottom": "4pt"}, + ".value": {"fontWeight": "600"}, + "table": {"width": "100%", "borderCollapse": "collapse", "tableLayout": "fixed"}, + "caption": {"textAlign": "left", "fontSize": "8pt", "textTransform": "uppercase", "letterSpacing": "1pt", "paddingBottom": "10pt", "fontWeight": "700"}, + "th": {"textAlign": "left", "padding": "8pt 0", "borderTop": "1pt solid #233c36", "borderBottom": "1pt solid #233c36", "fontSize": "7pt", "textTransform": "uppercase", "letterSpacing": "0.8pt"}, + "td": {"padding": "13pt 0", "borderBottom": "1pt solid #d3ded8", "verticalAlign": "top"}, + ".work": {"width": "52%", "paddingRight": "16pt"}, + ".hours": {"width": "10%"}, + ".rate": {"width": "18%"}, + ".amount": {"width": "20%"}, + ".number": {"textAlign": "right", "fontVariantNumeric": "tabular-nums"}, + ".item-name": {"fontSize": "10pt", "fontWeight": "650"}, + ".detail": {"marginTop": "3pt", "fontSize": "8pt", "color": "#63756e"}, + ".progress-track": {"height": "3pt", "width": "100%", "background": "#e6ece8", "marginTop": "8pt"}, + ".progress-fill": {"height": "3pt", "background": "#b55b3e"}, + ".summary": {"display": "grid", "gridTemplateColumns": "1fr 1fr", "gap": "24pt", "marginTop": "24pt"}, + ".note": {"paddingTop": "8pt", "fontSize": "9pt", "color": "#63756e"}, + ".note strong": {"display": "block", "color": "#233c36", "marginBottom": "5pt"}, + ".total": {"background": "#233c36", "color": "#fff", "padding": "18pt", "borderRadius": "3pt"}, + ".total .label": {"color": "#d4e4dc"}, + ".total-amount": {"fontSize": "26pt", "fontWeight": "650", "letterSpacing": "-0.8pt", "marginTop": "4pt"}, + ".total-note": {"color": "#d4e4dc", "fontSize": "8pt", "marginTop": "6pt"}, + ".footer": {"marginTop": "24pt", "borderTop": "1pt solid #d3ded8", "paddingTop": "10pt", "fontSize": "7pt", "color": "#63756e"}, + "tr, .summary, .meta": {"breakInside": "avoid"}, +}); diff --git a/examples/cloudflare/test/fixtures.mjs b/examples/cloudflare/test/fixtures.mjs new file mode 100644 index 0000000..d66ad0a --- /dev/null +++ b/examples/cloudflare/test/fixtures.mjs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +import { readFile } from 'node:fs/promises'; +const sample = JSON.parse(await readFile(new URL('../sample.json', import.meta.url), 'utf8')); +export const cases = [ + { name: 'sample', data: sample, minPages: 1, maxPages: 1, + text: ['Maple & Finch', '$8,100.00', '01 October 2026'] }, + { name: 'minimum', data: { ...sample, number: 'NS-1043', paid: true, + items: [{ ...sample.items[0], quantity: 1, rate_cents: 0, progress: 0 }] }, + minPages: 1, maxPages: 1, text: ['$0.00', 'This invoice has been settled.'] }, + { name: 'maximum-total', data: { ...sample, number: 'NS-1044', + items: [{ ...sample.items[0], quantity: 1, rate_cents: 99999999 }] }, + minPages: 1, maxPages: 1, text: ['$999,999.99'] }, + { name: 'long', data: { ...sample, number: 'NS-1045', + customer: 'Maple & Finch International Design and Community Research Partnership', + items: Array.from({ length: 30 }, (_, i) => ({ name: `Work package ${i + 1}: design, research and delivery`, + detail: 'A considered set of activities, documented decisions and practical resources for the next phase of the project.', + quantity: 2, rate_cents: 15000, progress: 75 })) }, + minPages: 3, maxPages: 10, text: ['Work package 30:', '$9,000.00'] }, + { name: 'escaped', data: { ...sample, number: 'NS-1046', customer: ' & Partners' }, + minPages: 1, maxPages: 2, text: [' & Partners', '$8,100.00'] }, +]; +export const invalidCases = [ + { name: 'json', body: '{', status: 400, code: 'INVALID_JSON' }, + { name: 'content-type', body: JSON.stringify(sample), contentType: 'text/plain', status: 415, code: 'JSON_REQUIRED' }, + { name: 'body-limit', body: JSON.stringify({ padding: 'x'.repeat(32769) }), status: 413, code: 'BODY_TOO_LARGE' }, + { name: 'number', data: { ...sample, number: '../private' }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'date', data: { ...sample, due: '2026-02-30' }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'money', data: { ...sample, items: [{ ...sample.items[0], rate_cents: 1.5 }] }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'total', data: { ...sample, items: [{ ...sample.items[0], rate_cents: 99999999 }] }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'row-limit', data: { ...sample, items: Array(31).fill(sample.items[0]) }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'unknown', data: { ...sample, html: '

Arbitrary template

' }, status: 400, code: 'INVALID_INVOICE' }, + { name: 'glyph', data: { ...sample, customer: 'Customer \u{10ffff}' }, status: 422, code: 'MISSING_GLYPHS' }, +]; diff --git a/examples/cloudflare/test/request.test.mjs b/examples/cloudflare/test/request.test.mjs new file mode 100644 index 0000000..ad20253 --- /dev/null +++ b/examples/cloudflare/test/request.test.mjs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { invoiceHandler, validateInvoice } from '../src/handler.mjs'; + +const sample = JSON.parse(await readFile(new URL('../sample.json', import.meta.url), 'utf8')); +const request = (body, init = {}) => new Request('http://localhost/invoice', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), ...init, +}); +test('validates calendar dates, integer money, bounded rows, and safe filenames', () => { + const valid = validateInvoice(sample); + assert.equal(valid.issued, '01 October 2026'); + for (const update of [ + { issued: '2026-02-30' }, { due: '2026-09-01' }, { paid: 'false' }, + { number: 'bad\r\nname' }, { number: '../invoice' }, { unknown: true }, + { customer: '' }, { items: [] }, { items: Array(31).fill(sample.items[0]) }, + { items: [{ ...sample.items[0], rate_cents: 1.2 }] }, + { items: [{ ...sample.items[0], quantity: 999, rate_cents: 99999999 }] }, + { items: [{ ...sample.items[0], progress: 101 }] }, + ]) assert.throws(() => validateInvoice({ ...sample, ...update })); + assert.equal(validateInvoice({ ...sample, items: [{ ...sample.items[0], quantity: 1, rate_cents: 99999999 }] }).items[0].rate_cents, 99999999); +}); +test('escapes customer text, forwards CSS, and returns a private attachment', async () => { + let calls = 0; + const app = invoiceHandler(async options => { + calls++; + assert(options.html.includes('<script>alert(1)</script>')); + assert(!options.html.includes('' })); + assert.equal(response.status, 200); + assert.equal(response.headers.get('Content-Type'), 'application/pdf'); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.equal(response.headers.get('Content-Disposition'), 'attachment; filename="invoice-NS-1042.pdf"'); + assert.equal(calls, 1); +}); +test('bounds streamed bytes even when Content-Length is absent or wrong', async () => { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(32769)); }, + cancel() { canceled = true; }, + }); + const app = invoiceHandler(() => { throw new Error('Must not render'); }); + const response = await app.fetch(request(null, { body: stream, duplex: 'half', + headers: { 'Content-Type': 'application/json', 'Content-Length': '1' } })); + assert.equal(response.status, 413); + assert.equal(canceled, true); +}); +test('rejects malformed JSON and media types before rendering', async () => { + let calls = 0; + const app = invoiceHandler(() => { calls++; throw new Error('Must not render'); }); + for (const [body, status] of [['{', 400], [new Uint8Array([0xff]), 400]]) { + assert.equal((await app.fetch(request(null, { body }))).status, status); + } + assert.equal((await app.fetch(request(sample, { headers: { 'Content-Type': 'text/plain' } }))).status, 415); + assert.equal(calls, 0); +}); +test('routes methods and hides internal failure details', async () => { + const app = invoiceHandler(() => { throw new Error('private customer and engine internals'); }); + assert.equal((await app.fetch(new Request('http://localhost/missing'))).status, 404); + assert.equal((await app.fetch(new Request('http://localhost/invoice'))).headers.get('Allow'), 'POST'); + const failure = await app.fetch(request(sample)); + assert.equal(failure.status, 500); + assert(!JSON.stringify(await failure.json()).includes('private customer')); + for (const code of ['PAGE_LIMIT', 'MISSING_GLYPHS']) { + const failing = invoiceHandler(() => { throw Object.assign(new Error('internal'), { code }); }); + const response = await failing.fetch(request(sample)); + assert.equal(response.status, 422); + assert.equal((await response.json()).error, code); + } +}); diff --git a/examples/cloudflare/test/worker.mjs b/examples/cloudflare/test/worker.mjs new file mode 100644 index 0000000..7ae69e6 --- /dev/null +++ b/examples/cloudflare/test/worker.mjs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { cases, invalidCases } from './fixtures.mjs'; + +export async function checkWorker(base, output) { + // These checks submit synthetic data only. Explicitly disallow remote targets. + const url = new URL(base); + assert(['localhost', '127.0.0.1', '[::1]'].includes(url.hostname), 'Use a local test Worker.'); + assert.equal(url.protocol, 'http:'); + await mkdir(output, { recursive: true }); + const post = (body, contentType = 'application/json') => fetch(new URL('/invoice', url), { + method: 'POST', headers: { 'Content-Type': contentType }, body, signal: AbortSignal.timeout(60000), + }); + const records = []; + async function success(item, name = item.name) { + const response = await post(JSON.stringify(item.data)); + assert.equal(response.status, 200, name + ': ' + (response.status === 200 ? '' : await response.text())); + assert.equal(response.headers.get('Content-Type'), 'application/pdf'); + assert.equal(response.headers.get('Content-Disposition'), `attachment; filename="invoice-${item.data.number}.pdf"`); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.equal(response.headers.get('X-Content-Type-Options'), 'nosniff'); + const pdf = Buffer.from(await response.arrayBuffer()); + assert.equal(pdf.subarray(0, 5).toString(), '%PDF-'); + await writeFile(resolve(output, `${name}.pdf`), pdf); + records.push({ name, pdf: createHash('sha256').update(pdf).digest('hex'), bytes: pdf.length, + minPages: item.minPages, maxPages: item.maxPages, text: item.text, + trackedText: [item.data.number] }); + } + assert.equal((await fetch(new URL('/health', url))).status, 200); + const get = await fetch(new URL('/invoice', url)); + assert.equal(get.status, 405); + assert.equal(get.headers.get('Allow'), 'POST'); + assert.equal((await fetch(new URL('/missing', url))).status, 404); + for (const item of cases) await success(item); + for (const item of invalidCases) { + const response = await post(item.body ?? JSON.stringify(item.data), item.contentType); + assert.equal(response.status, item.status, item.name); + assert.equal((await response.json()).error, item.code, item.name); + } + await success(cases[0], 'after-errors'); + assert.equal(records[0].pdf, records.at(-1).pdf, 'Failure recovery must preserve output.'); + const result = { ok: true, records, errors: invalidCases.map(({ name, status, code }) => ({ name, status, code })), recovery: true }; + await writeFile(resolve(output, 'http-verification.json'), JSON.stringify(result, null, 2) + '\n'); + return result; +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const output = resolve('output'); + const result = await checkWorker(process.env.FULLBLEED_WORKER_URL ?? 'http://127.0.0.1:8787', output); + console.log(JSON.stringify({ ok: result.ok, invoices: result.records.length, errors: result.errors.length, output })); +} diff --git a/examples/cloudflare/wrangler.json b/examples/cloudflare/wrangler.json new file mode 100644 index 0000000..b4c9b4f --- /dev/null +++ b/examples/cloudflare/wrangler.json @@ -0,0 +1,9 @@ +{ + "name": "fullbleed-invoice", + "main": "src/index.mjs", + "compatibility_date": "2026-10-20", + "send_metrics": false, + "workers_dev": false, + "preview_urls": false, + "rules": [{ "type": "Data", "globs": ["**/*.ttf"], "fallthrough": false }] +} diff --git a/package-lock.json b/package-lock.json index 54d6564..3f88ae5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fullbleed", - "version": "0.7.1", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fullbleed", - "version": "0.7.1", + "version": "0.8.0", "license": "MIT", "dependencies": { "@bjorn3/browser_wasi_shim": "0.4.2" @@ -17,7 +17,8 @@ "devDependencies": { "@types/node": "22.20.5", "esbuild": "0.28.2", - "typescript": "7.0.2" + "typescript": "7.0.2", + "wrangler": "4.151.3" }, "engines": { "node": ">=22" @@ -29,6 +30,141 @@ "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==", "license": "MIT OR Apache-2.0" }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261019.1.tgz", + "integrity": "sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261019.1.tgz", + "integrity": "sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261019.1.tgz", + "integrity": "sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261019.1.tgz", + "integrity": "sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261019.1.tgz", + "integrity": "sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -471,39 +607,45 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "22.20.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", - "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "engines": { + "node": ">=18" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "aix" + "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "Apache-2.0", @@ -512,262 +654,860 @@ "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "darwin" + "freebsd" ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "freebsd" + "darwin" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ - "loong64" + "ppc64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ - "mips64el" + "riscv64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ - "ppc64" + "s390x" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ - "riscv64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ - "s390x" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ "linux" ], - "engines": { - "node": ">=16.20.0" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ - "x64" + "riscv64" ], "dev": true, "license": "Apache-2.0", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" } }, "node_modules/@typescript/typescript-sunos-x64": { @@ -821,6 +1561,47 @@ "node": ">=16.20.0" } }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -863,6 +1644,147 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20261019.1-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261019.1-beta.tgz", + "integrity": "sha512-1n7MN2UqZnPniyYAqNuV1YEAaja66ozKGVCc5ZTmYQhTubnoAwGLccwb+ZWYLOajuw3pOaJ70u//bZMuURlRew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.5", + "undici": "7.29.1", + "workerd": "1.20261019.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -898,12 +1820,136 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20261019.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261019.1.tgz", + "integrity": "sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20261019.1", + "@cloudflare/workerd-darwin-arm64": "1.20261019.1", + "@cloudflare/workerd-linux-64": "1.20261019.1", + "@cloudflare/workerd-linux-arm64": "1.20261019.1", + "@cloudflare/workerd-windows-64": "1.20261019.1" + } + }, + "node_modules/wrangler": { + "version": "4.151.3", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.151.3.tgz", + "integrity": "sha512-mzPQLmkAhZUV7CdiAhE+hiIAJlS9xPL6vZe1B8oQFuWxFu7nmQnvHAVjcAcabwSGkXFXH9uspzXwHOFrfbQmqQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.2", + "miniflare": "5.20261019.1-beta", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20261019.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20261019.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } } } } diff --git a/package.json b/package.json index d007842..9bcc573 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fullbleed", - "version": "0.7.1", - "description": "Generate PDFs from function components, JSX, or HTML/CSS in Node.js or a browser worker, with bundled fonts and PNG previews.", + "version": "0.8.0", + "description": "Generate PDFs from HTML/CSS, components or JSX in Node.js, browsers and Cloudflare Workers, with bundled fonts and PNG previews.", "type": "module", "main": "./src/index.cjs", "types": "./src/index.d.ts", @@ -20,6 +20,10 @@ "types": "./src/browser.d.ts", "default": "./dist/browser/client.js" }, + "./cloudflare": { + "types": "./src/cloudflare.d.ts", + "default": "./dist/cloudflare.js" + }, "./ui": { "browser": { "types": "./src/ui.d.ts", @@ -35,14 +39,32 @@ } }, "./ui/jsx-runtime": { - "browser": { "types": "./src/jsx-runtime.d.ts", "default": "./dist/jsx-runtime.js" }, - "import": { "types": "./src/jsx-runtime.d.ts", "default": "./src/jsx-runtime.js" }, - "require": { "types": "./dist/jsx-runtime.d.cts", "default": "./src/jsx-runtime.cjs" } + "browser": { + "types": "./src/jsx-runtime.d.ts", + "default": "./dist/jsx-runtime.js" + }, + "import": { + "types": "./src/jsx-runtime.d.ts", + "default": "./src/jsx-runtime.js" + }, + "require": { + "types": "./dist/jsx-runtime.d.cts", + "default": "./src/jsx-runtime.cjs" + } }, "./ui/jsx-dev-runtime": { - "browser": { "types": "./src/jsx-runtime.d.ts", "default": "./dist/jsx-runtime.js" }, - "import": { "types": "./src/jsx-runtime.d.ts", "default": "./src/jsx-runtime.js" }, - "require": { "types": "./dist/jsx-runtime.d.cts", "default": "./src/jsx-runtime.cjs" } + "browser": { + "types": "./src/jsx-runtime.d.ts", + "default": "./dist/jsx-runtime.js" + }, + "import": { + "types": "./src/jsx-runtime.d.ts", + "default": "./src/jsx-runtime.js" + }, + "require": { + "types": "./dist/jsx-runtime.d.cts", + "default": "./src/jsx-runtime.cjs" + } } }, "engines": { @@ -59,10 +81,12 @@ "build": "node tools/build.mjs", "prepack": "node tools/check-build.mjs", "test": "node --test test/*.test.mjs", - "check:types": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json && tsc -p tsconfig.jsx.json && tsc -p tsconfig.jsx.json --jsx react-jsxdev && tsc -p tsconfig.jsx.browser.json && tsc -p tsconfig.jsx.browser.json --jsx react-jsxdev && tsc -p tsconfig.jsx.json --jsxImportSource react", + "check:types": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json && tsc -p tsconfig.cloudflare.json && tsc -p tsconfig.jsx.json && tsc -p tsconfig.jsx.json --jsx react-jsxdev && tsc -p tsconfig.jsx.browser.json && tsc -p tsconfig.jsx.browser.json --jsx react-jsxdev && tsc -p tsconfig.jsx.json --jsxImportSource react", "verify:native": "node tools/verify-native.mjs", "example": "node examples/invoice.mjs", "verify:pack": "node tools/verify-pack.mjs", + "verify:cloudflare": "node tools/check-cloudflare.mjs", + "verify:cloudflare:starter": "node tools/check-cloudflare-starter.mjs", "verify:text": "python tools/verify-installed-text.py", "render:fonts": "node tools/render-font-subsets.mjs", "verify:fonts": "python tools/verify-font-subsets.py", @@ -97,7 +121,8 @@ "rust", "webassembly", "jsx", - "components" + "components", + "cloudflare-workers" ], "publishConfig": { "access": "public" @@ -107,8 +132,9 @@ }, "devDependencies": { "@types/node": "22.20.5", + "esbuild": "0.28.2", "typescript": "7.0.2", - "esbuild": "0.28.2" + "wrangler": "4.151.3" }, "bin": { "fullbleed-browser-assets": "src/copy-browser-assets.cjs" diff --git a/src/cloudflare.d.ts b/src/cloudflare.d.ts new file mode 100644 index 0000000..187701a --- /dev/null +++ b/src/cloudflare.d.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +export interface CloudflareRenderOptions { + /** HTML and CSS together are limited to 1,000,000 UTF-8 bytes. */ + html: string; + css?: string; + /** Default true: prepend the package's Inter body rule. False preserves CSS. */ + defaultStyles?: boolean; + /** Additional font bytes. Fonts and assets together are limited to 8 MiB. */ + fonts?: readonly Uint8Array[]; + /** Relative names referenced as assets/name.svg in document HTML/CSS. */ + assets?: Readonly>; + /** 0 disables previews (default); otherwise 36–300 DPI. Account for Worker memory. */ + previewDpi?: number; + /** Default 20. This is a page limit, not a CPU or memory guarantee. */ + maxPages?: number; + allowMissingGlyphs?: boolean; +} +export interface CloudflareRenderResult { + pdf: Uint8Array; + previews: Uint8Array[]; + pages: number; + missingGlyphs: number; + engineVersion: string; +} +export class FullbleedError extends Error { + readonly code: string; + constructor(code: string, message: string, options?: ErrorOptions); +} +/** Runs synchronously in the isolate and returns a settled Promise. No SDK cancellation or timeout. */ +export function renderPdf(options: CloudflareRenderOptions): Promise; +export const version: string; +export const engineVersion: string; diff --git a/src/cloudflare.js b/src/cloudflare.js new file mode 100644 index 0000000..5f3d4c8 --- /dev/null +++ b/src/cloudflare.js @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +import module from '../dist/engine.wasm'; +import inter from '../assets/fonts/Inter-Variable.ttf'; +import serif from '../assets/fonts/DMSerifDisplay-Regular.ttf'; +import serifItalic from '../assets/fonts/DMSerifDisplay-Italic.ttf'; +import display from '../assets/fonts/BebasNeue-Regular.ttf'; +import input from './render-input.cjs'; +import { runEngineSync } from './engine-runner.js'; + +export const version = __FULLBLEED_PACKAGE_VERSION__; +export const engineVersion = __FULLBLEED_ENGINE_VERSION__; +export const FullbleedError = input.FullbleedError; + +const bundledFonts = [ + ['Inter-Variable.ttf', inter], ['DMSerifDisplay-Regular.ttf', serif], + ['DMSerifDisplay-Italic.ttf', serifItalic], ['BebasNeue-Regular.ttf', display], +].map(([name, bytes]) => ({ name: 'fonts/' + name, data: new Uint8Array(bytes) })); + +if (!(module instanceof WebAssembly.Module) || !WebAssembly.Module.imports(module) + .every(item => item.module === 'wasi_snapshot_preview1' && !item.name.startsWith('sock_'))) { + throw new FullbleedError('ENGINE_LOAD_FAILED', 'Bundle fullbleed/cloudflare with the engine as a compiled Wasm module.'); +} + +/** Render inside the current Worker isolate. CPU deadlines are platform-owned. */ +export async function renderPdf(options) { + const job = input.normalize(options, { browser: true, interruptible: false, + maxSourceBytes: 1_000_000, maxAssetBytes: 8 * 1024 * 1024, defaultMaxPages: 20 }); + // Deliberately no await before or during the render: another request must not + // acquire a second live engine instance while this call is executing. + const result = runEngineSync({ module, html: job.html, css: input.stylesheet(job), + fonts: [...bundledFonts, ...job.customFonts], assets: job.assetFiles, + previewDpi: job.previewDpi, maxPages: job.maxPages, allowMissingGlyphs: job.allowMissingGlyphs }); + if (!result.ok) throw new FullbleedError(result.code, result.message); + if (!(result.pdf instanceof Uint8Array) || !Number.isSafeInteger(result.pages) || result.pages < 1 + || result.pages > job.maxPages || !Number.isSafeInteger(result.missingGlyphs) || result.missingGlyphs < 0 + || !Array.isArray(result.previews) || result.previews.length !== (job.previewDpi ? result.pages : 0) + || !result.previews.every(bytes => bytes instanceof Uint8Array && + [137, 80, 78, 71, 13, 10, 26, 10].every((value, i) => bytes[i] === value))) { + throw new FullbleedError('RENDER_FAILED', 'The rendering engine returned an invalid result.'); + } + return { pdf: result.pdf, previews: result.previews, pages: result.pages, + missingGlyphs: result.missingGlyphs, engineVersion }; +} diff --git a/src/engine-runner.js b/src/engine-runner.js index 91e4cad..c77dd0c 100644 --- a/src/engine-runner.js +++ b/src/engine-runner.js @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT import { WASI, File, OpenFile, Directory, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; -export async function runEngine(job) { +// A compiled module can be instantiated synchronously. Keeping the entire +// in-process call synchronous avoids overlapping Wasm instances in one Worker +// isolate; the Node/browser wrappers still run it in their dedicated workers. +export function runEngineSync(job) { try { const root = new PreopenDirectory('.', new Map()); const put = (path, data) => { @@ -22,7 +25,7 @@ export async function runEngine(job) { const wasi = new WASI(['fullbleed', String(job.maxPages), String(job.previewDpi), job.allowMissingGlyphs ? 'allow' : 'reject', ...job.fonts.map(f => f.name)], [], [ new OpenFile(new File([])), ConsoleStdout.lineBuffered(collect), ConsoleStdout.lineBuffered(collect), root, ], { debug: false }); - const instance = await WebAssembly.instantiate(job.module, { wasi_snapshot_preview1: wasi.wasiImport }); + const instance = new WebAssembly.Instance(job.module, { wasi_snapshot_preview1: wasi.wasiImport }); const status = wasi.start(instance); if (status !== 0) { const code = ['PAGE_LIMIT', 'MISSING_GLYPHS', 'PREVIEW_FAILED'].find(c => log.includes(c + ':')) ?? 'RENDER_FAILED'; @@ -44,3 +47,7 @@ export async function runEngine(job) { return { ok: false, code, message: cause.message || String(cause) }; } } + +export async function runEngine(job) { + return runEngineSync(job); +} diff --git a/src/render-input.cjs b/src/render-input.cjs index 960819e..56dbac9 100644 --- a/src/render-input.cjs +++ b/src/render-input.cjs @@ -12,16 +12,20 @@ const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); const sourceLimit = 4_000_000; const assetLimit = 64 * 1024 * 1024; -function normalize(options, { browser = false } = {}) { +function normalize(options, { browser = false, interruptible = true, + maxSourceBytes = sourceLimit, maxAssetBytes = assetLimit, defaultMaxPages = 1000 } = {}) { if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an options object with an html string.'); - const supported = new Set(['html', 'css', 'defaultStyles', 'fonts', 'assets', 'previewDpi', 'timeoutMs', 'maxPages', 'allowMissingGlyphs', 'signal']); + const supported = new Set(['html', 'css', 'defaultStyles', 'fonts', 'assets', 'previewDpi', 'maxPages', 'allowMissingGlyphs']); + if (interruptible) { supported.add('timeoutMs'); supported.add('signal'); } if (!browser) supported.add('isolation'); for (const key of Object.keys(options)) if (!supported.has(key)) invalid(`Unknown option: ${key}`); - const { html, css = '', defaultStyles = true, fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = 1000, allowMissingGlyphs = false, signal, isolation = 'worker' } = options; + const { html, css = '', defaultStyles = true, fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = defaultMaxPages, allowMissingGlyphs = false, signal, isolation = 'worker' } = options; if (typeof html !== 'string' || !html.trim()) invalid('html must be a nonempty string.'); if (typeof css !== 'string') invalid('css must be a string.'); if (typeof defaultStyles !== 'boolean') invalid('defaultStyles must be a boolean.'); - if (new TextEncoder().encode(html).byteLength + new TextEncoder().encode(css).byteLength > sourceLimit) invalid('HTML and CSS together must not exceed 4,000,000 UTF-8 bytes.'); + if (new TextEncoder().encode(html).byteLength + new TextEncoder().encode(css).byteLength > maxSourceBytes) { + invalid(`HTML and CSS together must not exceed ${maxSourceBytes.toLocaleString('en-US')} UTF-8 bytes.`); + } if (!Number.isInteger(previewDpi) || (previewDpi !== 0 && (previewDpi < 36 || previewDpi > 300))) invalid('previewDpi must be 0 (disabled) or an integer from 36 to 300.'); if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) invalid('timeoutMs must be a positive integer no greater than 2147483647.'); if (!Number.isSafeInteger(maxPages) || maxPages < 1) invalid('maxPages must be a positive integer.'); @@ -34,7 +38,7 @@ function normalize(options, { browser = false } = {}) { function bytes(value, label) { if (!(value instanceof Uint8Array) || !value.byteLength) invalid(`${label} must be a nonempty Uint8Array or Buffer.`); assetBytes += value.byteLength; - if (assetBytes > assetLimit) invalid('Custom fonts and assets together must not exceed 64 MiB.'); + if (assetBytes > maxAssetBytes) invalid(`Custom fonts and assets together must not exceed ${maxAssetBytes / (1024 * 1024)} MiB.`); return Uint8Array.from(value); // Snapshot caller-owned buffers before any await. } const customFonts = fonts.map((font, i) => ({ name: `fonts/custom-${i}.ttf`, data: bytes(font, `fonts[${i}]`) })); diff --git a/test/cloudflare-input.test.mjs b/test/cloudflare-input.test.mjs new file mode 100644 index 0000000..4a51da1 --- /dev/null +++ b/test/cloudflare-input.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +const { normalize, FullbleedError } = createRequire(import.meta.url)('../src/render-input.cjs'); +const limits = { browser: true, interruptible: false, maxSourceBytes: 1_000_000, + maxAssetBytes: 8 * 1024 * 1024, defaultMaxPages: 20 }; +const invalid = fn => assert.throws(fn, error => error instanceof FullbleedError && error.code === 'INVALID_INPUT'); + +test('Workers source budget counts UTF-8 bytes, including CSS', () => { + assert.equal(normalize({ html: 'é'.repeat(500_000) }, limits).maxPages, 20); + invalid(() => normalize({ html: 'é'.repeat(500_000), css: 'x' }, limits)); + assert.equal(normalize({ html: 'x'.repeat(1_000_001) }).maxPages, 1000); +}); + +test('Workers font and asset budget is combined and snapshots accepted bytes', () => { + const font = new Uint8Array(4 * 1024 * 1024).fill(1); + const asset = new Uint8Array(4 * 1024 * 1024).fill(2); + const job = normalize({ html: 'x', fonts: [font], assets: { 'image.dat': asset } }, limits); + font[0] = 3; asset[0] = 4; + assert.equal(job.customFonts[0].data[0], 1); + assert.equal(job.assetFiles[0].data[0], 2); + invalid(() => normalize({ html: 'x', fonts: [font], assets: { a: asset, b: new Uint8Array([1]) } }, limits)); + assert.equal(normalize({ html: 'x', assets: { a: new Uint8Array(8 * 1024 * 1024 + 1) } }).assetFiles.length, 1); +}); + +test('Workers reject interrupt and isolation options instead of accepting ineffective controls', () => { + for (const option of [{ timeoutMs: 1000 }, { signal: new AbortController().signal }, { isolation: 'worker' }]) { + invalid(() => normalize({ html: 'x', ...option }, limits)); + } + assert.equal(normalize({ html: 'x', timeoutMs: 10, isolation: 'process' }).timeoutMs, 10); +}); diff --git a/test/cloudflare/baseline.mjs b/test/cloudflare/baseline.mjs new file mode 100644 index 0000000..b6c99b7 --- /dev/null +++ b/test/cloudflare/baseline.mjs @@ -0,0 +1,18 @@ +import { renderPdf, version, engineVersion } from 'fullbleed'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { caseNames, expected, optionsFor } from './cases.mjs'; +const output = process.argv[2]; +const font = new Uint8Array(await readFile(new URL('./custom-font.ttf', import.meta.url))); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const records = []; +for (const name of caseNames) { + const options = optionsFor(name, font); + const result = await renderPdf(options); + if (result.pages !== expected[name].pages || result.missingGlyphs !== 0) throw new Error('Invalid baseline: ' + name); + await writeFile(join(output, name + '-node.pdf'), result.pdf); + for (const [i, bytes] of result.previews.entries()) await writeFile(join(output, `${name}-node-${i + 1}.png`), bytes); + records.push({ name, ...expected[name], pdf: hash(result.pdf), previews: result.previews.map(hash), missingGlyphs: result.missingGlyphs }); +} +await writeFile(join(output, 'node-baseline.json'), JSON.stringify({ ok: true, version, engineVersion, records }, null, 2) + '\n'); diff --git a/test/cloudflare/cases.mjs b/test/cloudflare/cases.mjs new file mode 100644 index 0000000..5335aec --- /dev/null +++ b/test/cloudflare/cases.mjs @@ -0,0 +1,50 @@ +import { Document, Heading, Paragraph, Box, Table, THead, TBody, Tr, Th, Td } from 'fullbleed/ui'; +import raw from './raw.json' with { type: 'json' }; + +export const caseNames = ['invoice', 'report', 'custom-assets', 'components', 'components-inline']; +export const expected = { + invoice: { pages: 1, text: ['Northstar', 'NS-1042', 'Cedar & Co.', '$1,870.00'] }, + report: { pages: 3, text: ['COMMON', 'GROUND', '1,240', '4,860', '$420,000.00'] }, + 'custom-assets': { pages: 1, text: ['Custom font and image', 'Type Alpha with care.'], face: 'LibreCaslonText-Regular' }, + components: { pages: 1, text: ['Worker statement', 'Cedar & Co. safe', '$1,870.00'] }, + 'components-inline': { pages: 1, text: ['Worker statement', 'Cedar & Co. safe', '$1,870.00'] }, +}; + +export function optionsFor(name, customFont) { + const common = { defaultStyles: false, maxPages: 20, previewDpi: 96 }; + if (name === 'invoice' || name === 'report') return { ...common, ...raw[name] }; + if (name === 'custom-assets') return { ...common, fonts: [customFont], + html: '

Custom font and image

Type Alpha with care.

Green panel with an orange circle', + css: '@page{size:A4;margin:20mm} body{font-family:"Libre Caslon Text";font-size:18pt;color:#173b35} h1{font-size:24pt}', + assets: { 'charts/marker.svg': new TextEncoder().encode('') } }; + if (name === 'components' || name === 'components-inline') { + const doc = Document({ title: 'Worker statement', styles: [ + '@page { size:A4; margin:20mm }', + { body: { fontFamily: 'Inter', color: '#173b35' }, '.amount': { color: '#d85330', fontSize: '28pt' }, + table: { width: '100%', borderCollapse: 'collapse' }, 'th, td': { padding: '8pt', borderBottom: '1pt solid #a4ae78' } }, + ] }, Heading({ level: 1 }, 'Worker statement'), Paragraph('Cedar & Co. safe'), + Table(THead(Tr(Th('Work'), Th('Amount'))), TBody(Tr(Td('Design and delivery'), Td('$1,870.00')))), + Box({ className: 'amount' }, '$1,870.00')); + return name === 'components-inline' + ? { ...common, html: doc.toHtml({ includeStyles: true }), css: '' } + : { ...common, html: doc.toHtml(), css: doc.toCss() }; + } + throw new Error('Unknown fixture: ' + name); +} + +export function errorOptions(name, customFont) { + const basic = { html: '

Valid recovery document

' }; + switch (name) { + case 'timeout': return { ...basic, timeoutMs: 10 }; + case 'signal': return { ...basic, signal: new AbortController().signal }; + case 'isolation': return { ...basic, isolation: 'worker' }; + case 'source-limit': return { html: 'é'.repeat(500_001) }; + case 'asset-limit': return { ...basic, assets: { 'large.bin': new Uint8Array(8 * 1024 * 1024 + 1) } }; + case 'asset-path': return { ...basic, assets: { '../outside': new Uint8Array([1]) } }; + case 'asset-collision': return { ...basic, assets: { a: new Uint8Array([1]), 'a/b': new Uint8Array([2]) } }; + case 'pages': return { ...optionsFor('report', customFont), maxPages: 1, previewDpi: 0 }; + case 'default-pages': return { html: '
Page
'.repeat(21), css: 'section{break-after:page}' }; + case 'glyph': return { html: '

漢字

', css: 'body{font-family:Inter}' }; + default: throw new Error('Unknown error fixture'); + } +} diff --git a/test/cloudflare/worker.mjs b/test/cloudflare/worker.mjs new file mode 100644 index 0000000..7fc9b04 --- /dev/null +++ b/test/cloudflare/worker.mjs @@ -0,0 +1,48 @@ +import { renderPdf, FullbleedError, version, engineVersion } from 'fullbleed/cloudflare'; +import customFont from './custom-font.ttf'; +import { optionsFor, errorOptions } from './cases.mjs'; + +// The installed adapter must use static assets and a synchronous instance path. +globalThis.fetch = () => { throw new Error('SDK rendering must not fetch assets or document URLs.'); }; +for (const name of ['compile', 'compileStreaming', 'instantiate', 'instantiateStreaming']) { + WebAssembly[name] = () => { throw new Error('Use the statically bundled compiled module.'); }; +} +const OriginalInstance = WebAssembly.Instance; +let synchronous = false, instances = 0, outsideSynchronousCall = 0; +WebAssembly.Instance = function(...args) { + instances++; + if (!synchronous) outsideSynchronousCall++; + return Reflect.construct(OriginalInstance, args); +}; +const render = options => { + synchronous = true; + try { return renderPdf(options); } + finally { synchronous = false; } +}; +const base64 = bytes => { + let value = ''; + for (let offset = 0; offset < bytes.length; offset += 8192) value += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + return btoa(value); +}; +const encode = result => ({ ...result, pdf: base64(result.pdf), previews: result.previews.map(base64) }); + +export default { + async fetch(request) { + if (new URL(request.url).pathname === '/health') return Response.json({ version, engineVersion, instances, outsideSynchronousCall }); + try { + const { name, error, burst, allowMissingGlyphs } = await request.json(); + const font = new Uint8Array(customFont); + if (burst) { + const results = await Promise.all([render(optionsFor('invoice', font)), render(optionsFor('report', font))]); + return Response.json({ results: results.map(encode), outsideSynchronousCall, instances }); + } + const options = error ? errorOptions(name, font) : optionsFor(name, font); + if (allowMissingGlyphs) options.allowMissingGlyphs = true; + const result = await render(options); + return Response.json({ ...encode(result), outsideSynchronousCall, instances }); + } catch (error) { + return Response.json({ code: error.code, message: error.message, fullbleedError: error instanceof FullbleedError, + outsideSynchronousCall, instances }, { status: error instanceof FullbleedError ? 422 : 500 }); + } + }, +}; diff --git a/test/test_release.py b/test/test_release.py index 86aff17..5fb8d45 100644 --- a/test/test_release.py +++ b/test/test_release.py @@ -382,5 +382,54 @@ def test_native_gaps_and_differences_are_rejected(self): release_check.verify_engine_paint_evidence(self.report) +class CloudflareReleaseEvidenceTests(unittest.TestCase): + def setUp(self): + self.report = dict(package_version='0.8.0', engine_version='2.5.29', package_sha256='a' * 64) + for key, source, documents in [('cloudflare_workers', release_check.CLOUDFLARE_SOURCE, 7), + ('cloudflare_starter', release_check.CLOUDFLARE_STARTER_SOURCE, 6)]: + digest = hashlib.sha256(source.read_bytes()).hexdigest() + self.report[key] = [dict(platform=platform, ok=True, package_version='0.8.0', engine_version='2.5.29', + tarball_sha256='a' * 64, source_sha256=digest, documents=documents, error_cases=10, + node_bytes_identical=True, recovery=True, server_stopped=True, independent_reader=True, + local_only=True, hosted_verified=False, free_plan_verified=False, wrangler='4.151.3', workerd='1.20261019.1', + synchronous_instantiation=True, no_runtime_fetching_or_compilation=True, standalone_config=True, input_tests=5) + for platform in ['win32', 'linux']] + + def test_complete_local_evidence_is_accepted(self): + release_check.verify_cloudflare_evidence(self.report) + + def test_missing_or_duplicate_platform_is_rejected(self): + for key in ['cloudflare_workers', 'cloudflare_starter']: + self.setUp() + self.report[key][0]['platform'] = 'linux' + with self.assertRaisesRegex(ValueError, 'Windows and Linux'): + release_check.verify_cloudflare_evidence(self.report) + + def test_stale_incomplete_or_overstated_evidence_is_rejected(self): + changes = [('ok', False), ('package_version', '0.7.1'), ('engine_version', 'old'), + ('tarball_sha256', 'b' * 64), ('source_sha256', 'b' * 64), ('documents', 0), ('error_cases', 9), + ('node_bytes_identical', False), ('recovery', False), ('server_stopped', False), + ('independent_reader', False), ('local_only', False), ('hosted_verified', True), + ('free_plan_verified', True), ('workerd', ''), ('wrangler', '')] + for key in ['cloudflare_workers', 'cloudflare_starter']: + for field, value in changes: + with self.subTest(key=key, field=field): + self.setUp() + self.report[key][0][field] = value + with self.assertRaisesRegex(ValueError, 'Workers evidence is incomplete'): + release_check.verify_cloudflare_evidence(self.report) + + def test_runtime_boundary_and_standalone_checks_are_required(self): + for key, field, value in [('cloudflare_workers', 'synchronous_instantiation', False), + ('cloudflare_workers', 'no_runtime_fetching_or_compilation', False), + ('cloudflare_starter', 'standalone_config', False), + ('cloudflare_starter', 'input_tests', 0)]: + with self.subTest(field=field): + self.setUp() + self.report[key][0][field] = value + with self.assertRaisesRegex(ValueError, 'checks are incomplete'): + release_check.verify_cloudflare_evidence(self.report) + + if __name__ == '__main__': unittest.main() diff --git a/test/types/cloudflare.mts b/test/types/cloudflare.mts new file mode 100644 index 0000000..dace256 --- /dev/null +++ b/test/types/cloudflare.mts @@ -0,0 +1,16 @@ +import { renderPdf, FullbleedError, version, engineVersion, type CloudflareRenderOptions } from 'fullbleed/cloudflare'; +import { document, Heading, Paragraph } from 'fullbleed/ui'; +const authored = document(Heading('Worker report'), Paragraph('Component-authored PDF.')); +const options: CloudflareRenderOptions = { html: authored.toHtml(), css: authored.toCss(), maxPages: 5 }; +const result = await renderPdf(options); +const pdf: Uint8Array = result.pdf; +new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } }); +await authored.renderPdf(renderPdf, { maxPages: 5, defaultStyles: false }); +const labels: string[] = [version, engineVersion, new FullbleedError('EXAMPLE', 'Example').code]; +void labels; +// @ts-expect-error Workers cannot implement an interruptible SDK timeout. +await renderPdf({ html: 'x', timeoutMs: 1000 }); +// @ts-expect-error Worker and process isolation are Node options. +await renderPdf({ html: 'x', isolation: 'worker' }); +// @ts-expect-error In-isolate rendering cannot process cancellation during computation. +await renderPdf({ html: 'x', signal: new AbortController().signal }); diff --git a/tools/build-cloudflare.mjs b/tools/build-cloudflare.mjs new file mode 100644 index 0000000..c781f9c --- /dev/null +++ b/tools/build-cloudflare.mjs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +import { build } from 'esbuild'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export async function buildCloudflare(root, manifest) { + const path = 'dist/cloudflare.js'; + const result = await build({ absWorkingDir: root, entryPoints: ['src/cloudflare.js'], + outfile: join(root, path), bundle: true, platform: 'browser', format: 'esm', + target: 'es2022', legalComments: 'inline', metafile: true, + external: ['*.wasm', '*.ttf'], + define: { __FULLBLEED_PACKAGE_VERSION__: JSON.stringify(manifest.packageVersion), + __FULLBLEED_ENGINE_VERSION__: JSON.stringify(manifest.engineVersion) } }); + const imports = Object.values(result.metafile.outputs).flatMap(output => output.imports); + if (imports.length !== 5 || imports.some(item => !item.external || !/\.(?:wasm|ttf)$/.test(item.path))) { + throw new Error('The Workers adapter must only import its packaged Wasm and four font files.'); + } + const bytes = await readFile(join(root, path)); + return { [path]: { bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') } }; +} diff --git a/tools/build.mjs b/tools/build.mjs index 6201913..dae3177 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { join, dirname } from 'node:path'; import { buildBrowser } from './build-browser.mjs'; +import { buildCloudflare } from './build-cloudflare.mjs'; const root = fileURLToPath(new URL('../', import.meta.url)); const engine = join(root, 'engine'); @@ -69,5 +70,6 @@ const manifest = { files, }; Object.assign(manifest.files, await buildBrowser(root, manifest)); +Object.assign(manifest.files, await buildCloudflare(root, manifest)); await writeFile(join(dist, 'build.json'), JSON.stringify(manifest, null, 2) + '\n'); console.log(JSON.stringify({ ok: true, engine: core.version, wasm: files['dist/engine.wasm'] })); diff --git a/tools/check-build.mjs b/tools/check-build.mjs index cb5425d..0bfe12a 100644 --- a/tools/check-build.mjs +++ b/tools/check-build.mjs @@ -26,6 +26,7 @@ assert.equal((await readFile(new URL('src/jsx-runtime.d.ts', root), 'utf8')).rep await readFile(new URL('dist/jsx-runtime.d.cts', root), 'utf8')); assert(manifest.files['dist/ui.js'], 'Browser component module must be recorded in the build'); assert(manifest.files['dist/jsx-runtime.js'], 'Browser JSX runtime must be recorded in the build'); +assert(manifest.files['dist/cloudflare.js'], 'Workers adapter must be recorded in the build'); const browser = JSON.parse(await readFile(new URL('dist/browser/asset-manifest.json', root), 'utf8')); assert.equal(browser.packageVersion, pkg.version); assert.equal(browser.engineVersion, pkg.fullbleed.engineVersion); diff --git a/tools/check-cloudflare-starter.mjs b/tools/check-cloudflare-starter.mjs new file mode 100644 index 0000000..4793487 --- /dev/null +++ b/tools/check-cloudflare-starter.mjs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// Run the standalone example against the verified, isolated package installation. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { spawn, spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { once } from 'node:events'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const read = async path => JSON.parse(await readFile(path, 'utf8')); +const pkg = await read(join(root, 'package.json')); +const packed = await read(join(root, 'output/pack-verification/verification.json')); +assert(packed.ok && packed.package === `fullbleed-${pkg.version}.tgz`); +const consumer = await mkdtemp(join(root, 'output/pack-verification', packed.installDirectory, 'cloudflare starter ')); +const outputRoot = join(root, 'output/cloudflare-starter-verification'); +await mkdir(outputRoot, { recursive: true }); +const output = await mkdtemp(join(outputRoot, 'run-')); +for (const file of ['package.json', 'wrangler.json', 'sample.json', 'LICENSE', 'README.md', + 'src/index.mjs', 'src/handler.mjs', 'src/document.mjs', 'src/styles.mjs', + 'test/fixtures.mjs', 'test/request.test.mjs', 'test/worker.mjs']) { + await mkdir(dirname(join(consumer, file)), { recursive: true }); + await copyFile(join(root, 'examples/cloudflare', file), join(consumer, file)); +} +const starter = await read(join(consumer, 'package.json')); +assert.equal(starter.dependencies.fullbleed, pkg.version, 'The starter must target this package.'); +const env = { ...process.env, PATH: dirname(process.execPath) + (process.platform === 'win32' ? ';' : ':') + (process.env.PATH ?? ''), + WRANGLER_SEND_METRICS: 'false' }; +function run(args, timeout = 120000) { + const child = spawnSync(process.execPath, args, { cwd: consumer, env, encoding: 'utf8', timeout }); + assert.equal(child.status, 0, child.stdout + child.stderr); + return child.stdout + child.stderr; +} +await writeFile(join(output, 'input-tests.log'), run(['--test', 'test/request.test.mjs'])); +await writeFile(join(consumer, 'baseline.mjs'), ` +import { renderPdf, version, engineVersion } from 'fullbleed'; +import { Invoice } from './src/document.mjs'; +import { validateInvoice } from './src/handler.mjs'; +import { cases } from './test/fixtures.mjs'; +import { createHash } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +const output = process.argv[2]; +const records = []; +for (const item of cases) { + const data = validateInvoice(item.data); + const result = await Invoice({ data }).renderPdf(renderPdf, { maxPages: 10, defaultStyles: false, previewDpi: 96 }); + await writeFile(join(output, item.name + '-node.pdf'), result.pdf); + for (const [i, bytes] of result.previews.entries()) await writeFile(join(output, item.name + '-' + (i + 1) + '.png'), bytes); + records.push({ name: item.name, pdf: createHash('sha256').update(result.pdf).digest('hex'), + pages: result.pages, missingGlyphs: result.missingGlyphs, + previews: result.previews.map(bytes => createHash('sha256').update(bytes).digest('hex')) }); +} +await writeFile(join(output, 'node-baseline.json'), JSON.stringify({ version, engineVersion, records }, null, 2)); +`); +await writeFile(join(output, 'baseline.log'), run(['baseline.mjs', output])); +const baseline = await read(join(output, 'node-baseline.json')); +assert.equal(baseline.version, pkg.version); +assert.equal(baseline.engineVersion, pkg.fullbleed.engineVersion); +const wrangler = join(root, 'node_modules/wrangler/bin/wrangler.js'); +await writeFile(join(output, 'bundle.log'), run([wrangler, 'deploy', '--dry-run', '--outdir', join(output, 'bundle')])); +const port = Number(process.env.FULLBLEED_CLOUDFLARE_STARTER_PORT ?? 9489); +assert(Number.isSafeInteger(port) && port > 1024 && port < 65536); +const base = `http://127.0.0.1:${port}`; +try { await fetch(base + '/health', { signal: AbortSignal.timeout(1000) }); throw new Error(`Test port ${port} is already in use.`); } +catch (error) { if (!error.cause && error.name !== 'TimeoutError' && !String(error).includes('fetch failed')) throw error; } +let log = ''; +const child = spawn(process.execPath, [wrangler, 'dev', '--local', '--ip', '127.0.0.1', '--port', String(port), '--inspector-port', '0'], + { cwd: consumer, env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); +child.stdout.on('data', data => { log += data.toString(); }); +child.stderr.on('data', data => { log += data.toString(); }); +let spawnError; +child.on('error', error => { spawnError = error; }); +const record = { ok: false, packageVersion: pkg.version, engineVersion: pkg.fullbleed.engineVersion, + tarballSha256: packed.tarballSha256, node: process.version, platform: process.platform, + sourceSha256: createHash('sha256').update(await readFile(join(consumer, 'test/fixtures.mjs'))).digest('hex'), + wrangler: (await read(join(root, 'node_modules/wrangler/package.json'))).version, + workerd: (await read(join(root, 'node_modules/workerd/package.json'))).version, + isolatedTarball: true, standaloneConfig: true, inputTests: 5, + localOnly: true, hostedVerified: false, freePlanVerified: false }; +try { + const deadline = Date.now() + 60000; + let ready = false; + while (Date.now() < deadline) { + if (spawnError) throw spawnError; + if (child.exitCode !== null) throw new Error('Wrangler exited during startup:\n' + log); + try { + const response = await fetch(base + '/health', { signal: AbortSignal.timeout(1000) }); + if (response.ok && (await response.json()).ok === true) { ready = true; break; } + } catch { /* Poll while the local runtime starts. */ } + await new Promise(resolve => setTimeout(resolve, 250)); + } + assert(ready, 'Wrangler did not become ready:\n' + log); + const { checkWorker } = await import(pathToFileURL(join(consumer, 'test/worker.mjs')).href); + const http = await checkWorker(base, output); + for (const item of http.records) { + const expected = baseline.records.find(value => value.name === (item.name === 'after-errors' ? 'sample' : item.name)); + assert.equal(item.pdf, expected.pdf, item.name + ' Node/Workers PDF bytes'); + assert(expected.pages >= item.minPages && expected.pages <= item.maxPages, item.name + ' page range'); + assert.equal(expected.missingGlyphs, 0); + Object.assign(item, { pages: expected.pages, nodeBytesIdentical: true }); + } + Object.assign(record, http); +} finally { + if (child.exitCode === null && child.pid) { + if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { encoding: 'utf8' }); + else { try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } + if (child.exitCode === null && child.signalCode === null) await Promise.race([once(child, 'exit'), new Promise(resolve => setTimeout(resolve, 5000))]); + if (child.exitCode === null && child.signalCode === null && process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + await Promise.race([once(child, 'exit'), new Promise(resolve => setTimeout(resolve, 5000))]); + } + } + record.serverStopped = child.exitCode !== null || child.signalCode !== null; + await writeFile(join(output, 'wrangler.log'), log); + await writeFile(join(output, 'verification.json'), JSON.stringify(record, null, 2) + '\n'); + await writeFile(join(outputRoot, 'latest.json'), JSON.stringify({ output, ok: record.ok }, null, 2) + '\n'); +} +assert(record.ok && record.serverStopped); +console.log(JSON.stringify({ ok: record.ok, records: record.records.length, errors: record.errors.length, output })); diff --git a/tools/check-cloudflare.mjs b/tools/check-cloudflare.mjs new file mode 100644 index 0000000..bf351fa --- /dev/null +++ b/tools/check-cloudflare.mjs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +// Exercise the isolated npm tarball with Wrangler's real local workerd runtime. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { spawn, spawnSync } from 'node:child_process'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const read = async path => JSON.parse(await readFile(path, 'utf8')); +const pkg = await read(join(root, 'package.json')); +const packed = await read(join(root, 'output/pack-verification/verification.json')); +assert(packed.ok && packed.package === `fullbleed-${pkg.version}.tgz`, 'Verify the final tarball first.'); +const install = join(root, 'output/pack-verification', packed.installDirectory); +const consumer = await mkdtemp(join(install, 'cloudflare consumer ')); +const outputRoot = join(root, 'output/cloudflare-verification'); +await mkdir(outputRoot, { recursive: true }); +const output = await mkdtemp(join(outputRoot, 'run-')); +const port = Number(process.env.FULLBLEED_CLOUDFLARE_TEST_PORT ?? 9488); +assert(Number.isSafeInteger(port) && port > 1024 && port < 65536); +const base = `http://127.0.0.1:${port}`; +try { await fetch(base + '/health', { signal: AbortSignal.timeout(1000) }); throw new Error(`Test port ${port} is already in use.`); } +catch (error) { if (!error.cause && error.name !== 'TimeoutError' && !String(error).includes('fetch failed')) throw error; } + +for (const name of ['worker.mjs', 'cases.mjs', 'baseline.mjs']) { + await copyFile(join(root, 'test/cloudflare', name), join(consumer, name)); +} +await copyFile(join(root, 'test/fonts/family/LibreCaslonText[wght].ttf'), join(consumer, 'custom-font.ttf')); +await copyFile(join(root, 'test/fonts/family/OFL.txt'), join(consumer, 'custom-font-OFL.txt')); +const raw = {}; +for (const name of ['invoice', 'report']) { + const fill = text => text.replaceAll('{{reference}}', 'NS-1042').replaceAll('{{customer}}', 'Cedar & Co.').replaceAll('{{ink}}', '#183e2d'); + raw[name] = { html: fill(await readFile(join(root, `examples/browser/src/${name}.html`), 'utf8')), + css: fill(await readFile(join(root, `examples/browser/src/${name}.css`), 'utf8')) }; +} +await writeFile(join(consumer, 'raw.json'), JSON.stringify(raw)); +const config = { name: 'fullbleed-package-check', main: 'worker.mjs', compatibility_date: '2026-10-20', + send_metrics: false, workers_dev: false, preview_urls: false, + rules: [{ type: 'Data', globs: ['**/*.ttf'], fallthrough: false }] }; +await writeFile(join(consumer, 'wrangler.json'), JSON.stringify(config, null, 2)); +const env = { ...process.env, PATH: dirname(process.execPath) + (process.platform === 'win32' ? ';' : ':') + (process.env.PATH ?? ''), + WRANGLER_SEND_METRICS: 'false' }; +const baselineRun = spawnSync(process.execPath, ['baseline.mjs', output], { cwd: consumer, env, encoding: 'utf8', timeout: 120000 }); +assert.equal(baselineRun.status, 0, baselineRun.stderr || baselineRun.stdout); +const baseline = await read(join(output, 'node-baseline.json')); +assert.equal(baseline.version, pkg.version); +assert.equal(baseline.engineVersion, pkg.fullbleed.engineVersion); +const wrangler = join(root, 'node_modules/wrangler/bin/wrangler.js'); +const dry = spawnSync(process.execPath, [wrangler, 'deploy', '--dry-run', '--outdir', join(output, 'bundle')], + { cwd: consumer, env, encoding: 'utf8', timeout: 60000 }); +await writeFile(join(output, 'bundle.log'), dry.stdout + dry.stderr); +assert.equal(dry.status, 0, dry.stderr || dry.stdout); +let log = ''; +const child = spawn(process.execPath, [wrangler, 'dev', '--local', '--ip', '127.0.0.1', '--port', String(port), + '--inspector-port', '0'], { cwd: consumer, env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] }); +child.stdout.on('data', bytes => { log += bytes.toString(); }); +child.stderr.on('data', bytes => { log += bytes.toString(); }); +let spawnError; +child.on('error', error => { spawnError = error; }); +const record = { ok: false, packageVersion: pkg.version, engineVersion: pkg.fullbleed.engineVersion, + tarballSha256: packed.tarballSha256, node: process.version, platform: process.platform, + sourceSha256: hash(await readFile(join(root, 'test/cloudflare/cases.mjs'))), + wrangler: (await read(join(root, 'node_modules/wrangler/package.json'))).version, + workerd: (await read(join(root, 'node_modules/workerd/package.json'))).version, + localOnly: true, hostedVerified: false, freePlanVerified: false, records: [], errors: [] }; +async function post(payload) { + const response = await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), signal: AbortSignal.timeout(60000) }); + const data = await response.json(); + assert.equal(data.outsideSynchronousCall, 0, 'Engine instantiation escaped the synchronous render call.'); + return { status: response.status, data }; +} +async function checkResult(data, expected, suffix) { + assert.equal(data.engineVersion, pkg.fullbleed.engineVersion); + assert.equal(data.pages, expected.pages); + assert.equal(data.missingGlyphs, 0); + const pdf = Buffer.from(data.pdf, 'base64'); + assert.equal(hash(pdf), expected.pdf, expected.name + ' PDF bytes'); + assert.equal(data.previews.length, expected.previews.length); + await writeFile(join(output, `${expected.name}-${suffix}.pdf`), pdf); + for (const [i, encoded] of data.previews.entries()) { + const png = Buffer.from(encoded, 'base64'); + assert.equal(hash(png), expected.previews[i], expected.name + ' preview bytes'); + await writeFile(join(output, `${expected.name}-${suffix}-${i + 1}.png`), png); + } + record.records.push({ ...expected, output: `${expected.name}-${suffix}.pdf`, + previewFiles: data.previews.map((_, i) => `${expected.name}-${suffix}-${i + 1}.png`), nodeBytesIdentical: true }); +} +try { + const deadline = Date.now() + 60000; + let health; + while (Date.now() < deadline) { + if (spawnError) throw spawnError; + if (child.exitCode !== null) throw new Error('Wrangler exited during startup:\n' + log); + try { health = await (await fetch(base + '/health', { signal: AbortSignal.timeout(1000) })).json(); break; } + catch { await new Promise(resolve => setTimeout(resolve, 250)); } + } + assert(health, 'Wrangler did not become ready:\n' + log); + assert.equal(health.version, pkg.version); + assert.equal(health.engineVersion, pkg.fullbleed.engineVersion); + for (const expected of baseline.records) { + const { status, data } = await post({ name: expected.name }); + assert.equal(status, 200, JSON.stringify(data)); + await checkResult(data, expected, 'workerd'); + } + assert.equal(baseline.records.find(item => item.name === 'components').pdf, + baseline.records.find(item => item.name === 'components-inline').pdf, 'Styled and separate HTML/CSS component exports must agree.'); + const errors = { timeout: 'INVALID_INPUT', signal: 'INVALID_INPUT', isolation: 'INVALID_INPUT', + 'source-limit': 'INVALID_INPUT', 'asset-limit': 'INVALID_INPUT', 'asset-path': 'INVALID_INPUT', + 'asset-collision': 'INVALID_INPUT', pages: 'PAGE_LIMIT', 'default-pages': 'PAGE_LIMIT', glyph: 'MISSING_GLYPHS' }; + for (const [name, code] of Object.entries(errors)) { + const { status, data } = await post({ name, error: true }); + assert.equal(status, 422, JSON.stringify(data)); + assert.equal(data.code, code, name); + assert.equal(data.fullbleedError, true); + record.errors.push({ name, code }); + } + const allowed = await post({ name: 'glyph', error: true, allowMissingGlyphs: true }); + assert.equal(allowed.status, 200); + assert(allowed.data.missingGlyphs > 0, 'Explicitly allowed missing glyphs must remain diagnosed.'); + record.allowedMissingGlyphs = allowed.data.missingGlyphs; + const burstResponse = await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ burst: true }), signal: AbortSignal.timeout(60000) }); + assert.equal(burstResponse.status, 200); + const burst = await burstResponse.json(); + assert.equal(burst.outsideSynchronousCall, 0); + for (const [i, name] of ['invoice', 'report'].entries()) { + await checkResult(burst.results[i], baseline.records.find(item => item.name === name), 'after-errors'); + } + record.synchronousInstantiation = true; + record.noRuntimeFetchingOrCompilation = true; + record.recovery = true; + record.ok = true; +} finally { + if (child.exitCode === null && child.pid) { + if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { encoding: 'utf8' }); + else { try { process.kill(-child.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; } } + if (child.exitCode === null) await Promise.race([once(child, 'exit'), new Promise(resolve => setTimeout(resolve, 5000))]); + } + if (child.exitCode === null && child.signalCode === null && child.pid && process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; } + await Promise.race([once(child, 'exit'), new Promise(resolve => setTimeout(resolve, 5000))]); + } + record.serverStopped = child.exitCode !== null || child.signalCode !== null; + await writeFile(join(output, 'wrangler.log'), log); + await writeFile(join(output, 'verification.json'), JSON.stringify(record, null, 2) + '\n'); + await writeFile(join(outputRoot, 'latest.json'), JSON.stringify({ output, ok: record.ok }, null, 2) + '\n'); +} +assert(record.serverStopped, 'The test runtime must stop.'); +console.log(JSON.stringify({ ok: record.ok, packageVersion: record.packageVersion, records: record.records.length, + errors: record.errors.length, recovery: record.recovery, output })); diff --git a/tools/verify-cloudflare-starter.py b/tools/verify-cloudflare-starter.py new file mode 100644 index 0000000..38adea6 --- /dev/null +++ b/tools/verify-cloudflare-starter.py @@ -0,0 +1,38 @@ +"""Independently read the PDFs returned by the local invoice Worker.""" +from hashlib import sha256 +import json +from pathlib import Path +import sys +from PIL import Image +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] +folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(json.loads((ROOT / 'output/cloudflare-starter-verification/latest.json').read_text())['output']) +record = json.loads((folder / 'verification.json').read_text(encoding='utf-8')) +assert record['ok'] and record['serverStopped'] and record['recovery'] +baseline = json.loads((folder / 'node-baseline.json').read_text(encoding='utf-8')) +checked = [] +for item in record['records']: + path = folder / (item['name'] + '.pdf') + assert sha256(path.read_bytes()).hexdigest() == item['pdf'] + pdf = PdfReader(path) + assert len(pdf.pages) == item['pages'] + text = ' '.join(' '.join(page.extract_text().split()) for page in pdf.pages) + for expected in item['text']: + assert expected in text, (item['name'], expected, text) + # Readers insert spaces between glyphs when the invoice eyebrow uses tracking. + for expected in item['trackedText']: + assert expected in ''.join(text.split()), (item['name'], expected) + name = 'sample' if item['name'] == 'after-errors' else item['name'] + expected = next(value for value in baseline['records'] if value['name'] == name) + for index, digest in enumerate(expected['previews']): + path = folder / f'{name}-{index + 1}.png' + assert sha256(path.read_bytes()).hexdigest() == digest + with Image.open(path) as preview: + preview.load() + assert preview.width > 100 and preview.height > 100 + assert preview.convert('RGB').getextrema() != ((255, 255),) * 3 + checked.append({'name': item['name'], 'pages': len(pdf.pages), 'text': item['text'], 'trackedText': item['trackedText']}) +result = {'ok': True, 'packageVersion': record['packageVersion'], 'tarballSha256': record['tarballSha256'], 'records': checked} +(folder / 'independent-reader.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') +print(json.dumps({'ok': True, 'records': len(checked), 'pages': sum(item['pages'] for item in checked), 'output': str(folder)})) diff --git a/tools/verify-cloudflare.py b/tools/verify-cloudflare.py new file mode 100644 index 0000000..2d25421 --- /dev/null +++ b/tools/verify-cloudflare.py @@ -0,0 +1,40 @@ +"""Independent content, font and image checks for the installed Workers entry.""" +from hashlib import sha256 +import json +from pathlib import Path +import sys +from PIL import Image +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] +folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(json.loads((ROOT / 'output/cloudflare-verification/latest.json').read_text())['output']) +record = json.loads((folder / 'verification.json').read_text(encoding='utf-8')) +assert record['ok'] and record['serverStopped'] and record['recovery'] +checked = [] +for item in record['records']: + path = folder / item['output'] + assert sha256(path.read_bytes()).hexdigest() == item['pdf'] + pdf = PdfReader(path) + assert len(pdf.pages) == item['pages'] + text = '\n'.join(page.extract_text() for page in pdf.pages) + for expected in item['text']: + assert expected in text, (item['name'], expected, text) + if item.get('face'): + faces = [] + for page in pdf.pages: + resources = page['/Resources'].get_object() + for font in resources.get('/Font', {}).get_object().values(): + faces.append(str(font.get_object().get('/BaseFont', ''))) + assert any(item['face'] in face for face in faces), faces + for index, name in enumerate(item['previewFiles']): + path = folder / name + assert sha256(path.read_bytes()).hexdigest() == item['previews'][index] + with Image.open(path) as preview: + preview.load() + assert preview.width > 100 and preview.height > 100 + assert preview.convert('RGB').getextrema() != ((255, 255),) * 3, name + checked.append({'name': item['name'], 'file': item['output'], 'pages': len(pdf.pages), 'text': item['text'], 'previews': len(item['previewFiles'])}) +result = {'ok': True, 'packageVersion': record['packageVersion'], 'tarballSha256': record['tarballSha256'], 'records': checked, + 'scope': 'Independent page/text/font and PNG decoding checks for the retained local workerd outputs. Not hosted-platform or accessibility conformance verification.'} +(folder / 'independent-reader.json').write_text(json.dumps(result, indent=2) + '\n', encoding='utf-8') +print(json.dumps({'ok': True, 'records': len(checked), 'pages': sum(item['pages'] for item in checked), 'output': str(folder)})) diff --git a/tools/verify-pack.mjs b/tools/verify-pack.mjs index a080870..b68935f 100644 --- a/tools/verify-pack.mjs +++ b/tools/verify-pack.mjs @@ -28,6 +28,8 @@ for (const name of ['src/ui.js', 'src/ui.cjs', 'src/ui.d.ts', 'dist/ui.js', 'dis 'src/jsx-runtime.js', 'src/jsx-runtime.cjs', 'src/jsx-runtime.d.ts', 'dist/jsx-runtime.js', 'dist/jsx-runtime.d.cts']) assert(names.includes(name), 'Missing component authoring export: ' + name); assert(names.includes('src/render-queue.cjs'), 'The Node render queue must ship in the tarball.'); +for (const name of ['dist/cloudflare.js', 'src/cloudflare.d.ts']) + assert(names.includes(name), 'The Workers entry must ship in the tarball: ' + name); for (const required of ['dist/engine.wasm', 'dist/build.json', 'dist/index.d.cts', 'dist/THIRD_PARTY_NOTICES.txt', 'dist/browser/client.js', 'dist/browser/worker.js', 'dist/browser/asset-manifest.json', 'src/index.js', 'src/index.cjs', 'src/index.d.ts', 'src/browser.d.ts', 'src/engine-runner.js', 'src/render-input.cjs', 'src/copy-browser-assets.cjs', 'src/worker.js', 'src/process-worker.cjs', 'LICENSE', 'README.md', 'package.json']) assert(names.includes(required), required); for (const family of ['Liberation', 'NotoSans', 'NotoSansMath', 'NotoSansSymbols', 'NotoSansSymbols2']) { const required = `dist/preview-font-notices/LICENSE-${family}.txt`; diff --git a/tools/verify-release.py b/tools/verify-release.py index 1a52f84..e45bb7e 100644 --- a/tools/verify-release.py +++ b/tools/verify-release.py @@ -20,6 +20,8 @@ COMPONENT_SOURCE = ENGINE_REGRESSION_SOURCE.parents[1] / 'component-cases.mjs' HTML_EXPORT_SOURCE = Path(__file__).resolve().parent / 'html-export-fixtures.mjs' ENGINE_PAINT_SOURCE = ENGINE_REGRESSION_SOURCE.with_name('engine-paint-2.5.28.json') +CLOUDFLARE_SOURCE = ENGINE_REGRESSION_SOURCE.parents[1] / 'cloudflare/cases.mjs' +CLOUDFLARE_STARTER_SOURCE = Path(__file__).resolve().parents[1] / 'examples/cloudflare/test/fixtures.mjs' def require(condition, message): @@ -89,6 +91,32 @@ def verify_engine_paint_evidence(report): for name in names), 'Native/WASI paint comparison is incomplete or duplicated') +def verify_cloudflare_evidence(report): + for key, source, documents in [('cloudflare_workers', CLOUDFLARE_SOURCE, 7), + ('cloudflare_starter', CLOUDFLARE_STARTER_SOURCE, 6)]: + source_sha = hashlib.sha256(source.read_bytes()).hexdigest() + evidence = report.get(key, []) + require(len(evidence) == 2 and {item.get('platform') for item in evidence} == {'win32', 'linux'}, + 'Workers evidence requires Windows and Linux local runtime checks') + require(all(item.get('ok') is True and item.get('package_version') == report['package_version'] + and item.get('engine_version') == report['engine_version'] + and item.get('tarball_sha256') == report['package_sha256'] + and item.get('source_sha256') == source_sha and item.get('documents') == documents + and item.get('error_cases') == 10 and item.get('node_bytes_identical') is True + and item.get('recovery') is True and item.get('server_stopped') is True + and item.get('independent_reader') is True and item.get('local_only') is True + and item.get('hosted_verified') is False and item.get('free_plan_verified') is False + and item.get('wrangler') and item.get('workerd') for item in evidence), + 'Workers evidence is incomplete or targets another package, tarball, source or scope') + if key == 'cloudflare_workers': + require(all(item.get('synchronous_instantiation') is True + and item.get('no_runtime_fetching_or_compilation') is True for item in evidence), + 'Workers runtime boundary checks are incomplete') + else: + require(all(item.get('standalone_config') is True and item.get('input_tests') == 5 for item in evidence), + 'Workers starter checks are incomplete') + + def verify_binding(version, run_id, run, release, tag_commit, report): require(re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version), 'Use a stable numeric version') require(re.fullmatch(r'[0-9]+', run_id), 'Use a numeric CI run ID') @@ -188,6 +216,8 @@ def verify_binding(version, run_id, run, release, tag_commit, report): verify_html_export_evidence(report) if release_version >= (0, 7, 1): verify_engine_paint_evidence(report) + if release_version >= (0, 8, 0): + verify_cloudflare_evidence(report) def verify_tarball(data, info, report): @@ -223,6 +253,8 @@ def verify_tarball(data, info, report): require({'src/ui.js', 'src/ui.cjs', 'src/ui.d.ts', 'dist/ui.js', 'dist/ui.d.cts', 'src/jsx-runtime.js', 'src/jsx-runtime.cjs', 'src/jsx-runtime.d.ts', 'dist/jsx-runtime.js', 'dist/jsx-runtime.d.cts'}.issubset(names), 'Component/JSX package files are missing') + if tuple(map(int, info['version'].split('.'))) >= (0, 8, 0): + require({'dist/cloudflare.js', 'src/cloudflare.d.ts'}.issubset(names), 'Workers package files are missing') return sha diff --git a/tsconfig.cloudflare.json b/tsconfig.cloudflare.json new file mode 100644 index 0000000..faddcce --- /dev/null +++ b/tsconfig.cloudflare.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], "types": [], "strict": true, + "noEmit": true, "skipLibCheck": false, "customConditions": ["workerd", "browser"] + }, + "include": ["test/types/cloudflare.mts"] +}