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 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.1.5**. Normal text now selects a custom
9
- family's regular face even when its italic face is supplied first. Review saved
10
- PDF baselines when upgrading: affected documents can change appearance and line
11
- breaks. See the [font-family checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-families.md) and
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.1.5`.
23
- Release verification compares the npm tarball with the checked
24
- [GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases/tag/v0.1.5).
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 `WORKER_FAILED`. Failed or cancelled calls do not return a
157
- 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.
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. 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)
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.1.5",
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": "e95cc9132029b9c42ad684d898063b4efc043fa0b7b9fe2e7867f3eb3a17ef58"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullbleed",
3
- "version": "0.1.5",
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",
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
+ });