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.
package/dist/core.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { LogLevel } from "./log-level.js";
2
+ export const DIAGNOSTIC_CODES = {
3
+ LIMIT_TIMEOUT: "LIMIT_TIMEOUT",
4
+ LIMIT_RESOURCE_SIZE: "LIMIT_RESOURCE_SIZE",
5
+ LIMIT_TOTAL_SIZE: "LIMIT_TOTAL_SIZE",
6
+ LIMIT_RESOURCE_COUNT: "LIMIT_RESOURCE_COUNT",
7
+ LIMIT_PROTOCOL_BLOCKED: "LIMIT_PROTOCOL_BLOCKED",
8
+ LIMIT_HOST_BLOCKED: "LIMIT_HOST_BLOCKED",
9
+ };
2
10
  const emojiUrl = "https://cdn.jsdelivr.net/npm/@fontsource/noto-color-emoji/files/noto-color-emoji-emoji-400-normal.woff2";
3
11
  export const DEFAULT_FONT_MAP = {
4
12
  "sans-serif": "https://fonts.googleapis.com/css2?family=Noto+Sans+JP",
@@ -312,40 +320,32 @@ export class SatoruBase {
312
320
  }
313
321
  async render(options) {
314
322
  let { format = "svg", value, url, baseUrl } = options;
315
- const profileEnabled = options.profile === true;
323
+ const profileEnabled = options.profile === true || options.diagnostics === true;
316
324
  const profile = {};
317
325
  const now = () => typeof performance !== "undefined" && performance.now
318
326
  ? performance.now()
319
327
  : Date.now();
328
+ const startTime = now();
329
+ const limits = options.limits ?? {};
330
+ let totalResourceBytes = 0;
331
+ let resourceCount = 0;
320
332
  const addProfile = (name, elapsed) => {
321
333
  if (!profileEnabled)
322
334
  return;
323
335
  profile[name] = (profile[name] ?? 0) + elapsed;
324
336
  };
325
- if (format === "pdf" && Array.isArray(value) && value.length > 1) {
326
- const pagePdfs = [];
327
- const SatoruClass = this.constructor;
328
- for (const html of value) {
329
- const engine = await SatoruClass.create();
330
- const pagePdf = await engine.render({
331
- ...options,
332
- value: html,
333
- format: "pdf",
334
- });
335
- pagePdfs.push(pagePdf);
336
- }
337
- const mod = await this.getModule();
338
- const instancePtr = mod.create_instance();
339
- try {
340
- const result = mod.merge_pdfs(instancePtr, pagePdfs);
341
- if (!result)
342
- return new Uint8Array();
343
- return new Uint8Array(result);
344
- }
345
- finally {
346
- mod.destroy_instance(instancePtr);
347
- }
348
- }
337
+ const diagnosticsReport = options.diagnostics ? {
338
+ version: 1,
339
+ format: format,
340
+ width: options.width,
341
+ height: options.height,
342
+ mediaType: options.mediaType ?? "screen",
343
+ timings: profile,
344
+ resources: [],
345
+ fonts: [],
346
+ warnings: [],
347
+ errors: [],
348
+ } : null;
349
349
  let mod = await this.getModule();
350
350
  const { width, height = 0, fonts, images, css, logLevel, onLog } = options;
351
351
  if (!options.userAgent) {
@@ -370,6 +370,7 @@ export class SatoruBase {
370
370
  this.currentFontMap = options.fontMap ?? DEFAULT_FONT_MAP;
371
371
  const instancePtr = mod.create_instance();
372
372
  mod.set_font_map(instancePtr, this.currentFontMap);
373
+ mod.set_collect_profile_enabled(instancePtr, profileEnabled);
373
374
  try {
374
375
  const defaultResolver = (r) => this.resolveDefaultResource(r, baseUrl, options.userAgent);
375
376
  const resolver = options.resolveResource
@@ -380,12 +381,107 @@ export class SatoruBase {
380
381
  const cachedResolver = async (r) => {
381
382
  const cacheKey = `${r.type}:${r.url}:${r.characters ?? ""}`;
382
383
  const cached = this.resourceCache.get(cacheKey);
384
+ let resourceDiag;
385
+ if (diagnosticsReport) {
386
+ resourceDiag = {
387
+ type: r.type,
388
+ url: r.url,
389
+ name: r.name,
390
+ status: "pending",
391
+ };
392
+ diagnosticsReport.resources.push(resourceDiag);
393
+ }
383
394
  if (cached) {
384
395
  addProfile("resourceCacheHitsCount", 1);
396
+ if (resourceDiag) {
397
+ resourceDiag.status = "loaded";
398
+ if (cached instanceof Uint8Array) {
399
+ resourceDiag.bytes = cached.byteLength;
400
+ }
401
+ }
385
402
  return cached;
386
403
  }
404
+ // Apply Limits
405
+ if (limits.maxResourceCount && resourceCount >= limits.maxResourceCount) {
406
+ const msg = `Maximum resource count (${limits.maxResourceCount}) exceeded`;
407
+ if (resourceDiag) {
408
+ resourceDiag.status = "skipped";
409
+ resourceDiag.reason = msg;
410
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_COUNT, message: msg, source: r.url });
411
+ }
412
+ return null;
413
+ }
414
+ if (limits.allowedProtocols || limits.allowedHosts || limits.blockedHosts) {
415
+ try {
416
+ const urlObj = new URL(r.url);
417
+ if (limits.allowedProtocols && !limits.allowedProtocols.includes(urlObj.protocol)) {
418
+ const msg = `Protocol ${urlObj.protocol} is blocked`;
419
+ if (resourceDiag) {
420
+ resourceDiag.status = "skipped";
421
+ resourceDiag.reason = msg;
422
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_PROTOCOL_BLOCKED, message: msg, source: r.url });
423
+ }
424
+ return null;
425
+ }
426
+ if (limits.allowedHosts && !limits.allowedHosts.includes(urlObj.hostname)) {
427
+ const msg = `Host ${urlObj.hostname} is not in allowed list`;
428
+ if (resourceDiag) {
429
+ resourceDiag.status = "skipped";
430
+ resourceDiag.reason = msg;
431
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED, message: msg, source: r.url });
432
+ }
433
+ return null;
434
+ }
435
+ if (limits.blockedHosts && limits.blockedHosts.includes(urlObj.hostname)) {
436
+ const msg = `Host ${urlObj.hostname} is blocked`;
437
+ if (resourceDiag) {
438
+ resourceDiag.status = "skipped";
439
+ resourceDiag.reason = msg;
440
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_HOST_BLOCKED, message: msg, source: r.url });
441
+ }
442
+ return null;
443
+ }
444
+ }
445
+ catch (e) {
446
+ // Invalid URL - skip protocol/host checks for local/relative paths
447
+ }
448
+ }
387
449
  const resolveStart = now();
