@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.
- package/README.md +15 -5
- package/browser-extension/artifacts/{gno-browser-clipper-v1.29.6.zip → gno-browser-clipper-v1.30.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.30.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +4 -1
- package/src/core/network-boundary-inventory.ts +51 -0
- package/src/serve/AGENTS.md +5 -1
- package/src/serve/CLAUDE.md +5 -1
- package/src/serve/fn112-routes.ts +232 -0
- package/src/serve/pdfjs-assets.ts +391 -0
- package/src/serve/public/components/pdf/PdfPageView.tsx +427 -0
- package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
- package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
- package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +113 -0
- package/src/serve/public/hooks/use-pdf-document.ts +227 -0
- package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
- package/src/serve/public/lib/doc-asset-url.ts +57 -0
- package/src/serve/public/lib/math-sum-precise.ts +34 -0
- package/src/serve/public/lib/pdf.ts +772 -0
- package/src/serve/public/pages/DocView.tsx +295 -39
- package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
- package/src/serve/routes/api.ts +154 -14
- package/src/serve/server.ts +190 -37
- package/src/serve/spa-bundle-source.ts +99 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.29.6.zip.sha256 +0 -1
package/src/serve/server.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
headers
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
"/
|
|
284
|
-
"/
|
|
285
|
-
"/
|
|
286
|
-
"/
|
|
287
|
-
"/
|
|
288
|
-
"/
|
|
289
|
-
"/
|
|
290
|
-
"/
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|