@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.29.6",
3
+ "version": "1.30.4",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -66,6 +66,8 @@
66
66
  "test:web": "bun test test/serve/public --timeout 30000",
67
67
  "test:e2e": "bun scripts/web-ui-smoke.ts",
68
68
  "test:e2e:clipper": "bun test test/clipper/e2e.test.ts --timeout 240000",
69
+ "test:e2e:pdf": "bun scripts/pdf-viewer-smoke.ts",
70
+ "smoke:pdf-viewer": "bun scripts/pdf-viewer-smoke.ts",
69
71
  "test:e2e:install": "bunx playwright install chromium",
70
72
  "test:package": "bun scripts/package-smoke.ts",
71
73
  "test:package:clipper": "bun scripts/package-smoke-clipper-cli.ts",
@@ -151,77 +153,78 @@
151
153
  "prepack": "bun run package:clipper"
152
154
  },
153
155
  "dependencies": {
154
- "@codemirror/lang-markdown": "6.5.0",
156
+ "@codemirror/lang-markdown": "6.5.1",
155
157
  "@codemirror/theme-one-dark": "6.1.3",
156
- "@modelcontextprotocol/sdk": "1.29.0",
157
- "@radix-ui/react-collapsible": "1.1.12",
158
- "@radix-ui/react-dialog": "1.1.15",
159
- "@radix-ui/react-dropdown-menu": "2.1.16",
160
- "@radix-ui/react-hover-card": "1.1.15",
161
- "@radix-ui/react-progress": "1.1.8",
162
- "@radix-ui/react-scroll-area": "1.2.10",
163
- "@radix-ui/react-select": "2.2.6",
164
- "@radix-ui/react-separator": "1.1.8",
165
- "@radix-ui/react-slot": "1.2.4",
166
- "@radix-ui/react-tooltip": "1.2.8",
158
+ "@modelcontextprotocol/sdk": "1.30.0",
159
+ "@radix-ui/react-collapsible": "1.1.20",
160
+ "@radix-ui/react-dialog": "1.1.23",
161
+ "@radix-ui/react-dropdown-menu": "2.1.24",
162
+ "@radix-ui/react-hover-card": "1.1.23",
163
+ "@radix-ui/react-progress": "1.1.16",
164
+ "@radix-ui/react-scroll-area": "1.2.18",
165
+ "@radix-ui/react-select": "2.3.7",
166
+ "@radix-ui/react-separator": "1.1.15",
167
+ "@radix-ui/react-slot": "1.3.3",
168
+ "@radix-ui/react-tooltip": "1.2.16",
167
169
  "ai": "6.0.68",
168
170
  "bun-plugin-tailwind": "0.1.2",
169
171
  "class-variance-authority": "0.7.1",
170
172
  "clsx": "2.1.1",
171
173
  "cmdk": "1.1.1",
172
174
  "codemirror": "6.0.2",
173
- "commander": "14.0.3",
175
+ "commander": "15.0.0",
174
176
  "embla-carousel-react": "8.6.0",
175
177
  "franc": "6.2.0",
176
178
  "jsonc-parser": "3.3.1",
177
- "lucide-react": "1.8.0",
178
- "markitdown-ts": "0.0.9",
179
- "minimatch": "10.2.3",
180
- "nanoid": "5.1.6",
181
- "node-llama-cpp": "3.18.1",
182
- "officeparser": "6.0.4",
179
+ "lucide-react": "1.28.0",
180
+ "markitdown-ts": "0.0.10",
181
+ "minimatch": "10.2.6",
182
+ "nanoid": "6.0.0",
183
+ "node-llama-cpp": "3.19.1",
184
+ "officeparser": "7.5.0",
185
+ "pdfjs-dist": "6.2.108",
183
186
  "picocolors": "1.1.1",
184
- "react": "19.2.4",
185
- "react-dom": "19.2.4",
186
- "react-force-graph-2d": "1.29.0",
187
+ "react": "19.2.8",
188
+ "react-dom": "19.2.8",
189
+ "react-force-graph-2d": "1.29.1",
187
190
  "react-markdown": "10.1.0",
188
191
  "rehype-sanitize": "6.0.0",
189
192
  "remark-gfm": "4.0.1",
190
- "shiki": "4.0.2",
193
+ "shiki": "4.3.1",
191
194
  "sqlite-vec": "0.1.9",
192
- "streamdown": "2.1.0",
193
- "tailwind-merge": "3.4.0",
194
- "tailwindcss": "4.1.18",
195
- "use-stick-to-bottom": "1.1.2",
196
- "zod": "4.3.6"
195
+ "streamdown": "2.5.0",
196
+ "tailwind-merge": "3.6.0",
197
+ "tailwindcss": "4.3.3",
198
+ "use-stick-to-bottom": "1.1.6",
199
+ "zod": "4.4.3"
197
200
  },
