fullbleed 0.3.2 → 0.4.1

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
@@ -14,10 +14,10 @@ also includes a ZIP for local development.
14
14
  Using Vue? The [Vue and TypeScript starter](examples/vue) includes editable
15
15
  templates, automatic previews, downloads, and a reusable `usePdfPreview` composable.
16
16
 
17
- This is an optional integration around the published **Fullbleed 2.5.11** engine.
18
- The package has its own version, **0.3.2**. This patch fixes blank previews of
19
- unembedded Standard 14 fonts, including Helvetica, Times, and Courier. Preview
20
- pixels change for those fonts; review saved preview baselines when upgrading.
17
+ This is an optional integration around the published **Fullbleed 2.5.22** engine.
18
+ The package has its own version, **0.4.1**. Node applications can use a shared
19
+ render queue to bound active renders and waiting requests, include queue time in
20
+ deadlines, cancel waiting jobs, and stop rendering cleanly during shutdown.
21
21
  The browser entry renders in a Web Worker, and the Node API supports worker and
22
22
  process isolation. See the [browser guide](docs/browser.md),
23
23
  [inline wrapping checks](docs/inline-wrapping.md),
@@ -25,6 +25,11 @@ process isolation. See the [browser guide](docs/browser.md),
25
25
  [font-family checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-families.md), and
26
26
  [embedded-font checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/font-subsets.md) for the retained fixtures.
27
27
 
