@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.
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.40.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +88 -3
- package/src/serve/server.ts +163 -29
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
package/src/serve/server.ts
CHANGED
|
@@ -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(
|
|
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
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
fetch:
|
|
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
|