satoru-render 1.0.11 → 1.0.13

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.
@@ -250,6 +250,14 @@ let LogLevel = /* @__PURE__ */ function(LogLevel) {
250
250
  }({});
251
251
  //#endregion
252
252
  //#region src/core.ts
253
+ const DIAGNOSTIC_CODES = {
254
+ LIMIT_TIMEOUT: "LIMIT_TIMEOUT",
255
+ LIMIT_RESOURCE_SIZE: "LIMIT_RESOURCE_SIZE",
256
+ LIMIT_TOTAL_SIZE: "LIMIT_TOTAL_SIZE",
257
+ LIMIT_RESOURCE_COUNT: "LIMIT_RESOURCE_COUNT",
258
+ LIMIT_PROTOCOL_BLOCKED: "LIMIT_PROTOCOL_BLOCKED",
259
+ LIMIT_HOST_BLOCKED: "LIMIT_HOST_BLOCKED"
260
+ };
253
261
  const emojiUrl = "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2";
254
262
  const DEFAULT_FONT_MAP = {
255
263
  "sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
@@ -502,34 +510,29 @@ var SatoruBase = class {
502
510
  }
503
511
  async render(options) {
504
512
  let { format = "svg", value, url, baseUrl } = options;
505
- const profileEnabled = options.profile === true;
513
+ const profileEnabled = options.profile === true || options.diagnostics === true;
506
514
  const profile = {};
507
515
  const now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
516
+ const startTime = now();
517
+ const limits = options.limits ?? {};
518
+ let totalResourceBytes = 0;
519
+ let resourceCount = 0;
508
520
  const addProfile = (name, elapsed) => {
509
521
  if (!profileEnabled) return;
510
522
  profile[name] = (profile[name] ?? 0) + elapsed;
511
523
  };
512
- if (format === "pdf" && Array.isArray(value) && value.length > 1) {
513
- const pagePdfs = [];
514
- const SatoruClass = this.constructor;
515
- for (const html of value) {
516
- const pagePdf = await (await SatoruClass.create()).render({
517
- ...options,
518
- value: html,
519
- format: "pdf"
520
- });
521
- pagePdfs.push(pagePdf);
522
- }
523
- const mod = await this.getModule();
524
- const instancePtr = mod.create_instance();
525
- try {
526
- const result = mod.merge_pdfs(instancePtr, pagePdfs);
527
- if (!result) return new Uint8Array();
528
- return new Uint8Array(result);
529
- } finally {
530
- mod.destroy_instance(instancePtr);
531
- }
532
- }
524
+ const diagnosticsReport = options.diagnostics ? {
525
+ version: 1,
526
+ format,
527
+ width: options.width,
528
+ height: options.height,
529
+ mediaType: options.mediaType ?? "screen",
530
+ timings: profile,
531
+ resources: [],
532
+ fonts: [],
533
+ warnings: [],
534
+ errors: []
535
+ } : null;
533
536
  let mod = await this.getModule();
534
537
  const { width, height = 0, fonts, images, css, logLevel, onLog } = options;
535
538
  if (!options.userAgent) options.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
@@ -547,6 +550,7 @@ var SatoruBase = class {
547
550
  this.currentFontMap = options.fontMap ?? DEFAULT_FONT_MAP;
548
551
  const instancePtr = mod.create_instance();
549
552
  mod.set_font_map(instancePtr, this.currentFontMap);
553
+ mod.set_collect_profile_enabled(instancePtr, profileEnabled);
550
554
  try {
551
555
  const defaultResolver = (r) => this.resolveDefaultResource(r, baseUrl, options.userAgent);
552
556
  const resolver = options.resolveResource ? async (r) => {
@@ -555,12 +559,120 @@ var SatoruBase = class {
555
559
  const cachedResolver = async (r) => {
556
560
  const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
557
561
  const cached = this.resourceCache.get(cacheKey);
562
+ let resourceDiag;
563
+ if (diagnosticsReport) {
564
+ resourceDiag = {
565
+ type: r.type,
566
+ url: r.url,
567
+ name: r.name,
568
+ status: "pending"
569
+ };
570
+ diagnosticsReport.resources.push(resourceDiag);
571
+ }
558
572
  if (cached) {
559
573
  addProfile("resourceCacheHitsCount", 1);
574
+ if (resourceDiag) {
575
+ resourceDiag.status = "loaded";
576
+ if (cached instanceof Uint8Array) resourceDiag.bytes = cached.byteLength;
577
+ }
560
578
  return cached;
561
579
  }
580
+ if (limits.maxResourceCount && resourceCount >= limits.maxResourceCount) {
581
+ const msg = `Maximum resource count (${limits.maxResourceCount}) exceeded`;
582
+ if (resourceDiag) {
583
+ resourceDiag.status = "skipped";
584
+ resourceDiag.reason = msg;
585
+ diagnosticsReport?.errors.push({
586
+ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_COUNT,
587
+ message: msg,
588
+ source: r.url
589
+ });
590
+ }
591
+ return null;
592
+ }
593
+ if (limits.allowedProtocols || limits.allowedHosts || limits.blockedHosts) try {
594
+ const urlObj = new URL(r.url);
595
+ if (limits.allowedProtocols && !limits.allowedProtocols.includes(urlObj.protocol)) {
596
+ const msg = `Protocol ${urlObj.protocol} is blocked`;
597
+ if (resourceDiag) {
598
+ resourceDiag.status = "skipped";
599
+ resourceDiag.reason = msg;
600
+ diagnosticsReport?.errors.push({
601
+ code: DIAGNOSTIC_CODES.LIMIT_PROTOCOL_BLOCKED,
602
+ message: msg,
603
+ source: r.url
604
+ });
605
+ }
606
+ return null;
607
+ }
608
+ if (limits.allowedHosts && !limits.allowedHosts.includes(urlObj.hostname)) {
609
+ const msg = `Host ${urlObj.hostname} is not in allowed list`;
610
+ if (resourceDiag) {
611
+ resourceDiag.status = "skipped";
612
+ resourceDiag.reason = msg;
613
+ diagnosticsReport?.errors.push({
614
+ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED,
615
+ message: msg,
616
+ source: r.url
617
+ });
618
+ }
619
+ return null;
620
+ }
621
+ if (limits.blockedHosts && limits.blockedHosts.includes(urlObj.hostname)) {
622
+ const msg = `Host ${urlObj.hostname} is blocked`;
623
+ if (resourceDiag) {
624
+ resourceDiag.status = "skipped";
625
+ resourceDiag.reason = msg;
626
+ diagnosticsReport?.errors.push({
627
+ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED,
628
+ message: msg,
629
+ source: r.url
630
+ });
631
+ }
632
+ return null;
633
+ }
634
+ } catch (e) {}
562
635
  const resolveStart = now();
563
- const result = await resolver(r);
636
+ let result = null;
637
+ try {
638
+ result = await resolver(r);
639
+ if (resourceDiag) {
640
+ resourceDiag.status = result ? "loaded" : "failed";
641
+ if (result) {
642
+ const bytes = result instanceof Uint8Array ? result.byteLength : result.buffer ? result.byteLength : "css" in result ? result.css.byteLength + result.fonts.reduce((acc, f) => acc + f.data.byteLength, 0) : 0;
643
+ resourceDiag.bytes = bytes;
644
+ if (limits.maxResourceBytes && bytes > limits.maxResourceBytes) {
645
+ const msg = `Resource size (${bytes} bytes) exceeds limit (${limits.maxResourceBytes})`;
646
+ resourceDiag.status = "skipped";
647
+ resourceDiag.reason = msg;
648
+ diagnosticsReport?.errors.push({
649
+ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_SIZE,
650
+ message: msg,
651
+ source: r.url
652
+ });
653
+ return null;
654
+ }
655
+ if (limits.maxTotalResourceBytes && totalResourceBytes + bytes > limits.maxTotalResourceBytes) {
656
+ const msg = `Total resource size exceeds limit (${limits.maxTotalResourceBytes})`;
657
+ resourceDiag.status = "skipped";
658
+ resourceDiag.reason = msg;
659
+ diagnosticsReport?.errors.push({
660
+ code: DIAGNOSTIC_CODES.LIMIT_TOTAL_SIZE,
661
+ message: msg,
662
+ source: r.url
663
+ });
664
+ return null;
665
+ }
666
+ totalResourceBytes += bytes;
667
+ resourceCount++;
668
+ }
669
+ }
670
+ } catch (e) {
671
+ if (resourceDiag) {
672
+ resourceDiag.status = "failed";
673
+ resourceDiag.reason = e.message || String(e);
674
+ }
675
+ }
564
676
  addProfile("resolveResources", now() - resolveStart);
565
677
  if (result) {
566
678
  if (result instanceof Uint8Array) this.resourceCache.set(cacheKey, result);
@@ -590,14 +702,34 @@ var SatoruBase = class {
590
702
  };
591
703
  const inputHtmls = Array.isArray(value) ? value : [value];
592
704
  const processedHtmls = [];
705
+ const utf8Decoder = new TextDecoder();
706
+ const utf8Encoder = new TextEncoder();
593
707
  const resolvedResources = /* @__PURE__ */ new Set();
594
708
  for (const rawHtml of inputHtmls) {
595
709
  let processedHtml = rawHtml;
596
710
  for (let i = 0; i < 10; i++) {
711
+ if (options.signal?.aborted) throw new Error("Render aborted");
712
+ if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
713
+ const msg = `Render timed out after ${limits.timeoutMs}ms`;
714
+ diagnosticsReport?.errors.push({
715
+ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT,
716
+ message: msg
717
+ });
718
+ throw new Error(msg);
719
+ }
597
720
  addProfile("collectResourcesCount", 1);
598
721
  const collectStart = now();
599
722
  mod.collect_resources(instancePtr, processedHtml, width, height, options.mediaType === "print" ? 1 : 0);
600
- addProfile("collectResources", now() - collectStart);
723
+ const collectElapsed = now() - collectStart;
724
+ addProfile("collectResources", collectElapsed);
725
+ addProfile(`collectResourcesRound${i + 1}`, collectElapsed);
726
+ if (profileEnabled) try {
727
+ const collectProfile = JSON.parse(mod.get_collect_profile(instancePtr));
728
+ for (const [key, value] of Object.entries(collectProfile)) {
729
+ addProfile(key, value);
730
+ addProfile(`${key}Round${i + 1}`, value);
731
+ }
732
+ } catch {}
601
733
  const pendingStart = now();
602
734
  const binary = mod.get_pending_resources(instancePtr);
603
735
  addProfile("getPendingResources", now() - pendingStart);
@@ -613,15 +745,15 @@ var SatoruBase = class {
613
745
  const redraw_on_ready = view.getUint8(offset++) !== 0;
614
746
  const urlLen = view.getUint32(offset, true);
615
747
  offset += 4;
616
- const url = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
748
+ const url = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
617
749
  offset += urlLen;
618
750
  const nameLen = view.getUint32(offset, true);
619
751
  offset += 4;
620
- const name = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
752
+ const name = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
621
753
  offset += nameLen;
622
754
  const charsLen = view.getUint32(offset, true);
623
755
  offset += 4;
624
- const characters = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
756
+ const characters = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
625
757
  offset += charsLen;
626
758
  let type = "font";
627
759
  if (typeInt === 2) type = "image";
@@ -636,16 +768,31 @@ var SatoruBase = class {
636
768
  }
637
769
  const pending = resources.filter((r) => {
638
770
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
771
+ if (r.type === "font" && resolvedResources.has(`font:${r.url}:`)) return false;
639
772
  return !resolvedResources.has(key);
640
773
  });
774
+ addProfile("pendingResourcesCount", pending.length);
775
+ addProfile(`pendingResourcesRound${i + 1}Count`, pending.length);
776
+ for (const r of pending) if (r.type === "font") {
777
+ addProfile("pendingFontResourcesCount", 1);
778
+ addProfile(`pendingFontResourcesRound${i + 1}Count`, 1);
779
+ } else if (r.type === "css") {
780
+ addProfile("pendingCssResourcesCount", 1);
781
+ addProfile(`pendingCssResourcesRound${i + 1}Count`, 1);
782
+ } else if (r.type === "image") {
783
+ addProfile("pendingImageResourcesCount", 1);
784
+ addProfile(`pendingImageResourcesRound${i + 1}Count`, 1);
785
+ }
641
786
  addProfile("parsePendingResources", now() - parseStart);
642
787
  if (pending.length === 0) break;
788
+ addProfile("pendingResourceRoundsCount", 1);
643
789
  const loadStart = now();
644
790
  await Promise.all(pending.map(async (r) => {
645
791
  try {
646
792
  if (r.url.startsWith("data:")) return;
647
793
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
648
794
  resolvedResources.add(key);
795
+ if (r.type === "font") resolvedResources.add(`font:${r.url}:`);
649
796
  const data = await cachedResolver({ ...r });
650
797
  if (!data) return;
651
798
  if (typeof data === "object" && "css" in data && "fonts" in data) {
@@ -661,7 +808,7 @@ var SatoruBase = class {
661
808
  if (data instanceof Uint8Array || ArrayBuffer.isView(data)) {
662
809
  let finalUint8 = data instanceof Uint8Array ? data : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
663
810
  if (r.type === "css") {
664
- const cssText = new TextDecoder().decode(finalUint8);
811
+ const cssText = utf8Decoder.decode(finalUint8);
665
812
  const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) || r.url.startsWith("data:");
666
813
  let cssBaseUrl = r.url;
667
814
  if (!isAbsolute && baseUrl) try {
@@ -677,7 +824,7 @@ var SatoruBase = class {
677
824
  return match;
678
825
  }
679
826
  });
680
- finalUint8 = new TextEncoder().encode(rewrittenCss);
827
+ finalUint8 = utf8Encoder.encode(rewrittenCss);
681
828
  }
682
829
  }
683
830
  await loadResourceData(r, finalUint8);
@@ -709,6 +856,15 @@ var SatoruBase = class {
709
856
  pdf: 3
710
857
  };
711
858
  const renderStart = now();
859
+ if (options.signal?.aborted) throw new Error("Render aborted");
860
+ if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
861
+ const msg = `Render timed out after ${limits.timeoutMs}ms`;
862
+ diagnosticsReport?.errors.push({
863
+ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT,
864
+ message: msg
865
+ });
866
+ throw new Error(msg);
867
+ }
712
868
  const result = processedHtmls.length === 1 ? mod.render_from_state(instancePtr, width, height, formatMap[format] ?? 0, {
713
869
  svgTextToPaths: options.textToPaths ?? true,
714
870
  outputWidth: options.outputWidth ?? 0,
@@ -721,7 +877,19 @@ var SatoruBase = class {
721
877
  fitPositionX: options.fitPosition?.x ?? .5,
722
878
  fitPositionY: options.fitPosition?.y ?? .5,
723
879
  backgroundColor: this.parseColor(options.backgroundColor),
724
- mediaType: options.mediaType === "print" ? 1 : 0
880
+ mediaType: options.mediaType === "print" ? 1 : 0,
881
+ pdfTitle: options.pdfTitle ?? "",
882
+ pdfAuthor: options.pdfAuthor ?? "",
883
+ pdfSubject: options.pdfSubject ?? "",
884
+ pdfKeywords: options.pdfKeywords ?? "",
885
+ pdfCreator: options.pdfCreator ?? "",
886
+ pdfProducer: options.pdfProducer ?? "",
887
+ pdfMarginTop: options.pdfMargin?.top ?? 0,
888
+ pdfMarginRight: options.pdfMargin?.right ?? 0,
889
+ pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
890
+ pdfMarginLeft: options.pdfMargin?.left ?? 0,
891
+ pdfHeader: options.pdfHeader ?? "",
892
+ pdfFooter: options.pdfFooter ?? ""
725
893
  }) : mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
726
894
  svgTextToPaths: options.textToPaths ?? true,
727
895
  outputWidth: options.outputWidth ?? 0,
@@ -734,25 +902,55 @@ var SatoruBase = class {
734
902
  fitPositionX: options.fitPosition?.x ?? .5,
735
903
  fitPositionY: options.fitPosition?.y ?? .5,
736
904
  backgroundColor: this.parseColor(options.backgroundColor),
737
- mediaType: options.mediaType === "print" ? 1 : 0
905
+ mediaType: options.mediaType === "print" ? 1 : 0,
906
+ pdfTitle: options.pdfTitle ?? "",
907
+ pdfAuthor: options.pdfAuthor ?? "",
908
+ pdfSubject: options.pdfSubject ?? "",
909
+ pdfKeywords: options.pdfKeywords ?? "",
910
+ pdfCreator: options.pdfCreator ?? "",
911
+ pdfProducer: options.pdfProducer ?? "",
912
+ pdfMarginTop: options.pdfMargin?.top ?? 0,
913
+ pdfMarginRight: options.pdfMargin?.right ?? 0,
914
+ pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
915
+ pdfMarginLeft: options.pdfMargin?.left ?? 0,
916
+ pdfHeader: options.pdfHeader ?? "",
917
+ pdfFooter: options.pdfFooter ?? ""
738
918
  });
739
919
  addProfile("wasmRender", now() - renderStart);
740
920
  if (!result) {
741
921
  options.onProfile?.(profile);
922
+ if (diagnosticsReport) {
923
+ try {
924
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
925
+ } catch {}
926
+ options.onDiagnostics?.(diagnosticsReport);
927
+ }
742
928
  if (format === "svg") return "";
743
929
  return new Uint8Array();
744
930
  }
745
931
  if (format === "svg") {
746
932
  const decodeStart = now();
747
- const svg = new TextDecoder().decode(result);
933
+ const svg = utf8Decoder.decode(result);
748
934
  addProfile("decodeResult", now() - decodeStart);
749
935
  options.onProfile?.(profile);
936
+ if (diagnosticsReport) {
937
+ try {
938
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
939
+ } catch {}
940
+ options.onDiagnostics?.(diagnosticsReport);
941
+ }
750
942
  return svg;
751
943
  }
752
944
  const copyStart = now();
753
945
  const bytes = new Uint8Array(result);
754
946
  addProfile("copyResult", now() - copyStart);
755
947
  options.onProfile?.(profile);
948
+ if (diagnosticsReport) {
949
+ try {
950
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
951
+ } catch {}
952
+ options.onDiagnostics?.(diagnosticsReport);
953
+ }
756
954
  return bytes;
757
955
  } finally {
758
956
  mod.destroy_instance(instancePtr);
@@ -923,8 +1121,18 @@ var Satoru = class Satoru extends SatoruBase {
923
1121
  * Defaults to the bundled workers.js in the same directory.
924
1122
  * @param params.maxParallel Maximum number of parallel workers
925
1123
  */
1124
+ /**
1125
+ * Create a Satoru worker proxy using worker-lib.
1126
+ * @param params Initialization parameters
1127
+ * @param params.worker Optional: Path to the worker file, a URL, or a factory function.
1128
+ * Defaults to the bundled workers.js in the same directory.
1129
+ * @param params.maxParallel Maximum number of parallel workers (default: 4)
1130
+ * @param params.timeoutMs Optional: Default timeout in milliseconds for each render job.
1131
+ * If a render job times out, the pool is reset to prevent hung workers.
1132
+ * @param params.onWorkerLog Optional: Global callback to intercept logs emitted by the worker threads.
1133
+ */
926
1134
  const createSatoruWorker = (params) => {
927
- const { worker, maxParallel = 4 } = params ?? {};
1135
+ const { worker, maxParallel = 4, timeoutMs, onWorkerLog } = params ?? {};
928
1136
  const factory = () => {
929
1137
  let w$1;
930
1138
  if (worker) w$1 = typeof worker === "function" ? worker() : worker;
@@ -936,14 +1144,85 @@ const createSatoruWorker = (params) => {
936
1144
  return w$1;
937
1145
  };
938
1146
  const workerInstance = createWorker(factory, maxParallel);
1147
+ let totalPendingJobs = 0;
1148
+ let completedJobs = 0;
1149
+ let failedJobs = 0;
1150
+ let totalJobTimeMs = 0;
1151
+ /**
1152
+ * Retrieve operational stats of the worker pool.
1153
+ */
1154
+ const getStats = () => ({
1155
+ workerCount: maxParallel,
1156
+ activeJobs: Math.min(totalPendingJobs, maxParallel),
1157
+ queuedJobs: Math.max(0, totalPendingJobs - maxParallel),
1158
+ completedJobs,
1159
+ failedJobs,
1160
+ avgJobTimeMs: completedJobs > 0 ? totalJobTimeMs / completedJobs : 0
1161
+ });
1162
+ /**
1163
+ * Reset the worker pool by terminating all running workers and recreating them.
1164
+ * Useful to clear hung workers or reset statistics.
1165
+ */
1166
+ const reset = () => {
1167
+ workerInstance.setLimit(0);
1168
+ workerInstance.setLimit(maxParallel);
1169
+ totalPendingJobs = 0;
1170
+ completedJobs = 0;
1171
+ failedJobs = 0;
1172
+ totalJobTimeMs = 0;
1173
+ };
939
1174
  return new Proxy(workerInstance, { get(target, prop, receiver) {
1175
+ if (prop === "getStats") return getStats;
1176
+ if (prop === "reset") return reset;
940
1177
  if (prop === "render") return async (options) => {
941
- return await target.execute("render", options);
1178
+ totalPendingJobs++;
1179
+ const startTime = Date.now();
1180
+ const jobTimeoutMs = options.limits?.timeoutMs ?? timeoutMs;
1181
+ const originalOnLog = options.onLog;
1182
+ const mergedOnLog = (level, message) => {
1183
+ if (originalOnLog) originalOnLog(level, message);
1184
+ if (onWorkerLog) onWorkerLog(level, message);
1185
+ };
1186
+ const mergedOptions = {
1187
+ ...options,
1188
+ onLog: mergedOnLog
1189
+ };
1190
+ let timeoutId;
1191
+ let timeoutPromise;
1192
+ if (jobTimeoutMs !== void 0 && jobTimeoutMs > 0) timeoutPromise = new Promise((_, reject) => {
1193
+ timeoutId = setTimeout(() => {
1194
+ reset();
1195
+ reject(/* @__PURE__ */ new Error(`Render timed out after ${jobTimeoutMs}ms`));
1196
+ }, jobTimeoutMs);
1197
+ });
1198
+ try {
1199
+ const executePromise = target.execute("render", mergedOptions);
1200
+ const result = await (timeoutPromise ? Promise.race([executePromise, timeoutPromise]) : executePromise);
1201
+ completedJobs++;
1202
+ totalJobTimeMs += Date.now() - startTime;
1203
+ return result;
1204
+ } catch (e) {
1205
+ failedJobs++;
1206
+ throw e;
1207
+ } finally {
1208
+ if (timeoutId) clearTimeout(timeoutId);
1209
+ totalPendingJobs--;
1210
+ }
942
1211
  };
943
1212
  if (prop in target) return Reflect.get(target, prop, receiver);
944
- return (...args) => target.execute(prop, ...args);
1213
+ return async (...args) => {
1214
+ totalPendingJobs++;
1215
+ try {
1216
+ return await target.execute(prop, ...args);
1217
+ } finally {
1218
+ totalPendingJobs--;
1219
+ }
1220
+ };
945
1221
  } });
946
1222
  };
947
- const { close, render, launchWorker, setLimit, waitAll, waitReady } = createSatoruWorker({ maxParallel: 1 });
1223
+ const defaultWorker = createSatoruWorker({ maxParallel: 1 });
1224
+ const { close, render, launchWorker, setLimit, waitAll, waitReady } = defaultWorker;
1225
+ const reset = () => defaultWorker.reset();
1226
+ const getStats = () => defaultWorker.getStats();
948
1227
  //#endregion
949
- export { DEFAULT_FONT_MAP, LogLevel, Satoru, SatoruBase, close, createSatoruWorker, launchWorker, render, resolveGoogleFonts, setLimit, waitAll, waitReady };
1228
+ export { DEFAULT_FONT_MAP, DIAGNOSTIC_CODES, LogLevel, Satoru, SatoruBase, close, createSatoruWorker, getStats, launchWorker, render, reset, resolveGoogleFonts, setLimit, waitAll, waitReady };
package/dist/workers.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export type { SatoruWorker } from "./child-workers.js";
2
- import { type RenderOptions } from "./index.js";
3
- export { type RenderOptions } from "./index.js";
2
+ import { type RenderOptions, type WorkerPoolStats } from "./core.js";
3
+ export { type RenderOptions } from "./core.js";
4
4
  export { Satoru } from "./index.js";
5
5
  export * from "./index.js";
6
+ import { LogLevel } from "./log-level.js";
6
7
  export * from "./log-level.js";
7
8
  /**
8
9
  * Create a Satoru worker proxy using worker-lib.
@@ -11,9 +12,21 @@ export * from "./log-level.js";
11
12
  * Defaults to the bundled workers.js in the same directory.
12
13
  * @param params.maxParallel Maximum number of parallel workers
13
14
  */
15
+ /**
16
+ * Create a Satoru worker proxy using worker-lib.
17
+ * @param params Initialization parameters
18
+ * @param params.worker Optional: Path to the worker file, a URL, or a factory function.
19
+ * Defaults to the bundled workers.js in the same directory.
20
+ * @param params.maxParallel Maximum number of parallel workers (default: 4)
21
+ * @param params.timeoutMs Optional: Default timeout in milliseconds for each render job.
22
+ * If a render job times out, the pool is reset to prevent hung workers.
23
+ * @param params.onWorkerLog Optional: Global callback to intercept logs emitted by the worker threads.
24
+ */
14
25
  export declare const createSatoruWorker: (params?: {
15
26
  worker?: string | URL | (() => Worker | string | URL);
16
27
  maxParallel?: number;
28
+ timeoutMs?: number;
29
+ onWorkerLog?: (level: LogLevel, message: string) => void;
17
30
  }) => Omit<{
18
31
  execute: <K extends "render">(name: K, ...value: Parameters<{
19
32
  render(options: RenderOptions): Promise<string | Uint8Array<ArrayBufferLike>>;
@@ -27,6 +40,28 @@ export declare const createSatoruWorker: (params?: {
27
40
  launchWorker: () => Promise<void[]>;
28
41
  }, "execute"> & {
29
42
  render(options: RenderOptions): Promise<string | Uint8Array<ArrayBufferLike>>;
43
+ } & {
44
+ /**
45
+ * Retrieve operational stats of the worker pool.
46
+ */
47
+ getStats: () => WorkerPoolStats;
48
+ /**
49
+ * Reset the worker pool by terminating all running workers and recreating them.
50
+ */
51
+ reset: () => void;
52
+ /**
53
+ * Close and terminate all workers in the pool immediately.
54
+ */
55
+ close: () => void;
56
+ /**
57
+ * Wait for all running tasks in the pool to complete.
58
+ */
59
+ waitAll: () => Promise<void>;
60
+ /**
61
+ * Wait until there is an available worker slot in the pool.
62
+ */
63
+ waitReady: (retryTime?: number) => Promise<void>;
30
64
  };
31
- declare const close: () => void, render: (options: RenderOptions) => Promise<string | Uint8Array<ArrayBufferLike>>, launchWorker: () => Promise<void[]>, setLimit: (newLimit: number) => void, waitAll: () => Promise<void>, waitReady: (retryTime?: number) => Promise<void>;
32
- export { close, render, launchWorker, setLimit, waitAll, waitReady };
65
+ export declare const close: (() => void) & (() => void), render: (options: RenderOptions) => Promise<string | Uint8Array<ArrayBufferLike>>, launchWorker: () => Promise<void[]>, setLimit: (newLimit: number) => void, waitAll: (() => Promise<void>) & (() => Promise<void>), waitReady: ((retryTime?: number) => Promise<void>) & ((retryTime?: number) => Promise<void>);
66
+ export declare const reset: () => void;
67
+ export declare const getStats: () => WorkerPoolStats;