28
+ Version 0.4.1 brings the engine's recent layout fixes to both JavaScript entries.
29
+ The [engine update checks](https://github.com/fullbleed-engine/fullbleed-node/blob/main/docs/engine-2.5.22.md)
30
+ cover nested counters, clipped effects, border-image tiling, and floated initials
31
+ with independent text, color, and Chrome print expectations.
32
+
28
33
  ## Install and render
29
34
 
30
35
  Use Node.js 22 or newer. Install the [npm package](https://www.npmjs.com/package/fullbleed):
@@ -33,7 +38,7 @@ Use Node.js 22 or newer. Install the [npm package](https://www.npmjs.com/package
33
38
  npm install fullbleed
34
39
  ```
35
40
 
36
- For a version-pinned installation, use `npm install --save-exact fullbleed@0.3.2`.
41
+ For a version-pinned installation, use `npm install --save-exact fullbleed@0.4.1`.
37
42
  Package archives and retained PDF/PNG evidence are attached to each
38
43
  [GitHub release](https://github.com/fullbleed-engine/fullbleed-node/releases).
39
44
  The [installation verification records](https://github.com/fullbleed-engine/fullbleed-node/tree/main/verification)
@@ -157,6 +162,36 @@ cover the twelve Latin faces, an embedded-font control, and the blank-preview
157
162
  failure in public 0.3.1. Font provenance and complete license notices ship with
158
163
  both the npm package and copied browser runtime.
159
164
 
165
+ ## Limit concurrent renders
166
+
167
+ Create one queue per application process and reuse it across requests:
168
+
169
+ ```javascript
170
+ import { createRenderQueue } from 'fullbleed';
171
+
172
+ const documents = createRenderQueue({ concurrency: 2, maxQueue: 4 });
173
+ const result = await documents.renderPdf({
174
+ html: '<h1>Queued invoice</h1>',
175
+ isolation: 'process',
176
+ timeoutMs: 30_000,
177
+ });
178
+ // Return result.pdf from your request handler.
179
+ // At application shutdown, cancel outstanding work and await cleanup:
180
+ await documents.close();
181
+ ```
182
+
183
+ This queue admits two active renders and four waiting requests. Further requests
184
+ reject with `QUEUE_FULL`, so a server can return a busy response. Waiting jobs
185
+ can be aborted and their deadlines include the wait. Slots stay occupied until
186
+ their worker or child process has exited. `close()` rejects new work, cancels
187
+ accepted jobs with `QUEUE_CLOSED`, and waits for cleanup.
188
+
189
+ Choose limits for your host and document sizes. The queue bounds job counts, not
190
+ total memory, and each application process has its own queue. It is an in-memory
191
+ admission control; use your application's durable job system for persistence and
192
+ delivery. See the [queue guide and retained checks](docs/render-queue.md) and the
193
+ [six-invoice batch example](examples/render-queue.mjs).
194
+
160
195
  ## API
161
196
 
162
197
  This section describes the Node entry. The browser entry returns browser byte
@@ -225,6 +260,14 @@ Error codes include `INVALID_INPUT`, `MISSING_GLYPHS`, `PAGE_LIMIT`, `TIMEOUT`,
225
260
  include `exitCode` and `signal`. Failed or cancelled calls do not return a partial
226
261
  PDF. Caller-owned asset buffers are copied when a call starts.
227
262
 
263
+ `createRenderQueue({ concurrency?, maxQueue? })` exposes `renderPdf()` with the
264
+ same options and result. Defaults are one active render and sixteen waiting
265
+ jobs. `maxQueue: 0` rejects buffering. Accepted calls snapshot their strings and
266
+ asset/font bytes immediately. The queue adds `QUEUE_FULL` and `QUEUE_CLOSED`,
267
+ read-only `activeCount`, `pendingCount`, and `closed` properties, and an
268
+ idempotent asynchronous `close()` method. It does not reuse workers or change
269
+ the standalone `renderPdf()` concurrency behavior.
270
+
228
271
  This API covers ordinary document generation and previews. It does not expose
229
272
  PDF/A, PDF/UA, PDF/X, template overlays, or compiled VDP. Use the
230
273
  [Python or Rust APIs](https://docs.fullbleed.dev/) for those workflows and their
@@ -1,4 +1,4 @@
1
- fullbleed 2.5.11 / LICENSE
1
+ fullbleed 2.5.22 / 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.11 / THIRD_PARTY_LICENSES.md
26
+ fullbleed 2.5.22 / THIRD_PARTY_LICENSES.md
27
27
 
28
28
  <!-- SPDX-License-Identifier: MIT -->
29
29
  # Third-Party Licenses
@@ -826,7 +826,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
826
826
  DEALINGS IN THE SOFTWARE.
827
827
 
828
828
 
829
- fullbleed 2.5.11 / src/preview_fonts/LICENSE-Liberation.txt
829
+ fullbleed 2.5.22 / src/preview_fonts/LICENSE-Liberation.txt
830
830
 
831
831
  Digitized data copyright (c) 2010 Google Corporation
832
832
  with Reserved Font Arimo, Tinos and Cousine.
@@ -932,7 +932,7 @@ DEALINGS IN THE FONT SOFTWARE.
932
932
 
933
933
 
934
934
 
935
- fullbleed 2.5.11 / src/preview_fonts/LICENSE-NotoSans.txt
935
+ fullbleed 2.5.22 / src/preview_fonts/LICENSE-NotoSans.txt
936
936
 
937
937
  Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
938
938
 
@@ -1031,7 +1031,7 @@ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
1031
1031
  OTHER DEALINGS IN THE FONT SOFTWARE.
1032
1032
 
1033
1033
 
1034
- fullbleed 2.5.11 / src/preview_fonts/LICENSE-NotoSansMath.txt
1034
+ fullbleed 2.5.22 / src/preview_fonts/LICENSE-NotoSansMath.txt
1035
1035
 
1036
1036
  Copyright 2018 Google LLC. All Rights Reserved.
1037
1037
 
@@ -1130,7 +1130,7 @@ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
1130
1130
  OTHER DEALINGS IN THE FONT SOFTWARE.
1131
1131
 
1132
1132
 
1133
- fullbleed 2.5.11 / src/preview_fonts/LICENSE-NotoSansSymbols.txt
1133
+ fullbleed 2.5.22 / src/preview_fonts/LICENSE-NotoSansSymbols.txt
1134
1134
 
1135
1135
  Copyright 2016 Google LLC. All Rights Reserved.
1136
1136
 
@@ -1229,7 +1229,7 @@ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
1229
1229
  OTHER DEALINGS IN THE FONT SOFTWARE.
1230
1230
 
1231
1231
 
1232
- fullbleed 2.5.11 / src/preview_fonts/LICENSE-NotoSansSymbols2.txt
1232
+ fullbleed 2.5.22 / src/preview_fonts/LICENSE-NotoSansSymbols2.txt
1233
1233
 
1234
1234
  Copyright 2017 Google Inc. All Rights Reserved.
1235
1235
 
@@ -1,27 +1,30 @@
1
1
  {
2
2
  "schema": "fullbleed.browser-assets.v1",
3
- "packageVersion": "0.3.2",
4
- "engineVersion": "2.5.11",
3
+ "packageVersion": "0.4.1",
4
+ "engineVersion": "2.5.22",
5
+ "engineFeatures": [
6
+ "svg_raster"
7
+ ],
5
8
  "files": {
6
9
  "client.js": {
7
10
  "source": "dist/browser/client.js",
8
11
  "bytes": 12043,
9
- "sha256": "35639759feb84bd52c8cc3f8e077387b59815f319f2d3dff66c354ca88175ff3"
12
+ "sha256": "7cf701296f165f39bec30d992f6e9eacf2f0d09a6fa79bca84a598baa5fc3de9"
10
13
  },
11
14
  "worker.js": {
12
15
  "source": "dist/browser/worker.js",
13
16
  "bytes": 52109,
14
- "sha256": "b58e511fedc84369196e7f90a35cdc0338ca375de0c888b39dbdb7d4f9fbef39"
17
+ "sha256": "44152f218784ffd37f891ca10681264074d4eb6a08380cd15e3548aead3ad675"
15
18
  },
16
19
  "engine.wasm": {
17
20
  "source": "dist/engine.wasm",
18
- "bytes": 5585689,
19
- "sha256": "90caf8a1fe493a32cdc0d3d2e55db7986c9c4b4994d6d080c0cdd3aec1727bbd"
21
+ "bytes": 5640040,
22
+ "sha256": "508c3dc58b51ec5568610cd5b8a329683f9e6d3677a464d8fc422ea9071b450d"
20
23
  },
21
24
  "THIRD_PARTY_NOTICES.txt": {
22
25
  "source": "dist/THIRD_PARTY_NOTICES.txt",
23
26
  "bytes": 81572,
24
- "sha256": "2ce7e43bab79db6d21597872421561b58ba4e1fa310f7a0157a5f8d7531a7677"
27
+ "sha256": "b8e9cf0c642fb3790a77654d514dd8a1c37fb1edb2e92a6fffdeb62de0efb43d"
25
28
  },
26
29
  "LICENSE.txt": {
27
30
  "source": "LICENSE",
@@ -109,8 +109,8 @@ var require_render_input = __commonJS({
109
109
  init_define_FULLBLEED_FONT_NAMES();
110
110
  init_define_FULLBLEED_RUNTIME_FILES();
111
111
  var import_render_input = __toESM(require_render_input(), 1);
112
- var version = "0.3.2";
113
- var engineVersion = "2.5.11";
112
+ var version = "0.4.1";
113
+ var engineVersion = "2.5.22";
114
114
  var FullbleedError = import_render_input.default.FullbleedError;
115
115
  var invalid = (message) => {
116
116
  throw new FullbleedError("INVALID_INPUT", message);
@@ -2,7 +2,7 @@
2
2
  var define_FULLBLEED_FONT_NAMES_default = ["Inter-Variable.ttf", "DMSerifDisplay-Regular.ttf", "DMSerifDisplay-Italic.ttf", "BebasNeue-Regular.ttf"];
3
3
 
4
4
  // <define:__FULLBLEED_RUNTIME_FILES__>
5
- var define_FULLBLEED_RUNTIME_FILES_default = { "engine.wasm": { bytes: 5585689, sha256: "90caf8a1fe493a32cdc0d3d2e55db7986c9c4b4994d6d080c0cdd3aec1727bbd" }, "fonts/Inter-Variable.ttf": { bytes: 876576, sha256: "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031" }, "fonts/DMSerifDisplay-Regular.ttf": { bytes: 76580, sha256: "8cc3643535edf039aa5d95440a8542735e9197e4f4b8d9303e980fefbf5ab616" }, "fonts/DMSerifDisplay-Italic.ttf": { bytes: 71208, sha256: "df74c0ac387baeaeb0fe4f2324e1668e6a3ed8c09cd9796fe162c71753e19e45" }, "fonts/BebasNeue-Regular.ttf": { bytes: 61400, sha256: "08e4623805102d819f58601e46e345648846075e363b2ceb23313c2d1c83ec73" } };
5
+ var define_FULLBLEED_RUNTIME_FILES_default = { "engine.wasm": { bytes: 5640040, sha256: "508c3dc58b51ec5568610cd5b8a329683f9e6d3677a464d8fc422ea9071b450d" }, "fonts/Inter-Variable.ttf": { bytes: 876576, sha256: "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031" }, "fonts/DMSerifDisplay-Regular.ttf": { bytes: 76580, sha256: "8cc3643535edf039aa5d95440a8542735e9197e4f4b8d9303e980fefbf5ab616" }, "fonts/DMSerifDisplay-Italic.ttf": { bytes: 71208, sha256: "df74c0ac387baeaeb0fe4f2324e1668e6a3ed8c09cd9796fe162c71753e19e45" }, "fonts/BebasNeue-Regular.ttf": { bytes: 61400, sha256: "08e4623805102d819f58601e46e345648846075e363b2ceb23313c2d1c83ec73" } };
6
6
 
7
7
  // node_modules/@bjorn3/browser_wasi_shim/dist/wasi_defs.js
8
8
  var CLOCKID_REALTIME = 0;
@@ -1438,8 +1438,8 @@ async function runEngine(job) {
1438
1438
  }
1439
1439
 
1440
1440
  // src/browser-worker.js
1441
- var version = "0.3.2";
1442
- var engineVersion = "2.5.11";
1441
+ var version = "0.4.1";
1442
+ var engineVersion = "2.5.22";
1443
1443
  var files = define_FULLBLEED_RUNTIME_FILES_default;
1444
1444
  var fontNames = define_FULLBLEED_FONT_NAMES_default;
1445
1445
  var hex = (bytes) => Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join("");
package/dist/build.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
- "packageVersion": "0.3.2",
3
- "engineVersion": "2.5.11",
4
- "engineSource": "https://crates.io/crates/fullbleed/2.5.11",
2
+ "packageVersion": "0.4.1",
3
+ "engineVersion": "2.5.22",
4
+ "engineSource": "https://crates.io/crates/fullbleed/2.5.22",
5
+ "engineFeatures": [
6
+ "svg_raster"
7
+ ],
5
8
  "target": "wasm32-wasip1",
6
9
  "memoryMaximumBytes": 536870912,
7
10
  "fonts": [
@@ -13,7 +16,7 @@
13
16
  "dependencies": [
14
17
  {
15
18
  "name": "fullbleed",
16
- "version": "2.5.11",
19
+ "version": "2.5.22",
17
20
  "source": "registry+https://github.com/rust-lang/crates.io-index",
18
21
  "license": "MIT"
19
22
  },
@@ -44,12 +47,12 @@
44
47
  ],
45
48
  "files": {
46
49
  "dist/engine.wasm": {
47
- "bytes": 5585689,
48
- "sha256": "90caf8a1fe493a32cdc0d3d2e55db7986c9c4b4994d6d080c0cdd3aec1727bbd"
50
+ "bytes": 5640040,
51
+ "sha256": "508c3dc58b51ec5568610cd5b8a329683f9e6d3677a464d8fc422ea9071b450d"
49
52
  },
50
53
  "dist/THIRD_PARTY_NOTICES.txt": {
51
54
  "bytes": 81572,
52
- "sha256": "2ce7e43bab79db6d21597872421561b58ba4e1fa310f7a0157a5f8d7531a7677"
55
+ "sha256": "b8e9cf0c642fb3790a77654d514dd8a1c37fb1edb2e92a6fffdeb62de0efb43d"
53
56
  },
54
57
  "dist/preview-font-notices/LICENSE-Liberation.txt": {
55
58
  "bytes": 4414,
@@ -101,15 +104,15 @@
101
104
  },
102
105
  "dist/browser/client.js": {
103
106
  "bytes": 12043,
104
- "sha256": "35639759feb84bd52c8cc3f8e077387b59815f319f2d3dff66c354ca88175ff3"
107
+ "sha256": "7cf701296f165f39bec30d992f6e9eacf2f0d09a6fa79bca84a598baa5fc3de9"
105
108
  },
106
109
  "dist/browser/worker.js": {
107
110
  "bytes": 52109,
108
- "sha256": "b58e511fedc84369196e7f90a35cdc0338ca375de0c888b39dbdb7d4f9fbef39"
111
+ "sha256": "44152f218784ffd37f891ca10681264074d4eb6a08380cd15e3548aead3ad675"
109
112
  },
110
113
  "dist/browser/asset-manifest.json": {
111
- "bytes": 2399,
112
- "sha256": "48d17f6481ff27e82e6cd2c93626e9ccd5cd7cc563fc577b62b9bae22de5b906"
114
+ "bytes": 2443,
115
+ "sha256": "211481ebdda0022296dbce43d95b0dd49848c8a4e7298aa075fda1f3701c6a46"
113
116
  }
114
117
  }
115
118
  }
package/dist/engine.wasm CHANGED
Binary file
package/dist/index.d.cts CHANGED
@@ -10,7 +10,7 @@ export interface RenderOptions {
10
10
  assets?: Readonly<Record<string, Uint8Array>>;
11
11
  /** 0 disables previews (default); otherwise 36–300 DPI. */
12
12
  previewDpi?: number;
13
- /** Includes initial engine loading. Default: 30000 ms. */
13
+ /** Includes queue wait and initial engine loading. Default: 30000 ms. */
14
14
  timeoutMs?: number;
15
15
  /** Reject documents larger than this. Default: 1000. */
16
16
  maxPages?: number;
@@ -37,5 +37,23 @@ export class FullbleedError extends Error {
37
37
  constructor(code: string, message: string, options?: ErrorOptions);
38
38
  }
39
39
  export function renderPdf(options: RenderOptions): Promise<RenderResult>;
40
+ export interface RenderQueueOptions {
41
+ /** Maximum simultaneous renders in this queue. Default: 1. */
42
+ concurrency?: number;
43
+ /** Maximum waiting requests, excluding active renders. Default: 16; 0 rejects instead of waiting. */
44
+ maxQueue?: number;
45
+ }
46
+ export interface RenderQueue {
47
+ /** Render in FIFO start order, or reject with QUEUE_FULL/QUEUE_CLOSED. Inputs are copied at admission. */
48
+ renderPdf(options: RenderOptions): Promise<RenderResult>;
49
+ /** Stop admission, cancel accepted jobs and await worker/process cleanup. Idempotent. */
50
+ close(): Promise<void>;
51
+ readonly concurrency: number;
52
+ readonly maxQueue: number;
53
+ readonly activeCount: number;
54
+ readonly pendingCount: number;
55
+ readonly closed: boolean;
56
+ }
57
+ export function createRenderQueue(options?: RenderQueueOptions): RenderQueue;
40
58
  export const engineVersion: string;
41
59
  export const version: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullbleed",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
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",
@@ -77,7 +77,7 @@
77
77
  "access": "public"
78
78
  },
79
79
  "fullbleed": {
80
- "engineVersion": "2.5.11"
80
+ "engineVersion": "2.5.22"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@types/node": "22.20.5",
@@ -35,7 +35,7 @@ async function main() {
35
35
  await writeFile(path, file.data);
36
36
  }
37
37
  await writeFile(join(destination, 'build.json'), JSON.stringify({ schema: manifest.schema,
38
- packageVersion: pkg.version, engineVersion: manifest.engineVersion,
38
+ packageVersion: pkg.version, engineVersion: manifest.engineVersion, engineFeatures: manifest.engineFeatures,
39
39
  files: Object.fromEntries(files.map(({ name, bytes, sha256 }) => [name, { bytes, sha256 }])) }, null, 2) + '\n');
40
40
  console.log(JSON.stringify({ ok: true, directory: destination, packageVersion: pkg.version,
41
41
  engineVersion: manifest.engineVersion, files: files.length + 1, bytes: files.reduce((sum, file) => sum + file.bytes, 0) }));
package/src/index.cjs CHANGED
@@ -9,6 +9,7 @@ const { pathToFileURL } = require('node:url');
9
9
  const pkg = require('../package.json');
10
10
 
11
11
  const { FullbleedError, normalize } = require('./render-input.cjs');
12
+ const { createQueue } = require('./render-queue.cjs');
12
13
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
13
14
  const root = join(__dirname, '..');
14
15
  let runtimePromise;
@@ -156,6 +157,11 @@ async function renderPdf(options) {
156
157
  if (Number(process.versions.node.split('.')[0]) < 22) throw new FullbleedError('UNSUPPORTED_NODE', 'Fullbleed requires Node.js 22 or newer.');
157
158
  const start = Date.now();
158
159
  const job = normalize(options);
160
+ return renderJob(job, start);
161
+ }
162
+
163
+ async function renderJob(job, start) {
164
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new FullbleedError('UNSUPPORTED_NODE', 'Fullbleed requires Node.js 22 or newer.');
159
165
  const aborted = () => new FullbleedError('ABORTED', 'PDF rendering was aborted.');
160
166
  if (job.signal?.aborted) throw aborted();
161
167
  const remaining = job.timeoutMs - (Date.now() - start);
@@ -211,6 +217,7 @@ async function renderPdf(options) {
211
217
  }
212
218
 
213
219
  exports.renderPdf = renderPdf;
220
+ exports.createRenderQueue = options => createQueue(renderJob, options);
214
221
  exports.FullbleedError = FullbleedError;
215
222
  exports.engineVersion = pkg.fullbleed.engineVersion;
216
223
  exports.version = pkg.version;
package/src/index.d.ts CHANGED
@@ -10,7 +10,7 @@ export interface RenderOptions {
10
10
  assets?: Readonly<Record<string, Uint8Array>>;
11
11
  /** 0 disables previews (default); otherwise 36–300 DPI. */
12
12
  previewDpi?: number;
13
- /** Includes initial engine loading. Default: 30000 ms. */
13
+ /** Includes queue wait and initial engine loading. Default: 30000 ms. */
14
14
  timeoutMs?: number;
15
15
  /** Reject documents larger than this. Default: 1000. */
16
16
  maxPages?: number;
@@ -37,5 +37,23 @@ export class FullbleedError extends Error {
37
37
  constructor(code: string, message: string, options?: ErrorOptions);
38
38
  }
39
39
  export function renderPdf(options: RenderOptions): Promise<RenderResult>;
40
+ export interface RenderQueueOptions {
41
+ /** Maximum simultaneous renders in this queue. Default: 1. */
42
+ concurrency?: number;
43
+ /** Maximum waiting requests, excluding active renders. Default: 16; 0 rejects instead of waiting. */
44
+ maxQueue?: number;
45
+ }
46
+ export interface RenderQueue {
47
+ /** Render in FIFO start order, or reject with QUEUE_FULL/QUEUE_CLOSED. Inputs are copied at admission. */
48
+ renderPdf(options: RenderOptions): Promise<RenderResult>;
49
+ /** Stop admission, cancel accepted jobs and await worker/process cleanup. Idempotent. */
50
+ close(): Promise<void>;
51
+ readonly concurrency: number;
52
+ readonly maxQueue: number;
53
+ readonly activeCount: number;
54
+ readonly pendingCount: number;
55
+ readonly closed: boolean;
56
+ }
57
+ export function createRenderQueue(options?: RenderQueueOptions): RenderQueue;
40
58
  export const engineVersion: string;
41
59
  export const version: string;
package/src/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import api from './index.cjs';
3
- export const { renderPdf, FullbleedError, engineVersion, version } = api;
3
+ export const { renderPdf, createRenderQueue, FullbleedError, engineVersion, version } = api;
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: MIT
2
+ 'use strict';
3
+ const { FullbleedError, normalize } = require('./render-input.cjs');
4
+
5
+ const failure = (code, message) => new FullbleedError(code, message);
6
+ const closedError = () => failure('QUEUE_CLOSED', 'The rendering queue is closed.');
7
+ const abortedError = () => failure('ABORTED', 'PDF rendering was aborted.');
8
+
9
+ function createQueue(run, options = {}) {
10
+ const invalid = message => { throw failure('INVALID_INPUT', message); };
11
+ if (!options || typeof options !== 'object' || Array.isArray(options)) invalid('Pass queue options as an object.');
12
+ for (const key of Object.keys(options)) {
13
+ if (key !== 'concurrency' && key !== 'maxQueue') invalid(`Unknown queue option: ${key}`);
14
+ }
15
+ const { concurrency = 1, maxQueue = 16 } = options;
16
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) invalid('concurrency must be a positive safe integer.');
17
+ if (!Number.isSafeInteger(maxQueue) || maxQueue < 0) invalid('maxQueue must be a nonnegative safe integer.');
18
+ const pending = [];
19
+ const active = new Set();
20
+ let closed = false, closePromise, resolveClose;
21
+
22
+ function finishClose() {
23
+ if (closed && active.size === 0 && pending.length === 0) resolveClose();
24
+ }
25
+
26
+ function cleanup(entry) {
27
+ clearTimeout(entry.timer);
28
+ entry.sourceSignal?.removeEventListener('abort', entry.onAbort);
29
+ }
30
+
31
+ function cancel(entry, error) {
32
+ if (entry.state === 'settled') return;
33
+ if (entry.state === 'active') {
34
+ entry.stopError ??= error;
35
+ entry.controller.abort();
36
+ return;
37
+ }
38
+ pending.splice(pending.indexOf(entry), 1);
39
+ entry.state = 'settled';
40
+ cleanup(entry);
41
+ entry.reject(error);
42
+ pump();
43
+ finishClose();
44
+ }
45
+
46
+ function complete(entry, error, result) {
47
+ // run() settles only after its worker or process exits. Keep the slot
48
+ // occupied through that cleanup, including failure and cancellation.
49
+ active.delete(entry);
50
+ entry.state = 'settled';
51
+ cleanup(entry);
52
+ error = entry.stopError ?? error;
53
+ if (error) entry.reject(error); else entry.resolve(result);
54
+ pump();
55
+ finishClose();
56
+ }
57
+
58
+ function pump() {
59
+ if (closed) return;
60
+ while (active.size < concurrency && pending.length) {
61
+ const entry = pending.shift();
62
+ entry.state = 'active';
63
+ clearTimeout(entry.timer);
64
+ active.add(entry);
65
+ Promise.resolve().then(() => run(entry.job, entry.start)).then(
66
+ result => complete(entry, null, result),
67
+ error => complete(entry, error),
68
+ );
69
+ }
70
+ }
71
+
72
+ function admissionError() {
73
+ if (closed) return closedError();
74
+ if (active.size >= concurrency && pending.length >= maxQueue) {
75
+ return failure('QUEUE_FULL', 'The rendering queue has no available slot.');
76
+ }
77
+ }
78
+
79
+ function renderPdf(options) {
80
+ let error = admissionError();
81
+ if (error) return Promise.reject(error);
82
+ const start = Date.now();
83
+ let job;
84
+ try { job = normalize(options); } catch (error) { return Promise.reject(error); }
85
+ // Getters on the input can invoke application code during normalization.
86
+ // Recheck admission before retaining the copied document buffers.
87
+ error = admissionError();
88
+ if (error) return Promise.reject(error);
89
+ if (job.signal?.aborted) return Promise.reject(abortedError());
90
+ const remaining = job.timeoutMs - (Date.now() - start);
91
+ if (remaining <= 0) return Promise.reject(failure('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`));
92
+ const sourceSignal = job.signal;
93
+ const controller = new AbortController();
94
+ job.signal = controller.signal;
95
+ return new Promise((resolve, reject) => {
96
+ const entry = { job, start, sourceSignal, controller, resolve, reject, state: 'pending' };
97
+ entry.onAbort = () => cancel(entry, abortedError());
98
+ pending.push(entry);
99
+ entry.timer = setTimeout(() => cancel(entry,
100
+ failure('TIMEOUT', `PDF rendering exceeded ${job.timeoutMs} ms.`)), remaining);
101
+ sourceSignal?.addEventListener('abort', entry.onAbort, { once: true });
102
+ if (sourceSignal?.aborted) entry.onAbort();
103
+ else pump();
104
+ });
105
+ }
106
+
107
+ function close() {
108
+ if (closed) return closePromise;
109
+ closed = true;
110
+ closePromise = new Promise(resolve => { resolveClose = resolve; });
111
+ for (const entry of [...pending, ...active]) cancel(entry, closedError());
112
+ finishClose();
113
+ return closePromise;
114
+ }
115
+
116
+ return Object.freeze({
117
+ renderPdf, close, concurrency, maxQueue,
118
+ get activeCount() { return active.size; },
119
+ get pendingCount() { return pending.length; },
120
+ get closed() { return closed; },
121
+ });
122
+ }
123
+
124
+ module.exports = { createQueue };