@gmickel/gno 1.29.6 → 1.30.4

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.
Files changed (36) hide show
  1. package/README.md +15 -5
  2. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.30.4.zip.sha256 +1 -0
  4. package/browser-extension/dist/chunk-627emwpj.js +75 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/browser-extension/dist/preview.html +1 -1
  7. package/browser-extension/dist/service-worker.js +47 -22
  8. package/package.json +42 -39
  9. package/src/converters/adapters/officeparser/adapter.ts +7 -3
  10. package/src/core/network-boundary-inventory.ts +51 -0
  11. package/src/core/project-profile.ts +2 -2
  12. package/src/publish/encrypted-export.ts +1 -1
  13. package/src/serve/AGENTS.md +5 -1
  14. package/src/serve/CLAUDE.md +5 -1
  15. package/src/serve/fn112-routes.ts +232 -0
  16. package/src/serve/pdfjs-assets.ts +391 -0
  17. package/src/serve/public/components/ai-elements/code-block.tsx +28 -10
  18. package/src/serve/public/components/pdf/PdfPageView.tsx +428 -0
  19. package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
  20. package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
  21. package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
  22. package/src/serve/public/globals.built.css +2 -2
  23. package/src/serve/public/globals.css +113 -0
  24. package/src/serve/public/hooks/use-pdf-document.ts +199 -0
  25. package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
  26. package/src/serve/public/lib/doc-asset-url.ts +57 -0
  27. package/src/serve/public/lib/math-sum-precise.ts +34 -0
  28. package/src/serve/public/lib/pdf.ts +772 -0
  29. package/src/serve/public/pages/DocView.tsx +295 -39
  30. package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
  31. package/src/serve/routes/api.ts +154 -14
  32. package/src/serve/server.ts +190 -37
  33. package/src/serve/spa-bundle-source.ts +99 -0
  34. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip +0 -0
  35. package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
  36. package/browser-extension/dist/chunk-b2zm0jjd.js +0 -50
