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.
',
+ 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: ''.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"]
+}