fullbleed 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/build.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "packageVersion": "0.1.5",
2
+ "packageVersion": "0.3.0",
3
3
  "engineVersion": "2.5.8",
4
4
  "engineSource": "https://crates.io/crates/fullbleed/2.5.8",
5
5
  "target": "wasm32-wasip1",
@@ -78,6 +78,18 @@
78
78
  "assets/fonts/Inter-Variable.ttf": {
79
79
  "bytes": 876576,
80
80
  "sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031"
81
+ },
82
+ "dist/browser/client.js": {
83
+ "bytes": 12042,
84
+ "sha256": "9de6e9b58387b419bd9469d9ac462907cd1f17c787dc89c86ee5e2c96309c0bf"
85
+ },
86
+ "dist/browser/worker.js": {
87
+ "bytes": 52108,
88
+ "sha256": "732050b295547a95b2eef88926c64f442fe3cb399282bcfc3f7feb08393b35e5"
89
+ },
90
+ "dist/browser/asset-manifest.json": {
91
+ "bytes": 2398,
92
+ "sha256": "3438e91fa80490ff6bee2ed058c9e62abd91f4c2084bc5536d180a1f7ff14216"
81
93
  }
82
94
  }
83
95
  }
package/dist/index.d.cts CHANGED
@@ -17,6 +17,8 @@ export interface RenderOptions {
17
17
  /** Default false: missing glyphs reject the job instead of silently emitting them. */
18
18
  allowMissingGlyphs?: boolean;
19
19
  signal?: AbortSignal;
20
+ /** Default: worker. Process starts a fresh Node child and waits for its exit. */
21
+ isolation?: 'worker' | 'process';
20
22
  }
21
23
 
22
24
  export interface RenderResult {
@@ -29,6 +31,9 @@ export interface RenderResult {
29
31
 
30
32
  export class FullbleedError extends Error {
31
33
  readonly code: string;
34
+ /** Present on PROCESS_FAILED errors after the child closes. */
35
+ readonly exitCode?: number | null;
36
+ readonly signal?: NodeJS.Signals | null;
32
37
  constructor(code: string, message: string, options?: ErrorOptions);
33
38
  }
34
39
  export function renderPdf(options: RenderOptions): Promise<RenderResult>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fullbleed",
3
- "version": "0.1.5",
4
- "description": "Generate PDFs from HTML and CSS in Node.js with the Fullbleed Rust engine, bundled fonts, and optional PNG previews.",
3
+ "version": "0.3.0",
4
+ "description": "Generate PDFs from HTML and CSS in Node.js or a browser worker with the Fullbleed Rust engine, bundled fonts, and optional PNG previews.",
5
5
  "type": "module",
6
6
  "main": "./src/index.cjs",
7
7
  "types": "./src/index.d.ts",
@@ -15,6 +15,10 @@
15
15
  "types": "./dist/index.d.cts",
16
16
  "default": "./src/index.cjs"
17
17
  }
18
+ },
19
+ "./browser": {
20
+ "types": "./src/browser.d.ts",
21
+ "default": "./dist/browser/client.js"
18
22
  }
19
23
  },
20
24
  "engines": {
@@ -31,7 +35,7 @@
31
35
  "build": "node tools/build.mjs",
32
36
  "prepack": "node tools/check-build.mjs",
33
37
  "test": "node --test test/*.test.mjs",
34
- "check:types": "tsc -p tsconfig.json",
38
+ "check:types": "tsc -p tsconfig.json && tsc -p tsconfig.browser.json",
35
39
  "verify:native": "node tools/verify-native.mjs",
36
40
  "example": "node examples/invoice.mjs",
37
41
  "verify:pack": "node tools/verify-pack.mjs",
@@ -39,7 +43,9 @@
39
43
  "render:fonts": "node tools/render-font-subsets.mjs",
40
44
  "verify:fonts": "python tools/verify-font-subsets.py",
41
45
  "render:families": "node tools/render-font-families.mjs",
42
- "verify:families": "python tools/verify-font-families.py output/font-families"
46
+ "verify:families": "python tools/verify-font-families.py output/font-families",
47
+ "build:browser": "node tools/build-browser.mjs",
48
+ "verify:browser:prepare": "node tools/prepare-browser-check.mjs"
43
49
  },
