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/README.md +66 -13
- package/dist/browser/asset-manifest.json +67 -0
- package/dist/browser/client.js +225 -0
- package/dist/browser/worker.js +1477 -0
- package/dist/build.json +13 -1
- package/dist/index.d.cts +5 -0
- package/package.json +15 -5
- package/src/browser-worker.js +39 -0
- package/src/browser.d.ts +37 -0
- package/src/browser.js +103 -0
- package/src/copy-browser-assets.cjs +43 -0
- package/src/engine-runner.js +46 -0
- package/src/index.cjs +118 -42
- package/src/index.d.ts +5 -0
- package/src/process-worker.cjs +20 -0
- package/src/render-input.cjs +51 -0
- package/src/worker.js +3 -43
package/dist/build.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"packageVersion": "0.
|
|
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.
|
|
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 });
|
package/src/browser.d.ts
ADDED
|
@@ -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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
+
});
|