@gmickel/gno 1.39.2 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { HttpGatewayOverrides } from "../mcp/http-security";
10
+ import type { RequestPeerServer } from "./request-locality";
10
11
  import type { ResidentRuntime } from "./resident-runtime";
11
12
  import type { ContextHolder } from "./routes/api";
12
13
 
@@ -22,6 +23,7 @@ import {
22
23
  handlePdfjsVendorRequest,
23
24
  isPdfjsVendorPath,
24
25
  } from "./fn112-routes";
26
+ import { PDFJS_ASSET_CACHE_CONTROL } from "./pdfjs-assets";
25
27
  // HTML import - Bun handles bundling TSX/CSS automatically via routes
26
28
  import homepage from "./public/index.html";
27
29
  import { handleResidentRead } from "./resident-request";
@@ -209,6 +211,156 @@ export function withSecurityHeaders(
209
211
  }
210
212
  }
211
213
 
214
+ /**
215
+ * Hashed SPA chunk paths emitted by the production split build
216
+ * (`/chunk-<hash>.js|css`). The entry HTML never matches, so it keeps its
217
+ * existing headers and ETag pass-through.
218
+ */
219
+ const SPA_HASHED_CHUNK_RE = /^\/chunk-[A-Za-z0-9]+\.(?:js|css)$/u;
220
+
221
+ /** One-year immutable policy shared with the version-pinned pdfjs assets. */
222
+ export const SPA_CHUNK_CACHE_CONTROL = PDFJS_ASSET_CACHE_CONTROL;
223
+
224
+ export const isHashedSpaChunkPath = (pathname: string): boolean =>
225
+ SPA_HASHED_CHUNK_RE.test(pathname);
226
+
227
+ const GZIP_CODINGS = new Set(["gzip", "x-gzip"]);
228
+
229
+ /**
230
+ * True when an Accept-Encoding value accepts gzip: gzip (or x-gzip) listed
231
+ * with a non-zero q-value, or a bare `*` with a non-zero q-value while gzip
232
+ * is not explicitly refused (RFC 9110 §12.5.3: `*` matches any coding not
233
+ * otherwise listed). Absent header → identity only.
234
+ */
235
+ export function acceptsGzip(acceptEncoding: string | null): boolean {
236
+ if (!acceptEncoding) {
237
+ return false;
238
+ }
239
+ let gzipQ: number | undefined;
240
+ let wildcardQ: number | undefined;
241
+ for (const member of acceptEncoding.split(",")) {
242
+ const [rawCoding, ...params] = member.trim().toLowerCase().split(";");
243
+ const coding = rawCoding?.trim();
244
+ if (!coding) {
245
+ continue;
246
+ }
247
+ const q = params
248
+ .map((param) => param.trim())
249
+ .find((param) => param.startsWith("q="))
250
+ ?.slice(2);
251
+ const weight = q === undefined ? 1 : Number.parseFloat(q);
252
+ if (GZIP_CODINGS.has(coding)) {
253
+ gzipQ = Math.max(gzipQ ?? 0, Number.isNaN(weight) ? 0 : weight);
254
+ } else if (coding === "*") {
255
+ wildcardQ = Math.max(wildcardQ ?? 0, Number.isNaN(weight) ? 0 : weight);
256
+ }
257
+ }
258
+ if (gzipQ !== undefined) {
259
+ return gzipQ > 0;
260
+ }
261
+ return (wildcardQ ?? 0) > 0;
262
+ }
263
+
264
+ export type PublicFetchFallbackOptions = {
265
+ isDev: boolean;
266
+ spaBundleSource: SpaBundleSource | null;
267
+ };
268
+
269
+ /**
270
+ * Production catch-all `fetch` for the public listener: the pdfjs vendor
271
+ * prefix, then the private SPA source. Encoding negotiation lives here, not in
272
+ * the source, because `hostPrivateSource` re-issues internal requests with
273
+ * only the method, so `Accept-Encoding` never reaches it. Hashed chunks gain
274
+ * an immutable cache policy and a gzip body computed once per pathname;
275
+ * identity clients get the same cache headers on the original bytes. Tests
276
+ * mount this same factory on a real listener (fn-112 I1-04 discipline).
277
+ */
278
+ export function createPublicFetchFallback(
279
+ options: PublicFetchFallbackOptions
280
+ ): (req: Request) => Promise<Response> {
281
+ const { isDev, spaBundleSource } = options;
282
+ const gzipBodies = new Map<string, Uint8Array<ArrayBuffer>>();
283
+
284
+ const gzipBodyFor = async (
285
+ req: Request,
286
+ pathname: string,
287
+ asset: Response
288
+ ): Promise<Uint8Array<ArrayBuffer>> => {
289
+ const cached = gzipBodies.get(pathname);
290
+ if (cached) {
291
+ await asset.body?.cancel();
292
+ return cached;
293
+ }
294
+ // HEAD reaches the source as HEAD, so its body is empty; fetch the bytes.
295
+ const identity =
296
+ req.method === "HEAD" && spaBundleSource
297
+ ? await spaBundleSource.fetch(new Request(req.url, { method: "GET" }))
298
+ : asset;
299
+ // Copy into an ArrayBuffer-backed view so the cached bytes are a BodyInit.
300
+ const encoded = new Uint8Array(
301
+ Bun.gzipSync(new Uint8Array(await identity.arrayBuffer()))
302
+ );
303
+ gzipBodies.set(pathname, encoded);
304
+ return encoded;
305
+ };
306
+
307
+ const serveHashedChunk = async (
308
+ req: Request,
309
+ pathname: string,
310
+ asset: Response
311
+ ): Promise<Response> => {
312
+ const headers = new Headers(asset.headers);
313
+ headers.set("Cache-Control", SPA_CHUNK_CACHE_CONTROL);
314
+ headers.set("Vary", "Accept-Encoding");
315
+ if (!acceptsGzip(req.headers.get("accept-encoding"))) {
316
+ if (req.method === "HEAD") {
317
+ // HEAD reaches the source as HEAD: its body is empty but its headers
318
+ // (Content-Length included) already describe the GET body. Keep them.
319
+ await asset.body?.cancel();
320
+ return new Response(null, { status: asset.status, headers });
321
+ }
322
+ // Buffer the identity bytes: re-wrapping the proxied stream would drop
323
+ // Content-Length and fall back to chunked transfer.
324
+ const identity = await asset.arrayBuffer();
325
+ headers.set("Content-Length", String(identity.byteLength));
326
+ return new Response(identity, { status: asset.status, headers });
327
+ }
328
+ const encoded = await gzipBodyFor(req, pathname, asset);
329
+ headers.set("Content-Encoding", "gzip");
330
+ headers.set("Content-Length", String(encoded.byteLength));
331
+ return new Response(req.method === "HEAD" ? null : encoded, {
332
+ status: asset.status,
333
+ headers,
334
+ });
335
+ };
336
+
337
+ return async (req: Request): Promise<Response> => {
338
+ const pathname = new URL(req.url).pathname;
339
+ if (isPdfjsVendorPath(pathname)) {
340
+ return handlePdfjsVendorRequest(req, {
341
+ isDev,
342
+ withSecurityHeaders,
343
+ });
344
+ }
345
+ if (spaBundleSource && (req.method === "GET" || req.method === "HEAD")) {
346
+ const asset = await spaBundleSource.fetch(req);
347
+ if (asset.status === 200 && !isDev && isHashedSpaChunkPath(pathname)) {
348
+ return withSecurityHeaders(
349
+ await serveHashedChunk(req, pathname, asset),
350
+ isDev
351
+ );
352
+ }
353
+ if (asset.status !== 404) {
354
+ return withSecurityHeaders(asset, isDev);
355
+ }
356
+ }
357
+ return withSecurityHeaders(
358
+ new Response("Not Found", { status: 404 }),
359
+ isDev
360
+ );
361
+ };
362
+ }
363
+
212
364
  /** Build a loopback origin with correct IPv6 authority formatting. */