198
201
  "devDependencies": {
199
202
  "@ai-sdk/openai": "3.0.25",
200
- "@biomejs/biome": "2.3.14",
201
- "@tailwindcss/cli": "4.1.18",
203
+ "@biomejs/biome": "2.5.6",
204
+ "@tailwindcss/cli": "4.3.3",
202
205
  "@testing-library/react": "16.3.2",
203
206
  "@testing-library/user-event": "14.6.1",
204
- "@types/bun": "1.3.8",
205
- "@types/react": "19.2.14",
207
+ "@types/bun": "1.3.14",
208
+ "@types/react": "19.2.17",
206
209
  "@types/react-dom": "19.2.3",
207
210
  "@vscode/tree-sitter-wasm": "0.3.1",
208
211
  "ajv": "8.17.1",
209
212
  "ajv-formats": "3.0.1",
210
213
  "bun": "1.3.14",
211
- "docx": "9.5.1",
214
+ "docx": "9.7.1",
212
215
  "evalite": "1.0.0-beta.16",
213
216
  "exceljs": "4.4.0",
214
- "happy-dom": "20.8.9",
215
- "lefthook": "2.1.4",
217
+ "happy-dom": "20.11.1",
218
+ "lefthook": "2.1.10",
216
219
  "oxfmt": "0.28.0",
217
220
  "oxlint": "1.43.0",
218
221
  "oxlint-tsgolint": "0.11.5",
219
222
  "pdf-lib": "1.17.1",
220
- "playwright": "1.58.2",
223
+ "playwright": "1.62.0",
221
224
  "pptxgenjs": "4.0.1",
222
225
  "ultracite": "7.1.5",
223
- "vitest": "4.1.0",
224
- "web-tree-sitter": "0.26.8"
226
+ "vitest": "4.1.10",
227
+ "web-tree-sitter": "0.26.11"
225
228
  },
