From 4f574b5e11d3a680a50c561bf9ea071de29d0cb8 Mon Sep 17 00:00:00 2001 From: krflol Date: Mon, 5 Oct 2026 20:03:10 -0500 Subject: [PATCH] Use isolated render processes in the Next.js starter --- README.md | 3 ++ examples/nextjs/README.md | 27 +++++++---- .../nextjs/app/api/invoices/[id]/route.js | 2 +- examples/nextjs/package-lock.json | 8 ++-- examples/nextjs/package.json | 2 +- examples/nextjs/tools/verify.mjs | 30 +++++++++++- verification/npm-publication-0.2.0.json | 47 +++++++++++++++++++ 7 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 verification/npm-publication-0.2.0.json diff --git a/README.md b/README.md index e2f7c5c..6e9d2eb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ npm install fullbleed For a version-pinned installation, use `npm install --save-exact fullbleed@0.2.0`. Package archives and retained PDF/PNG evidence are attached to each [GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases). +A [fresh npm installation](verification/npm-publication-0.2.0.json) matches the +release tarball and reproduces the retained PDF/PNG output in both isolation +modes, including recovery after forced child termination. See the [installation verification records](https://github.com/fullbleed-engine/fullbleed-node/tree/main/verification). Save this as `invoice.mjs` and run `node invoice.mjs`: diff --git a/examples/nextjs/README.md b/examples/nextjs/README.md index 2df2008..5adac21 100644 --- a/examples/nextjs/README.md +++ b/examples/nextjs/README.md @@ -20,7 +20,7 @@ Open `http://127.0.0.1:3000` and select **Download sample PDF**. The route IDs return 404. Use `npm run dev` while editing the app. `PORT` changes the port; `FULLBLEED_HOST` changes the production server's loopback binding. -The lockfile pins Next.js 16.3.8, React 19.3.0 and Fullbleed Node 0.1.5 +The lockfile pins Next.js 16.3.8, React 19.3.0 and Fullbleed Node 0.2.0 (engine 2.5.8) from npm. The registry package is byte-identical to the verified GitHub release tarball. Installing this starter needs no Python, Rust, system fonts or browser renderer. @@ -43,18 +43,18 @@ checked-in image is output from Fullbleed, not a browser screenshot or mockup. ## Keep the renderer on the server -The route declares `runtime = 'nodejs'`. It uses filesystem reads, worker -threads and WebAssembly, so this integration needs the Node runtime. +The route declares `runtime = 'nodejs'` and uses `isolation: 'process'`. It needs +a Node host that permits child processes, filesystem reads, workers and WebAssembly. `serverExternalPackages: ['fullbleed']` keeps Next.js from bundling the engine's -Node entrypoint. Explicit output tracing includes the worker, WASM, fonts, +Node entrypoint. Explicit output tracing includes the child entrypoint, worker, WASM, fonts, WASI dependency and template files needed by the standalone server. See Next.js's [external-package configuration](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages) and [standalone output documentation](https://nextjs.org/docs/app/api-reference/config/next-config-js/output). `npm run build` copies the public preview and browser chunks into `.next/standalone`, after Next generates it. That folder is the deployment -artifact. Start its `server.js` with Node on a host that supports workers and -WebAssembly; set the host's normal `PORT` and `HOSTNAME` environment variables. +artifact. Start its `server.js` with Node on a host that permits child processes, +workers and WebAssembly; set the host's normal `PORT` and `HOSTNAME` environment variables. The local start script defaults to loopback. Hosting-provider limits and serverless or Edge deployments have not been verified by this starter. @@ -72,9 +72,13 @@ content length and `nosniff`. Jobs have a 15-second rendering deadline and a five-page cap, and receive the request's abort signal. No generated PDF is written to application storage by the route. -The render promise waits for its worker to exit, including after errors, -timeouts, or cancellation. The route therefore holds its capacity slot until -that worker stops; sequential downloads do not overlap worker lifetimes. +Each request runs the renderer in a fresh Node process. The promise waits for +process exit and IPC disconnection, including after errors, timeouts, or +cancellation, so the route holds its capacity slot until cleanup finishes. +A failed render process produces a generic HTTP error and the server can handle +the next request. Process startup adds latency and memory overhead; there is no +process pool or automatic retry. This contains tested child failures and does +not establish a fix for the native crash tracked in [issue #7](https://github.com/fullbleed-engine/fullbleed-node/issues/7). Use shared rate/usage controls for multiple processes or replicas. Measure your host's memory and CPU before increasing capacity; each render worker can grow @@ -93,7 +97,10 @@ The check copies the standalone artifact to a fresh temporary directory outside the app and starts it on an available loopback port. It compares a real HTTP PDF to the direct engine output, checks headers and missing IDs, sends a concurrent burst, then verifies recovery from actual missing-glyph and page-cap -failures. Client JavaScript is checked for accidental engine/worker inclusion. +failures. It temporarily replaces only the isolated artifact's child entrypoint +with a deliberate failed exit, checks the HTTP error and live server, restores +the entrypoint, and downloads a valid PDF. Client JavaScript is checked for +accidental engine/worker inclusion. Results and the downloaded PDF are retained under `output/`. Temporary artifact directories use the `fullbleed-nextjs-` prefix and are kept diff --git a/examples/nextjs/app/api/invoices/[id]/route.js b/examples/nextjs/app/api/invoices/[id]/route.js index 28ec29a..6b80f9f 100644 --- a/examples/nextjs/app/api/invoices/[id]/route.js +++ b/examples/nextjs/app/api/invoices/[id]/route.js @@ -24,7 +24,7 @@ export async function GET(request, { params }) { active++; try { const template = await invoiceTemplate(invoice); - const { pdf } = await renderPdf({ ...template, maxPages: 5, timeoutMs: 15_000, signal: request.signal }); + const { pdf } = await renderPdf({ ...template, isolation: 'process', maxPages: 5, timeoutMs: 15_000, signal: request.signal }); return new Response(pdf, { headers: { ...privateHeaders, diff --git a/examples/nextjs/package-lock.json b/examples/nextjs/package-lock.json index f77b909..73e11af 100644 --- a/examples/nextjs/package-lock.json +++ b/examples/nextjs/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "fullbleed": "0.1.5", + "fullbleed": "0.2.0", "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0" @@ -787,9 +787,9 @@ } }, "node_modules/fullbleed": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.1.5.tgz", - "integrity": "sha512-3AvPt+n2j/0KMoUk1rq/cu4dzcsnyUNKJNOwpUJkW48vkndFcroFtXgMx4CXJX46O2/gDSli67tuhUUiVsu5OQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.2.0.tgz", + "integrity": "sha512-7khp008/oaJxMbroCQuv82ICOnJVQ1RUgYsOZWAqcW92qjKEAVzRK80UZDIJkNWcaMh1yDdTR8BJ5PAFc3QbnA==", "license": "MIT", "dependencies": { "@bjorn3/browser_wasi_shim": "0.4.2" diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index cb295f2..23ca97f 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -16,7 +16,7 @@ "verify": "node tools/verify.mjs" }, "dependencies": { - "fullbleed": "0.1.5", + "fullbleed": "0.2.0", "next": "16.3.8", "react": "19.3.0", "react-dom": "19.3.0" diff --git a/examples/nextjs/tools/verify.mjs b/examples/nextjs/tools/verify.mjs index 3ca0671..03c9eb5 100644 --- a/examples/nextjs/tools/verify.mjs +++ b/examples/nextjs/tools/verify.mjs @@ -105,6 +105,32 @@ try { await pdfResponse(await get(url)); check('a valid document renders after the page limit rejects a job', true); + // Next can keep both the traced package and a renamed external-package copy. + // Target every verified copy in this disposable artifact so the injected + // failure reaches the package actually imported by the production route. + async function processEntries(directory) { + const found = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) found.push(...await processEntries(path)); + else if (entry.name === 'process-worker.cjs') found.push(path); + } + return found; + } + const processPaths = await processEntries(folder); + assert(processPaths.length > 0, 'Standalone tracing must include the render child.'); + const originalProcessEntry = await readFile('node_modules/fullbleed/src/process-worker.cjs'); + for (const path of processPaths) assert.deepEqual(await readFile(path), originalProcessEntry); + try { + for (const path of processPaths) await writeFile(path, 'process.exit(23);\n'); + const failure = await get(url); + check('a failed render child returns an HTTP error while the production server stays alive', + failure.status === 500 && (await failure.json()).error.code === 'RENDER_FAILED' + && logs.includes('PROCESS_FAILED') && child.exitCode === null); + } finally { for (const path of processPaths) await writeFile(path, originalProcessEntry); } + await pdfResponse(await get(url)); + check('the production server returns a valid PDF after a render-process failure', true); + async function jsFiles(directory) { const result = []; for (const entry of await readdir(directory, { withFileTypes: true })) { @@ -118,9 +144,9 @@ try { for (const file of chunks) assert.doesNotMatch(await readFile(file, 'utf8'), /browser_wasi_shim|engine\.wasm|node:worker_threads/); check('client JavaScript excludes the server PDF engine and worker', chunks.length > 0); const info = JSON.parse(await readFile('package.json', 'utf8')); - const record = { checkedAt: new Date().toISOString(), node: process.version, platform: platform(), next: info.dependencies.next, nodePackage: version, engineVersion, + const record = { checkedAt: new Date().toISOString(), node: process.version, platform: platform(), next: info.dependencies.next, nodePackage: version, engineVersion, isolation: 'process', packageLockSha256: hash(await readFile('package-lock.json')), base, standaloneFolder: folder, pages: expected.pages, pdfSha256: hash(bytes), burst, checks, - scope: 'Real next build standalone server with released Fullbleed tarball and fictional data. No hosted platform or application-authentication claim.' }; + scope: 'Real next build standalone server with released Fullbleed npm package and fictional data; HTTP recovery after deliberate child failure. No hosted platform or application-authentication claim.' }; await writeFile('output/verification.json', JSON.stringify(record, null, 2) + '\n'); await writeFile('output/server.log', logs); if (process.argv.includes('--serve')) { diff --git a/verification/npm-publication-0.2.0.json b/verification/npm-publication-0.2.0.json new file mode 100644 index 0000000..f20699e --- /dev/null +++ b/verification/npm-publication-0.2.0.json @@ -0,0 +1,47 @@ +{ + "schema": "fullbleed.npm_publication.v1", + "ok": true, + "package": "fullbleed", + "version": "0.2.0", + "engine": "2.5.8", + "checked_at": "2026-10-06T00:58:18.175861+00:00", + "source": "npm", + "source_commit": "969f23c479cd02f366cc1d2e099b6bda19dfaac9", + "prepublication_ci": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/37396006764", + "tarball": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.2.0.tgz", + "tarball_bytes": 2362870, + "tarball_sha256": "90bd0bbf4ae542b6be1deb3103ba492b9d8675e47b71f895060b40419d705f4a", + "package_integrity": "sha512-7khp008/oaJxMbroCQuv82ICOnJVQ1RUgYsOZWAqcW92qjKEAVzRK80UZDIJkNWcaMh1yDdTR8BJ5PAFc3QbnA==", + "installed_files_matched": 19, + "fresh_cache": true, + "empty_user_config": true, + "runtime_path": "Node executable directory only", + "node": "v22.23.3", + "identical_to_verified_github_release": true, + "release": "https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.2.0", + "publishing_method": "GitHub Actions trusted publisher (OIDC)", + "smoke": { + "ok": true, + "engine": "2.5.8", + "pdf": "01fc1ca82a6ef2f50805fe7a4f00a173dc59beb09196af12dd2547f8c4c94b47", + "png": "d13276f02f35653e97fd484ec0a3e2951f250a290ffe99655b9854dce2d35bc4", + "cjs": true, + "coldTimeoutRecovered": true, + "settledWorkersReleased": true + }, + "process_smoke": { + "ok": true, + "parentWasmForbidden": true, + "parentWorkersForbidden": true, + "applicationPreloadsExcluded": true, + "cjs": true, + "forcedTerminationRecovered": true, + "settledProcessesReleased": true, + "children": 4, + "pdf": "01fc1ca82a6ef2f50805fe7a4f00a173dc59beb09196af12dd2547f8c4c94b47", + "png": "d13276f02f35653e97fd484ec0a3e2951f250a290ffe99655b9854dce2d35bc4" + }, + "scope": "Exact release bytes; fresh public installation; worker/process output equality; ESM/CommonJS; cold timeout recovery; forced child termination and recovery; no parent Wasm or workers and no replayed application preloads.", + "spending_usd": 0, + "publication_workflow": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/37396588438" +}