388
- const result = await resolver(r);
450
+ let result = null;
451
+ try {
452
+ result = await resolver(r);
453
+ if (resourceDiag) {
454
+ resourceDiag.status = result ? "loaded" : "failed";
455
+ if (result) {
456
+ const bytes = result instanceof Uint8Array ? result.byteLength :
457
+ result.buffer ? result.byteLength :
458
+ ("css" in result) ? result.css.byteLength + result.fonts.reduce((acc, f) => acc + f.data.byteLength, 0) : 0;
459
+ resourceDiag.bytes = bytes;
460
+ if (limits.maxResourceBytes && bytes > limits.maxResourceBytes) {
461
+ const msg = `Resource size (${bytes} bytes) exceeds limit (${limits.maxResourceBytes})`;
462
+ resourceDiag.status = "skipped";
463
+ resourceDiag.reason = msg;
464
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_RESOURCE_SIZE, message: msg, source: r.url });
465
+ return null;
466
+ }
467
+ if (limits.maxTotalResourceBytes && totalResourceBytes + bytes > limits.maxTotalResourceBytes) {
468
+ const msg = `Total resource size exceeds limit (${limits.maxTotalResourceBytes})`;
469
+ resourceDiag.status = "skipped";
470
+ resourceDiag.reason = msg;
471
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TOTAL_SIZE, message: msg, source: r.url });
472
+ return null;
473
+ }
474
+ totalResourceBytes += bytes;
475
+ resourceCount++;
476
+ }
477
+ }
478
+ }
479
+ catch (e) {
480
+ if (resourceDiag) {
481
+ resourceDiag.status = "failed";
482
+ resourceDiag.reason = e.message || String(e);
483
+ }
484
+ }
389
485
  addProfile("resolveResources", now() - resolveStart);