226
229
  "peerDependencies": {
227
230
  "typescript": "^5"
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * officeparser adapter for PPTX conversion.
3
- * Uses parseOffice() v6 API with Buffer for in-memory extraction.
3
+ * Uses parseOffice() v7 API with Buffer for in-memory extraction.
4
4
  */
5
5
 
6
6
  import { parseOffice } from "officeparser";
@@ -76,12 +76,16 @@ export const officeparserAdapter: Converter = {
76
76
  try {
77
77
  // Zero-copy Buffer view (input.bytes is immutable by contract)
78
78
  const buffer = toBuffer(input.bytes);
79
- // v6 API: parseOffice returns AST, use .toText() for plain text
79
+ // v7's async text renderer preserves notes while keeping the previous
80
+ // plain-text layout and omitting image placeholders.
80
81
  const ast = await parseOffice(buffer, {
81
82
  newlineDelimiter: "\n",
82
83
  ignoreNotes: false, // Include speaker notes
83
84
  });
84
- const text = ast.toText();
85
+ const { value: text } = await ast.to("text", {
86
+ includeImages: false,
87
+ textConfig: { preserveLayout: false, renderNotes: true },
88
+ });
85
89
 
86
90
  if (!text || text.trim().length === 0) {
87
91
  return {
@@ -218,6 +218,57 @@ export const NETWORK_BOUNDARY_INVENTORY = [
218
218
  action: "serve",
219
219
  enforcement: "loopback_only",
220
220
  },
221
+ {
222
+ // Loopback self-request that warms the SPA shell cache from the server's
223
+ // own bound port (127.0.0.1). Carries no collection data and never leaves
224
+ // the host.
225
+ id: "serve-spa-shell",
226
+ key: "src/serve/server.ts::fetch#1",
227
+ path: "src/serve/server.ts",
228
+ primitive: "fetch",
229
+ action: "serve",
230
+ enforcement: "loopback_only",
231
+ },
232
+ {
233
+ // Windows-only private SPA bundle listener. Bun does not support Unix
234
+ // sockets on Windows, so this binds an ephemeral loopback port instead.
235
+ id: "serve-spa-bundle-windows-listener",
236
+ key: "src/serve/spa-bundle-source.ts::bun_serve#1",
237
+ path: "src/serve/spa-bundle-source.ts",
238
+ primitive: "bun_serve",
239
+ action: "serve",
240
+ enforcement: "loopback_only",
241
+ },
242
+ {
243
+ // Unix-only private SPA bundle listener. The generated assets are exposed
244
+ // through a process-owned Unix-domain socket, never a TCP interface.
245
+ id: "serve-spa-bundle-unix-listener",
246
+ key: "src/serve/spa-bundle-source.ts::bun_serve#2",
247
+ path: "src/serve/spa-bundle-source.ts",
248
+ primitive: "bun_serve",
249
+ action: "serve",
250
+ enforcement: "local_process_only",
251
+ },
252
+ {
253
+ // Windows-only request from the public server to its private, ephemeral
254
+ // loopback SPA bundle listener.
255
+ id: "serve-spa-bundle-windows-fetch",
256
+ key: "src/serve/spa-bundle-source.ts::fetch#1",
257
+ path: "src/serve/spa-bundle-source.ts",
258
+ primitive: "fetch",
259
+ action: "serve",
260
+ enforcement: "loopback_only",
261
+ },
262
+ {
263
+ // Unix-only request over the process-owned SPA bundle socket. The HTTP URL
264
+ // is a Bun fetch API requirement; the request is routed through `unix`.
265
+ id: "serve-spa-bundle-unix-fetch",
266
+ key: "src/serve/spa-bundle-source.ts::fetch#2",
267
+ path: "src/serve/spa-bundle-source.ts",
268
+ primitive: "fetch",
269
+ action: "serve",
270
+ enforcement: "local_process_only",
271
+ },
221
272
  {
222
273
  id: "http-mcp-tools",
223
274
  key: "logical::http-mcp-tools",
@@ -146,13 +146,13 @@ const resolveContainedPath = async (
146
146
  | {
147
147
  ok: true;
148
148
  absolutePath: string;
149
- metadata: Awaited<ReturnType<typeof stat>>;
149
+ metadata: NonNullable<Awaited<ReturnType<typeof stat>>>;
150
150
  }
151
151
  | { ok: false; diagnostic: ProjectProfileDiagnostic }
152
152
  > => {
153
153
  const candidate = resolve(profileRoot, normalizeLogicalPath(logicalPath));
154
154
  let absolutePath: string;
155
- let metadata: Awaited<ReturnType<typeof stat>>;
155
+ let metadata: NonNullable<Awaited<ReturnType<typeof stat>>>;
156
156
  try {
157
157
  absolutePath = await realpath(candidate);
158
158
  metadata = await stat(absolutePath);
@@ -221,7 +221,7 @@ const deriveKey = async (passphrase: string, salt: Uint8Array) => {
221
221
  {
222
222
  name: "PBKDF2",
223
223
  hash: "SHA-256",
224
- salt,
224
+ salt: Uint8Array.from(salt),
225
225
  iterations: PBKDF2_ITERATIONS,
226
226
  },
227
227
  material,
@@ -74,6 +74,8 @@ Answer generation uses shared module to stay in sync with CLI:
74
74
  | `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
75
75
  | `/api/docs` | GET | List documents |
76
76
  | `/api/doc` | GET | Get document content |
77
+ | `/api/doc-asset` | GET | Original source file bytes (Range, HEAD) |
78
+ | `/vendor/pdfjs/*` | GET | Same-origin pdfjs worker/cmaps/fonts |
77
79
  | `/api/search` | POST | BM25 search |
78
80
  | `/api/query` | POST | Hybrid search |
79
81
  | `/api/ask` | POST | AI answer with citations |
@@ -106,7 +108,9 @@ gno serve --port 3000
106
108
  - Binds to `127.0.0.1` only (no LAN exposure)
107
109
  - CSP headers on all responses
108
110
  - CORS protection on POST endpoints
109
- - No external font/script loading
111
+ - No external font/script loading — the PDF.js worker, cMaps, and standard fonts
112
+ are served same-origin from the installed `pdfjs-dist` package via
113
+ `/vendor/pdfjs/*`, so the CSP keeps `worker-src 'self'` and `font-src 'self'`
110
114
 
111
115
  ## Bun.serve() Patterns
112
116
 
@@ -76,6 +76,8 @@ Answer generation uses shared module to stay in sync with CLI:
76
76
  | `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
77
77
  | `/api/docs` | GET | List documents |
78
78
  | `/api/doc` | GET | Get document content |
79
+ | `/api/doc-asset` | GET | Original source file bytes (Range, HEAD) |
80
+ | `/vendor/pdfjs/*` | GET | Same-origin pdfjs worker/cmaps/fonts |
79
81
  | `/api/search` | POST | BM25 search |
80
82
  | `/api/query` | POST | Hybrid search |
81
83
  | `/api/ask` | POST | AI answer with citations |
@@ -108,7 +110,9 @@ gno serve --port 3000
108
110
  - Binds to `127.0.0.1` only (no LAN exposure)
109
111
  - CSP headers on all responses
110
112
  - CORS protection on POST endpoints
111
- - No external font/script loading
113
+ - No external font/script loading — the PDF.js worker, cMaps, and standard fonts
114
+ are served same-origin from the installed `pdfjs-dist` package via
115
+ `/vendor/pdfjs/*`, so the CSP keeps `worker-src 'self'` and `font-src 'self'`
112
116
 
113
117
  ## Bun.serve() Patterns
114
118
 
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Production route factories for fn-112 doc-asset + pdfjs vendor surfaces.
3
+ * server.ts and tests MUST consume these — no duplicated route maps.
4
+ *
5
+ * Vendor: ONE production dispatcher (`handlePdfjsVendorRequest`) is the sole
6
+ * path for every /vendor/pdfjs/* request (valid or malformed, any method).
7
+ * server.ts routes that prefix through fetch; tests call the same function.
8
+ */
9
+
10
+ import type { Config } from "../config/types";
11
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
12
+ import type { ResidentRuntime } from "./resident-runtime";
13
+
14
+ import { handlePdfjsAsset, PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
15
+ import { handleResidentRead } from "./resident-request";
16
+ import { handleDocAsset } from "./routes/api";
17
+
18
+ const PDFJS_WORKER_BOOTSTRAP = `
19
+ if (typeof Math.sumPrecise !== "function") {
20
+ Object.defineProperty(Math, "sumPrecise", {
21
+ configurable: true,
22
+ writable: true,
23
+ value(values) {
24
+ let sum = 0;
25
+ let compensation = 0;
26
+ for (const value of values) {
27
+ const next = sum + value;
28
+ compensation += Math.abs(sum) >= Math.abs(value)
29
+ ? sum - next + value
30
+ : value - next + sum;
31
+ sum = next;
32
+ }
33
+ return sum + compensation;
34
+ },
35
+ });
36
+ }
37
+ await import("/vendor/pdfjs/pdf.worker.raw.min.mjs");
38
+ `.trimStart();
39
+
40
+ export type SecurityHeaderWrap = (
41
+ response: Response,
42
+ isDev: boolean
43
+ ) => Response;
44
+
45
+ export type DocAssetRouteContext = {
46
+ store: SqliteAdapter;
47
+ /** Live config getter (ctxHolder.config may change). */
48
+ getConfig: () => Config;
49
+ runtime: ResidentRuntime;
50
+ isDev: boolean;
51
+ withSecurityHeaders: SecurityHeaderWrap;
52
+ };
53
+
54
+ export type MethodHandlers = {
55
+ GET: (req: Request) => Promise<Response> | Response;
56
+ HEAD: (req: Request) => Promise<Response> | Response;
57
+ };
58
+
59
+ /** Exact production error envelopes for vendor routes (I1-04 round 3). */
60
+ export const PDFJS_VENDOR_ERRORS = {
61
+ NOT_FOUND: {
62
+ code: "NOT_FOUND",
63
+ message: "Asset not found",
64
+ },
65
+ METHOD_NOT_ALLOWED: {
66
+ code: "METHOD_NOT_ALLOWED",
67
+ message: "Only GET and HEAD are supported",
68
+ },
69
+ } as const;
70
+
71
+ /**
72
+ * Production `/api/doc-asset` GET+HEAD handlers.
73
+ * Both methods traverse handleResidentRead (admission) then withSecurityHeaders.
74
+ */
75
+ export function createDocAssetRouteHandlers(
76
+ ctx: DocAssetRouteContext
77
+ ): MethodHandlers {
78
+ const dispatch = async (req: Request): Promise<Response> => {
79
+ const url = new URL(req.url);
80
+ return ctx.withSecurityHeaders(
81
+ await handleResidentRead(ctx.runtime, req, () =>
82
+ handleDocAsset(ctx.store, ctx.getConfig(), url, req)
83
+ ),
84
+ ctx.isDev
85
+ );
86
+ };
87
+ return {
88
+ GET: dispatch,
89
+ HEAD: dispatch,
90
+ };
91
+ }
92
+
93
+ export type PdfjsVendorDispatchOptions = {
94
+ isDev: boolean;
95
+ withSecurityHeaders: SecurityHeaderWrap;
96
+ };
97
+
98
+ /**
99
+ * Production vendor dispatcher — the ONLY production path for `/vendor/pdfjs/*`.
100
+ *
101
+ * Handles every method and every pathname under the prefix (worker, cmaps,
102
+ * standard_fonts, multi-segment, empty, encoded traversal, invalid encoding).
103
+ * ALL responses — success, 404, 405 — go through withSecurityHeaders exactly once.
104
+ *
105
+ * server.ts must call this for the prefix (via fetch); tests must call this
106
+ * identical function (no separate test-only fallback).
107
+ */
108
+ export async function handlePdfjsVendorRequest(
109
+ request: Request,
110
+ options: PdfjsVendorDispatchOptions
111
+ ): Promise<Response> {
112
+ const wrap = options.withSecurityHeaders;
113
+ const isDev = options.isDev;
114
+ const method = request.method.toUpperCase();
115
+ const pathname = new URL(request.url).pathname;
116
+
117
+ // Never double-wrap: build the inner response, then wrap once.
118
+ const inner = await dispatchPdfjsVendorInner(method, pathname);
119
+ return wrap(inner, isDev);
120
+ }
121
+
122
+ /**
123
+ * True when this request should be handled by the vendor dispatcher.
124
+ * Used by server fetch to claim the whole prefix.
125
+ */
126
+ export function isPdfjsVendorPath(pathname: string): boolean {
127
+ return pathname === "/vendor/pdfjs" || pathname.startsWith("/vendor/pdfjs/");
128
+ }
129
+
130
+ async function dispatchPdfjsVendorInner(
131
+ method: string,
132
+ pathname: string
133
+ ): Promise<Response> {
134
+ // Only GET/HEAD are ever valid on any vendor path
135
+ if (method !== "GET" && method !== "HEAD") {
136
+ return methodNotAllowedResponse();
137
+ }
138
+
139
+ // Public worker bootstrap: install the ES2026 Math.sumPrecise compatibility
140
+ // required by pdfjs-dist before evaluating its worker module.
141
+ if (pathname === "/vendor/pdfjs/pdf.worker.min.mjs") {
142
+ const headers = {
143
+ "Cache-Control": PDFJS_ASSET_CACHE_CONTROL,
144
+ "Content-Type": "text/javascript",
145
+ "Content-Length": String(
146
+ new TextEncoder().encode(PDFJS_WORKER_BOOTSTRAP).byteLength
147
+ ),
148
+ };
149
+ return new Response(method === "HEAD" ? null : PDFJS_WORKER_BOOTSTRAP, {
150
+ headers,
151
+ });
152
+ }
153
+
154
+ // Package worker stays behind a fixed same-origin route; never referenced
155
+ // directly by application code, only by the bootstrap above.
156
+ if (pathname === "/vendor/pdfjs/pdf.worker.raw.min.mjs") {
157
+ return handlePdfjsAsset({ kind: "worker", method });
158
+ }
159
+
160
+ // cmaps/:file — single segment only
161
+ const cmapMatch = pathname.match(/^\/vendor\/pdfjs\/cmaps\/([^/]*)$/u);
162
+ if (cmapMatch) {
163
+ const raw = cmapMatch[1] ?? "";
164
+ if (raw === "") {
165
+ return notFoundResponse();
166
+ }
167
+ const file = decodeRouteSegment(raw);
168
+ if (file === null) {
169
+ return notFoundResponse();
170
+ }
171
+ return handlePdfjsAsset({ kind: "cmaps", file, method });
172
+ }
173
+
174
+ // standard_fonts/:file — single segment only
175
+ const fontMatch = pathname.match(
176
+ /^\/vendor\/pdfjs\/standard_fonts\/([^/]*)$/u
177
+ );
178
+ if (fontMatch) {
179
+ const raw = fontMatch[1] ?? "";
180
+ if (raw === "") {
181
+ return notFoundResponse();
182
+ }
183
+ const file = decodeRouteSegment(raw);
184
+ if (file === null) {
185
+ return notFoundResponse();
186
+ }
187
+ return handlePdfjsAsset({ kind: "standard_fonts", file, method });
188
+ }
189
+
190
+ // Multi-segment, empty prefix, unknown subpaths, traversal that doesn't
191
+ // collapse to a single-segment match → 404
192
+ return notFoundResponse();
193
+ }
194
+
195
+ function decodeRouteSegment(segment: string): string | null {
196
+ try {
197
+ return decodeURIComponent(segment);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ function notFoundResponse(): Response {
204
+ return Response.json(
205
+ { error: { ...PDFJS_VENDOR_ERRORS.NOT_FOUND } },
206
+ { status: 404 }
207
+ );
208
+ }
209
+
210
+ function methodNotAllowedResponse(): Response {
211
+ return Response.json(
212
+ { error: { ...PDFJS_VENDOR_ERRORS.METHOD_NOT_ALLOWED } },
213
+ { status: 405 }
214
+ );
215
+ }
216
+
217
+ /**
218
+ * @deprecated Use handlePdfjsVendorRequest — kept only if any import remains.
219
+ * Prefer the single production dispatcher.
220
+ */
221
+ export async function dispatchPdfjsVendorRoute(
222
+ _routeMap: unknown,
223
+ request: Request,
224
+ options?: PdfjsVendorDispatchOptions
225
+ ): Promise<Response> {
226
+ if (!options) {
227
+ throw new Error(
228
+ "dispatchPdfjsVendorRoute requires production options; use handlePdfjsVendorRequest"
229
+ );
230
+ }
231
+ return handlePdfjsVendorRequest(request, options);
232
+ }