fullbleed 0.1.4 → 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 +25 -11
- package/dist/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/build.json +7 -7
- package/dist/engine.wasm +0 -0
- package/dist/index.d.cts +5 -0
- package/package.json +5 -3
- package/src/index.cjs +124 -4
- package/src/index.d.ts +5 -0
- package/src/process-worker.cjs +20 -0
package/README.md
CHANGED
|
@@ -4,11 +4,12 @@ Generate PDFs from static HTML and CSS in Node.js. The package includes the
|
|
|
4
4
|
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
|
-
This is an optional integration around the published **Fullbleed 2.5.
|
|
8
|
-
The Node package has its own version, **0.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
This is an optional integration around the published **Fullbleed 2.5.8** engine.
|
|
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
|
+
[embedded-font checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-subsets.md) for the retained fixtures.
|
|
12
13
|
|
|
13
14
|
## Install and render
|
|
14
15
|
|
|
@@ -18,9 +19,9 @@ Use Node.js 22 or newer. Install the [npm package](https://www.npmjs.com/package
|
|
|
18
19
|
npm install fullbleed
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
For a version-pinned installation, use `npm install --save-exact fullbleed@0.
|
|
22
|
-
|
|
23
|
-
[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).
|
|
24
25
|
See the [installation verification records](https://github.com/fullbleed-engine/fullbleed-node/tree/main/verification).
|
|
25
26
|
|
|
26
27
|
Save this as `invoice.mjs` and run `node invoice.mjs`:
|
|
@@ -120,6 +121,7 @@ native engine.
|
|
|
120
121
|
| `maxPages` | Reject larger documents. Default: 1000. |
|
|
121
122
|
| `timeoutMs` | Deadline including initial engine loading. Default: 30000 ms. |
|
|
122
123
|
| `signal` | An `AbortSignal` to cancel rendering. |
|
|
124
|
+
| `isolation` | `'worker'` (default) or `'process'` for a separate Node process per call. |
|
|
123
125
|
| `allowMissingGlyphs` | Default `false`: reject engine-reported missing glyphs. |
|
|
124
126
|
|
|
125
127
|
Rendering runs in a dedicated worker. Concurrent calls use separate workers and
|
|
@@ -128,6 +130,13 @@ render failure, timeout, or cancellation. Awaiting jobs in sequence therefore
|
|
|
128
130
|
does not overlap their worker lifetimes. A timeout or abort starts termination;
|
|
129
131
|
settlement includes the time needed to stop the worker.
|
|
130
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
|
+
|
|
131
140
|
Bound concurrency in a server according to its available memory;
|
|
132
141
|
each worker's WebAssembly memory has a 512 MiB ceiling. HTML and CSS together are
|
|
133
142
|
limited to 4,000,000 UTF-8 bytes; supplied fonts and assets together to 64 MiB.
|
|
@@ -141,6 +150,7 @@ try {
|
|
|
141
150
|
html: '<h1>Monthly statement</h1>',
|
|
142
151
|
maxPages: 20,
|
|
143
152
|
timeoutMs: 10_000,
|
|
153
|
+
isolation: 'process',
|
|
144
154
|
signal: AbortSignal.timeout(8_000),
|
|
145
155
|
});
|
|
146
156
|
// Return result.pdf from your HTTP handler, or write it to your storage.
|
|
@@ -152,8 +162,9 @@ try {
|
|
|
152
162
|
|
|
153
163
|
Error codes include `INVALID_INPUT`, `MISSING_GLYPHS`, `PAGE_LIMIT`, `TIMEOUT`,
|
|
154
164
|
`ABORTED`, `ENGINE_LOAD_FAILED`, `ENGINE_LIMIT`, `RENDER_FAILED`,
|
|
155
|
-
`PREVIEW_FAILED`, and `
|
|
156
|
-
|
|
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.
|
|
157
168
|
|
|
158
169
|
This API covers ordinary document generation and previews. It does not expose
|
|
159
170
|
PDF/A, PDF/UA, PDF/X, template overlays, or compiled VDP. Use the
|
|
@@ -180,6 +191,8 @@ python -m pip install pypdf==6.19.0 pypdfium2==5.14.0 fonttools==4.65.0 pillow==
|
|
|
180
191
|
npm run verify:text
|
|
181
192
|
npm run render:fonts
|
|
182
193
|
npm run verify:fonts
|
|
194
|
+
npm run render:families
|
|
195
|
+
npm run verify:families
|
|
183
196
|
```
|
|
184
197
|
|
|
185
198
|
The build records engine metadata, licenses, and artifact hashes in `dist/`.
|
|
@@ -196,7 +209,8 @@ the previous public npm release.
|
|
|
196
209
|
An intermittent process crash observed on Linux with Node 24.21.0 remains under
|
|
197
210
|
investigation in [issue #7](https://github.com/fullbleed-engine/fullbleed-node/issues/7).
|
|
198
211
|
Version 0.1.3 fixes worker shutdown timing, but does not establish that this crash
|
|
199
|
-
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)
|
|
200
214
|
for the observed scope, retained results, and a synthetic reproducer.
|
|
201
215
|
|
|
202
216
|
## License and support
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
fullbleed 2.5.
|
|
1
|
+
fullbleed 2.5.8 / LICENSE
|
|
2
2
|
|
|
3
3
|
MIT License
|
|
4
4
|
|
|
@@ -23,7 +23,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
23
23
|
SOFTWARE.
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
fullbleed 2.5.
|
|
26
|
+
fullbleed 2.5.8 / THIRD_PARTY_LICENSES.md
|
|
27
27
|
|
|
28
28
|
<!-- SPDX-License-Identifier: MIT -->
|
|
29
29
|
# Third-Party Licenses
|
package/dist/build.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"packageVersion": "0.
|
|
3
|
-
"engineVersion": "2.5.
|
|
4
|
-
"engineSource": "https://crates.io/crates/fullbleed/2.5.
|
|
2
|
+
"packageVersion": "0.2.0",
|
|
3
|
+
"engineVersion": "2.5.8",
|
|
4
|
+
"engineSource": "https://crates.io/crates/fullbleed/2.5.8",
|
|
5
5
|
"target": "wasm32-wasip1",
|
|
6
6
|
"memoryMaximumBytes": 536870912,
|
|
7
7
|
"fonts": [
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"dependencies": [
|
|
14
14
|
{
|
|
15
15
|
"name": "fullbleed",
|
|
16
|
-
"version": "2.5.
|
|
16
|
+
"version": "2.5.8",
|
|
17
17
|
"source": "registry+https://github.com/rust-lang/crates.io-index",
|
|
18
18
|
"license": "MIT"
|
|
19
19
|
},
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
],
|
|
45
45
|
"files": {
|
|
46
46
|
"dist/engine.wasm": {
|
|
47
|
-
"bytes":
|
|
48
|
-
"sha256": "
|
|
47
|
+
"bytes": 5069661,
|
|
48
|
+
"sha256": "2f41ac7c2d85f7724166ea0d466cc55b6d6f5ca0014014d667f54f7ac93577cb"
|
|
49
49
|
},
|
|
50
50
|
"dist/THIRD_PARTY_NOTICES.txt": {
|
|
51
51
|
"bytes": 57660,
|
|
52
|
-
"sha256": "
|
|
52
|
+
"sha256": "5475f50666d3af5df89048cc1ddc98d153cd813019afbc213be20e4f3838911f"
|
|
53
53
|
},
|
|
54
54
|
"assets/fonts/BebasNeue-OFL.txt": {
|
|
55
55
|
"bytes": 4337,
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullbleed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Generate PDFs from HTML and CSS in Node.js with the Fullbleed Rust engine, bundled fonts, and optional PNG previews.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.cjs",
|
|
@@ -37,7 +37,9 @@
|
|
|
37
37
|
"verify:pack": "node tools/verify-pack.mjs",
|
|
38
38
|
"verify:text": "python tools/verify-installed-text.py",
|
|
39
39
|
"render:fonts": "node tools/render-font-subsets.mjs",
|
|
40
|
-
"verify:fonts": "python tools/verify-font-subsets.py"
|
|
40
|
+
"verify:fonts": "python tools/verify-font-subsets.py",
|
|
41
|
+
"render:families": "node tools/render-font-families.mjs",
|
|
42
|
+
"verify:families": "python tools/verify-font-families.py output/font-families"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
43
45
|
"@bjorn3/browser_wasi_shim": "0.4.2"
|
|
@@ -65,7 +67,7 @@
|
|
|
65
67
|
"access": "public"
|
|
66
68
|
},
|
|
67
69
|
"fullbleed": {
|
|
68
|
-
"engineVersion": "2.5.
|
|
70
|
+
"engineVersion": "2.5.8"
|
|
69
71
|
},
|
|
70
72
|
"devDependencies": {
|
|
71
73
|
"@types/node": "22.20.5",
|
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
|
+
});
|