213
365
  export function loopbackHttpOrigin(host: string, port: number): string {
214
366
  const normalizedHost =
@@ -824,7 +976,7 @@ export async function startServer(
824
976
  },
825
977
  },
826
978
  "/api/docs/:id/reveal": {
827
- POST: async (req: Request) => {
979
+ POST: async (req: Request, server: RequestPeerServer) => {
828
980
  if (!isRequestAllowed(req, port)) {
829
981
  return withSecurityHeaders(forbiddenResponse(), isDev);
830
982
  }
@@ -832,7 +984,7 @@ export async function startServer(
832
984
  const parts = url.pathname.split("/");
833
985
  const id = decodeURIComponent(parts[3] || "");
834
986
  return withSecurityHeaders(
835
- await handleRevealDoc(ctxHolder, store, id, req),
987
+ await handleRevealDoc(ctxHolder, store, id, req, { server }),
836
988
  isDev
837
989
  );
838
990
  },
@@ -1015,8 +1167,11 @@ export async function startServer(
1015
1167
  },
1016
1168
  },
1017
1169
  "/api/capabilities": {
1018
- GET: () =>
1019
- withSecurityHeaders(handleCapabilities(ctxHolder.current), isDev),
1170
+ GET: (req: Request, server: RequestPeerServer) =>
1171
+ withSecurityHeaders(
1172
+ handleCapabilities(ctxHolder.current, req, server),
1173
+ isDev
1174
+ ),
1020
1175
  },
1021
1176
  "/api/presets": {
1022
1177
  GET: () =>
@@ -1292,31 +1447,10 @@ export async function startServer(
1292
1447
  },
1293
1448
  },
1294
1449
  },
1295
- // Production vendor dispatcher for the entire /vendor/pdfjs prefix.
1296
- // Covers valid worker/cMap/font AND malformed/unknown/POST — same function
1297
- // that tests invoke (no test-only fallback path).
1298
- fetch: async (req: Request): Promise<Response> => {
1299
- const pathname = new URL(req.url).pathname;
1300
- if (isPdfjsVendorPath(pathname)) {
1301
- return handlePdfjsVendorRequest(req, {
1302
- isDev,
1303
- withSecurityHeaders,
1304
- });
1305
- }
1306
- if (
1307
- spaBundleSource &&
1308
- (req.method === "GET" || req.method === "HEAD")
1309
- ) {
1310
- const asset = await spaBundleSource.fetch(req);
1311
- if (asset.status !== 404) {
1312
- return withSecurityHeaders(asset, isDev);
1313
- }
1314
- }
1315
- return withSecurityHeaders(
1316
- new Response("Not Found", { status: 404 }),
1317
- isDev
1318
- );
1319
- },
1450
+ // Production catch-all: /vendor/pdfjs prefix, then hashed SPA chunks
1451
+ // (gzip + immutable) and the private SPA source the same factory the
1452
+ // tests mount (no test-only fallback path).
1453
+ fetch: createPublicFetchFallback({ isDev, spaBundleSource }),
1320
1454
  });
1321
1455
  } catch (e) {
1322
1456
  removeShutdownHandlers();
@@ -1 +0,0 @@
1
- 82afff37d4a331933c19bebb9c73972fd6eb665ffd1444bbdf65cfb0220dd692 gno-browser-clipper-v1.39.2.zip