Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
27 changes: 17 additions & 10 deletions examples/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/app/api/invoices/[id]/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions examples/nextjs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 28 additions & 2 deletions examples/nextjs/tools/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) {
Expand All @@ -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')) {
Expand Down
47 changes: 47 additions & 0 deletions verification/npm-publication-0.2.0.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading