diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8645a60..6afa205 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -74,6 +74,19 @@ jobs: output/reference-env/bin/python -I tools/verify_quickstarts.py - name: Verify the downloadable Node invoice and guide run: python tools/verify_node_examples.py + - name: Build and exercise the downloadable Next.js starter + run: python tools/verify_nextjs_starter.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: nextjs-download-verification + path: | + output/nextjs-verification/*.json + output/nextjs-verification/*.txt + output/nextjs-verification/*.log + output/nextjs-verification/*.pdf + output/nextjs-verification/*.png + retention-days: 14 - uses: actions/upload-artifact@v4 if: always() with: diff --git a/docs/assets/nextjs/invoice.pdf b/docs/assets/nextjs/invoice.pdf new file mode 100644 index 0000000..09f202e Binary files /dev/null and b/docs/assets/nextjs/invoice.pdf differ diff --git a/docs/assets/nextjs/invoice.png b/docs/assets/nextjs/invoice.png new file mode 100644 index 0000000..82d648f Binary files /dev/null and b/docs/assets/nextjs/invoice.png differ diff --git a/docs/assets/nextjs/project.zip b/docs/assets/nextjs/project.zip new file mode 100644 index 0000000..10ffbd5 Binary files /dev/null and b/docs/assets/nextjs/project.zip differ diff --git a/docs/assets/nextjs/source.json b/docs/assets/nextjs/source.json new file mode 100644 index 0000000..ab5ccab --- /dev/null +++ b/docs/assets/nextjs/source.json @@ -0,0 +1,22 @@ +{ + "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-node", + "sourceCommit": "b78f643836bc724a1b2f678df442f08ed38f7b58", + "files": 18, + "assets": [ + { + "file": "project.zip", + "sha256": "75f58804ce6a28889cb3b621e9b6e5ecd9ed6a5607477f0c784bf5842c747a6f", + "bytes": 104396 + }, + { + "file": "invoice.pdf", + "sha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "bytes": 91590 + }, + { + "file": "invoice.png", + "sha256": "b76f6db748fb47b928c93c12fe788de94a0f5d8da4bb350f644a58705c163cf7", + "bytes": 107399 + } + ] +} diff --git a/docs/assets/nextjs/verification.json b/docs/assets/nextjs/verification.json new file mode 100644 index 0000000..a2c8457 --- /dev/null +++ b/docs/assets/nextjs/verification.json @@ -0,0 +1,240 @@ +{ + "checkedAt": "2026-10-03T11:34:10.884039+00:00", + "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-node", + "sourceCommit": "b78f643836bc724a1b2f678df442f08ed38f7b58", + "mergedCommit": "6bce33c576bf8ff9150995e4f0c60b7e416eecda", + "pullRequest": "https://github.com/fullbleed-engine/fullbleed-node/pull/2", + "ciRun": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/37119598863", + "standaloneChecks": [ + { + "name": "standalone artifact is isolated from the source application", + "passed": true + }, + { + "name": "standalone tracing retains the verified engine and all bundled fonts", + "passed": true + }, + { + "name": "isolated production server starts on loopback", + "passed": true + }, + { + "name": "app exposes the sample download without a prefetched render", + "passed": true + }, + { + "name": "standalone server includes the actual PDF preview asset", + "passed": true + }, + { + "name": "production route returns complete private PDF bytes with a useful filename", + "passed": true + }, + { + "name": "unknown invoice IDs return a private JSON error", + "passed": true + }, + { + "name": "unsupported writes are rejected by the route", + "passed": true + }, + { + "name": "a concurrent burst receives explicit capacity errors instead of an unbounded queue", + "passed": true + }, + { + "name": "normal PDF downloads recover after a capacity burst", + "passed": true + }, + { + "name": "a real missing-glyph failure returns a generic non-PDF error", + "passed": true + }, + { + "name": "a valid document renders after an engine failure", + "passed": true + }, + { + "name": "the route enforces its page cap against a real multipage document", + "passed": true + }, + { + "name": "a valid document renders after the page limit rejects a job", + "passed": true + }, + { + "name": "client JavaScript excludes the server PDF engine and worker", + "passed": true + } + ], + "ciResults": [ + { + "artifact": "nextjs-ubuntu-latest-22", + "checkedAt": "2026-10-03T11:25:44.154Z", + "node": "v22.23.3", + "platform": "linux", + "next": "16.3.8", + "nodePackage": "0.1.1", + "engineVersion": "2.5.5", + "packageLockSha256": "e064ee3d318cd0926b589e41bf91341c9b6643ed661e7de8cf610bf6f12f56bc", + "pages": 1, + "pdfSha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "burst": [ + 200, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503 + ], + "checksPassed": 15 + }, + { + "artifact": "nextjs-ubuntu-latest-24", + "checkedAt": "2026-10-03T11:25:43.587Z", + "node": "v24.21.0", + "platform": "linux", + "next": "16.3.8", + "nodePackage": "0.1.1", + "engineVersion": "2.5.5", + "packageLockSha256": "e064ee3d318cd0926b589e41bf91341c9b6643ed661e7de8cf610bf6f12f56bc", + "pages": 1, + "pdfSha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "burst": [ + 200, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503 + ], + "checksPassed": 15 + }, + { + "artifact": "nextjs-windows-latest-24", + "checkedAt": "2026-10-03T11:26:05.604Z", + "node": "v24.21.0", + "platform": "win32", + "next": "16.3.8", + "nodePackage": "0.1.1", + "engineVersion": "2.5.5", + "packageLockSha256": "e064ee3d318cd0926b589e41bf91341c9b6643ed661e7de8cf610bf6f12f56bc", + "pages": 1, + "pdfSha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "burst": [ + 200, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503, + 503 + ], + "checksPassed": 15 + } + ], + "packageChecks": { + "run": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/37119598893", + "jobsPassed": 10, + "conclusion": "success" + }, + "browser": { + "checkedAt": "2026-10-03T11:21:13.905423+00:00", + "browser": "154.0.8037.93", + "checks": [ + { + "name": "starter shows its real PDF preview", + "passed": true + }, + { + "name": "browser downloads the exact standalone route PDF", + "passed": true + }, + { + "name": "PDF retains the designed one-page invoice and recorded amount", + "passed": true + }, + { + "name": "the native PDF download leaves the starter page usable", + "passed": true + }, + { + "name": "the starter fits a narrow viewport", + "passed": true + }, + { + "name": "keyboard activation downloads the same PDF", + "passed": true + }, + { + "name": "the journey makes no third-party requests", + "passed": true + }, + { + "name": "the browser reports no uncaught JavaScript errors", + "passed": true + } + ], + "pdfSha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "pageErrors": [], + "screenshots": [ + { + "file": "desktop.png", + "sha256": "ce67ee655422570b9e5aa5e932eb3ce5d49ca46f869daf92845cf2280a471082" + }, + { + "file": "mobile.png", + "sha256": "3b3085d91dcb0abed1487f55b1eb853f70757cc0f2ef10b2f6f7a126a389973f" + } + ], + "scope": "Local Windows Chrome; owned test context, desktop 1440 px and mobile 390 px viewports; synthetic sample only." + }, + "scope": "Released Fullbleed tarball in a Next.js standalone example. Byte equality applies to this retained synthetic invoice. Does not establish application authorization, hosting-provider support, or PDF standards compliance.", + "downloadVerification": { + "checkedAt": "2026-10-03T11:33:55.093911+00:00", + "sourceCommit": "b78f643836bc724a1b2f678df442f08ed38f7b58", + "downloadFiles": 18, + "projectZipSha256": "75f58804ce6a28889cb3b621e9b6e5ecd9ed6a5607477f0c784bf5842c747a6f", + "node": "v24.21.0", + "platform": "win32", + "commands": [ + { + "name": "install", + "exitCode": 0 + }, + { + "name": "preview", + "exitCode": 0 + }, + { + "name": "build", + "exitCode": 0 + }, + { + "name": "verify", + "exitCode": 0 + } + ], + "pdfSha256": "e09514ad458c62f9f9ef5df76168717b78b20ed365e892118f7aff6a4492e34b", + "previewSha256": "b76f6db748fb47b928c93c12fe788de94a0f5d8da4bb350f644a58705c163cf7", + "scope": "Fresh public ZIP installation, production build, isolated standalone HTTP server, and exact sample-asset comparison. No application authentication or hosted-provider claim.", + "standaloneChecksPassed": 15 + } +} diff --git a/docs/getting-started/node.md b/docs/getting-started/node.md index 4d0ec6e..4481ed9 100644 --- a/docs/getting-started/node.md +++ b/docs/getting-started/node.md @@ -90,6 +90,10 @@ includes a complete font and asset example. ## Use it in an application +For a working web-app integration, use the [Next.js PDF download starter](../guides/nextjs-pdf.md). +It includes an App Router handler, designed invoice, bundled-asset configuration +and an isolated check of the production standalone server. + Rendering runs in a worker, with separate document state for concurrent calls. Use `timeoutMs`, `maxPages`, and an `AbortSignal` to bound a job. Limit concurrent calls to fit your server's memory; each worker's WebAssembly memory ceiling is diff --git a/docs/guides/nextjs-pdf.md b/docs/guides/nextjs-pdf.md new file mode 100644 index 0000000..7147adf --- /dev/null +++ b/docs/guides/nextjs-pdf.md @@ -0,0 +1,133 @@ +--- +title: Generate PDF downloads in Next.js with HTML and CSS +description: Build a Next.js App Router PDF download with Fullbleed. Includes a runnable starter, a designed invoice, private responses, bounded rendering, and standalone deployment checks. +--- +# Generate PDF downloads in Next.js + +Add a PDF download to an App Router application using Fullbleed's Node package. +This starter serves a designed invoice from static HTML/CSS on your Node.js +server. The engine and fonts are included; no Python, browser renderer or +external PDF service is needed. + +[Download the Next.js starter](../assets/nextjs/project.zip){ .md-button .md-button--primary } +[Open the sample PDF](../assets/nextjs/invoice.pdf){ .md-button } + +[![Actual invoice PDF with oversized green typography, cream paper, an itemized table and an orange-accented total.](../assets/nextjs/invoice.png)](../assets/nextjs/invoice.pdf) + +The invoice is a fictional Northstar Studio sample. The project is a runnable +integration example, not an authenticated billing application. +The [source record and download hashes](../assets/nextjs/source.json) identify +the exact project included in the ZIP. + +## Run the complete starter + +Use Node.js 22 or newer. Extract the ZIP, open its `fullbleed-nextjs` directory, +and run: + +```bash +npm ci --ignore-scripts +npm run build +npm start +``` + +Open `http://127.0.0.1:3000` and select **Download sample PDF**. The route +`/api/invoices/NS-1042` returns the invoice as an attachment. The browser stays +on the starter page. Use `npm run dev` while editing the app. + +The lockfile pins Next.js 16.3.8, React 19.3.0 and Fullbleed Node 0.1.1, powered +by engine 2.5.5. Fullbleed is installed from its versioned GitHub tarball; +`npm install fullbleed` is not yet a verified registry installation command. + +## Keep Fullbleed on the Node server + +The route declares `runtime = 'nodejs'`. Fullbleed uses worker threads and an +in-memory WebAssembly engine, and reads bundled assets from its installed +package. The app keeps it outside Next's server bundling and explicitly includes +those files in the standalone output: + +```javascript +// next.config.mjs — the project also sets its tracing/build root. +export default { + output: 'standalone', + serverExternalPackages: ['fullbleed'], + outputFileTracingIncludes: { + '/api/invoices/*': [ + './templates/**/*', + './node_modules/fullbleed/**/*', + './node_modules/@bjorn3/browser_wasi_shim/**/*', + ], + }, +}; +``` + +Next.js documents [external server packages](https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages) +and [standalone file tracing](https://nextjs.org/docs/app/api-reference/config/next-config-js/output). +The starter's build also copies `public` and `.next/static` into +`.next/standalone`, because Next's standalone output does not include those +browser assets automatically. + +The PDF engine is imported only by the server route. The browser receives the +page, a static PNG preview and the requested PDF; it does not load Fullbleed's +worker or WASM. This integration needs a Node server, not an Edge runtime or +a static site export. + +## Return a private PDF response + +The [complete route](https://github.com/fullbleed-engine/fullbleed-node/blob/main/examples/nextjs/app/api/invoices/%5Bid%5D/route.js) +looks up the fictional invoice, loads its template, then calls `renderPdf` with +a five-page cap, a 15-second deadline and the request's abort signal. + +| Result | HTTP behavior | +| --- | --- | +| Invoice rendered | `200 application/pdf`, attachment filename, full content length, `private, no-store` and `nosniff` | +| Unknown fixture ID | `404` with a small JSON error | +| Another render is active | `503 BUSY` with `Retry-After: 2`; no waiting queue | +| Renderer rejects the document | Generic `500` error; no partial PDF or document input in the response | +| Render deadline expires | `504` error | + +One render is allowed per server process. Use shared rate and usage controls +across replicas, and measure memory before raising concurrency. A worker can +grow to the Node package's 512 MiB WASM ceiling. Hosting-provider limits and +serverless deployments require their own validation. + +## Use your design and authorized data + +Edit `templates/invoice.html` and `templates/invoice.css`. The sample uses the +bundled Inter, DM Serif Display and Bebas Neue fonts. Regenerate the actual +PDF and preview after a layout change: + +```bash +npm run preview:pdf +``` + +Inspect `output/invoice.pdf` and `public/invoice.png`, then rebuild the app. +See the [Node API guide](../getting-started/node.md) for custom fonts and images. + +Replace the fictional `getDemoInvoice(id)` lookup with your application's +session authentication and account-scoped invoice query before using real +records. Verify ownership before rendering, keep data escaped, and keep the +private response headers. Do not turn the route into an arbitrary HTML or +remote-asset rendering endpoint. The sample's dates and amounts are fixture +content; it does not implement invoice numbering, tax calculation or billing. + +## Check the deployment artifact + +After building, run `npm run verify`. It copies the standalone app into a fresh +temporary directory outside the source project, starts it on loopback, and +checks real HTTP PDFs against direct engine output. It also exercises unknown +IDs, a concurrent burst, missing-glyph and page-limit failures, and recovery. +The server closes when the checks finish; reports and a PDF remain in `output`. + +The [retained verification](../assets/nextjs/verification.json) records 15 passing +standalone checks on Linux with Node 22/24 and Windows with Node 24. All three +runs produced the same sample PDF and PNG bytes. Local Chrome checks also cover +the real download, keyboard activation and a narrow viewport. Documentation CI +installs and exercises the actual downloadable ZIP before publishing the guide. + +These checks use the released Fullbleed tarball and fictional data. They do not +establish your application's authorization, production hosting, or PDF standards +compliance. + +[Starter source](https://github.com/fullbleed-engine/fullbleed-node/tree/main/examples/nextjs) · +[Verification workflow](https://github.com/fullbleed-engine/fullbleed-node/actions/workflows/nextjs.yml) · +[Fullbleed Node API](https://github.com/fullbleed-engine/fullbleed-node) diff --git a/mkdocs.yml b/mkdocs.yml index e5c5d2f..3e481af 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Rebuild as you edit: guides/render-watch.md - Check PDFs in CI: guides/pdf-regression-ci.md - FastAPI, Flask, and Django: guides/web-frameworks.md + - Next.js PDF downloads: guides/nextjs-pdf.md - Variable-data documents: guides/bank-statements.md - Tagged PDF and accessibility: accessibility/overview.md - Print output and PDF/VT: guides/print-output.md diff --git a/tools/verify_nextjs_starter.py b/tools/verify_nextjs_starter.py new file mode 100644 index 0000000..f316062 --- /dev/null +++ b/tools/verify_nextjs_starter.py @@ -0,0 +1,99 @@ +"""Build and exercise the actual downloadable Next.js starter in a fresh directory.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +import tempfile +import zipfile + +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/nextjs-verification") +args = parser.parse_args() +if not args.node: + raise SystemExit("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) +if npm is None: + raise SystemExit("Use an official Node distribution containing npm.") + +digest = lambda data: hashlib.sha256(data).hexdigest() +assets = ROOT / "docs/assets/nextjs" +manifest = json.loads((assets / "source.json").read_text(encoding="utf8")) +expected_assets = {item["file"]: item for item in manifest["assets"]} +assert set(expected_assets) == {"project.zip", "invoice.pdf", "invoice.png"} +for name, expected in expected_assets.items(): + data = (assets / name).read_bytes() + assert len(data) == expected["bytes"] and digest(data) == expected["sha256"], name + +out = args.out.resolve() +out.mkdir(parents=True, exist_ok=True) +workspace = Path(tempfile.mkdtemp(prefix="download with spaces ", dir=out)) +with zipfile.ZipFile(assets / "project.zip") as archive: + names = archive.namelist() + assert len(names) == len(set(names)) == manifest["files"] + for entry in archive.infolist(): + relative = PurePosixPath(entry.filename) + assert not relative.is_absolute() and ".." not in relative.parts + assert "\\" not in entry.filename and ":" not in entry.filename + assert relative.parts[0] == "fullbleed-nextjs" and len(relative.parts) > 1 + assert not entry.is_dir() and entry.file_size <= 10 * 1024 * 1024 + assert (entry.external_attr >> 16) & 0o170000 != 0o120000, "No symlinks" + destination = workspace.joinpath(*relative.parts).resolve() + assert destination.is_relative_to(workspace) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(archive.read(entry)) +project = workspace / "fullbleed-nextjs" +env = dict(os.environ, PATH=str(node.parent) + os.pathsep + os.environ.get("PATH", ""), + NEXT_TELEMETRY_DISABLED="1") +commands = [] + + +def run(label, arguments): + result = subprocess.run([str(node), str(npm), *arguments], cwd=project, env=env, + capture_output=True, text=True, encoding="utf8", timeout=360) + (out / (label + ".stdout.txt")).write_text(result.stdout, encoding="utf8") + (out / (label + ".stderr.txt")).write_text(result.stderr, encoding="utf8") + if result.returncode: + raise RuntimeError(f"{label} failed ({result.returncode}); inspect {out}") + commands.append({"name": label, "exitCode": 0}) + print(label + ": passed", flush=True) + + +run("install", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"]) +run("preview", ["run", "preview:pdf"]) +run("build", ["run", "build"]) +run("verify", ["run", "verify"]) +preview = json.loads((project / "output/preview.json").read_text(encoding="utf8")) +verification = json.loads((project / "output/verification.json").read_text(encoding="utf8")) +assert preview["pages"] == 1 and preview["missingGlyphs"] == 0 +assert len(verification["checks"]) == 15 and all(item["passed"] for item in verification["checks"]) +for actual, expected in [("output/invoice.pdf", "invoice.pdf"), + ("output/route.pdf", "invoice.pdf"), + ("public/invoice.png", "invoice.png")]: + assert (project / actual).read_bytes() == (assets / expected).read_bytes(), actual + shutil.copyfile(project / actual, out / Path(actual).name) +for name in ["verification.json", "preview.json", "server.log"]: + shutil.copyfile(project / "output" / name, out / ("starter-" + name)) +report = { + "ok": True, "checkedAt": datetime.now(timezone.utc).isoformat(), + "sourceCommit": manifest["sourceCommit"], "downloadFiles": len(names), + "projectZipSha256": expected_assets["project.zip"]["sha256"], + "node": verification["node"], "platform": verification["platform"], + "next": verification["next"], "nodePackage": verification["nodePackage"], + "engineVersion": verification["engineVersion"], "commands": commands, + "pdfSha256": digest((out / "route.pdf").read_bytes()), + "previewSha256": digest((out / "invoice.png").read_bytes()), + "checks": verification["checks"], + "scope": "Fresh public ZIP installation, production build, isolated standalone HTTP server, and exact sample-asset comparison. No application authentication or hosted-provider claim.", +} +(out / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf8") +print(json.dumps({"ok": True, "files": len(names), "checks": len(report["checks"])}))