@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.
Binary file
@@ -0,0 +1 @@
1
+ 1d6a17618bddf4425527e94961459fa2a414d06f5123d43ffebadfeca8a3a89b gno-browser-clipper-v1.40.0.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.39.2"
24
+ "version": "1.40.0"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.39.2",
3
+ "version": "1.40.0",
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",
@@ -192,6 +192,16 @@ export const NETWORK_BOUNDARY_INVENTORY = [
192
192
  enforcement: "client_transport",
193
193
  serverBoundary: "src/serve/server.ts",
194
194
  },
195
+ {
196
+ // Same-origin HEAD against /api/doc-asset to pick the PDF transport tier.
197
+ id: "browser-pdf-transport-probe",
198
+ key: "src/serve/public/hooks/use-pdf-document.ts::fetch#1",
199
+ path: "src/serve/public/hooks/use-pdf-document.ts",
200
+ primitive: "fetch",
201
+ action: "serve",
202
+ enforcement: "client_transport",
203
+ serverBoundary: "src/serve/routes/api.ts",
204
+ },
195
205
  {
196
206
  id: "clipper-pair-csrf",
197
207
  key: "src/serve/public/lib/clipper-approval.ts::fetch#1",
@@ -71,12 +71,13 @@ Answer generation uses shared module to stay in sync with CLI:
71
71
  | --------------------- | ------ | ------------------------------------------ |
72
72
  | `/api/health` | GET | Health check |
73
73
  | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
74
- | `/api/capabilities` | GET | Available features |
74
+ | `/api/capabilities` | GET | Available features + `localClient` |
75
75
  | `/api/collections` | GET | List collections |
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) |
79
+ | `/api/doc-asset` | GET | Source file bytes (Range, HEAD, ETag/304) |
80
+ | `/api/docs/:id/reveal`| POST | Reveal source on host (local client only) |
80
81
  | `/vendor/pdfjs/*` | GET | Same-origin pdfjs worker/cmaps/fonts |
81
82
  | `/api/search` | POST | BM25 search |
82
83
  | `/api/query` | POST | Hybrid search |
@@ -94,6 +95,17 @@ Answer generation uses shared module to stay in sync with CLI:
94
95
  - **AI Elements**: Conversation, Message, Sources, CodeBlock, Loader
95
96
  - **Tag Components**: TagInput, TagFacets (filter sidebar)
96
97
  - **Routing**: Simple hash-free SPA routing in App.tsx
98
+ - **Server capabilities**: `public/lib/server-capabilities.ts` is the one
99
+ shared `ServerCapabilities` type (`localClient`) plus
100
+ `fetchServerCapabilities()`; a failed read counts as remote
101
+ - **PDF viewer transport**: `public/lib/pdf-transport.ts` holds the tier
102
+ constants (`PDF_WHOLE_FILE_MAX_BYTES` = 8 MB, `PDF_RANGE_CHUNK_BYTES` =
103
+ 1 MB) with no pdfjs import so Bun-side tooling can read them;
104
+ `hooks/use-pdf-document.ts` sends one same-origin `HEAD` per load to pick
105
+ the tier and falls back to ranged on any probe failure;
106
+ `hooks/use-pdf-pages.ts` publishes page slots from page 1 geometry and
107
+ corrects sizes and fit scales in one commit when the full pass lands
108
+ (fatal error = page 1 or document; later pages carry `slot.error`)
97
109
 
98
110
  ## Development
99
111
 
@@ -110,6 +122,14 @@ gno serve --port 3000
110
122
  - Binds to `127.0.0.1` only (no LAN exposure)
111
123
  - CSP headers on all responses
112
124
  - CORS protection on POST endpoints
125
+ - Request locality (`request-locality.ts`): `localClient` is true only for a
126
+ loopback peer, a loopback `Host`, and no forwarding headers; the reveal
127
+ route refuses non-local clients with 403
128
+ - HTTP caching: `/api/doc-asset` sends a strong `ETag` (size + mtime) with
129
+ `private, max-age=0, must-revalidate` and answers `If-None-Match` with 304
130
+ before Range handling; hashed SPA chunks (`/chunk-*.js|css`) get a one-year
131
+ `immutable` policy and a gzip body via `createPublicFetchFallback` in
132
+ `server.ts` (production only; the entry HTML and dev/HMR are untouched)
113
133
  - No external font/script loading — the PDF.js worker, cMaps, and standard fonts
114
134
  are served same-origin from the installed `pdfjs-dist` package via