@@ -0,0 +1,772 @@
1
+ /**
2
+ * Single pdfjs-dist import facade for the GNO native PDF viewer.
3
+ * Runtime and type imports of pdfjs-dist are allowed ONLY in this module.
4
+ */
5
+
6
+ import "./math-sum-precise";
7
+ import {
8
+ getDocument as pdfjsGetDocument,
9
+ GlobalWorkerOptions,
10
+ PasswordResponses,
11
+ TextLayer,
12
+ type PDFDocumentLoadingTask,
13
+ type PDFDocumentProxy,
14
+ type PDFPageProxy,
15
+ type RenderTask,
16
+ type PageViewport,
17
+ } from "pdfjs-dist";
18
+
19
+ // ── Worker / asset bootstrap ────────────────────────────────────────────────
20
+ //
21
+ // Browser (and Electrobun webview) production: same-origin /vendor/pdfjs/* routes.
22
+ // Bun/Node unit tests: resolve assets from the installed pdfjs-dist package so
23
+ // real getDocument loads succeed without a running gno serve. Detection uses
24
+ // `typeof Bun` — never present in real browser bundles — so browser CSP/offline
25
+ // invariants stay intact.
26
+
27
+ function useLocalPdfjsPackageAssets(): boolean {
28
+ return typeof Bun !== "undefined";
29
+ }
30
+
31
+ function packageSiblingDirUrl(sampleSpecifier: string): string {
32
+ // import.meta.resolve returns a file:// URL in Bun; strip the filename.
33
+ const resolved = import.meta.resolve(sampleSpecifier);
34
+ return resolved.replace(/[^/]+$/u, "");
35
+ }
36
+
37
+ const BROWSER_WORKER_SRC = "/vendor/pdfjs/pdf.worker.min.mjs";
38
+ const BROWSER_CMAP_URL = "/vendor/pdfjs/cmaps/";
39
+ const BROWSER_STANDARD_FONT_URL = "/vendor/pdfjs/standard_fonts/";
40
+
41
+ function resolveWorkerSrc(): string {
42
+ if (!useLocalPdfjsPackageAssets()) {
43
+ return BROWSER_WORKER_SRC;
44
+ }
45
+ try {
46
+ return import.meta.resolve("pdfjs-dist/build/pdf.worker.min.mjs");
47
+ } catch {
48
+ return BROWSER_WORKER_SRC;
49
+ }
50
+ }
51
+
52
+ function resolveCMapUrl(): string {
53
+ if (!useLocalPdfjsPackageAssets()) {
54
+ return BROWSER_CMAP_URL;
55
+ }
56
+ try {
57
+ return packageSiblingDirUrl("pdfjs-dist/cmaps/UniJIS-UCS2-H.bcmap");
58
+ } catch {
59
+ return BROWSER_CMAP_URL;
60
+ }
61
+ }
62
+
63
+ function resolveStandardFontDataUrl(): string {
64
+ if (!useLocalPdfjsPackageAssets()) {
65
+ return BROWSER_STANDARD_FONT_URL;
66
+ }
67
+ try {
68
+ return packageSiblingDirUrl(
69
+ "pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf"
70
+ );
71
+ } catch {
72
+ return BROWSER_STANDARD_FONT_URL;
73
+ }
74
+ }
75
+
76
+ /** Same-origin worker in browsers; package-local worker under Bun tests. */
77
+ GlobalWorkerOptions.workerSrc = resolveWorkerSrc();
78
+
79
+ const CMAP_URL = resolveCMapUrl();
80
+ const STANDARD_FONT_DATA_URL = resolveStandardFontDataUrl();
81
+
82
+ // ── Public types ────────────────────────────────────────────────────────────
83
+
84
+ export type PdfFallbackReason =
85
+ | "corrupt"
86
+ | "password"
87
+ | "network"
88
+ | "bootstrap";
89
+
90
+ /**
91
+ * Compatible render-params shape for page.render().
92
+ * Not re-exported from the pdfjs-dist package root in v5.7.x — defined here so
93
+ * downstream modules never import pdfjs-dist directly for this type.
94
+ */
95
+ export type RenderParameters = {
96
+ canvasContext: CanvasRenderingContext2D;
97
+ viewport: PageViewport;
98
+ canvas?: HTMLCanvasElement | null;
99
+ intent?: string;
100
+ background?: string | CanvasGradient | CanvasPattern | null;
101
+ transform?: number[] | null;
102
+ };
103
+
104
+ export type {
105
+ PDFDocumentProxy,
106
+ PDFPageProxy,
107
+ RenderTask,
108
+ PageViewport,
109
+ PDFDocumentLoadingTask,
110
+ };
111
+
112
+ export { TextLayer };
113
+
114
+ // Annotation shape used by the link layer (subset of pdfjs annotation dict).
115
+ export type PdfAnnotation = {
116
+ subtype?: string;
117
+ annotationType?: number;
118
+ url?: string;
119
+ unsafeUrl?: string;
120
+ dest?: unknown;
121
+ rect?: [number, number, number, number];
122
+ newWindow?: boolean;
123
+ };
124
+
125
+ // ── Document load wrapper ───────────────────────────────────────────────────
126
+
127
+ export type GnoGetDocumentParams = {
128
+ url: string;
129
+ // Intentionally NO caller-controlled document id — every load mints a fresh
130
+ // opaque instance id internally (I2-6 / Sol rereview).
131
+ };
132
+
133
+ /**
134
+ * Loading task augmented with the opaque per-load document instance id.
135
+ * `gnoDocId` is never derived from URL/path/URI/filename/title/content and is
136
+ * never caller-supplied.
137
+ */
138
+ export type GnoDocumentLoadingTask = PDFDocumentLoadingTask & {
139
+ readonly gnoDocId: string;
140
+ };
141
+
142
+ /**
143
+ * Create a pdfjs loading task and always mint a distinct opaque doc instance id.
144
+ * Two calls for the same URL always get different gnoDocId values.
145
+ */
146
+ /**
147
+ * `globalThis.pdfjsWorker` is a single process-wide slot that every pdfjs-dist
148
+ * copy reads when setting up a fake (non-Worker) worker. Whichever copy imports
149
+ * its worker first wins for all of them — so a transitive dependency's older
150
+ * pdfjs-dist (e.g. `pdf-parse`'s 5.4.x) can install a worker that our pinned
151
+ * API then rejects with "API version does not match the Worker version".
152
+ *
153
+ * Clearing the slot before each load makes pdf.js import *our* `workerSrc`, so
154
+ * the worker always matches this module's API. The dynamic import is module
155
+ * cached, so this costs nothing after the first load. Guarded to the Bun path:
156
+ * browsers use a real Worker and never populate this global.
157
+ */
158
+ function releaseForeignFakeWorkerGlobal(): void {
159
+ if (!useLocalPdfjsPackageAssets()) {
160
+ return;
161
+ }
162
+ const g = globalThis as { pdfjsWorker?: unknown };
163
+ if (g.pdfjsWorker !== undefined) {
164
+ g.pdfjsWorker = undefined;
165
+ }
166
+ }
167
+
168
+ export function getDocument(
169
+ params: GnoGetDocumentParams
170
+ ): GnoDocumentLoadingTask {
171
+ releaseForeignFakeWorkerGlobal();
172
+ // Always mint — no override path exists on the public API.
173
+ const gnoDocId = getPdfMetrics().mintDocId();
174
+ const loadingTask = pdfjsGetDocument({
175
+ url: params.url,
176
+ cMapUrl: CMAP_URL,
177
+ cMapPacked: true,
178
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
179
+ // Browser default useSystemFonts=true substitutes OS Helvetica and never
180
+ // hits standardFontDataUrl, breaking the offline standard-font contract.
181
+ // Force pdfjs-dist standard_fonts/* over same-origin routes instead.
182
+ useSystemFonts: false,
183
+ // Range-mode loading (product policy, not a test accommodation).
184
+ // With disableStream, pdfjs-dist cancels the full-body reader as soon as
185
+ // response headers arrive when ranges are supported, so every subsequent
186
+ // byte is fetched as a discrete Range request (pdf.mjs PDFFetchStreamReader).
187
+ // disableAutoFetch keeps pdf.js from eagerly pulling the remaining chunks —
188
+ // correct for a windowed/virtualized viewer that only paints a bounded page
189
+ // window. Range eligibility still requires Content-Length > 2×rangeChunkSize
190
+ // plus Accept-Ranges: bytes (already emitted by GET /api/doc-asset).
191
+ disableStream: true,
192
+ disableAutoFetch: true,
193
+ // Never enable embedded PDF scripting.
194
+ // enableScripting is intentionally omitted (defaults false).
195
+ // pdfjs v5 removed the former eval-support flag; CSP enforces no unsafe-eval.
196
+ }) as GnoDocumentLoadingTask;
197
+ let rejectPassword!: (error: Error) => void;
198
+ const passwordFailure = new Promise<PDFDocumentProxy>((_resolve, reject) => {
199
+ rejectPassword = reject;
200
+ });
201
+ const productPromise = Promise.race([loadingTask.promise, passwordFailure]);
202
+ // PDFDocumentLoadingTask exposes promise as a prototype getter. Shadow it on
203
+ // this facade-owned instance so GNO can model password cancellation without
204
+ // reaching into pdf.js's private capability fields.
205
+ Object.defineProperty(loadingTask, "promise", {
206
+ value: productPromise,
207
+ writable: false,
208
+ enumerable: true,
209
+ configurable: false,
210
+ });
211
+ // GNO does not collect PDF passwords. Without an onPassword callback pdf.js
212
+ // intentionally leaves loadingTask.promise pending forever while it waits for
213
+ // UI input. Reject the facade promise so the existing password state/fallback
214
+ // renders deterministically; usePdfDocument then destroys the waiting task.
215
+ loadingTask.onPassword = (
216
+ updatePassword: (password: string | Error) => void,
217
+ reason: number
218
+ ): void => {
219
+ const error = new Error(
220
+ `Password-protected PDF requires an external reader (${reason || PasswordResponses.NEED_PASSWORD})`
221
+ );
222
+ error.name = "PasswordException";
223
+ updatePassword(error);
224
+ rejectPassword(error);
225
+ };
226
+ Object.defineProperty(loadingTask, "gnoDocId", {
227
+ value: gnoDocId,
228
+ writable: false,
229
+ enumerable: true,
230
+ configurable: false,
231
+ });
232
+ return loadingTask;
233
+ }
234
+
235
+ // ── Error classification ────────────────────────────────────────────────────
236
+
237
+ function errorName(err: unknown): string {
238
+ if (err && typeof err === "object" && "name" in err) {
239
+ const name = (err as { name?: unknown }).name;
240
+ if (typeof name === "string") {
241
+ return name;
242
+ }
243
+ }
244
+ return "";
245
+ }
246
+
247
+ function errorMessage(err: unknown): string {
248
+ if (err instanceof Error) {
249
+ return err.message;
250
+ }
251
+ if (typeof err === "string") {
252
+ return err;
253
+ }
254
+ if (err == null) {
255
+ return "";
256
+ }
257
+ try {
258
+ return JSON.stringify(err);
259
+ } catch {
260
+ return "unknown error";
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Classify a document-load rejection.
266
+ * `"bootstrap"` is only for worker startup / document-load bootstrap failures.
267
+ * Auxiliary cMap/standard-font 404s do not necessarily reject getDocument.
268
+ */
269
+ export function classifyPdfError(err: unknown): PdfFallbackReason {
270
+ const name = errorName(err);
271
+ const msg = errorMessage(err).toLowerCase();
272
+
273
+ if (
274
+ name === "PasswordException" ||
275
+ msg.includes("password") ||
276
+ msg.includes("needpassword")
277
+ ) {
278
+ return "password";
279
+ }
280
+
281
+ if (
282
+ name === "InvalidPDFException" ||
283
+ msg.includes("invalid pdf") ||
284
+ msg.includes("invalidpdf") ||
285
+ msg.includes("missing pdf") ||
286
+ msg.includes("corrupted")
287
+ ) {
288
+ return "corrupt";
289
+ }
290
+
291
+ // Worker bootstrap / missing worker / module worker failure
292
+ if (
293
+ msg.includes("worker") ||
294
+ msg.includes("setting up fake worker") ||
295
+ msg.includes("failed to fetch dynamically imported module") ||
296
+ msg.includes("pdf.worker") ||
297
+ msg.includes("cannot load") ||
298
+ (name === "UnknownErrorException" && msg.includes("worker"))
299
+ ) {
300
+ return "bootstrap";
301
+ }
302
+
303
+ // Network / HTTP errors from doc-asset fetch
304
+ if (
305
+ name === "ResponseException" ||
306
+ msg.includes("network") ||
307
+ msg.includes("fetch") ||
308
+ msg.includes("failed to fetch") ||
309
+ msg.includes("http status") ||
310
+ msg.includes("status code") ||
311
+ /\b(4\d\d|5\d\d)\b/.test(msg)
312
+ ) {
313
+ return "network";
314
+ }
315
+
316
+ // Default: treat unknown load failures as corrupt for actionable UI
317
+ if (name === "InvalidPDFException") {
318
+ return "corrupt";
319
+ }
320
+
321
+ // Missing worker often surfaces as generic Error after 404
322
+ if (msg.includes("unexpected server response") || msg.includes("404")) {
323
+ return "network";
324
+ }
325
+
326
+ return "corrupt";
327
+ }
328
+
329
+ export function isRenderingCancelled(err: unknown): boolean {
330
+ const name = errorName(err);
331
+ if (name === "RenderingCancelledException") {
332
+ return true;
333
+ }
334
+ const msg = errorMessage(err).toLowerCase();
335
+ return msg.includes("rendering cancelled") || msg.includes("cancelled");
336
+ }
337
+
338
+ // ── Zoom / fit / canvas cap math ────────────────────────────────────────────
339
+
340
+ export const MIN_ZOOM = 0.25;
341
+ export const MAX_ZOOM = 4;
342
+ export const ZOOM_STEP = 0.1;
343
+ export const DEFAULT_ZOOM = 1;
344
+ /** Cap device pixel ratio contribution (pdfjs does not do this for us). */
345
+ export const MAX_DEVICE_PIXEL_RATIO = 2;
346
+ /**
347
+ * Max canvas pixel area (width*height). Guard against 8K-wide pages.
348
+ * ~16 megapixels is a safe desktop bound.
349
+ */
350
+ export const MAX_CANVAS_PIXELS = 16_777_216;
351
+
352
+ export function clampZoom(zoom: number): number {
353
+ if (!Number.isFinite(zoom)) {
354
+ return DEFAULT_ZOOM;
355
+ }
356
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
357
+ }
358
+
359
+ export function stepZoom(zoom: number, direction: 1 | -1): number {
360
+ const next = zoom + direction * ZOOM_STEP;
361
+ // Snap to 1 decimal place to avoid float drift
362
+ return clampZoom(Math.round(next * 100) / 100);
363
+ }
364
+
365
+ export type ViewportDims = { width: number; height: number };
366
+ export type ContainerDims = { width: number; height: number };
367
+
368
+ /** Scale so page width fits the container (rotation-aware via viewport dims). */
369
+ export function fitWidthScale(
370
+ viewport: ViewportDims,
371
+ container: ContainerDims,
372
+ baseScale = 1
373
+ ): number {
374
+ if (viewport.width <= 0 || container.width <= 0) {
375
+ return DEFAULT_ZOOM;
376
+ }
377
+ const atBase = viewport.width / baseScale;
378
+ return clampZoom(container.width / atBase);
379
+ }
380
+
381
+ /** Scale so the whole page fits in the container. */
382
+ export function fitPageScale(
383
+ viewport: ViewportDims,
384
+ container: ContainerDims,
385
+ baseScale = 1
386
+ ): number {
387
+ if (
388
+ viewport.width <= 0 ||
389
+ viewport.height <= 0 ||
390
+ container.width <= 0 ||
391
+ container.height <= 0
392
+ ) {
393
+ return DEFAULT_ZOOM;
394
+ }
395
+ const atBaseW = viewport.width / baseScale;
396
+ const atBaseH = viewport.height / baseScale;
397
+ const sx = container.width / atBaseW;
398
+ const sy = container.height / atBaseH;
399
+ return clampZoom(Math.min(sx, sy));
400
+ }
401
+
402
+ export type EffectiveScaleInput = {
403
+ zoom: number;
404
+ devicePixelRatio: number;
405
+ /** CSS-pixel page width at the logical zoom (viewport.width). */
406
+ cssWidth: number;
407
+ /** CSS-pixel page height at the logical zoom (viewport.height). */
408
+ cssHeight: number;
409
+ maxCanvasPixels?: number;
410
+ maxDevicePixelRatio?: number;
411
+ };
412
+
413
+ export type EffectiveScaleResult = {
414
+ /** Scale passed to pdfjs render (includes DPR and area clamp). */
415
+ renderScale: number;
416
+ /** Logical CSS scale (zoom only). */
417
+ cssScale: number;
418
+ canvasWidth: number;
419
+ canvasHeight: number;
420
+ dpr: number;
421
+ };
422
+
423
+ /**
424
+ * Effective render scale = min(dpr, 2) * zoom, then area-clamped so
425
+ * canvasWidth * canvasHeight <= maxCanvasPixels.
426
+ */
427
+ export function computeEffectiveScale(
428
+ input: EffectiveScaleInput
429
+ ): EffectiveScaleResult {
430
+ const zoom = clampZoom(input.zoom);
431
+ const maxDpr = input.maxDevicePixelRatio ?? MAX_DEVICE_PIXEL_RATIO;
432
+ const maxPixels = input.maxCanvasPixels ?? MAX_CANVAS_PIXELS;
433
+ const dpr = Math.min(Math.max(input.devicePixelRatio || 1, 1), maxDpr);
434
+
435
+ let renderScale = zoom * dpr;
436
+ let canvasWidth = Math.max(
437
+ 1,
438
+ Math.floor(
439
+ input.cssWidth * (dpr / (input.zoom > 0 ? input.zoom / zoom : 1))
440
+ )
441
+ );
442
+ // Prefer deriving from css dims at logical zoom:
443
+ // cssWidth/cssHeight are already at `zoom` scale from getViewport({scale: zoom}).
444
+ canvasWidth = Math.max(1, Math.floor(input.cssWidth * (renderScale / zoom)));
445
+ let canvasHeight = Math.max(
446
+ 1,
447
+ Math.floor(input.cssHeight * (renderScale / zoom))
448
+ );
449
+
450
+ const area = canvasWidth * canvasHeight;
451
+ if (area > maxPixels) {
452
+ const factor = Math.sqrt(maxPixels / area);
453
+ renderScale = renderScale * factor;
454
+ canvasWidth = Math.max(
455
+ 1,
456
+ Math.floor(input.cssWidth * (renderScale / zoom))
457
+ );
458
+ canvasHeight = Math.max(
459
+ 1,
460
+ Math.floor(input.cssHeight * (renderScale / zoom))
461
+ );
462
+ }
463
+
464
+ return {
465
+ renderScale,
466
+ cssScale: zoom,
467
+ canvasWidth,
468
+ canvasHeight,
469
+ dpr,
470
+ };
471
+ }
472
+
473
+ // ── Link annotation sanitizer ───────────────────────────────────────────────
474
+
475
+ /**
476
+ * Allowlist external annotation URLs to http(s) only.
477
+ * Returns null for javascript:, file:, data:, relative, or empty.
478
+ */
479
+ export function sanitizeAnnotationUrl(url: string): string | null {
480
+ if (typeof url !== "string") {
481
+ return null;
482
+ }
483
+ const trimmed = url.trim();
484
+ if (!trimmed) {
485
+ return null;
486
+ }
487
+ try {
488
+ // Absolute URLs only — relative schemes rejected
489
+ const parsed = new URL(trimmed);
490
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
491
+ return parsed.href;
492
+ }
493
+ return null;
494
+ } catch {
495
+ return null;
496
+ }
497
+ }
498
+
499
+ // ── Metrics channel (__gnoPdfMetrics) ───────────────────────────────────────
500
+
501
+ export type PdfMetricKind =
502
+ | "renderStart"
503
+ | "renderCancel"
504
+ | "renderSettle"
505
+ | "pageCleanup"
506
+ | "documentDestroy";
507
+
508
+ export type PdfMetricOutcome = "completed" | "cancelled" | "failed";
509
+
510
+ export type PdfMetricEvent = {
511
+ seq: number;
512
+ t: number;
513
+ docId: string;
514
+ pageNumber: number | null;
515
+ taskId: string | null;
516
+ genId: number | null;
517
+ kind: PdfMetricKind;
518
+ outcome: PdfMetricOutcome | null;
519
+ scale: number | null;
520
+ canvasWidth: number | null;
521
+ canvasHeight: number | null;
522
+ };
523
+
524
+ export type PdfMetricsSnapshotMeta = {
525
+ capacity: number;
526
+ dropped: number;
527
+ seqHigh: number;
528
+ t0Epoch: number;
529
+ };
530
+
531
+ export type PdfMetricsSnapshot = PdfMetricsSnapshotMeta & {
532
+ events: readonly PdfMetricEvent[];
533
+ };
534
+
535
+ const DEFAULT_METRICS_CAPACITY = 2000;
536
+
537
+ type MetricsState = {
538
+ capacity: number;
539
+ dropped: number;
540
+ seq: number;
541
+ t0Epoch: number;
542
+ t0Perf: number;
543
+ events: PdfMetricEvent[];
544
+ docCounter: number;
545
+ taskCounter: number;
546
+ genByDoc: Map<string, number>;
547
+ };
548
+
549
+ function nowPerf(): number {
550
+ if (
551
+ typeof performance !== "undefined" &&
552
+ typeof performance.now === "function"
553
+ ) {
554
+ return performance.now();
555
+ }
556
+ return Date.now();
557
+ }
558
+
559
+ function createMetricsState(capacity = DEFAULT_METRICS_CAPACITY): MetricsState {
560
+ return {
561
+ capacity: Math.max(1, capacity),
562
+ dropped: 0,
563
+ seq: 0,
564
+ t0Epoch: Date.now(),
565
+ t0Perf: nowPerf(),
566
+ events: [],
567
+ docCounter: 0,
568
+ taskCounter: 0,
569
+ genByDoc: new Map(),
570
+ };
571
+ }
572
+
573
+ function pushEvent(
574
+ state: MetricsState,
575
+ partial: Omit<PdfMetricEvent, "seq" | "t">
576
+ ): PdfMetricEvent {
577
+ state.seq += 1;
578
+ // Spec: t is the direct monotonic performance.now() reading (ms).
579
+ // t0Epoch (wall clock at channel start/reset) + t0Perf enable wall mapping:
580
+ // wall ≈ t0Epoch + (t - t0Perf).
581
+ const event: PdfMetricEvent = {
582
+ seq: state.seq,
583
+ t: nowPerf(),
584
+ ...partial,
585
+ };
586
+ state.events.push(event);
587
+ while (state.events.length > state.capacity) {
588
+ state.events.shift();
589
+ state.dropped += 1;
590
+ }
591
+ return event;
592
+ }
593
+
594
+ export type GnoPdfMetrics = {
595
+ reset: (opts?: { capacity?: number }) => void;
596
+ snapshot: () => PdfMetricsSnapshot;
597
+ export: () => PdfMetricsSnapshot;
598
+ mintDocId: () => string;
599
+ mintTaskId: () => string;
600
+ bumpGen: (docId: string) => number;
601
+ currentGen: (docId: string) => number;
602
+ recordRenderStart: (args: {
603
+ docId: string;
604
+ pageNumber: number;
605
+ taskId: string;
606
+ genId: number;
607
+ scale: number;
608
+ canvasWidth: number;
609
+ canvasHeight: number;
610
+ }) => PdfMetricEvent;
611
+ recordRenderCancel: (args: {
612
+ docId: string;
613
+ pageNumber: number;
614
+ taskId: string;
615
+ genId: number;
616
+ }) => PdfMetricEvent;
617
+ recordRenderSettle: (args: {
618
+ docId: string;
619
+ pageNumber: number;
620
+ taskId: string;
621
+ genId: number;
622
+ outcome: PdfMetricOutcome;
623
+ scale?: number | null;
624
+ }) => PdfMetricEvent;
625
+ recordPageCleanup: (args: {
626
+ docId: string;
627
+ pageNumber: number;
628
+ }) => PdfMetricEvent;
629
+ recordDocumentDestroy: (args: { docId: string }) => PdfMetricEvent;
630
+ };
631
+
632
+ function createMetricsApi(state: MetricsState): GnoPdfMetrics {
633
+ return {
634
+ reset(opts) {
635
+ const nextCap = opts?.capacity ?? state.capacity;
636
+ state.capacity = Math.max(1, nextCap);
637
+ state.dropped = 0;
638
+ state.seq = 0;
639
+ state.t0Epoch = Date.now();
640
+ state.t0Perf = nowPerf();
641
+ state.events = [];
642
+ // Preserve opaque counters across reset so ids stay unique channel-wide
643
+ // within a process; gen map resets with the measurement window.
644
+ state.genByDoc = new Map();
645
+ },
646
+ snapshot() {
647
+ // Deep freeze: container, events array, and each event object.
648
+ // Structural clone so mutations cannot reach the live buffer.
649
+ const events = Object.freeze(
650
+ state.events.map((e) => Object.freeze({ ...e }))
651
+ );
652
+ return Object.freeze({
653
+ capacity: state.capacity,
654
+ dropped: state.dropped,
655
+ seqHigh: state.seq,
656
+ t0Epoch: state.t0Epoch,
657
+ events,
658
+ });
659
+ },
660
+ export() {
661
+ // JSON-serializable structural clone
662
+ return JSON.parse(JSON.stringify(this.snapshot())) as PdfMetricsSnapshot;
663
+ },
664
+ mintDocId() {
665
+ state.docCounter += 1;
666
+ return `d${state.docCounter}`;
667
+ },
668
+ mintTaskId() {
669
+ state.taskCounter += 1;
670
+ return `r${state.taskCounter}`;
671
+ },
672
+ bumpGen(docId: string) {
673
+ const next = (state.genByDoc.get(docId) ?? 0) + 1;
674
+ state.genByDoc.set(docId, next);
675
+ return next;
676
+ },
677
+ currentGen(docId: string) {
678
+ return state.genByDoc.get(docId) ?? 0;
679
+ },
680
+ recordRenderStart(args) {
681
+ return pushEvent(state, {
682
+ docId: args.docId,
683
+ pageNumber: args.pageNumber,
684
+ taskId: args.taskId,
685
+ genId: args.genId,
686
+ kind: "renderStart",
687
+ outcome: null,
688
+ scale: args.scale,
689
+ canvasWidth: args.canvasWidth,
690
+ canvasHeight: args.canvasHeight,
691
+ });
692
+ },
693
+ recordRenderCancel(args) {
694
+ return pushEvent(state, {
695
+ docId: args.docId,
696
+ pageNumber: args.pageNumber,
697
+ taskId: args.taskId,
698
+ genId: args.genId,
699
+ kind: "renderCancel",
700
+ outcome: null,
701
+ scale: null,
702
+ canvasWidth: null,
703
+ canvasHeight: null,
704
+ });
705
+ },
706
+ recordRenderSettle(args) {
707
+ return pushEvent(state, {
708
+ docId: args.docId,
709
+ pageNumber: args.pageNumber,
710
+ taskId: args.taskId,
711
+ genId: args.genId,
712
+ kind: "renderSettle",
713
+ outcome: args.outcome,
714
+ scale: args.scale ?? null,
715
+ canvasWidth: null,
716
+ canvasHeight: null,
717
+ });
718
+ },
719
+ recordPageCleanup(args) {
720
+ return pushEvent(state, {
721
+ docId: args.docId,
722
+ pageNumber: args.pageNumber,
723
+ taskId: null,
724
+ genId: null,
725
+ kind: "pageCleanup",
726
+ outcome: null,
727
+ scale: null,
728
+ canvasWidth: null,
729
+ canvasHeight: null,
730
+ });
731
+ },
732
+ recordDocumentDestroy(args) {
733
+ return pushEvent(state, {
734
+ docId: args.docId,
735
+ pageNumber: null,
736
+ taskId: null,
737
+ genId: null,
738
+ kind: "documentDestroy",
739
+ outcome: null,
740
+ scale: null,
741
+ canvasWidth: null,
742
+ canvasHeight: null,
743
+ });
744
+ },
745
+ };
746
+ }
747
+
748
+ declare global {
749
+ // eslint-disable-next-line no-var
750
+ var __gnoPdfMetrics: GnoPdfMetrics | undefined;
751
+ }
752
+
753
+ const metricsState = createMetricsState();
754
+ const metricsApi = createMetricsApi(metricsState);
755
+
756
+ /** Attach once to globalThis so the channel survives React unmount. */
757
+ function attachMetrics(): GnoPdfMetrics {
758
+ const g = globalThis as typeof globalThis & {
759
+ __gnoPdfMetrics?: GnoPdfMetrics;
760
+ };
761
+ if (!g.__gnoPdfMetrics) {
762
+ g.__gnoPdfMetrics = metricsApi;
763
+ }
764
+ return g.__gnoPdfMetrics;
765
+ }
766
+
767
+ export const pdfMetrics: GnoPdfMetrics = attachMetrics();
768
+
769
+ // Convenience re-export of the channel for hooks
770
+ export function getPdfMetrics(): GnoPdfMetrics {
771
+ return attachMetrics();
772
+ }