fullbleed 0.1.5 → 0.2.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 +21 -10
- package/dist/build.json +2 -2
- package/dist/engine.wasm +0 -0
- package/dist/index.d.cts +5 -0
- package/package.json +1 -1
- package/src/index.cjs +124 -4
- package/src/index.d.ts +5 -0
- package/src/process-worker.cjs +20 -0
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ Fullbleed Rust engine compiled to WebAssembly and four font faces, so installing
|
|
|
5
5
|
it does not require Python, Rust, a browser, or system fonts. MIT licensed.
|
|
6
6
|
|
|
7
7
|
This is an optional integration around the published **Fullbleed 2.5.8** engine.
|
|
8
|
-
The Node package has its own version, **0.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
The Node package has its own version, **0.2.0**. It adds optional process isolation
|
|
9
|
+
for server applications. The engine and default worker mode are unchanged from
|
|
10
|
+
0.1.5. See the [process isolation guide](docs/process-isolation.md),
|
|
11
|
+
[font-family checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-families.md), and
|
|
12
12
|
[embedded-font checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-subsets.md) for the retained fixtures.
|
|
13
13
|
|
|
14
14
|
## Install and render
|
|
@@ -19,9 +19,9 @@ Use Node.js 22 or newer. Install the [npm package](https://www.npmjs.com/package
|
|
|
19
19
|
npm install fullbleed
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
For a version-pinned installation, use `npm install --save-exact fullbleed@0.
|
|
23
|
-
|
|
24
|
-
[GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases
|
|
22
|
+
For a version-pinned installation, use `npm install --save-exact fullbleed@0.2.0`.
|
|
23
|
+
Package archives and retained PDF/PNG evidence are attached to each
|
|
24
|
+
[GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases).
|
|
25
25
|
See the [installation verification records](https://github.com/fullbleed-engine/fullbleed-node/tree/main/verification).
|
|
26
26
|
|
|
27
27
|
Save this as `invoice.mjs` and run `node invoice.mjs`:
|
|
@@ -121,6 +121,7 @@ native engine.
|
|
|
121
121
|
| `maxPages` | Reject larger documents. Default: 1000. |
|
|
122
122
|
| `timeoutMs` | Deadline including initial engine loading. Default: 30000 ms. |
|
|
123
123
|
| `signal` | An `AbortSignal` to cancel rendering. |
|
|
124
|
+
| `isolation` | `'worker'` (default) or `'process'` for a separate Node process per call. |
|
|
124
125
|
| `allowMissingGlyphs` | Default `false`: reject engine-reported missing glyphs. |
|
|
125
126
|
|
|
126
127
|
Rendering runs in a dedicated worker. Concurrent calls use separate workers and
|
|
@@ -129,6 +130,13 @@ render failure, timeout, or cancellation. Awaiting jobs in sequence therefore
|
|
|
129
130
|
does not overlap their worker lifetimes. A timeout or abort starts termination;
|
|
130
131
|
settlement includes the time needed to stop the worker.
|
|
131
132
|
|
|
133
|
+
Use `isolation: 'process'` when a render-process failure should become a rejected
|
|
134
|
+
request while the calling application stays running. This mode runs the engine
|
|
135
|
+
and its worker in a fresh Node process, and waits for process exit and IPC
|
|
136
|
+
disconnection before settling. It requires a host that permits child processes
|
|
137
|
+
and adds startup and memory overhead. It does not pool processes or retry jobs.
|
|
138
|
+
See the [guide and failure checks](docs/process-isolation.md).
|
|
139
|
+
|
|
132
140
|
Bound concurrency in a server according to its available memory;
|
|
133
141
|
each worker's WebAssembly memory has a 512 MiB ceiling. HTML and CSS together are
|
|
134
142
|
limited to 4,000,000 UTF-8 bytes; supplied fonts and assets together to 64 MiB.
|
|
@@ -142,6 +150,7 @@ try {
|
|
|
142
150
|
html: '<h1>Monthly statement</h1>',
|
|
143
151
|
maxPages: 20,
|
|
144
152
|
timeoutMs: 10_000,
|
|
153
|
+
isolation: 'process',
|
|
145
154
|
signal: AbortSignal.timeout(8_000),
|
|
146
155
|
});
|
|
147
156
|
// Return result.pdf from your HTTP handler, or write it to your storage.
|
|
@@ -153,8 +162,9 @@ try {
|
|
|
153
162
|
|
|
154
163
|
Error codes include `INVALID_INPUT`, `MISSING_GLYPHS`, `PAGE_LIMIT`, `TIMEOUT`,
|
|
155
164
|
`ABORTED`, `ENGINE_LOAD_FAILED`, `ENGINE_LIMIT`, `RENDER_FAILED`,
|
|
156
|
-
`PREVIEW_FAILED`, and `
|
|
157
|
-
|
|
165
|
+
`PREVIEW_FAILED`, `WORKER_FAILED`, and `PROCESS_FAILED`. Process failures may also
|
|
166
|
+
include `exitCode` and `signal`. Failed or cancelled calls do not return a partial
|
|
167
|
+
PDF. Caller-owned asset buffers are copied when a call starts.
|
|
158
168
|
|
|
159
169
|
This API covers ordinary document generation and previews. It does not expose
|
|
160
170
|
PDF/A, PDF/UA, PDF/X, template overlays, or compiled VDP. Use the
|
|
@@ -199,7 +209,8 @@ the previous public npm release.
|
|
|
199
209
|
An intermittent process crash observed on Linux with Node 24.21.0 remains under
|
|
200
210
|
investigation in [issue #7](https://github.com/fullbleed-engine/fullbleed-node/issues/7).
|
|
201
211
|
Version 0.1.3 fixes worker shutdown timing, but does not establish that this crash
|
|
202
|
-
is resolved.
|
|
212
|
+
is resolved. Version 0.2.0 adds optional process fault containment; it does not
|
|
213
|
+
claim to fix the native crash. See the [runtime investigation and diagnostic](docs/runtime-diagnostics.md)
|
|
203
214
|
for the observed scope, retained results, and a synthetic reproducer.
|
|
204
215
|
|
|
205
216
|
## License and support
|
package/dist/build.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"packageVersion": "0.
|
|
2
|
+
"packageVersion": "0.2.0",
|
|
3
3
|
"engineVersion": "2.5.8",
|
|
4
4
|
"engineSource": "https://crates.io/crates/fullbleed/2.5.8",
|
|
5
5
|
"target": "wasm32-wasip1",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"files": {
|
|
46
46
|
"dist/engine.wasm": {
|
|
47
47
|
"bytes": 5069661,
|
|
48
|
-
"sha256": "
|
|
48
|
+
"sha256": "2f41ac7c2d85f7724166ea0d466cc55b6d6f5ca0014014d667f54f7ac93577cb"
|
|
49
49
|
},
|
|
50
50
|
"dist/THIRD_PARTY_NOTICES.txt": {
|
|
51
51
|
"bytes": 57660,
|
package/dist/engine.wasm
CHANGED
|
Binary file
|
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
package/src/index.cjs
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
|
|
@@ -45,9 +46,9 @@ function runtime() {
|
|
|
45
46
|
|
|
46
47
|
function normalize(options) {
|
|
47
48
|
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
|
+
const supported = new Set(['html', 'css', 'fonts', 'assets', 'previewDpi', 'timeoutMs', 'maxPages', 'allowMissingGlyphs', 'signal', 'isolation']);
|
|
49
50
|
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
|
+
const { html, css = '', fonts = [], assets = {}, previewDpi = 0, timeoutMs = 30000, maxPages = 1000, allowMissingGlyphs = false, signal, isolation = 'worker' } = options;
|
|
51
52
|
if (typeof html !== 'string' || !html.trim()) invalid('html must be a nonempty string.');
|
|
52
53
|
if (typeof css !== 'string') invalid('css must be a string.');
|
|
53
54
|
if (Buffer.byteLength(html) + Buffer.byteLength(css) > sourceLimit) invalid('HTML and CSS together must not exceed 4,000,000 UTF-8 bytes.');
|
|
@@ -56,6 +57,7 @@ function normalize(options) {
|
|
|
56
57
|
if (!Number.isSafeInteger(maxPages) || maxPages < 1) invalid('maxPages must be a positive integer.');
|
|
57
58
|
if (typeof allowMissingGlyphs !== 'boolean') invalid('allowMissingGlyphs must be a boolean.');
|
|
58
59
|
if (signal !== undefined && !(signal instanceof AbortSignal)) invalid('signal must be an AbortSignal.');
|
|
60
|
+
if (isolation !== 'worker' && isolation !== 'process') invalid('isolation must be worker or process.');
|
|
59
61
|
if (!Array.isArray(fonts)) invalid('fonts must be an array of font byte arrays.');
|
|
60
62
|
if (!assets || typeof assets !== 'object' || Array.isArray(assets)) invalid('assets must map relative names to byte arrays.');
|
|
61
63
|
let assetBytes = 0;
|
|
@@ -74,7 +76,124 @@ function normalize(options) {
|
|
|
74
76
|
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
77
|
return { name: 'assets/' + name, data: bytes(assets[name], `assets[${name}]`) };
|
|
76
78
|
});
|
|
77
|
-
return { html, css
|
|
79
|
+
return { html, css, customFonts, assetFiles, previewDpi, timeoutMs, maxPages, allowMissingGlyphs, signal, isolation };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderProcess(job, remaining) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
let child, timer, closed = false, failure, reply, exited, disconnected = false;
|
|
85
|
+
const timeout = () => new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
|
|
86
|
+
const cleanup = () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
job.signal?.removeEventListener('abort', abort);
|
|
89
|
+
};
|
|
90
|
+
const stop = error => {
|
|
91
|
+
if (closed || failure) return;
|
|
92
|
+
failure = error;
|
|
93
|
+
// Cancellation can occur synchronously inside fork instrumentation. Wait
|
|
94
|
+
// until the returned process handle has been assigned before killing it.
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
if (!closed && child?.pid) child.kill('SIGKILL');
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const abort = () => stop(new FullbleedError('ABORTED', 'PDF rendering was aborted.'));
|
|
100
|
+
timer = setTimeout(() => stop(timeout()), remaining);
|
|
101
|
+
job.signal?.addEventListener('abort', abort, { once: true });
|
|
102
|
+
if (job.signal?.aborted) {
|
|
103
|
+
cleanup();
|
|
104
|
+
reject(new FullbleedError('ABORTED', 'PDF rendering was aborted.'));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const env = { ...process.env };
|
|
109
|
+
// The bundled child needs no application preloads, inspector, eval script,
|
|
110
|
+
// or custom loader. Avoid replaying them in a fresh application process.
|
|
111
|
+
for (const key of Object.keys(env)) if (key.toUpperCase() === 'NODE_OPTIONS') delete env[key];
|
|
112
|
+
child = fork(join(__dirname, 'process-worker.cjs'), [], {
|
|
113
|
+
execPath: process.execPath, execArgv: [], env,
|
|
114
|
+
serialization: 'advanced', stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
115
|
+
windowsHide: true,
|
|
116
|
+
});
|
|
117
|
+
child.on('error', cause => stop(new FullbleedError('PROCESS_FAILED', 'The rendering process failed.', { cause })));
|
|
118
|
+
child.on('message', message => {
|
|
119
|
+
if (reply || message?.type !== 'result' || typeof message.ok !== 'boolean') {
|
|
120
|
+
stop(new FullbleedError('PROCESS_FAILED', 'The rendering process returned an invalid response.'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const result = message.result;
|
|
124
|
+
if (message.ok
|
|
125
|
+
? !(result?.pdf instanceof Uint8Array) || Buffer.from(result.pdf.subarray(0, 5)).toString() !== '%PDF-'
|
|
126
|
+
|| !Number.isSafeInteger(result.pages) || result.pages < 1 || result.pages > job.maxPages
|
|
127
|
+
|| !Array.isArray(result.previews) || result.previews.length !== (job.previewDpi ? result.pages : 0)
|
|
128
|
+
|| !Array.from(result.previews).every(bytes => bytes instanceof Uint8Array
|
|
129
|
+
&& Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
130
|
+
|| !Number.isSafeInteger(result.missingGlyphs) || result.missingGlyphs < 0
|
|
131
|
+
|| result.engineVersion !== pkg.fullbleed.engineVersion
|
|
132
|
+
: typeof message.code !== 'string' || typeof message.message !== 'string') {
|
|
133
|
+
stop(new FullbleedError('PROCESS_FAILED', 'The rendering process returned an invalid result.'));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
reply = message;
|
|
137
|
+
});
|
|
138
|
+
const finish = (code, signal) => {
|
|
139
|
+
if (closed) return;
|
|
140
|
+
closed = true;
|
|
141
|
+
cleanup();
|
|
142
|
+
// Receiving PDF bytes is insufficient: a native crash during shutdown
|
|
143
|
+
// must reject the request, even if its result was sent before the crash.
|
|
144
|
+
if (!failure && (code !== 0 || signal || !reply)) {
|
|
145
|
+
failure = new FullbleedError('PROCESS_FAILED',
|
|
146
|
+
`Rendering process exited without completing successfully (code ${code}, signal ${signal ?? 'none'}).`);
|
|
147
|
+
}
|
|
148
|
+
if (failure) {
|
|
149
|
+
if (failure.code === 'PROCESS_FAILED') {
|
|
150
|
+
failure.exitCode = code;
|
|
151
|
+
failure.signal = signal;
|
|
152
|
+
}
|
|
153
|
+
reject(failure);
|
|
154
|
+
} else if (!reply.ok) {
|
|
155
|
+
reject(new FullbleedError(reply.code, reply.message));
|
|
156
|
+
} else {
|
|
157
|
+
const result = reply.result;
|
|
158
|
+
resolve({ pdf: Buffer.from(result.pdf), previews: result.previews.map(bytes => Buffer.from(bytes)),
|
|
159
|
+
pages: result.pages, missingGlyphs: result.missingGlyphs, engineVersion: result.engineVersion });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// All stdio is ignored except IPC. Waiting for both exit and disconnect
|
|
163
|
+
// drains messages and proves the process has stopped. On Windows, an
|
|
164
|
+
// interrupted IPC write can omit 'close' even after both these events.
|
|
165
|
+
child.once('exit', (code, signal) => {
|
|
166
|
+
exited = { code, signal };
|
|
167
|
+
if (disconnected) finish(code, signal);
|
|
168
|
+
});
|
|
169
|
+
child.once('disconnect', () => {
|
|
170
|
+
disconnected = true;
|
|
171
|
+
if (exited) finish(exited.code, exited.signal);
|
|
172
|
+
});
|
|
173
|
+
// A spawn error emits close without exit.
|
|
174
|
+
child.once('close', finish);
|
|
175
|
+
if (failure || job.signal?.aborted) {
|
|
176
|
+
if (!failure) abort();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
child.send({ type: 'render', options: {
|
|
180
|
+
html: job.html, css: job.css, fonts: job.customFonts.map(font => font.data),
|
|
181
|
+
assets: Object.fromEntries(job.assetFiles.map(file => [file.name.slice('assets/'.length), file.data])),
|
|
182
|
+
previewDpi: job.previewDpi, maxPages: job.maxPages, allowMissingGlyphs: job.allowMissingGlyphs,
|
|
183
|
+
timeoutMs: remaining,
|
|
184
|
+
} }, error => {
|
|
185
|
+
if (error) stop(new FullbleedError('PROCESS_FAILED', 'Could not send the rendering request.', { cause: error }));
|
|
186
|
+
});
|
|
187
|
+
} catch (cause) {
|
|
188
|
+
if (child) {
|
|
189
|
+
stop(new FullbleedError('PROCESS_FAILED', 'Could not start the rendering request.', { cause }));
|
|
190
|
+
} else {
|
|
191
|
+
closed = true;
|
|
192
|
+
cleanup();
|
|
193
|
+
reject(new FullbleedError('PROCESS_FAILED', 'Could not start the rendering process.', { cause }));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
78
197
|
}
|
|
79
198
|
|
|
80
199
|
async function renderPdf(options) {
|
|
@@ -85,6 +204,7 @@ async function renderPdf(options) {
|
|
|
85
204
|
if (job.signal?.aborted) throw aborted();
|
|
86
205
|
const remaining = job.timeoutMs - (Date.now() - start);
|
|
87
206
|
if (remaining <= 0) throw new FullbleedError('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`);
|
|
207
|
+
if (job.isolation === 'process') return renderProcess(job, remaining);
|
|
88
208
|
return new Promise((resolve, reject) => {
|
|
89
209
|
let worker, timer, settled = false;
|
|
90
210
|
const finish = async (error, result) => {
|
|
@@ -116,7 +236,7 @@ async function renderPdf(options) {
|
|
|
116
236
|
eval: true,
|
|
117
237
|
workerData: {
|
|
118
238
|
module: loaded.module, fonts: [...loaded.fonts, ...job.customFonts], assets: job.assetFiles,
|
|
119
|
-
html: job.html, css: job.css, previewDpi: job.previewDpi, maxPages: job.maxPages,
|
|
239
|
+
html: job.html, css: 'body { font-family: Inter; }\n' + job.css, previewDpi: job.previewDpi, maxPages: job.maxPages,
|
|
120
240
|
allowMissingGlyphs: job.allowMissingGlyphs,
|
|
121
241
|
},
|
|
122
242
|
});
|
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
|
+
});
|