115
135
  `/vendor/pdfjs/*`, so the CSP keeps `worker-src 'self'` and `font-src 'self'`
@@ -4,6 +4,7 @@ import type { HttpMcpPeerServer } from "../mcp/http-security";
4
4
 
5
5
  import { readClipperBoundedJson } from "./clipper-body";
6
6
  import { clipperSecurityErrorResponse } from "./clipper-security-errors";
7
+ import { isLoopbackAddress } from "./request-locality";
7
8
  import { ReaderGate } from "./resident-admission";
8
9
 
9
10
  export { readClipperBoundedJson } from "./clipper-body";
@@ -126,7 +127,7 @@ function validateSameOriginEntry(origin: string): boolean {
126
127
 
127
128
  function isLoopbackHostname(hostname: string): boolean {
128
129
  if (hostname === "localhost") return true;
129
- return isClipperLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
130
+ return isLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
130
131
  }
131
132
 
132
133
  function normalizedHeaderNames(headers: readonly string[]): string[] {
@@ -144,21 +145,6 @@ function normalizedHeaderNames(headers: readonly string[]): string[] {
144
145
  return [...new Set(normalized)];
145
146
  }
146
147
 
147
- export function isClipperLoopbackAddress(address: string): boolean {
148
- const normalized = address.toLowerCase();
149
- if (normalized === "::1") return true;
150
- const mapped = normalized.startsWith("::ffff:")
151
- ? normalized.slice("::ffff:".length)
152
- : normalized;
153
- const octets = mapped.split(".");
154
- return (
155
- octets.length === 4 &&
156
- octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
157
- Number(octets[0]) === 127 &&
158
- octets.every((octet) => Number(octet) <= 255)
159
- );
160
- }
161
-
162
148
  export function parseClipperExtensionOrigin(
163
149
  origin: string
164
150
  ): { extensionId: string; origin: string } | null {
@@ -370,7 +356,7 @@ export class ClipperSecurityBoundary {
370
356
  }
371
357
  | ClipperSecurityFailure {
372
358
  const peer = server.requestIP(request);
373
- if (!peer || !isClipperLoopbackAddress(peer.address)) {
359
+ if (!peer || !isLoopbackAddress(peer.address)) {
374
360
  return {
375
361
  ok: false,
376
362
  response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
@@ -99,6 +99,69 @@ function StatePanel({
99
99
  );
100
100
  }
101
101
 
102
+ const ERROR_EYEBROW: Record<PdfFallbackReason, string> = {
103
+ corrupt: "CANNOT RENDER",
104
+ password: "PASSWORD PROTECTED",
105
+ network: "COULD NOT LOAD",
106
+ bootstrap: "VIEWER UNAVAILABLE",
107
+ };
108
+
109
+ const PAGE_ERROR_BODY: Record<PdfFallbackReason, string> = {
110
+ corrupt: "This page could not be rendered.",
111
+ password: "This page is password protected.",
112
+ network: "This page could not be loaded from this session.",
113
+ bootstrap: "The PDF viewer could not render this page.",
114
+ };
115
+
116
+ type PdfPageErrorSlotProps = {
117
+ pageNumber: number;
118
+ width: number;
119
+ height: number;
120
+ error: PdfFallbackReason;
121
+ onMount: (pageNumber: number, el: HTMLElement | null) => void;
122
+ };
123
+
124
+ /**
125
+ * A later page whose geometry failed (R2): the page error state rendered in
126
+ * place of the page, at the slot's placeholder size so the scroll model and
127
+ * the visibility window keep their shape. Never fires the text fallback.
128
+ */
129
+ function PdfPageErrorSlot({
130
+ pageNumber,
131
+ width,
132
+ height,
133
+ error,
134
+ onMount,
135
+ }: PdfPageErrorSlotProps) {
136
+ const rootRef = useRef<HTMLDivElement | null>(null);
137
+
138
+ useEffect(() => {
139
+ onMount(pageNumber, rootRef.current);
140
+ return () => {
141
+ onMount(pageNumber, null);
142
+ };
143
+ }, [pageNumber, onMount]);
144
+
145
+ return (
146
+ <div
147
+ className="gno-pdf-page flex flex-col items-center justify-center gap-2 px-4 text-center"
148
+ data-page-error={error}
149
+ data-page-number={pageNumber}
150
+ data-testid={`pdf-page-${pageNumber}`}
151
+ ref={rootRef}
152
+ role="status"
153
+ style={{ width: width || undefined, height: height || undefined }}
154
+ >
155
+ <p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
156
+ {ERROR_EYEBROW[error]}
157
+ </p>
158
+ <p className="text-[13px] text-foreground/90 leading-relaxed">
159
+ Page {pageNumber}: {PAGE_ERROR_BODY[error]}
160
+ </p>
161
+ </div>
162
+ );
163
+ }
164
+
102
165
  function prefersReducedMotion(): boolean {
103
166
  if (
104
167
  typeof window === "undefined" ||
@@ -184,7 +247,11 @@ export function PdfViewer({
184
247
  containerWidth,
185
248
  containerHeight,
186
249
  genId,
250
+ scrollContainerRef: columnRef,
187
251
  });
252
+ // Fatal only: a document load failure or the hook's fatal error (page 1
253
+ // geometry, render-path acquisition). A later page's geometry failure rides
254
+ // on its slot and never reaches this path.
188
255
  const viewerError = status === "error" ? error : pages.error;
189
256
 
190
257
  // Fallback: exactly once per failed load/page attempt when extracted text exists.
@@ -400,7 +467,7 @@ export function PdfViewer({
400
467
  <StatePanel
401
468
  body="This PDF could not be rendered. Download the original to read it."
402
469
  downloadUrl={downloadUrl}
403
- eyebrow="CANNOT RENDER"
470
+ eyebrow={ERROR_EYEBROW.corrupt}
404
471
  onRetry={handleRetry}
405
472
  role="alert"
406
473
  testId="pdf-state-corrupt"
@@ -411,7 +478,7 @@ export function PdfViewer({
411
478
  <StatePanel
412
479
  body="This PDF is password protected. Download the original to open it in a PDF reader."
413
480
  downloadUrl={downloadUrl}
414
- eyebrow="PASSWORD PROTECTED"
481
+ eyebrow={ERROR_EYEBROW.password}
415
482
  role="alert"
416
483
  testId="pdf-state-password"
417
484
  />
@@ -421,7 +488,7 @@ export function PdfViewer({
421
488
  <StatePanel
422
489
  body="The document could not be loaded from this session. Try again, or download the original."
423
490
  downloadUrl={downloadUrl}
424
- eyebrow="COULD NOT LOAD"
491
+ eyebrow={ERROR_EYEBROW.network}
425
492
  onRetry={handleRetry}
426
493
  role="alert"
427
494
  testId="pdf-state-network"
@@ -432,7 +499,7 @@ export function PdfViewer({
432
499
  <StatePanel
433
500
  body="The PDF viewer could not start in this window. Download the original to read it."
434
501
  downloadUrl={downloadUrl}
435
- eyebrow="VIEWER UNAVAILABLE"
502
+ eyebrow={ERROR_EYEBROW.bootstrap}
436
503
  onRetry={handleRetry}
437
504
  role="alert"
438
505
  testId="pdf-state-bootstrap"
@@ -512,21 +579,32 @@ export function PdfViewer({
512
579
 
513
580
  {showProgressive ? (
514
581
  <div className="mx-auto flex w-fit flex-col items-center gap-6">
515
- {pages.slots.map((slot) => (
516
- <PdfPageView
517
- key={slot.pageNumber}
518
- active={slot.active}
519
- doc={doc}
520
- height={slot.height}
521
- onMount={pages.observePage}
522
- onInternalNavigate={goToPage}
523
- onRender={pages.ensureRendered}
524
- pageNumber={slot.pageNumber}
525
- rendered={slot.rendered}
526
- scale={pages.scale}
527
- width={slot.width}
528
- />
529
- ))}
582
+ {pages.slots.map((slot) =>
583
+ slot.error ? (
584
+ <PdfPageErrorSlot
585
+ key={slot.pageNumber}
586
+ error={slot.error}
587
+ height={slot.height}
588
+ onMount={pages.observePage}
589
+ pageNumber={slot.pageNumber}
590
+ width={slot.width}
591
+ />
592
+ ) : (
593
+ <PdfPageView
594
+ key={slot.pageNumber}
595
+ active={slot.active}
596
+ doc={doc}
597
+ height={slot.height}
598
+ onMount={pages.observePage}
599
+ onInternalNavigate={goToPage}
600
+ onRender={pages.ensureRendered}
601
+ pageNumber={slot.pageNumber}
602
+ rendered={slot.rendered}
603
+ scale={pages.scale}
604
+ width={slot.width}
605
+ />
606
+ )
607
+ )}
530
608
  </div>
531
609
  ) : null}
532
610
  </div>