fullbleed 0.3.1 → 0.4.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/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 };