diff --git a/.gitattributes b/.gitattributes index cdbaf3a..4f75624 100644 --- a/.gitattributes +++ b/.gitattributes @@ -175,3 +175,10 @@ docs/assets/styling-notebook-alpha11/*.pdf binary # React starter baselines are verified by exact PDF bytes. docs/assets/react-starter/baseline/*.pdf binary + +# Keep the locked Workers download reproducible and preserve exact PDF/PNG bytes. +examples/cloudflare-pdf/** text eol=lf +docs/assets/cloudflare-starter/** -text +docs/assets/cloudflare-starter/*.pdf binary +docs/assets/cloudflare-starter/*.png binary +docs/assets/cloudflare-starter/*.zip binary diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bdcc20f..9e0cc5c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -872,6 +872,22 @@ jobs: retention-days: 14 - name: Build and exercise the downloadable Next.js starter run: python tools/verify_nextjs_starter.py + - name: Verify the locked Workers invoice download in local workerd + run: | + python -m pip install pypdf==6.19.0 pillow==12.3.0 + python tools/build_cloudflare_starter.py + git diff --exit-code -- docs/assets/cloudflare-starter/project.zip docs/assets/cloudflare-starter/source.json + python tools/verify_cloudflare_starter.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cloudflare-starter-verification + path: | + output/cloudflare-starter-verification/*.json + output/cloudflare-starter-verification/*.log + output/cloudflare-starter-verification/*.pdf + output/cloudflare-starter-verification/*.png + retention-days: 14 - uses: actions/upload-artifact@v4 if: always() with: diff --git a/docs/assets/cloudflare-starter/invoice.pdf b/docs/assets/cloudflare-starter/invoice.pdf new file mode 100644 index 0000000..0cf628a Binary files /dev/null and b/docs/assets/cloudflare-starter/invoice.pdf differ diff --git a/docs/assets/cloudflare-starter/invoice.png b/docs/assets/cloudflare-starter/invoice.png new file mode 100644 index 0000000..4596057 Binary files /dev/null and b/docs/assets/cloudflare-starter/invoice.png differ diff --git a/docs/assets/cloudflare-starter/long-invoice.pdf b/docs/assets/cloudflare-starter/long-invoice.pdf new file mode 100644 index 0000000..7fd6d6b Binary files /dev/null and b/docs/assets/cloudflare-starter/long-invoice.pdf differ diff --git a/docs/assets/cloudflare-starter/project.zip b/docs/assets/cloudflare-starter/project.zip new file mode 100644 index 0000000..6483b72 Binary files /dev/null and b/docs/assets/cloudflare-starter/project.zip differ diff --git a/docs/assets/cloudflare-starter/source.json b/docs/assets/cloudflare-starter/source.json new file mode 100644 index 0000000..eaddfeb --- /dev/null +++ b/docs/assets/cloudflare-starter/source.json @@ -0,0 +1,113 @@ +{ + "schema": "fullbleed.cloudflare-starter.v1", + "prefix": "fullbleed-cloudflare-invoice/", + "packageVersion": "0.8.0", + "engineVersion": "2.5.29", + "wrangler": "4.151.3", + "sourceCommit": "e9582e417e8cbe49e41aabd0c695524920a1840d", + "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-node", + "release": "https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.8.0", + "packageIntegrity": "sha512-olEU8ARb8i5ASStXX7uGAZ2RaGaKj2uL8cL81su+M46xo3AdxcGL3j8SCcaXEmTJUzKE0o5E6djykPTbC0u2FA==", + "packageSha256": "86af460db1f29c94e65daff3563f9f64f0f52e0af47f02adf3ff39cb2ece1691", + "wasmSha256": "bbcf9b09a60a38d9a884f227fd7f7d31e17e4d611c91027775059f05e2bb46f2", + "localOnly": true, + "hostedVerified": false, + "freePlanVerified": false, + "sourceNote": "SDK examples/cloudflare source; npm lockfile and this release record added for the downloadable project.", + "files": [ + { + "path": ".gitignore", + "bytes": 55, + "sha256": "7a1f0472a0d8ee63343cb58345d9af19d1e71310d9c696d9d1578e7bc743262b" + }, + { + "path": "LICENSE", + "bytes": 1079, + "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9" + }, + { + "path": "README.md", + "bytes": 2919, + "sha256": "503a8caf5cd39a4f310badcf417b006cdfc874f71be42fde5943aef3d57db34c" + }, + { + "path": "package-lock.json", + "bytes": 50461, + "sha256": "07818436d4e5486c1694bc38652a792e0093448cd12baf63bd718f4c01a8308b" + }, + { + "path": "package.json", + "bytes": 499, + "sha256": "fe2c82d9f68ba4fe91f69ba3f32a3dd21cb455f72ca8820929e889924490795c" + }, + { + "path": "release.json", + "bytes": 804, + "sha256": "57521c01fcbcc6eed69c6e7ad8838e0e1b4f44a4d6be304c8229084d1d9d92ae" + }, + { + "path": "sample.json", + "bytes": 733, + "sha256": "82a94cc4f15bd26d7060bc201a6b3f7ff36307875158b9b0a7e8aeb5ab2cbfb9" + }, + { + "path": "src/document.mjs", + "bytes": 3509, + "sha256": "72153fd3fbc3ca1183e801fdc53a2a9ddf7a9f4c046f7b6c5bea8bff63bf7b06" + }, + { + "path": "src/handler.mjs", + "bytes": 5997, + "sha256": "8b2de5cbddb531481baaf5adcd6865f11e9d69fbff5b95c7f294cfed95f15e4e" + }, + { + "path": "src/index.mjs", + "bytes": 173, + "sha256": "a955aabac33e5524ac96c55213264ecf338d53486d45362b58253ae67177727e" + }, + { + "path": "src/styles.mjs", + "bytes": 3219, + "sha256": "1cd818aec8cf2e9def15b51ce043bfdfcfc06626fd9df63372f34f9a43160a59" + }, + { + "path": "test/fixtures.mjs", + "bytes": 2628, + "sha256": "2292c21a378d8afc466c2c9c8cd541322f71dcf8cfdd9b8bdabafa9d71fe17f0" + }, + { + "path": "test/request.test.mjs", + "bytes": 4179, + "sha256": "7f180dd62aa03231d8fd2d97a72cb9f14b5ddbc97e97165c31562f6256c3d951" + }, + { + "path": "test/worker.mjs", + "bytes": 3156, + "sha256": "e2355fc855877fc66e35554be2e72bc29412141fa53b4aeb59313c388146f1dc" + }, + { + "path": "wrangler.json", + "bytes": 251, + "sha256": "ddf7b4df5ae09c70ab9d3be8b1a8026b6f8951971ee9602b2dce88614200c3bf" + } + ], + "zipBytes": 25246, + "zipSha256": "1aa32c075f65844fba69bb0909985c60d35e40ef488c2f889bbae2e9303d9ccd", + "assets": [ + { + "file": "invoice.pdf", + "bytes": 103212, + "sha256": "17437a93f960b80d16bbbe92f573c7c62a9d9d39eca6899fb9b642ebfb9feca3" + }, + { + "file": "invoice.png", + "bytes": 100558, + "sha256": "4714cda69ce852490602986efc07793de605bd6ca92172e21ab91243268bfb13" + }, + { + "file": "long-invoice.pdf", + "bytes": 114406, + "sha256": "2a36293e76043cb65cb2a208c28f43c526299ed17e0972ba0e0ccbff1ff071a5" + } + ] +} diff --git a/docs/assets/cloudflare-starter/verification.json b/docs/assets/cloudflare-starter/verification.json new file mode 100644 index 0000000..14a30fc --- /dev/null +++ b/docs/assets/cloudflare-starter/verification.json @@ -0,0 +1,185 @@ +{ + "ok": true, + "checkedAt": "2026-10-21T00:20:10.418993+00:00", + "packageVersion": "0.8.0", + "engineVersion": "2.5.29", + "sourceCommit": "e9582e417e8cbe49e41aabd0c695524920a1840d", + "zipSha256": "1aa32c075f65844fba69bb0909985c60d35e40ef488c2f889bbae2e9303d9ccd", + "packageIntegrity": "sha512-olEU8ARb8i5ASStXX7uGAZ2RaGaKj2uL8cL81su+M46xo3AdxcGL3j8SCcaXEmTJUzKE0o5E6djykPTbC0u2FA==", + "platform": "win32", + "wrangler": "4.151.3", + "workerd": "1.20261019.1", + "commands": [ + { + "name": "install", + "exitCode": 0 + }, + { + "name": "input-tests", + "exitCode": 0 + }, + { + "name": "bundle", + "exitCode": 0 + }, + { + "name": "http-tests", + "exitCode": 0 + }, + { + "name": "node-preview", + "exitCode": 0 + } + ], + "records": [ + { + "name": "sample", + "pdf": "17437a93f960b80d16bbbe92f573c7c62a9d9d39eca6899fb9b642ebfb9feca3", + "bytes": 103212, + "minPages": 1, + "maxPages": 1, + "text": [ + "Maple & Finch", + "$8,100.00", + "01 October 2026" + ], + "trackedText": [ + "NS-1042" + ], + "pages": 1 + }, + { + "name": "minimum", + "pdf": "825a084cd377f4ed6b3cfe7e378be636e2cca2675b8f1bab467966e4418ec4c8", + "bytes": 105334, + "minPages": 1, + "maxPages": 1, + "text": [ + "$0.00", + "This invoice has been settled." + ], + "trackedText": [ + "NS-1043" + ], + "pages": 1 + }, + { + "name": "maximum-total", + "pdf": "63c2bdd3d78a0656cff4b6919c302a4ce0385621ea28acc6fbb96426324b36ff", + "bytes": 102597, + "minPages": 1, + "maxPages": 1, + "text": [ + "$999,999.99" + ], + "trackedText": [ + "NS-1044" + ], + "pages": 1 + }, + { + "name": "long", + "pdf": "2a36293e76043cb65cb2a208c28f43c526299ed17e0972ba0e0ccbff1ff071a5", + "bytes": 114406, + "minPages": 3, + "maxPages": 10, + "text": [ + "Work package 30:", + "$9,000.00" + ], + "trackedText": [ + "NS-1045" + ], + "pages": 5 + }, + { + "name": "escaped", + "pdf": "6f1191f16e4f4023cc35d9426cd26ebb7ee1e37a2da6209df3105bcfd19b2fbf", + "bytes": 103773, + "minPages": 1, + "maxPages": 2, + "text": [ + " & Partners", + "$8,100.00" + ], + "trackedText": [ + "NS-1046" + ], + "pages": 1 + }, + { + "name": "after-errors", + "pdf": "17437a93f960b80d16bbbe92f573c7c62a9d9d39eca6899fb9b642ebfb9feca3", + "bytes": 103212, + "minPages": 1, + "maxPages": 1, + "text": [ + "Maple & Finch", + "$8,100.00", + "01 October 2026" + ], + "trackedText": [ + "NS-1042" + ], + "pages": 1 + } + ], + "errors": [ + { + "name": "json", + "status": 400, + "code": "INVALID_JSON" + }, + { + "name": "content-type", + "status": 415, + "code": "JSON_REQUIRED" + }, + { + "name": "body-limit", + "status": 413, + "code": "BODY_TOO_LARGE" + }, + { + "name": "number", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "date", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "money", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "total", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "row-limit", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "unknown", + "status": 400, + "code": "INVALID_INVOICE" + }, + { + "name": "glyph", + "status": 422, + "code": "MISSING_GLYPHS" + } + ], + "recovery": true, + "serverStopped": true, + "previewMatches": true, + "localOnly": true, + "hostedVerified": false, + "freePlanVerified": false +} diff --git a/docs/examples.md b/docs/examples.md index 0c6a455..2e93d88 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -131,6 +131,7 @@ Start with the [invoice guide](guides/invoices.md), or The Python starter also includes a [tested Docker image](guides/python-docker.md). For Node applications, download the [six-invoice queue project](guides/node-render-queue.md) with editable HTML/CSS and limits on active and waiting renders. +For Cloudflare Workers, try the [invoice endpoint with local runtime checks](guides/cloudflare-pdf.md). For C# web applications, try the [ASP.NET Core download starter](guides/aspnet-pdf.md). For Rust applications, use the [Axum PDF download starter](guides/axum-pdf.md) with an editable invoice template and bounded rendering work. diff --git a/docs/getting-started/node.md b/docs/getting-started/node.md index 65be4a3..9701247 100644 --- a/docs/getting-started/node.md +++ b/docs/getting-started/node.md @@ -28,6 +28,8 @@ for an explicit face. Review appearance and line breaks when upgrading; the [font-family guide](../engine/font-registration.md) explains font selection. For rendering inside a web page, [use the browser entry and starter](../guides/browser-pdf.md). +For Cloudflare Workers, [use the Workers adapter and invoice endpoint](../guides/cloudflare-pdf.md) +introduced in package 0.8.0. This guide's invoice download remains pinned to 0.7.1. ## Create your first PDF diff --git a/docs/guides/cloudflare-pdf.md b/docs/guides/cloudflare-pdf.md new file mode 100644 index 0000000..5ee9577 --- /dev/null +++ b/docs/guides/cloudflare-pdf.md @@ -0,0 +1,154 @@ +--- +title: Generate PDFs in Cloudflare Workers with HTML and CSS +description: Render PDF invoices inside a Cloudflare Worker with Fullbleed's bundled WebAssembly engine and fonts. Download an editable invoice endpoint with local workerd tests. +--- +# Generate PDFs in Cloudflare Workers + +Import `renderPdf` from `fullbleed/cloudflare` to turn static HTML/CSS into PDF +bytes inside a Worker. Wrangler bundles the engine and fonts with your code. +The renderer uses an in-memory filesystem; document content does not need to +leave the Worker for a PDF service. + +The adapter is verified in **local workerd on Windows and Linux**. Hosted CPU, +memory, startup behavior and free-plan suitability have not been qualified. +Measure representative documents in your deployment before choosing a plan or +workload limits. [Cloudflare's platform limits](https://developers.cloudflare.com/workers/platform/limits/) +apply to the entire isolate, including WebAssembly memory. + +## Run a complete invoice endpoint + +[Download the Workers invoice starter](../assets/cloudflare-starter/project.zip){ .md-button .md-button--primary } +[Open the generated PDF](../assets/cloudflare-starter/invoice.pdf){ .md-button } +[See a five-page invoice](../assets/cloudflare-starter/long-invoice.pdf){ .md-button } + +[![Northstar Studio invoice with forest-green typography, project progress bars and a dark-green total panel.](../assets/cloudflare-starter/invoice.png)](../assets/cloudflare-starter/invoice.pdf) + +The project uses **npm package 0.8.0**, **engine 2.5.29**, and **Wrangler 4.151.3**. +It accepts structured invoice JSON, applies an editable template, and returns a +PDF attachment. The sample organization and customer are fictional. + +Extract the ZIP and, with Node.js 22 or newer, run: + +```sh +npm ci --ignore-scripts +npm test +npm run build +npm run dev +``` + +`build` produces a local bundle without deploying. `dev` starts the local Worker +at `http://127.0.0.1:8787`; no Cloudflare account is needed. 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 +``` + +Open `invoice.pdf`. On Windows PowerShell, use `curl.exe` and put that command on +one line. Run `npm run test:worker` while the server is running to check sample, +zero-total, maximum-total, long, escaped-text and invalid invoices. The test uses +synthetic data and saves its PDFs in `output/`. + +## Use your own HTML and CSS + +The adapter accepts the same static document sources as the Node renderer: + +```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', + }, + }); + }, +}; +``` + +This is a complete Worker entry file. Keep document input behind your +application's authorization and workload limits. HTML scripts do not +run, and document URLs do not fetch remote resources. Supply images and custom +fonts as byte arrays through `assets` and `fonts`. + +Add this rule to an existing Wrangler configuration so the bundled TrueType +fonts become data modules: + +```json +{ + "rules": [{ "type": "Data", "globs": ["**/*.ttf"], "fallthrough": false }] +} +``` + +Wrangler already recognizes the engine's compiled `.wasm` import. The download +includes the complete configuration; see Cloudflare's +[WebAssembly module documentation](https://developers.cloudflare.com/workers/runtime-apis/webassembly/javascript/) +for the runtime mechanism. + +## Edit the starter's design + +| File | What to change | +| --- | --- | +| `sample.json` | Customer, reference, dates, paid status, and line items. | +| `src/document.mjs` | Headings, labels, document sections, and table rows. | +| `src/styles.mjs` | Page geometry, fonts, colors, spacing, and class rules. | +| `src/handler.mjs` | Input schema, limits, and download headers. | +| `src/index.mjs` | The platform renderer import. | + +This template uses [JavaScript function components](javascript-components.md). +`className` adds an HTML class, and a matching rule in the document's +`StyleSheet` supplies its styling. `Invoice({ data }).renderPdf(renderPdf)` passes +the generated HTML and CSS to the Workers renderer. You can replace that call +with your own HTML/CSS template. + +For standalone HTML, use `Invoice({ data }).toHtml({ includeStyles: true })`. +The embedded stylesheet also renders to PDF with `css: ''` and +`defaultStyles: false`. `includeStyles: false` exports HTML without styles. + +The sample accepts 1–30 rows and at most 32 KiB of JSON. Rates are nonnegative +integer cents, quantities are integers, dates use `YYYY-MM-DD`, and the total is +limited to $999,999.99. It uses USD and does not calculate tax. Tables flow across +pages, with a ten-page render limit. Change these bounds together with layout +tests for your own documents. + +## Connect it to your application + +The starter disables `workers_dev` and `preview_urls` and defines no public +route. Its local endpoint has no authentication. Configure an authenticated +application route or private service binding before deployment, apply rate and +concurrency limits, and keep invoice data out of logs. The handler stores no +invoices or PDFs and returns `Cache-Control: no-store`. + +Rendering is synchronous inside the isolate and returns a settled Promise. +Each call gets a fresh engine instance, but that does not guarantee that previous +allocations have already been garbage-collected. `timeoutMs`, `signal`, and +`isolation` are rejected on this entry. Cloudflare enforces CPU and memory limits; +page and input limits are not guarantees about either resource. + +The adapter defaults to 20 pages, caps HTML/CSS at 1,000,000 UTF-8 bytes, and +caps caller-provided fonts and assets at 8 MiB combined. Optional previews use +additional memory. These are ordinary PDFs; this entry does not expose PDF/A, +PDF/UA, PDF/X, or VDP profiles. + +## Verification and source + +The release checks the installed npm tarball in real local workerd, compares +PDF and preview bytes with Node, and exercises custom fonts, SVG assets, +components, embedded styles, input limits, missing glyphs and failure recovery. +The downloadable project is also installed from its lockfile and exercised over +local HTTP. Independent readers check the retained PDF contents, and the +sample and long-invoice layouts are visually reviewed. + +[Download source manifest](../assets/cloudflare-starter/source.json) · +[Local verification record](../assets/cloudflare-starter/verification.json) · +[SDK guide](https://github.com/fullbleed-engine/fullbleed-node/blob/v0.8.0/docs/cloudflare.md) · +[Release evidence](https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.8.0) diff --git a/examples/cloudflare-pdf/.gitignore b/examples/cloudflare-pdf/.gitignore new file mode 100644 index 0000000..54434c7 --- /dev/null +++ b/examples/cloudflare-pdf/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +dist/ +output/ +.dev.vars +.env* diff --git a/examples/cloudflare-pdf/LICENSE b/examples/cloudflare-pdf/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/README.md b/examples/cloudflare-pdf/README.md new file mode 100644 index 0000000..d90ac27 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/package-lock.json b/examples/cloudflare-pdf/package-lock.json new file mode 100644 index 0000000..372d06b --- /dev/null +++ b/examples/cloudflare-pdf/package-lock.json @@ -0,0 +1,1572 @@ +{ + "name": "fullbleed-cloudflare-invoice", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fullbleed-cloudflare-invoice", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "fullbleed": "0.8.0" + }, + "devDependencies": { + "wrangler": "4.151.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz", + "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", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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", + "engines": { + "node": ">=18" + } + }, + "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": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "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": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "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": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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": [ + "arm" + ], + "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-arm": "1.3.4" + } + }, + "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": [ + "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-linux-arm64": "1.3.4" + } + }, + "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": [ + "ppc64" + ], + "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-ppc64": "1.3.4" + } + }, + "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": [ + "riscv64" + ], + "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-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/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", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@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/fullbleed": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.8.0.tgz", + "integrity": "sha512-olEU8ARb8i5ASStXX7uGAZ2RaGaKj2uL8cL81su+M46xo3AdxcGL3j8SCcaXEmTJUzKE0o5E6djykPTbC0u2FA==", + "license": "MIT", + "dependencies": { + "@bjorn3/browser_wasi_shim": "0.4.2" + }, + "bin": { + "fullbleed-browser-assets": "src/copy-browser-assets.cjs" + }, + "engines": { + "node": ">=22" + } + }, + "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/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/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/examples/cloudflare-pdf/package.json b/examples/cloudflare-pdf/package.json new file mode 100644 index 0000000..e01cdaa --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/release.json b/examples/cloudflare-pdf/release.json new file mode 100644 index 0000000..b21188e --- /dev/null +++ b/examples/cloudflare-pdf/release.json @@ -0,0 +1,15 @@ +{ + "packageVersion": "0.8.0", + "engineVersion": "2.5.29", + "wrangler": "4.151.3", + "sourceCommit": "e9582e417e8cbe49e41aabd0c695524920a1840d", + "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-node", + "release": "https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.8.0", + "packageIntegrity": "sha512-olEU8ARb8i5ASStXX7uGAZ2RaGaKj2uL8cL81su+M46xo3AdxcGL3j8SCcaXEmTJUzKE0o5E6djykPTbC0u2FA==", + "packageSha256": "86af460db1f29c94e65daff3563f9f64f0f52e0af47f02adf3ff39cb2ece1691", + "wasmSha256": "bbcf9b09a60a38d9a884f227fd7f7d31e17e4d611c91027775059f05e2bb46f2", + "localOnly": true, + "hostedVerified": false, + "freePlanVerified": false, + "sourceNote": "SDK examples/cloudflare source; npm lockfile and this release record added for the downloadable project." +} diff --git a/examples/cloudflare-pdf/sample.json b/examples/cloudflare-pdf/sample.json new file mode 100644 index 0000000..6370866 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/src/document.mjs b/examples/cloudflare-pdf/src/document.mjs new file mode 100644 index 0000000..7a77a33 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/src/handler.mjs b/examples/cloudflare-pdf/src/handler.mjs new file mode 100644 index 0000000..6ec8d9c --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/src/index.mjs b/examples/cloudflare-pdf/src/index.mjs new file mode 100644 index 0000000..897b536 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/src/styles.mjs b/examples/cloudflare-pdf/src/styles.mjs new file mode 100644 index 0000000..22e3177 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/test/fixtures.mjs b/examples/cloudflare-pdf/test/fixtures.mjs new file mode 100644 index 0000000..d66ad0a --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/test/request.test.mjs b/examples/cloudflare-pdf/test/request.test.mjs new file mode 100644 index 0000000..ad20253 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/test/worker.mjs b/examples/cloudflare-pdf/test/worker.mjs new file mode 100644 index 0000000..7ae69e6 --- /dev/null +++ b/examples/cloudflare-pdf/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-pdf/wrangler.json b/examples/cloudflare-pdf/wrangler.json new file mode 100644 index 0000000..b4c9b4f --- /dev/null +++ b/examples/cloudflare-pdf/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/mkdocs.yml b/mkdocs.yml index 7ea81d8..26f2aaa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -106,6 +106,7 @@ nav: - Rust and Axum PDF downloads: guides/axum-pdf.md - Python in Docker: guides/python-docker.md - Python in AWS Lambda: guides/aws-lambda-pdf.md + - PDFs in Cloudflare Workers: guides/cloudflare-pdf.md - n8n invoice workflows: guides/n8n-pdf.md - Laravel Blade and queues: guides/laravel-pdf.md - Browser PDF generation: guides/browser-pdf.md diff --git a/tools/build_cloudflare_starter.py b/tools/build_cloudflare_starter.py new file mode 100644 index 0000000..d1a5ba0 --- /dev/null +++ b/tools/build_cloudflare_starter.py @@ -0,0 +1,45 @@ +"""Package the locked Workers invoice example with a reproducible inventory.""" +from hashlib import sha256 +from pathlib import Path +import json +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / 'examples/cloudflare-pdf' +DEST = ROOT / 'docs/assets/cloudflare-starter' +PREFIX = 'fullbleed-cloudflare-invoice/' +FILES = ['.gitignore', 'LICENSE', 'README.md', 'package.json', 'package-lock.json', + 'release.json', 'sample.json', 'wrangler.json', 'src/index.mjs', 'src/handler.mjs', + 'src/document.mjs', 'src/styles.mjs', 'test/fixtures.mjs', 'test/request.test.mjs', 'test/worker.mjs'] + + +def main(): + release = json.loads((SOURCE / 'release.json').read_text(encoding='utf-8')) + package = json.loads((SOURCE / 'package.json').read_text(encoding='utf-8')) + lock = json.loads((SOURCE / 'package-lock.json').read_text(encoding='utf-8')) + assert package['dependencies']['fullbleed'] == release['packageVersion'] == '0.8.0' + assert lock['packages']['node_modules/fullbleed']['integrity'] == release['packageIntegrity'] + DEST.mkdir(parents=True, exist_ok=True) + records = [] + path = DEST / 'project.zip' + with zipfile.ZipFile(path, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name in sorted(FILES): + data = (SOURCE / name).read_bytes() + entry = zipfile.ZipInfo(PREFIX + name, (2026, 1, 1, 0, 0, 0)) + entry.create_system = 0 + entry.compress_type = zipfile.ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data) + records.append(dict(path=name, bytes=len(data), sha256=sha256(data).hexdigest())) + assets = [] + for name in ['invoice.pdf', 'invoice.png', 'long-invoice.pdf']: + data = (DEST / name).read_bytes() + assets.append(dict(file=name, bytes=len(data), sha256=sha256(data).hexdigest())) + manifest = dict(schema='fullbleed.cloudflare-starter.v1', prefix=PREFIX, **release, + files=records, zipBytes=path.stat().st_size, zipSha256=sha256(path.read_bytes()).hexdigest(), assets=assets) + (DEST / 'source.json').write_bytes((json.dumps(manifest, indent=2) + '\n').encode('utf-8')) + print(json.dumps(dict(files=len(records), zipBytes=manifest['zipBytes'], sha256=manifest['zipSha256']))) + + +if __name__ == '__main__': + main() diff --git a/tools/verify_cloudflare_starter.py b/tools/verify_cloudflare_starter.py new file mode 100644 index 0000000..d2c14c8 --- /dev/null +++ b/tools/verify_cloudflare_starter.py @@ -0,0 +1,161 @@ +"""Install the actual downloadable ZIP and exercise its Worker over local HTTP.""" +import argparse +from datetime import datetime, timezone +from hashlib import sha256 +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import signal +import socket +import stat +import subprocess +import sys +import tempfile +import time +from urllib.request import urlopen +import zipfile +from pypdf import PdfReader +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--node', default=shutil.which('node')) +parser.add_argument('--out', type=Path, default=ROOT / 'output/cloudflare-starter-verification') +parser.add_argument('--port', type=int, default=9490) +args = parser.parse_args() +assert args.node, 'Node.js 22 or newer is required.' +node = Path(args.node).resolve() +npm = next((path for path in [node.parent / 'node_modules/npm/bin/npm-cli.js', + node.parent.parent / 'lib/node_modules/npm/bin/npm-cli.js'] if path.is_file()), None) +assert npm, 'Use an official Node distribution containing npm.' +assert 1024 < args.port < 65536 +assets = ROOT / 'docs/assets/cloudflare-starter' +manifest = json.loads((assets / 'source.json').read_text(encoding='utf-8')) +assert manifest['schema'] == 'fullbleed.cloudflare-starter.v1' +archive_path = assets / 'project.zip' +assert sha256(archive_path.read_bytes()).hexdigest() == manifest['zipSha256'] +for item in manifest['assets']: + data = (assets / item['file']).read_bytes() + assert len(data) == item['bytes'] and sha256(data).hexdigest() == item['sha256'] +out = args.out.resolve() +out.mkdir(parents=True, exist_ok=True) +workspace = Path(tempfile.mkdtemp(prefix='download with spaces ', dir=out)) +expected = {manifest['prefix'] + item['path']: item for item in manifest['files']} +with zipfile.ZipFile(archive_path) as archive: + assert len(archive.namelist()) == len(set(archive.namelist())) == len(expected) + assert set(archive.namelist()) == set(expected) + for item in archive.infolist(): + path = PurePosixPath(item.filename) + assert not path.is_absolute() and '..' not in path.parts and '\\' not in item.filename and ':' not in item.filename + assert not item.is_dir() and not stat.S_ISLNK(item.external_attr >> 16) and item.file_size < 5 * 1024 * 1024 + data = archive.read(item) + assert len(data) == expected[item.filename]['bytes'] + assert sha256(data).hexdigest() == expected[item.filename]['sha256'] + target = workspace.joinpath(*path.parts).resolve() + assert target.is_relative_to(workspace) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) +project = workspace / manifest['prefix'].rstrip('/') +env = {**os.environ, 'PATH': str(node.parent) + os.pathsep + os.environ.get('PATH', ''), 'WRANGLER_SEND_METRICS': 'false', + 'FULLBLEED_WORKER_URL': f'http://127.0.0.1:{args.port}'} +commands = [] + + +def run(label, arguments): + with (out / (label + '.log')).open('w', encoding='utf-8') as log: + result = subprocess.run(list(map(str, arguments)), cwd=project, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=240) + assert result.returncode == 0, f'{label} failed; inspect {out / (label + ".log")}' + commands.append(dict(name=label, exitCode=0)) + print(label + ': passed', flush=True) + + +run('install', [node, npm, 'ci', '--ignore-scripts', '--no-audit', '--no-fund']) +installed = json.loads((project / 'node_modules/fullbleed/package.json').read_text()) +build = json.loads((project / 'node_modules/fullbleed/dist/build.json').read_text()) +assert installed['version'] == build['packageVersion'] == manifest['packageVersion'] == '0.8.0' +assert build['engineVersion'] == manifest['engineVersion'] == '2.5.29' +assert build['files']['dist/engine.wasm']['sha256'] == manifest['wasmSha256'] +lock = json.loads((project / 'package-lock.json').read_text()) +assert lock['packages']['node_modules/fullbleed']['integrity'] == manifest['packageIntegrity'] +run('input-tests', [node, npm, 'test']) +run('bundle', [node, npm, 'run', 'build']) +config = json.loads((project / 'wrangler.json').read_text()) +assert config['workers_dev'] is False and config['preview_urls'] is False +assert not config.get('route') and not config.get('routes') +with socket.socket() as probe: + assert probe.connect_ex(('127.0.0.1', args.port)) != 0, 'The test port is already occupied.' +wrangler = project / 'node_modules/wrangler/bin/wrangler.js' +creation = dict(creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == 'nt' else dict(start_new_session=True) +with (out / 'worker.log').open('w', encoding='utf-8') as log: + worker = subprocess.Popen([str(node), str(wrangler), 'dev', '--local', '--ip', '127.0.0.1', '--port', str(args.port), + '--inspector-port', '0'], cwd=project, env=env, stdout=log, stderr=subprocess.STDOUT, **creation) + try: + deadline = time.monotonic() + 60 + while True: + assert worker.poll() is None, 'Wrangler exited; inspect worker.log.' + try: + with urlopen(env['FULLBLEED_WORKER_URL'] + '/health', timeout=1) as response: + if json.load(response).get('ok') is True: break + except Exception: + pass + if time.monotonic() >= deadline: raise TimeoutError('Local Worker did not start.') + time.sleep(0.25) + run('http-tests', [node, npm, 'run', 'test:worker']) + finally: + if worker.poll() is None: + if os.name == 'nt': + subprocess.run(['taskkill', '/PID', str(worker.pid), '/T', '/F'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20) + else: + try: os.killpg(worker.pid, signal.SIGTERM) + except ProcessLookupError: pass + try: worker.wait(timeout=10) + except subprocess.TimeoutExpired: + if os.name != 'nt': os.killpg(worker.pid, signal.SIGKILL) + else: worker.kill() + worker.wait(timeout=10) +assert worker.poll() is not None +with socket.socket() as probe: + assert probe.connect_ex(('127.0.0.1', args.port)) != 0, 'The local Worker must stop.' +http = json.loads((project / 'output/http-verification.json').read_text(encoding='utf-8')) +assert http['ok'] and http['recovery'] and len(http['records']) == 6 and len(http['errors']) == 10 +checked = [] +for item in http['records']: + path = project / 'output' / (item['name'] + '.pdf') + assert sha256(path.read_bytes()).hexdigest() == item['pdf'] + pdf = PdfReader(path) + assert item['minPages'] <= len(pdf.pages) <= item['maxPages'] + text = ' '.join(' '.join(page.extract_text().split()) for page in pdf.pages) + assert all(value in text for value in item['text']) + assert all(value in ''.join(text.split()) for value in item['trackedText']) + shutil.copyfile(path, out / path.name) + checked.append({**item, 'pages': len(pdf.pages)}) +assert (out / 'sample.pdf').read_bytes() == (assets / 'invoice.pdf').read_bytes() +assert (out / 'long.pdf').read_bytes() == (assets / 'long-invoice.pdf').read_bytes() +preview_script = project / 'verify-node-preview.mjs' +preview_script.write_text(''' +import {renderPdf} from 'fullbleed'; +import {Invoice} from './src/document.mjs'; +import {validateInvoice} from './src/handler.mjs'; +import {readFile,writeFile} from 'node:fs/promises'; +const data=validateInvoice(JSON.parse(await readFile('sample.json','utf8'))); +const result=await Invoice({data}).renderPdf(renderPdf,{defaultStyles:false,maxPages:10,previewDpi:96}); +await writeFile('output/node.pdf',result.pdf); +await writeFile('output/node.png',result.previews[0]); +''', encoding='utf-8', newline='\n') +run('node-preview', [node, preview_script]) +assert (project / 'output/node.pdf').read_bytes() == (out / 'sample.pdf').read_bytes() +assert (project / 'output/node.png').read_bytes() == (assets / 'invoice.png').read_bytes() +with Image.open(project / 'output/node.png') as image: + image.load() + assert image.width > 100 and image.height > 100 and image.convert('RGB').getextrema() != ((255, 255),) * 3 +shutil.copyfile(project / 'output/node.png', out / 'invoice.png') +report = dict(ok=True, checkedAt=datetime.now(timezone.utc).isoformat(), packageVersion=manifest['packageVersion'], + engineVersion=manifest['engineVersion'], sourceCommit=manifest['sourceCommit'], zipSha256=manifest['zipSha256'], + packageIntegrity=manifest['packageIntegrity'], platform=sys.platform, + wrangler=json.loads((project / 'node_modules/wrangler/package.json').read_text())['version'], + workerd=json.loads((project / 'node_modules/workerd/package.json').read_text())['version'], + commands=commands, records=checked, errors=http['errors'], recovery=True, serverStopped=True, + previewMatches=True, localOnly=True, hostedVerified=False, freePlanVerified=False) +(out / 'verification.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') +print(json.dumps(dict(ok=True, invoices=len(checked), pages=sum(row['pages'] for row in checked), errors=len(http['errors']))))