@gmickel/gno 1.29.6 → 1.30.1

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.
@@ -17,6 +17,11 @@ import {
17
17
  import { startBackgroundRuntime } from "./background-runtime";
18
18
  import { handleContextBuild, handleContextVerify } from "./context-capsule";
19
19
  import { DocumentEventBus } from "./doc-events";
20
+ import {
21
+ createDocAssetRouteHandlers,
22
+ handlePdfjsVendorRequest,
23
+ isPdfjsVendorPath,
24
+ } from "./fn112-routes";
20
25
  // HTML import - Bun handles bundling TSX/CSS automatically via routes
21
26
  import homepage from "./public/index.html";
22
27
  import { handleResidentRead } from "./resident-request";
@@ -38,7 +43,6 @@ import {
38
43
  handleDeactivateDoc,
39
44
  handleDeleteCollection,
40
45
  handleDoc,
41
- handleDocAsset,
42
46
  handleDocSections,
43
47
  handleDocsAutocomplete,
44
48
  handleDocs,
@@ -98,6 +102,10 @@ import {
98
102
  handleTraceShow,
99
103
  } from "./routes/traces";
100
104
  import { forbiddenResponse, isRequestAllowed } from "./security";
105
+ import {
106
+ createSpaBundleSource,
107
+ type SpaBundleSource,
108
+ } from "./spa-bundle-source";
101
109
 
102
110
  export interface ServeOptions extends HttpGatewayOverrides {
103
111
  /** Port to listen on (default: 3000) */
@@ -135,7 +143,8 @@ interface StartServerDependencies {
135
143
  * Get CSP based on environment.
136
144
  * Dev mode allows WebSocket connections for HMR.
137
145
  */
138
- function getCspHeader(isDev: boolean): string {
146
+ /** Exported for security tests (CSP contract). */
147
+ export function getCspHeader(isDev: boolean): string {
139
148
  // Local fonts only - no Google Fonts for true offline-first
140
149
  const base = [
141
150
  "default-src 'self'",
@@ -143,6 +152,7 @@ function getCspHeader(isDev: boolean): string {
143
152
  "style-src 'self' 'unsafe-inline'",
144
153
  "font-src 'self'",
145
154
  "img-src 'self' data: blob:",
155
+ "worker-src 'self'", // explicit for PDF.js module worker (fn-112)
146
156
  "frame-ancestors 'none'",
147
157
  "base-uri 'none'", // Prevent base tag injection
148
158
  "object-src 'none'", // Prevent plugin execution
@@ -160,20 +170,49 @@ function getCspHeader(isDev: boolean): string {
160
170
 
161
171
  /**
162
172
  * Apply security headers to a Response.
173
+ * Exported for unit tests that assert the envelope on specific responses.
174
+ *
175
+ * Mutates headers on the original Response when possible. Re-wrapping via
176
+ * `new Response(response.body, …)` breaks Bun.file().slice() range bodies
177
+ * (the stream re-reads the full file), which would corrupt HTTP 206 slices.
163
178
  */
164
- function withSecurityHeaders(response: Response, isDev: boolean): Response {
165
- const headers = new Headers(response.headers);
166
- headers.set("Content-Security-Policy", getCspHeader(isDev));
167
- headers.set("X-Content-Type-Options", "nosniff");
168
- headers.set("X-Frame-Options", "DENY");
169
- headers.set("Referrer-Policy", "no-referrer");
170
- headers.set("Cross-Origin-Resource-Policy", "same-origin");
179
+ export function withSecurityHeaders(
180
+ response: Response,
181
+ isDev: boolean
182
+ ): Response {
183
+ const apply = (headers: Headers): void => {
184
+ headers.set("Content-Security-Policy", getCspHeader(isDev));
185
+ headers.set("X-Content-Type-Options", "nosniff");
186
+ headers.set("X-Frame-Options", "DENY");
187
+ headers.set("Referrer-Policy", "no-referrer");
188
+ headers.set("Cross-Origin-Resource-Policy", "same-origin");
189
+ };
171
190
 
172
- return new Response(response.body, {
173
- status: response.status,
174
- statusText: response.statusText,
175
- headers,
176
- });
191
+ try {
192
+ apply(response.headers);
193
+ return response;
194
+ } catch {
195
+ // Headers locked — fall back to a new envelope. Prefer cloning via
196
+ // arrayBuffer only when body is already consumed is not possible here
197
+ // (sync API); empty-body responses (HEAD/416) are the common case.
198
+ const headers = new Headers(response.headers);
199
+ apply(headers);
200
+ return new Response(response.body, {
201
+ status: response.status,
202
+ statusText: response.statusText,
203
+ headers,
204
+ });
205
+ }
206
+ }
207
+
208
+ /** Build a loopback origin with correct IPv6 authority formatting. */
209
+ export function loopbackHttpOrigin(host: string, port: number): string {
210
+ const normalizedHost =
211
+ host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
212
+ const authority = normalizedHost.includes(":")
213
+ ? `[${normalizedHost}]`
214
+ : normalizedHost;
215
+ return `http://${authority}:${port}`;
177
216
  }
178
217
 
179
218
  /**
@@ -261,6 +300,86 @@ export async function startServer(
261
300
 
262
301
  // Start server with try/catch for port-in-use etc.
263
302
  let server: ReturnType<typeof Bun.serve>;
303
+ // Bun HTMLBundle route values cannot carry custom headers. In production,
304
+ // host it on a private Unix socket (ephemeral loopback on Windows), then
305
+ // proxy bytes through this public listener's security envelope. Injected
306
+ // server tests keep an in-listener bundle route because their fake Bun
307
+ // server cannot host the private source.
308
+ let spaBundleSource: SpaBundleSource | null = null;
309
+ const usesInjectedServer = dependencies.serve !== undefined;
310
+ try {
311
+ if (!usesInjectedServer) {
312
+ spaBundleSource = createSpaBundleSource(homepage, isDev);
313
+ }
314
+ } catch (error) {
315
+ removeShutdownHandlers();
316
+ await Promise.allSettled([gateway.close()]);
317
+ await Promise.allSettled([runtime.dispose()]);
318
+ return {
319
+ success: false,
320
+ error: error instanceof Error ? error.message : String(error),
321
+ };
322
+ }
323
+ const spaInternalPath =
324
+ spaBundleSource?.entryPath ??
325
+ `/__gno_spa_${crypto.randomUUID().replaceAll("-", "")}`;
326
+ let spaHtmlCache: {
327
+ body: ArrayBuffer;
328
+ contentType: string;
329
+ etag: string | null;
330
+ } | null = null;
331
+
332
+ const serveSpaHtml = async (): Promise<Response> => {
333
+ if (!isDev && spaHtmlCache) {
334
+ const headers = new Headers({
335
+ "Content-Type": spaHtmlCache.contentType,
336
+ });
337
+ if (spaHtmlCache.etag) {
338
+ headers.set("ETag", spaHtmlCache.etag);
339
+ }
340
+ return withSecurityHeaders(
341
+ new Response(spaHtmlCache.body.slice(0), { headers }),
342
+ isDev
343
+ );
344
+ }
345
+ const boundPort = server.port ?? port;
346
+ const internalRequest = new Request(
347
+ `${loopbackHttpOrigin(gatewayConfig.host, boundPort)}${spaInternalPath}`
348
+ );
349
+ const raw = spaBundleSource
350
+ ? await spaBundleSource.fetch(internalRequest)
351
+ : await fetch(internalRequest);
352
+ if (!raw.ok) {
353
+ return withSecurityHeaders(
354
+ new Response("SPA unavailable", { status: 503 }),
355
+ isDev
356
+ );
357
+ }
358
+ if (!isDev) {
359
+ spaHtmlCache = {
360
+ body: await raw.arrayBuffer(),
361
+ contentType:
362
+ raw.headers.get("content-type") ?? "text/html;charset=utf-8",
363
+ etag: raw.headers.get("etag"),
364
+ };
365
+ const headers = new Headers({
366
+ "Content-Type": spaHtmlCache.contentType,
367
+ });
368
+ if (spaHtmlCache.etag) {
369
+ headers.set("ETag", spaHtmlCache.etag);
370
+ }
371
+ return withSecurityHeaders(
372
+ new Response(spaHtmlCache.body.slice(0), { headers }),
373
+ isDev
374
+ );
375
+ }
376
+ return withSecurityHeaders(raw, isDev);
377
+ };
378
+
379
+ const spaPageRoute = {
380
+ GET: serveSpaHtml,
381
+ };
382
+
264
383
  try {
265
384
  server = (dependencies.serve ?? Bun.serve)({
266
385
  port,
@@ -276,18 +395,21 @@ export async function startServer(
276
395
  isHttpGatewayLoopbackBind(gatewayConfig.host),
277
396
  clipperGateway
278
397
  ),
279
- // SPA routes - all serve the same React app
280
- "/": homepage,
281
- "/search": homepage,
282
- "/browse": homepage,
283
- "/doc": homepage,
284
- "/edit": homepage,
285
- "/collections": homepage,
286
- "/connectors": homepage,
287
- "/traces": homepage,
288
- "/ask": homepage,
289
- "/graph": homepage,
290
- "/clipper/pair": homepage,
398
+ // Injected-server tests only. Real runs keep the raw HTMLBundle off the
399
+ // public listener in createSpaBundleSource above.
400
+ ...(spaBundleSource ? {} : { [spaInternalPath]: homepage }),
401
+ // SPA routes - same React app, security envelope on every document
402
+ "/": spaPageRoute,
403
+ "/search": spaPageRoute,
404
+ "/browse": spaPageRoute,
405
+ "/doc": spaPageRoute,
406
+ "/edit": spaPageRoute,
407
+ "/collections": spaPageRoute,
408
+ "/connectors": spaPageRoute,
409
+ "/traces": spaPageRoute,
410
+ "/ask": spaPageRoute,
411
+ "/graph": spaPageRoute,
412
+ "/clipper/pair": spaPageRoute,
291
413
 
292
414
  // API routes with CSRF protection wrapper
293
415
  "/api/health": {
@@ -750,17 +872,17 @@ export async function startServer(
750
872
  );
751
873
  },
752
874
  },
753
- "/api/doc-asset": {
754
- GET: async (req: Request) => {
755
- const url = new URL(req.url);
756
- return withSecurityHeaders(
757
- await handleResidentRead(runtime as ResidentRuntime, req, () =>
758
- handleDocAsset(store, ctxHolder.config, url)
759
- ),
760
- isDev
761
- );
762
- },
763
- },
875
+ // fn-112: production factories shared with route-level tests (I1-04)
876
+ "/api/doc-asset": createDocAssetRouteHandlers({
877
+ store,
878
+ getConfig: () => ctxHolder.config,
879
+ runtime: runtime as ResidentRuntime,
880
+ isDev,
881
+ withSecurityHeaders,
882
+ }),
883
+ // Vendor assets are NOT mounted as valid-only patterns here.
884
+ // ALL /vendor/pdfjs/* traffic is handled by handlePdfjsVendorRequest
885
+ // in the fetch fallback below (same production dispatcher tests use).
764
886
  "/api/events": {
765
887
  GET: (req: Request) => {
766
888
  const residentRuntime = runtime as ResidentRuntime;
@@ -1136,9 +1258,37 @@ export async function startServer(
1136
1258
  },
1137
1259
  },
1138
1260
  },
1261
+ // Production vendor dispatcher for the entire /vendor/pdfjs prefix.
1262
+ // Covers valid worker/cMap/font AND malformed/unknown/POST — same function
1263
+ // that tests invoke (no test-only fallback path).
1264
+ fetch: async (req: Request): Promise<Response> => {
1265
+ const pathname = new URL(req.url).pathname;
1266
+ if (isPdfjsVendorPath(pathname)) {
1267
+ return handlePdfjsVendorRequest(req, {
1268
+ isDev,
1269
+ withSecurityHeaders,
1270
+ });
1271
+ }
1272
+ if (
1273
+ spaBundleSource &&
1274
+ (req.method === "GET" || req.method === "HEAD")
1275
+ ) {
1276
+ const asset = await spaBundleSource.fetch(req);
1277
+ if (asset.status !== 404) {
1278
+ return withSecurityHeaders(asset, isDev);
1279
+ }
1280
+ }
1281
+ return withSecurityHeaders(
1282
+ new Response("Not Found", { status: 404 }),
1283
+ isDev
1284
+ );
1285
+ },
1139
1286
  });
1140
1287
  } catch (e) {
1141
1288
  removeShutdownHandlers();
1289
+ if (spaBundleSource) {
1290
+ await Promise.allSettled([spaBundleSource.close()]);
1291
+ }
1142
1292
  await Promise.allSettled([gateway.close()]);
1143
1293
  await Promise.allSettled([runtime.dispose()]);
1144
1294
  return {
@@ -1168,6 +1318,9 @@ export async function startServer(
1168
1318
  try {
1169
1319
  await server.stop(true);
1170
1320
  } finally {
1321
+ if (spaBundleSource) {
1322
+ await Promise.allSettled([spaBundleSource.close()]);
1323
+ }
1171
1324
  await Promise.allSettled([gateway.close()]);
1172
1325
  await Promise.allSettled([runtime.dispose()]);
1173
1326
  }
@@ -0,0 +1,99 @@
1
+ import type { HTMLBundle, Server } from "bun";
2
+
3
+ // node:fs/promises — no Bun equivalent for removing a Unix socket pathname.
4
+ import { unlink } from "node:fs/promises";
5
+ // node:os — no Bun equivalent for the platform temporary directory.
6
+ import { tmpdir } from "node:os";
7
+ // node:path — no Bun equivalent for joining the socket path.
8
+ import { join } from "node:path";
9
+
10
+ type BunServer = Server<unknown>;
11
+
12
+ export type SpaBundleSource = {
13
+ entryPath: string;
14
+ fetch(request: Request): Promise<Response>;
15
+ close(): Promise<void>;
16
+ };
17
+
18
+ const notFound = (): Response => new Response("Not Found", { status: 404 });
19
+
20
+ /**
21
+ * Host Bun's headerless HTMLBundle surface outside the public listener.
22
+ *
23
+ * Unix hosts use a private Unix-domain socket, so the raw bundle and generated
24
+ * assets have no TCP origin at all. Windows falls back to an ephemeral
25
+ * loopback listener plus an unguessable entry path. The public server proxies
26
+ * the bytes and applies its normal security envelope before browser delivery.
27
+ */
28
+ export function createSpaBundleSource(
29
+ bundle: HTMLBundle,
30
+ isDev: boolean
31
+ ): SpaBundleSource {
32
+ const nonce = crypto.randomUUID().replaceAll("-", "");
33
+ const entryPath = `/__gno_spa_${nonce}`;
34
+ let server: BunServer;
35
+ let fetchPrivate: (request: Request) => Promise<Response>;
36
+ let socketPath: string | null = null;
37
+
38
+ const routes = { [entryPath]: bundle };
39
+ const fallback = { fetch: notFound };
40
+
41
+ if (process.platform === "win32") {
42
+ server = Bun.serve({
43
+ hostname: "127.0.0.1",
44
+ port: 0,
45
+ development: isDev,
46
+ routes,
47
+ ...fallback,
48
+ });
49
+ const origin = `http://127.0.0.1:${server.port}`;
50
+ fetchPrivate = async (request): Promise<Response> => {
51
+ const url = new URL(request.url);
52
+ return fetch(`${origin}${url.pathname}${url.search}`, {
53
+ method: request.method,
54
+ });
55
+ };
56
+ } else {
57
+ socketPath = join(tmpdir(), `gno-spa-${nonce.slice(0, 20)}.sock`);
58
+ server = Bun.serve({
59
+ unix: socketPath,
60
+ development: isDev,
61
+ routes,
62
+ ...fallback,
63
+ });
64
+ fetchPrivate = async (request): Promise<Response> => {
65
+ const url = new URL(request.url);
66
+ return fetch(`http://localhost${url.pathname}${url.search}`, {
67
+ method: request.method,
68
+ unix: socketPath ?? undefined,
69
+ });
70
+ };
71
+ }
72
+
73
+ let closed = false;
74
+ return {
75
+ entryPath,
76
+ fetch: fetchPrivate,
77
+ async close(): Promise<void> {
78
+ if (closed) {
79
+ return;
80
+ }
81
+ closed = true;
82
+ await server.stop(true);
83
+ if (socketPath) {
84
+ try {
85
+ await unlink(socketPath);
86
+ } catch (error) {
87
+ if (
88
+ !error ||
89
+ typeof error !== "object" ||
90
+ !("code" in error) ||
91
+ error.code !== "ENOENT"
92
+ ) {
93
+ throw error;
94
+ }
95
+ }
96
+ }
97
+ },
98
+ };
99
+ }
@@ -1 +0,0 @@
1
- c1fc560d5f77022b3123d36c0f488b0e662883b0b67e6e40fe7476674d70e085 gno-browser-clipper-v1.29.6.zip