390
486
  if (result) {
391
487
  if (result instanceof Uint8Array) {
@@ -441,14 +537,36 @@ export class SatoruBase {
441
537
  };
442
538
  const inputHtmls = Array.isArray(value) ? value : [value];
443
539
  const processedHtmls = [];
540
+ const utf8Decoder = new TextDecoder();
541
+ const utf8Encoder = new TextEncoder();
444
542
  const resolvedResources = new Set();
445
543
  for (const rawHtml of inputHtmls) {
446
544
  let processedHtml = rawHtml;
447
545
  for (let i = 0; i < 10; i++) {
546
+ if (options.signal?.aborted) {
547
+ throw new Error("Render aborted");
548
+ }
549
+ if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
550
+ const msg = `Render timed out after ${limits.timeoutMs}ms`;
551
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT, message: msg });
552
+ throw new Error(msg);
553
+ }
448
554
  addProfile("collectResourcesCount", 1);
449
555
  const collectStart = now();
450
556
  mod.collect_resources(instancePtr, processedHtml, width, height, options.mediaType === "print" ? 1 : 0);
451
- addProfile("collectResources", now() - collectStart);
557
+ const collectElapsed = now() - collectStart;
558
+ addProfile("collectResources", collectElapsed);
559
+ addProfile(`collectResourcesRound${i + 1}`, collectElapsed);
560
+ if (profileEnabled) {
561
+ try {
562
+ const collectProfile = JSON.parse(mod.get_collect_profile(instancePtr));
563
+ for (const [key, value] of Object.entries(collectProfile)) {
564
+ addProfile(key, value);
565
+ addProfile(`${key}Round${i + 1}`, value);
566
+ }
567
+ }
568
+ catch { }
569
+ }
452
570
  const pendingStart = now();
453
571
  const binary = mod.get_pending_resources(instancePtr);
454
572
  addProfile("getPendingResources", now() - pendingStart);
@@ -465,15 +583,15 @@ export class SatoruBase {
465
583
  const redraw_on_ready = view.getUint8(offset++) !== 0;
466
584
  const urlLen = view.getUint32(offset, true);
467
585
  offset += 4;
468
- const url = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
586
+ const url = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, urlLen));
469
587
  offset += urlLen;
470
588
  const nameLen = view.getUint32(offset, true);
471
589
  offset += 4;
472
- const name = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
590
+ const name = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, nameLen));
473
591
  offset += nameLen;
474
592
  const charsLen = view.getUint32(offset, true);
475
593
  offset += 4;
476
- const characters = new TextDecoder().decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
594
+ const characters = utf8Decoder.decode(new Uint8Array(binary.buffer, binary.byteOffset + offset, charsLen));
477
595
  offset += charsLen;
478
596
  let type = "font";
479
597
  if (typeInt === 2)
@@ -484,11 +602,31 @@ export class SatoruBase {
484
602
  }
485
603
  const pending = resources.filter((r) => {
486
604
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
605
+ if (r.type === "font" && resolvedResources.has(`font:${r.url}:`)) {
606
+ return false;
607
+ }
487
608
  return !resolvedResources.has(key);
488
609
  });
