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 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.7** engine.
8
- The Node package has its own version, **0.1.4**. This release reduces embedded
9
- font metadata while preserving the retained glyph outlines, metrics, and font
10
- notices. See the [installed-package font checks](docs/font-subsets.md) for the
11
- fixtures and verification commands.
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.1.4`.
22
- The npm tarball is byte-identical to the verified
23
- [GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.1.4).
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 `WORKER_FAILED`. Failed or cancelled calls do not return a
156
- partial PDF. Caller-owned asset buffers are copied when a call starts.
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. See the [runtime investigation and diagnostic](docs/runtime-diagnostics.md)
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.7 / LICENSE
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.7 / THIRD_PARTY_LICENSES.md
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.1.4",
3
- "engineVersion": "2.5.7",
4
- "engineSource": "https://crates.io/crates/fullbleed/2.5.7",
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.7",
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": 5064368,
48
- "sha256": "3b5b0200268264b947f7b5bdc658a2d32d9fd160b8652f0ee6834e5296db9abb"
47
+ "bytes": 5069661,
48
+ "sha256": "2f41ac7c2d85f7724166ea0d466cc55b6d6f5ca0014014d667f54f7ac93577cb"
49
49
  },
50
50
  "dist/THIRD_PARTY_NOTICES.txt": {
51
51
  "bytes": 57660,
52
- "sha256": "90c2828609b2a61323dfd97564a023123e38e1df7c9e9a9db0a29bb12aad1438"
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.1.4",
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.7"
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: 'body { font-family: Inter; }\n' + css, customFonts, assetFiles, previewDpi, timeoutMs, maxPages, allowMissingGlyphs, signal };
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
+ });