44
50
  "dependencies": {
45
51
  "@bjorn3/browser_wasi_shim": "0.4.2"
@@ -71,6 +77,10 @@
71
77
  },
72
78
  "devDependencies": {
73
79
  "@types/node": "22.20.5",
74
- "typescript": "7.0.2"
80
+ "typescript": "7.0.2",
81
+ "esbuild": "0.28.2"
82
+ },
83
+ "bin": {
84
+ "fullbleed-browser-assets": "src/copy-browser-assets.cjs"
75
85
  }
76
86
  }
@@ -0,0 +1,39 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { runEngine } from './engine-runner.js';
3
+
4
+ const version = __FULLBLEED_PACKAGE_VERSION__;
5
+ const engineVersion = __FULLBLEED_ENGINE_VERSION__;
6
+ const files = __FULLBLEED_RUNTIME_FILES__;
7
+ const fontNames = __FULLBLEED_FONT_NAMES__;
8
+ const hex = bytes => Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, '0')).join('');
9
+
10
+ async function load(name) {
11
+ const url = new URL(name, self.location.href);
12
+ url.searchParams.set('v', version);
13
+ const response = await fetch(url, { credentials: 'omit', redirect: 'error' });
14
+ if (!response.ok) throw new Error(`Could not load ${name} (HTTP ${response.status}).`);
15
+ const bytes = new Uint8Array(await response.arrayBuffer());
16
+ if (bytes.length !== files[name].bytes || hex(await crypto.subtle.digest('SHA-256', bytes)) !== files[name].sha256) {
17
+ throw new Error(`Bundled asset failed verification: ${name}.`);
18
+ }
19
+ return bytes;
20
+ }
21
+
22
+ self.addEventListener('message', async ({ data }) => {
23
+ let result;
24
+ try {
25
+ if (data?.type !== 'render') throw new Error('Invalid render request.');
26
+ const [wasm, ...fontBytes] = await Promise.all([load('engine.wasm'), ...fontNames.map(name => load('fonts/' + name))]);
27
+ const module = await WebAssembly.compile(wasm);
28
+ if (!WebAssembly.Module.imports(module).every(item => item.module === 'wasi_snapshot_preview1' && !item.name.startsWith('sock_'))) {
29
+ throw new Error('Unexpected WebAssembly host imports.');
30
+ }
31
+ result = await runEngine({ ...data.job, module,
32
+ fonts: [...fontNames.map((name, i) => ({ name: 'fonts/' + name, data: fontBytes[i] })), ...data.job.fonts] });
33
+ } catch (cause) {
34
+ result = { ok: false, code: 'ENGINE_LOAD_FAILED', message: 'Could not load the bundled Fullbleed engine: ' + cause.message };
35
+ }
36
+ self.postMessage({ type: 'result', engineVersion, ...result }, result.ok ? [result.pdf.buffer, ...result.previews.map(page => page.buffer)] : []);
37
+ self.close();
38
+ }, { once: true });
39
+ self.postMessage({ type: 'ready', protocol: 1, version, engineVersion });
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export interface BrowserRendererOptions {
3
+ /** Same-origin directory URL ending in /, containing the copied browser assets. */
4
+ assetBaseUrl: string | URL;
5
+ }
6
+ export interface BrowserRenderOptions {
7
+ html: string;
8
+ css?: string;
9
+ /** Additional TrueType font bytes; use the font family names in CSS. */
10
+ fonts?: readonly Uint8Array[];
11
+ /** Relative names referenced as assets/name.svg in document HTML/CSS. */
12
+ assets?: Readonly<Record<string, Uint8Array>>;
13
+ /** 0 disables previews (default); otherwise 36–300 DPI. */
14
+ previewDpi?: number;
15
+ /** Includes worker startup and asset loading. Default: 30000 ms. */
16
+ timeoutMs?: number;
17
+ maxPages?: number;
18
+ allowMissingGlyphs?: boolean;
19
+ signal?: AbortSignal;
20
+ }
21
+ export interface BrowserRenderResult {
22
+ pdf: Uint8Array<ArrayBuffer>;
23
+ previews: Uint8Array<ArrayBuffer>[];
24
+ pages: number;
25
+ missingGlyphs: number;
26
+ engineVersion: string;
27
+ }
28
+ export interface BrowserRenderer {
29
+ renderPdf(options: BrowserRenderOptions): Promise<BrowserRenderResult>;
30
+ }
31
+ export class FullbleedError extends Error {
32
+ readonly code: string;
33
+ constructor(code: string, message: string, options?: ErrorOptions);
34
+ }
35
+ export function createRenderer(options: BrowserRendererOptions): BrowserRenderer;
36
+ export const version: string;
37
+ export const engineVersion: string;
package/src/browser.js ADDED
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import input from './render-input.cjs';
3
+
4
+ export const version = __FULLBLEED_PACKAGE_VERSION__;
5
+ export const engineVersion = __FULLBLEED_ENGINE_VERSION__;
6
+ export const FullbleedError = input.FullbleedError;
7
+ const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); };
8
+
9
+ /** Create a browser renderer using the assets copied to your site's static directory. */
10
+ export function createRenderer(options) {
11
+ if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an assetBaseUrl pointing to your hosted Fullbleed assets.');
12
+ for (const key of Object.keys(options)) if (key !== 'assetBaseUrl') invalid(`Unknown renderer option: ${key}`);
13
+ if (typeof globalThis.Worker !== 'function' || !globalThis.location || !globalThis.crypto?.subtle || !globalThis.WebAssembly) {
14
+ throw new FullbleedError('UNSUPPORTED_BROWSER', 'Use a browser with Web Workers, WebAssembly and Web Crypto over HTTPS or localhost.');
15
+ }
16
+ if (typeof options.assetBaseUrl !== 'string' && !(options.assetBaseUrl instanceof URL)) invalid('assetBaseUrl must be a URL or URL string.');
17
+ let base;
18
+ try { base = new URL(options.assetBaseUrl, globalThis.location.href); }
19
+ catch { invalid('assetBaseUrl is not a valid URL.'); }
20
+ if (!['http:', 'https:'].includes(base.protocol) || base.origin !== globalThis.location.origin || base.username || base.password || base.search || base.hash || !base.pathname.endsWith('/')) {
21
+ invalid('assetBaseUrl must be a same-origin HTTP(S) directory URL ending in /, without credentials, a query or a fragment.');
22
+ }
23
+ const entry = new URL('worker.js', base);
24
+ entry.searchParams.set('v', version);
25
+
26
+ async function renderPdf(options) {
27
+ const start = Date.now();
28
+ const job = input.normalize(options, { browser: true });
29
+ const aborted = () => new FullbleedError('ABORTED', 'PDF rendering was aborted.');
30
+ if (job.signal?.aborted) throw aborted();
31
+ const remaining = job.timeoutMs - (Date.now() - start);
32
+ if (remaining <= 0) throw new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
33
+ return new Promise((resolve, reject) => {
34
+ let worker, timer, settled = false, ready = false;
35
+ const finish = (error, result) => {
36
+ if (settled) return;
37
+ settled = true;
38
+ clearTimeout(timer);
39
+ job.signal?.removeEventListener('abort', abort);
40
+ // A caller may synchronously abort while a Worker constructor is being
41
+ // instrumented. Let its handle be assigned before requesting termination.
42
+ queueMicrotask(() => {
43
+ try { worker?.terminate(); }
44
+ catch (cause) { reject(new FullbleedError('WORKER_FAILED', 'Could not stop the render worker.', { cause })); return; }
45
+ if (error) reject(error); else resolve(result);
46
+ });
47
+ };
48
+ const abort = () => finish(aborted());
49
+ timer = setTimeout(() => finish(new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`)), remaining);
50
+ job.signal?.addEventListener('abort', abort, { once: true });
51
+ if (job.signal?.aborted) { abort(); return; }
52
+ try {
53
+ worker = new Worker(entry.href, { type: 'module', name: 'Fullbleed PDF renderer', credentials: 'omit' });
54
+ worker.addEventListener('error', event => {
55
+ event.preventDefault();
56
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker could not run. Check the copied assets and the site content security policy.'));
57
+ });
58
+ worker.addEventListener('messageerror', () => finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an unreadable response.')));
59
+ worker.addEventListener('message', ({ data }) => {
60
+ if (settled) return;
61
+ if (!ready) {
62
+ if (data?.type !== 'ready' || data.protocol !== 1 || data.version !== version || data.engineVersion !== engineVersion) {
63
+ finish(new FullbleedError('VERSION_MISMATCH', 'The hosted browser assets do not match this package. Copy the assets again and deploy them together.'));
64
+ return;
65
+ }
66
+ ready = true;
67
+ try {
68
+ worker.postMessage({ type: 'render', job: {
69
+ html: job.html, css: 'body { font-family: Inter; }\n' + job.css,
70
+ fonts: job.customFonts, assets: job.assetFiles, previewDpi: job.previewDpi,
71
+ maxPages: job.maxPages, allowMissingGlyphs: job.allowMissingGlyphs,
72
+ } });
73
+ } catch (cause) { finish(new FullbleedError('WORKER_FAILED', 'Could not send the document to the rendering worker.', { cause })); }
74
+ return;
75
+ }
76
+ if (data?.type !== 'result' || typeof data.ok !== 'boolean') {
77
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid response.'));
78
+ return;
79
+ }
80
+ if (!data.ok) {
81
+ finish(typeof data.code === 'string' && typeof data.message === 'string'
82
+ ? new FullbleedError(data.code, data.message)
83
+ : new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid error.'));
84
+ return;
85
+ }
86
+ if (!(data.pdf instanceof Uint8Array) || new TextDecoder().decode(data.pdf.subarray(0, 5)) !== '%PDF-'
87
+ || !Number.isSafeInteger(data.pages) || data.pages < 1 || data.pages > job.maxPages
88
+ || !Array.isArray(data.previews) || data.previews.length !== (job.previewDpi ? data.pages : 0)
89
+ || !data.previews.every(bytes => bytes instanceof Uint8Array && [137, 80, 78, 71, 13, 10, 26, 10].every((value, i) => bytes[i] === value))
90
+ || !Number.isSafeInteger(data.missingGlyphs) || data.missingGlyphs < 0 || data.engineVersion !== engineVersion) {
91
+ finish(new FullbleedError('WORKER_FAILED', 'The rendering worker returned an invalid result.'));
92
+ return;
93
+ }
94
+ finish(null, { pdf: data.pdf, previews: data.previews, pages: data.pages, missingGlyphs: data.missingGlyphs, engineVersion });
95
+ });
96
+ if (job.signal?.aborted) abort();
97
+ } catch (cause) {
98
+ finish(new FullbleedError('WORKER_FAILED', 'Could not start the rendering worker.', { cause }));
99
+ }
100
+ });
101
+ }
102
+ return Object.freeze({ renderPdf });
103
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ 'use strict';
4
+ const { readFile, writeFile, mkdir } = require('node:fs/promises');
5
+ const { resolve, join, dirname, relative, isAbsolute } = require('node:path');
6
+ const { createHash } = require('node:crypto');
7
+
8
+ async function main() {
9
+ const args = process.argv.slice(2);
10
+ if (args.length === 1 && ['--help', '-h'].includes(args[0])) {
11
+ console.log('Usage: fullbleed-browser-assets OUTPUT_DIRECTORY\nCopies the browser client, worker, engine, fonts and license notices. Existing runtime files in the named directory are replaced.');
12
+ return;
13
+ }
14
+ if (args.length !== 1 || args[0].startsWith('-') || !args[0].trim()) throw new Error('Pass one output directory, for example: fullbleed-browser-assets public/fullbleed');
15
+ const root = resolve(__dirname, '..');
16
+ const destination = resolve(args[0]);
17
+ const inside = relative(root, destination);
18
+ if (!inside || (!inside.startsWith('..') && !isAbsolute(inside))) throw new Error('Choose an output directory outside the installed Fullbleed package.');
19
+ const manifest = JSON.parse(await readFile(join(root, 'dist/browser/asset-manifest.json'), 'utf8'));
20
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
21
+ if (manifest.schema !== 'fullbleed.browser-assets.v1' || manifest.packageVersion !== pkg.version || manifest.engineVersion !== pkg.fullbleed.engineVersion) throw new Error('The browser build does not match the installed package.');
22
+ const files = [];
23
+ for (const [name, record] of Object.entries(manifest.files)) {
24
+ for (const path of [name, record.source]) {
25
+ if (typeof path !== 'string' || /[\\:\0]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('The browser asset manifest contains an invalid path.');
26
+ }
27
+ const data = await readFile(join(root, record.source));
28
+ if (data.length !== record.bytes || createHash('sha256').update(data).digest('hex') !== record.sha256) throw new Error('The installed browser asset failed verification: ' + name);
29
+ files.push({ name, data, bytes: record.bytes, sha256: record.sha256 });
30
+ }
31
+ // Verify the complete installed set before writing any output files.
32
+ for (const file of files) {
33
+ const path = join(destination, file.name);
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, file.data);
36
+ }
37
+ await writeFile(join(destination, 'build.json'), JSON.stringify({ schema: manifest.schema,
38
+ packageVersion: pkg.version, engineVersion: manifest.engineVersion,
39
+ files: Object.fromEntries(files.map(({ name, bytes, sha256 }) => [name, { bytes, sha256 }])) }, null, 2) + '\n');
40
+ console.log(JSON.stringify({ ok: true, directory: destination, packageVersion: pkg.version,
41
+ engineVersion: manifest.engineVersion, files: files.length + 1, bytes: files.reduce((sum, file) => sum + file.bytes, 0) }));
42
+ }
43
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { WASI, File, OpenFile, Directory, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim';
3
+
4
+ export async function runEngine(job) {
5
+ try {
6
+ const root = new PreopenDirectory('.', new Map());
7
+ const put = (path, data) => {
8
+ const parts = path.split('/');
9
+ let directory = root.dir;
10
+ for (const part of parts.slice(0, -1)) {
11
+ if (!directory.contents.has(part)) directory.contents.set(part, new Directory(new Map()));
12
+ directory = directory.contents.get(part);
13
+ }
14
+ directory.contents.set(parts.at(-1), new File(data));
15
+ };
16
+ const encoder = new TextEncoder();
17
+ put('input.html', encoder.encode(job.html));
18
+ put('style.css', encoder.encode(job.css));
19
+ for (const file of [...job.fonts, ...job.assets]) put(file.name, file.data);
20
+ let log = '';
21
+ const collect = line => { if (log.length < 4000) log += String(line).slice(0, 4000 - log.length) + '\n'; };
22
+ const wasi = new WASI(['fullbleed', String(job.maxPages), String(job.previewDpi), job.allowMissingGlyphs ? 'allow' : 'reject', ...job.fonts.map(f => f.name)], [], [
23
+ new OpenFile(new File([])), ConsoleStdout.lineBuffered(collect), ConsoleStdout.lineBuffered(collect), root,
24
+ ], { debug: false });
25
+ const instance = await WebAssembly.instantiate(job.module, { wasi_snapshot_preview1: wasi.wasiImport });
26
+ const status = wasi.start(instance);
27
+ if (status !== 0) {
28
+ const code = ['PAGE_LIMIT', 'MISSING_GLYPHS', 'PREVIEW_FAILED'].find(c => log.includes(c + ':')) ?? 'RENDER_FAILED';
29
+ return { ok: false, code, message: log.trim() || `The engine exited with code ${status}.` };
30
+ } else {
31
+ const read = name => {
32
+ const file = root.dir.contents.get(name);
33
+ if (!(file instanceof File)) throw new Error(`Missing engine output: ${name}`);
34
+ return Uint8Array.from(file.data);
35
+ };
36
+ const report = JSON.parse(new TextDecoder().decode(read('result.json')));
37
+ const pdf = read('document.pdf');
38
+ if (new TextDecoder().decode(pdf.slice(0, 5)) !== '%PDF-') throw new Error('Engine output is not a PDF.');
39
+ const previews = job.previewDpi ? Array.from({ length: report.pages }, (_, i) => read(`preview-${i + 1}.png`)) : [];
40
+ return { ok: true, pdf, previews, ...report };
41
+ }
42
+ } catch (cause) {
43
+ const code = cause instanceof WebAssembly.RuntimeError || cause instanceof RangeError ? 'ENGINE_LIMIT' : 'RENDER_FAILED';
44
+ return { ok: false, code, message: cause.message || String(cause) };
45
+ }
46
+ }
package/src/index.cjs CHANGED
@@ -4,20 +4,12 @@ const { readFile } = require('node:fs/promises');
4
4
  const { createHash } = require('node:crypto');
5
5
  const { join } = require('node:path');
6
6
  const { Worker } = require('node:worker_threads');
7
+ const { fork } = require('node:child_process');
7
8
  const { pathToFileURL } = require('node:url');
8
9
  const pkg = require('../package.json');
9
10
 
10
- class FullbleedError extends Error {
11
- constructor(code, message, options) {
12
- super(message, options);
13
- this.name = 'FullbleedError';
14
- this.code = code;
15
- }
16
- }
17
- const invalid = message => { throw new FullbleedError('INVALID_INPUT', message); };
11
+ const { FullbleedError, normalize } = require('./render-input.cjs');
18
12
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
19
- const sourceLimit = 4_000_000;
20
- const assetLimit = 64 * 1024 * 1024;
21
13
  const root = join(__dirname, '..');
22
14
  let runtimePromise;
23
15
 
@@ -43,38 +35,121 @@ function runtime() {
43
35
  return runtimePromise;
44
36
  }
45
37
 
46
- function normalize(options) {
47
- if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass an options object with an html string.');
48
- const supported = new Set(['html', 'css', 'fonts', 'assets', 'previewDpi', 'timeoutMs', 'maxPages', 'allowMissingGlyphs', 'signal']);
49
- for (const key of Object.keys(options)) if (!supported.has(key)) invalid(`Unknown option: ${key}`);
50
- const { html, css = '', fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = 1000, allowMissingGlyphs = false, signal } = options;
51
- if (typeof html !== 'string' || !html.trim()) invalid('html must be a nonempty string.');
52
- if (typeof css !== 'string') invalid('css must be a string.');
53
- if (Buffer.byteLength(html) + Buffer.byteLength(css) > sourceLimit) invalid('HTML and CSS together must not exceed 4,000,000 UTF-8 bytes.');
54
- if (!Number.isInteger(previewDpi) || (previewDpi !== 0 && (previewDpi < 36 || previewDpi > 300))) invalid('previewDpi must be 0 (disabled) or an integer from 36 to 300.');
55
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) invalid('timeoutMs must be a positive integer no greater than 2147483647.');
56
- if (!Number.isSafeInteger(maxPages) || maxPages < 1) invalid('maxPages must be a positive integer.');
57
- if (typeof allowMissingGlyphs !== 'boolean') invalid('allowMissingGlyphs must be a boolean.');
58
- if (signal !== undefined && !(signal instanceof AbortSignal)) invalid('signal must be an AbortSignal.');
59
- if (!Array.isArray(fonts)) invalid('fonts must be an array of font byte arrays.');
60
- if (!assets || typeof assets !== 'object' || Array.isArray(assets)) invalid('assets must map relative names to byte arrays.');
61
- let assetBytes = 0;
62
- function bytes(value, label) {
63
- if (!(value instanceof Uint8Array) || !value.byteLength) invalid(`${label} must be a nonempty Uint8Array or Buffer.`);
64
- assetBytes += value.byteLength;
65
- if (assetBytes > assetLimit) invalid('Custom fonts and assets together must not exceed 64 MiB.');
66
- return Uint8Array.from(value); // Snapshot caller-owned buffers before any await.
67
- }
68
- const customFonts = fonts.map((font, i) => ({ name: `fonts/custom-${i}.ttf`, data: bytes(font, `fonts[${i}]`) }));
69
- const names = Object.keys(assets);
70
- const fileNames = new Set(names);
71
- const assetFiles = names.map(name => {
72
- const segments = name.split('/');
73
- if (!name || /[\\:\0]/.test(name) || segments.some(s => !s || s === '.' || s === '..')) invalid(`Invalid asset path: ${name}`);
74
- for (let i = 1; i < segments.length; i++) if (fileNames.has(segments.slice(0, i).join('/'))) invalid(`Asset is both a file and a directory: ${name}`);
75
- return { name: 'assets/' + name, data: bytes(assets[name], `assets[${name}]`) };
38
+ function renderProcess(job, remaining) {
39
+ return new Promise((resolve, reject) => {
40
+ let child, timer, closed = false, failure, reply, exited, disconnected = false;
41
+ const timeout = () => new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
42
+ const cleanup = () => {
43
+ clearTimeout(timer);
44
+ job.signal?.removeEventListener('abort', abort);
45
+ };
46
+ const stop = error => {
47
+ if (closed || failure) return;
48
+ failure = error;
49
+ // Cancellation can occur synchronously inside fork instrumentation. Wait
50
+ // until the returned process handle has been assigned before killing it.
51
+ queueMicrotask(() => {
52
+ if (!closed && child?.pid) child.kill('SIGKILL');
53
+ });
54
+ };
55
+ const abort = () => stop(new FullbleedError('ABORTED', 'PDF rendering was aborted.'));
56
+ timer = setTimeout(() => stop(timeout()), remaining);
57
+ job.signal?.addEventListener('abort', abort, { once: true });
58
+ if (job.signal?.aborted) {
59
+ cleanup();
60
+ reject(new FullbleedError('ABORTED', 'PDF rendering was aborted.'));
61
+ return;
62
+ }
63
+ try {
64
+ const env = { ...process.env };
65
+ // The bundled child needs no application preloads, inspector, eval script,
66
+ // or custom loader. Avoid replaying them in a fresh application process.
67
+ for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
68
+ child = fork(join(__dirname, 'process-worker.cjs'), [], {
69
+ execPath: process.execPath, execArgv: [], env,
70
+ serialization: 'advanced', stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
71
+ windowsHide: true,
72
+ });
73
+ child.on('error', cause => stop(new FullbleedError('PROCESS_FAILED', 'The rendering process failed.', { cause })));
74
+ child.on('message', message => {
75
+ if (reply || message?.type !== 'result' || typeof message.ok !== 'boolean') {
76
+ stop(new FullbleedError('PROCESS_FAILED', 'The rendering process returned an invalid response.'));
77
+ return;
78
+ }
79
+ const result = message.result;
80
+ if (message.ok
81
+ ? !(result?.pdf instanceof Uint8Array) || Buffer.from(result.pdf.subarray(0, 5)).toString() !== '%PDF-'
82
+ || !Number.isSafeInteger(result.pages) || result.pages < 1 || result.pages > job.maxPages
83
+ || !Array.isArray(result.previews) || result.previews.length !== (job.previewDpi ? result.pages : 0)
84
+ || !Array.from(result.previews).every(bytes => bytes instanceof Uint8Array
85
+ && Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
86
+ || !Number.isSafeInteger(result.missingGlyphs) || result.missingGlyphs < 0
87
+ || result.engineVersion !== pkg.fullbleed.engineVersion
88
+ : typeof message.code !== 'string' || typeof message.message !== 'string') {
89
+ stop(new FullbleedError('PROCESS_FAILED', 'The rendering process returned an invalid result.'));
90
+ return;
91
+ }
92
+ reply = message;
93
+ });
94
+ const finish = (code, signal) => {
95
+ if (closed) return;
96
+ closed = true;
97
+ cleanup();
98
+ // Receiving PDF bytes is insufficient: a native crash during shutdown
99
+ // must reject the request, even if its result was sent before the crash.
100
+ if (!failure && (code !== 0 || signal || !reply)) {
101
+ failure = new FullbleedError('PROCESS_FAILED',
102
+ `Rendering process exited without completing successfully (code ${code}, signal ${signal ?? 'none'}).`);
103
+ }
104
+ if (failure) {
105
+ if (failure.code === 'PROCESS_FAILED') {
106
+ failure.exitCode = code;
107
+ failure.signal = signal;
108
+ }
109
+ reject(failure);
110
+ } else if (!reply.ok) {
111
+ reject(new FullbleedError(reply.code, reply.message));
112
+ } else {
113
+ const result = reply.result;
114
+ resolve({ pdf: Buffer.from(result.pdf), previews: result.previews.map(bytes => Buffer.from(bytes)),
115
+ pages: result.pages, missingGlyphs: result.missingGlyphs, engineVersion: result.engineVersion });
116
+ }
117
+ };
118
+ // All stdio is ignored except IPC. Waiting for both exit and disconnect
119
+ // drains messages and proves the process has stopped. On Windows, an
120
+ // interrupted IPC write can omit 'close' even after both these events.
121
+ child.once('exit', (code, signal) => {
122
+ exited = { code, signal };
123
+ if (disconnected) finish(code, signal);
124
+ });
125
+ child.once('disconnect', () => {
126
+ disconnected = true;
127
+ if (exited) finish(exited.code, exited.signal);
128
+ });
129
+ // A spawn error emits close without exit.
130
+ child.once('close', finish);
131
+ if (failure || job.signal?.aborted) {
132
+ if (!failure) abort();
133
+ return;
134
+ }
135
+ child.send({ type: 'render', options: {
136
+ html: job.html, css: job.css, fonts: job.customFonts.map(font => font.data),
137
+ assets: Object.fromEntries(job.assetFiles.map(file => [file.name.slice('assets/'.length), file.data])),
138
+ previewDpi: job.previewDpi, maxPages: job.maxPages, allowMissingGlyphs: job.allowMissingGlyphs,
139
+ timeoutMs: remaining,
140
+ } }, error => {
141
+ if (error) stop(new FullbleedError('PROCESS_FAILED', 'Could not send the rendering request.', { cause: error }));
142
+ });
143
+ } catch (cause) {
144
+ if (child) {
145
+ stop(new FullbleedError('PROCESS_FAILED', 'Could not start the rendering request.', { cause }));
146
+ } else {
147
+ closed = true;
148
+ cleanup();
149
+ reject(new FullbleedError('PROCESS_FAILED', 'Could not start the rendering process.', { cause }));
150
+ }
151
+ }
76
152
  });
77
- return { html, css: 'body { font-family: Inter; }\n' + css, customFonts, assetFiles, previewDpi, timeoutMs, maxPages, allowMissingGlyphs, signal };
78
153
  }
79
154
 
80
155
  async function renderPdf(options) {
@@ -85,6 +160,7 @@ async function renderPdf(options) {
85
160
  if (job.signal?.aborted) throw aborted();
86
161
  const remaining = job.timeoutMs - (Date.now() - start);
87
162
  if (remaining <= 0) throw new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
163
+ if (job.isolation === 'process') return renderProcess(job, remaining);
88
164
  return new Promise((resolve, reject) => {
89
165
  let worker, timer, settled = false;
90
166
  const finish = async (error, result) => {
@@ -116,7 +192,7 @@ async function renderPdf(options) {
116
192
  eval: true,
117
193
  workerData: {
118
194
  module: loaded.module, fonts: [...loaded.fonts, ...job.customFonts], assets: job.assetFiles,
119
- html: job.html, css: job.css, previewDpi: job.previewDpi, maxPages: job.maxPages,
195
+ html: job.html, css: 'body { font-family: Inter; }\n' + job.css, previewDpi: job.previewDpi, maxPages: job.maxPages,
120
196
  allowMissingGlyphs: job.allowMissingGlyphs,
121
197
  },
122
198
  });
package/src/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface RenderOptions {
17
17
  /** Default false: missing glyphs reject the job instead of silently emitting them. */
18
18
  allowMissingGlyphs?: boolean;
19
19
  signal?: AbortSignal;
20
+ /** Default: worker. Process starts a fresh Node child and waits for its exit. */
21
+ isolation?: 'worker' | 'process';
20
22
  }
21
23
 
22
24
  export interface RenderResult {
@@ -29,6 +31,9 @@ export interface RenderResult {
29
31
 
30
32
  export class FullbleedError extends Error {
31
33
  readonly code: string;
34
+ /** Present on PROCESS_FAILED errors after the child closes. */
35
+ readonly exitCode?: number | null;
36
+ readonly signal?: NodeJS.Signals | null;
32
37
  constructor(code: string, message: string, options?: ErrorOptions);
33
38
  }
34
39
  export function renderPdf(options: RenderOptions): Promise<RenderResult>;
@@ -0,0 +1,20 @@
1
+ // SPDX-License-Identifier: MIT
2
+ 'use strict';
3
+ const { renderPdf } = require('./index.cjs');
4
+ const controller = new AbortController();
5
+ process.once('disconnect', () => controller.abort());
6
+ process.once('message', async message => {
7
+ let reply;
8
+ try {
9
+ if (message?.type !== 'render') throw new Error('Invalid render request.');
10
+ const result = await renderPdf({ ...message.options, isolation: 'worker', signal: controller.signal });
11
+ reply = { type: 'result', ok: true, result };
12
+ } catch (error) {
13
+ reply = { type: 'result', ok: false, code: error.code ?? 'RENDER_FAILED', message: error.message };
14
+ }
15
+ if (!process.connected) return;
16
+ process.send(reply, error => {
17
+ if (error) process.exitCode = 1;
18
+ if (process.connected) process.disconnect();
19
+ });
20
+ });