610
+ addProfile("pendingResourcesCount", pending.length);
611
+ addProfile(`pendingResourcesRound${i + 1}Count`, pending.length);
612
+ for (const r of pending) {
613
+ if (r.type === "font") {
614
+ addProfile("pendingFontResourcesCount", 1);
615
+ addProfile(`pendingFontResourcesRound${i + 1}Count`, 1);
616
+ }
617
+ else if (r.type === "css") {
618
+ addProfile("pendingCssResourcesCount", 1);
619
+ addProfile(`pendingCssResourcesRound${i + 1}Count`, 1);
620
+ }
621
+ else if (r.type === "image") {
622
+ addProfile("pendingImageResourcesCount", 1);
623
+ addProfile(`pendingImageResourcesRound${i + 1}Count`, 1);
624
+ }
625
+ }
489
626
  addProfile("parsePendingResources", now() - parseStart);
490
627
  if (pending.length === 0)
491
628
  break;
629
+ addProfile("pendingResourceRoundsCount", 1);
492
630
  const loadStart = now();
493
631
  await Promise.all(pending.map(async (r) => {
494
632
  try {
@@ -497,6 +635,9 @@ export class SatoruBase {
497
635
  }
498
636
  const key = `${r.type}:${r.url}:${r.characters ?? ""}`;
499
637
  resolvedResources.add(key);
638
+ if (r.type === "font") {
639
+ resolvedResources.add(`font:${r.url}:`);
640
+ }
500
641
  const data = await cachedResolver({ ...r });
501
642
  if (!data)
502
643
  return;
@@ -523,7 +664,7 @@ export class SatoruBase {
523
664
  ? data
524
665
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
525
666
  if (r.type === "css") {
526
- const cssText = new TextDecoder().decode(finalUint8);
667
+ const cssText = utf8Decoder.decode(finalUint8);
527
668
  const isAbsolute = /^[a-z][a-z0-9+.-]*:/i.test(r.url) ||
528
669
  r.url.startsWith("data:");
529
670
  let cssBaseUrl = r.url;
@@ -551,7 +692,7 @@ export class SatoruBase {
551
692
  return match;
552
693
  }
553
694
  });
554
- finalUint8 = new TextEncoder().encode(rewrittenCss);
695
+ finalUint8 = utf8Encoder.encode(rewrittenCss);
555
696
  }
556
697
  }
557
698
  await loadResourceData(r, finalUint8);
@@ -586,6 +727,14 @@ export class SatoruBase {
586
727
  pdf: 3,
587
728
  };
588
729
  const renderStart = now();
730
+ if (options.signal?.aborted) {
731
+ throw new Error("Render aborted");
732
+ }
733
+ if (typeof limits.timeoutMs !== "undefined" && now() - startTime >= limits.timeoutMs) {
734
+ const msg = `Render timed out after ${limits.timeoutMs}ms`;
735
+ diagnosticsReport?.errors.push({ code: DIAGNOSTIC_CODES.LIMIT_TIMEOUT, message: msg });
736
+ throw new Error(msg);
737
+ }
589
738
  const result = (processedHtmls.length === 1)
590
739
  ? mod.render_from_state(instancePtr, width, height, formatMap[format] ?? 0, {
591
740
  svgTextToPaths: options.textToPaths ?? true,
@@ -600,6 +749,18 @@ export class SatoruBase {
600
749
  fitPositionY: options.fitPosition?.y ?? 0.5,
601
750
  backgroundColor: this.parseColor(options.backgroundColor),
602
751
  mediaType: options.mediaType === "print" ? 1 : 0,
752
+ pdfTitle: options.pdfTitle ?? "",
753
+ pdfAuthor: options.pdfAuthor ?? "",
754
+ pdfSubject: options.pdfSubject ?? "",
755
+ pdfKeywords: options.pdfKeywords ?? "",
756
+ pdfCreator: options.pdfCreator ?? "",
757
+ pdfProducer: options.pdfProducer ?? "",
758
+ pdfMarginTop: options.pdfMargin?.top ?? 0,
759
+ pdfMarginRight: options.pdfMargin?.right ?? 0,
760
+ pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
761
+ pdfMarginLeft: options.pdfMargin?.left ?? 0,
762
+ pdfHeader: options.pdfHeader ?? "",
763
+ pdfFooter: options.pdfFooter ?? "",
603
764
  })
604
765
  : mod.render(instancePtr, processedHtmls, width, height, formatMap[format] ?? 0, {
605
766
  svgTextToPaths: options.textToPaths ?? true,
@@ -614,25 +775,58 @@ export class SatoruBase {
614
775
  fitPositionY: options.fitPosition?.y ?? 0.5,
615
776
  backgroundColor: this.parseColor(options.backgroundColor),
616
777
  mediaType: options.mediaType === "print" ? 1 : 0,
778
+ pdfTitle: options.pdfTitle ?? "",
779
+ pdfAuthor: options.pdfAuthor ?? "",
780
+ pdfSubject: options.pdfSubject ?? "",
781
+ pdfKeywords: options.pdfKeywords ?? "",
782
+ pdfCreator: options.pdfCreator ?? "",
783
+ pdfProducer: options.pdfProducer ?? "",
784
+ pdfMarginTop: options.pdfMargin?.top ?? 0,
785
+ pdfMarginRight: options.pdfMargin?.right ?? 0,
786
+ pdfMarginBottom: options.pdfMargin?.bottom ?? 0,
787
+ pdfMarginLeft: options.pdfMargin?.left ?? 0,
788
+ pdfHeader: options.pdfHeader ?? "",
789
+ pdfFooter: options.pdfFooter ?? "",
617
790
  });
618
791
  addProfile("wasmRender", now() - renderStart);
619
792
  if (!result) {
620
793
  options.onProfile?.(profile);
794
+ if (diagnosticsReport) {
795
+ try {
796
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
797
+ }
798
+ catch { }
799
+ options.onDiagnostics?.(diagnosticsReport);
800
+ }
621
801
  if (format === "svg")
622
802
  return "";
623
803
  return new Uint8Array();
624
804
  }
625
805
  if (format === "svg") {
626
806
  const decodeStart = now();
627
- const svg = new TextDecoder().decode(result);
807
+ const svg = utf8Decoder.decode(result);
628
808
  addProfile("decodeResult", now() - decodeStart);
629
809
  options.onProfile?.(profile);
810
+ if (diagnosticsReport) {
811
+ try {
812
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
813
+ }
814
+ catch { }
815
+ options.onDiagnostics?.(diagnosticsReport);
816
+ }
630
817
  return svg;
631
818
  }
632
819
  const copyStart = now();
633
820
  const bytes = new Uint8Array(result);
634
821
  addProfile("copyResult", now() - copyStart);
635
822
  options.onProfile?.(profile);
823
+ if (diagnosticsReport) {
824
+ try {
825
+ diagnosticsReport.fonts = JSON.parse(mod.get_font_diagnostics(instancePtr));
826
+ }
827
+ catch { }
828
+ options.onDiagnostics?.(diagnosticsReport);
829
+ }
636
830
  return bytes;
637
831
  }
638
832
  finally {
@@ -0,0 +1,60 @@
1
+ import { ResourceResolver, ResolvedFontResult } from "./core.js";
2
+ export interface ResourceCacheEntry {
3
+ data: Uint8Array | ResolvedFontResult;
4
+ bytes: number;
5
+ created: number;
6
+ lastHit: number;
7
+ }
8
+ export interface ResourceCacheOptions {
9
+ /** Maximum number of entries to keep in cache. Default: 100 */
10
+ maxEntries?: number;
11
+ /** Maximum total bytes to keep in cache. Default: 64MB */
12
+ maxBytes?: number;
13
+ /** Time to live in milliseconds. Default: 0 (no TTL) */
14
+ ttl?: number;
15
+ }
16
+ /**
17
+ * In-memory resource cache that works in all environments.
18
+ */
19
+ export declare class MemoryResourceCache {
20
+ private cache;
21
+ private totalBytes;
22
+ private options;
23
+ constructor(options?: ResourceCacheOptions);
24
+ get(url: string): Promise<Uint8Array | ResolvedFontResult | null>;
25
+ set(url: string, data: Uint8Array | ResolvedFontResult): Promise<void>;
26
+ private delete;
27
+ private evict;
28
+ private calculateBytes;
29
+ /**
30
+ * Returns a ResourceResolver that wraps another resolver (or the default one) with this cache.
31
+ */
32
+ wrap(resolver?: ResourceResolver): ResourceResolver;
33
+ /**
34
+ * Clear the cache.
35
+ */
36
+ clear(): void;
37
+ /**
38
+ * Get cache statistics.
39
+ */
40
+ getStats(): {
41
+ entries: number;
42
+ totalBytes: number;
43
+ };
44
+ }
45
+ /**
46
+ * Composes multiple resource resolvers into a single one.
47
+ * They are executed in order; each one can choose to call the next one in the chain.
48
+ */
49
+ export declare function composeResourceResolvers(...resolvers: ResourceResolver[]): ResourceResolver;
50
+ /**
51
+ * CacheStorage adapter for browser and Cloudflare Workers.
52
+ */
53
+ export declare class CacheStorageResourceCache {
54
+ private cacheName;
55
+ constructor(cacheName?: string);
56
+ private getCache;
57
+ get(url: string): Promise<Uint8Array | null>;
58
+ set(url: string, data: Uint8Array): Promise<void>;
59
+ wrap(resolver?: ResourceResolver): ResourceResolver;
60
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * In-memory resource cache that works in all environments.
3
+ */
4
+ export class MemoryResourceCache {
5
+ cache = new Map();
6
+ totalBytes = 0;
7
+ options;
8
+ constructor(options = {}) {
9
+ this.options = {
10
+ maxEntries: options.maxEntries ?? 100,
11
+ maxBytes: options.maxBytes ?? 64 * 1024 * 1024,
12
+ ttl: options.ttl ?? 0,
13
+ };
14
+ }
15
+ async get(url) {
16
+ const entry = this.cache.get(url);
17
+ if (!entry)
18
+ return null;
19
+ if (this.options.ttl && Date.now() - entry.created > this.options.ttl) {
20
+ this.delete(url);
21
+ return null;
22
+ }
23
+ entry.lastHit = Date.now();
24
+ return entry.data;
25
+ }
26
+ async set(url, data) {
27
+ const bytes = this.calculateBytes(data);
28
+ if (this.options.maxBytes && bytes > this.options.maxBytes)
29
+ return;
30
+ this.evict(bytes);
31
+ this.cache.set(url, {
32
+ data,
33
+ bytes,
34
+ created: Date.now(),
35
+ lastHit: Date.now(),
36
+ });
37
+ this.totalBytes += bytes;
38
+ }
39
+ delete(url) {
40
+ const entry = this.cache.get(url);
41
+ if (entry) {
42
+ this.totalBytes -= entry.bytes;
43
+ this.cache.delete(url);
44
+ }
45
+ }
46
+ evict(incomingBytes) {
47
+ if (this.cache.size >= this.options.maxEntries || (this.options.maxBytes && this.totalBytes + incomingBytes > this.options.maxBytes)) {
48
+ const sorted = Array.from(this.cache.entries()).sort((a, b) => a[1].lastHit - b[1].lastHit);
49
+ for (const [url, entry] of sorted) {
50
+ this.delete(url);
51
+ if (this.cache.size < this.options.maxEntries && (!this.options.maxBytes || this.totalBytes + incomingBytes <= this.options.maxBytes)) {
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ }
57
+ calculateBytes(data) {
58
+ if (data instanceof Uint8Array)
59
+ return data.byteLength;
60
+ let sum = data.css.byteLength;
61
+ for (const f of data.fonts)
62
+ sum += f.data.byteLength;
63
+ return sum;
64
+ }
65
+ /**
66
+ * Returns a ResourceResolver that wraps another resolver (or the default one) with this cache.
67
+ */
68
+ wrap(resolver) {
69
+ return async (resource, defaultResolver) => {
70
+ const cached = await this.get(resource.url);
71
+ if (cached)
72
+ return cached;
73
+ const result = await (resolver ? resolver(resource, defaultResolver) : defaultResolver(resource));
74
+ if (result) {
75
+ // Normalize to Uint8Array if it's an ArrayBufferView but not ResolvedFontResult
76
+ let dataToCache;
77
+ if (typeof result.css !== 'undefined') {
78
+ dataToCache = result;
79
+ }
80
+ else {
81
+ const view = result;
82
+ dataToCache = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
83
+ }
84
+ await this.set(resource.url, dataToCache);
85
+ }
86
+ return result;
87
+ };
88
+ }
89
+ /**
90
+ * Clear the cache.
91
+ */
92
+ clear() {
93
+ this.cache.clear();
94
+ this.totalBytes = 0;
95
+ }
96
+ /**
97
+ * Get cache statistics.
98
+ */
99
+ getStats() {
100
+ return {
101
+ entries: this.cache.size,
102
+ totalBytes: this.totalBytes,
103
+ };
104
+ }
105
+ }
106
+ /**
107
+ * Composes multiple resource resolvers into a single one.
108
+ * They are executed in order; each one can choose to call the next one in the chain.
109
+ */
110
+ export function composeResourceResolvers(...resolvers) {
111
+ return (resource, defaultResolver) => {
112
+ const run = (i) => {
113
+ const resolver = resolvers[i];
114
+ if (resolver) {
115
+ return resolver(resource, (r) => run(i + 1));
116
+ }
117
+ return defaultResolver(resource);
118
+ };
119
+ return run(0);
120
+ };
121
+ }
122
+ /**
123
+ * CacheStorage adapter for browser and Cloudflare Workers.
124
+ */
125
+ export class CacheStorageResourceCache {
126
+ cacheName;
127
+ constructor(cacheName = "satoru-resources") {
128
+ this.cacheName = cacheName;
129
+ }
130
+ async getCache() {
131
+ return await caches.open(this.cacheName);
132
+ }
133
+ async get(url) {
134
+ try {
135
+ const cache = await this.getCache();
136
+ const response = await cache.match(url);
137
+ if (response) {
138
+ return new Uint8Array(await response.arrayBuffer());
139
+ }
140
+ }
141
+ catch (e) {
142
+ console.warn("CacheStorage error:", e);
143
+ }
144
+ return null;
145
+ }
146
+ async set(url, data) {
147
+ try {
148
+ const cache = await this.getCache();
149
+ await cache.put(url, new Response(data, {
150
+ headers: { "Content-Type": "application/octet-stream" }
151
+ }));
152
+ }
153
+ catch (e) {
154
+ console.warn("CacheStorage error:", e);
155
+ }
156
+ }
157
+ wrap(resolver) {
158
+ return async (resource, defaultResolver) => {
159
+ // Note: CacheStorage only supports Uint8Array easily, not ResolvedFontResult
160
+ if (resource.type === "font") {
161
+ // Fonts are complex because they might return multiple files.
162
+ // For now we don't cache ResolvedFontResult in CacheStorage.
163
+ return resolver ? resolver(resource, defaultResolver) : defaultResolver(resource);
164
+ }
165
+ const cached = await this.get(resource.url);
166
+ if (cached)
167
+ return cached;
168
+ const result = await (resolver ? resolver(resource, defaultResolver) : defaultResolver(resource));
169
+ if (result && result instanceof Uint8Array) {
170
+ await this.set(resource.url, result);
171
+ }
172
+ return result;
173
+ };
174
+ }
175
+ }
Binary file