@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.
@@ -18,7 +18,17 @@ import {
18
18
  TextIcon,
19
19
  TrashIcon,
20
20
  } from "lucide-react";
21
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import {
22
+ lazy,
23
+ Suspense,
24
+ useCallback,
25
+ useEffect,
26
+ useMemo,
27
+ useRef,
28
+ useState,
29
+ } from "react";
30
+
31
+ import type { PdfFallbackReason } from "../lib/pdf";
22
32
 
23
33
  import { extractSections } from "../../../core/sections";
24
34
  import {
@@ -63,6 +73,11 @@ import {
63
73
  buildEditDeepLink,
64
74
  parseDocumentDeepLink,
65
75
  } from "../lib/deep-links";
76
+ import {
77
+ buildDocAssetUrl,
78
+ isExtractedTextAvailable,
79
+ isPdfDocument,
80
+ } from "../lib/doc-asset-url";
66
81
  import { waitForDocumentAvailability } from "../lib/document-availability";
67
82
  import {
68
83
  downloadPublishArtifactFile,
@@ -70,6 +85,67 @@ import {
70
85
  } from "../lib/publish-export";
71
86
  import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
72
87
 
88
+ /** Lazy so pdfjs is never pulled for non-PDF documents. */
89
+ const PdfViewer = lazy(() => import("./doc-pdf-viewer"));
90
+
91
+ /** Spec "Canonical fallback-notice copy" — DocView Text branch only. */
92
+ const PDF_FALLBACK_NOTICE: Record<
93
+ PdfFallbackReason,
94
+ { eyebrow: string; body: string }
95
+ > = {
96
+ corrupt: {
97
+ eyebrow: "CANNOT RENDER",
98
+ body: "This PDF could not be rendered. View the extracted text or download the original.",
99
+ },
100
+ password: {
101
+ eyebrow: "PASSWORD PROTECTED",
102
+ body: "This PDF is password protected. Showing the extracted text instead. Download the original to open it in a PDF reader.",
103
+ },
104
+ network: {
105
+ eyebrow: "COULD NOT LOAD",
106
+ body: "The document could not be loaded from this session. Showing the extracted text instead. Switch to Pages to try again, or download the original.",
107
+ },
108
+ bootstrap: {
109
+ eyebrow: "VIEWER UNAVAILABLE",
110
+ body: "The PDF viewer could not start in this window. Showing the extracted text instead. Download the original to read it.",
111
+ },
112
+ };
113
+
114
+ function PdfFallbackNotice({
115
+ reason,
116
+ downloadUrl,
117
+ }: {
118
+ reason: PdfFallbackReason;
119
+ downloadUrl: string;
120
+ }) {
121
+ const copy = PDF_FALLBACK_NOTICE[reason];
122
+ return (
123
+ <div
124
+ className="mb-4 flex max-w-2xl flex-col items-start gap-2 py-2 text-left"
125
+ data-testid={`pdf-fallback-${reason}`}
126
+ role="status"
127
+ >
128
+ <p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
129
+ {copy.eyebrow}
130
+ </p>
131
+ <p className="text-[13px] text-foreground/90 leading-relaxed">
132
+ {copy.body}
133
+ </p>
134
+ <Button
135
+ asChild
136
+ className="cursor-pointer focus-visible:ring-primary/50"
137
+ data-testid="pdf-notice-download"
138
+ size="sm"
139
+ variant="secondary"
140
+ >
141
+ <a download href={downloadUrl || undefined}>
142
+ Download original
143
+ </a>
144
+ </Button>
145
+ </div>
146
+ );
147
+ }
148
+
73
149
  interface PageProps {
74
150
  navigate: (to: string | number) => void;
75
151
  }
@@ -287,6 +363,9 @@ export default function DocView({ navigate }: PageProps) {
287
363
  const [duplicateName, setDuplicateName] = useState("");
288
364
  const [duplicateWarnings, setDuplicateWarnings] = useState<string[]>([]);
289
365
  const [showRawView, setShowRawView] = useState(false);
366
+ /** DocView-owned PDF fallback reason (null when Pages or no fallback). */
367
+ const [pdfFallbackReason, setPdfFallbackReason] =
368
+ useState<PdfFallbackReason | null>(null);
290
369
  const [creatingCopy, setCreatingCopy] = useState(false);
291
370
  const [copyError, setCopyError] = useState<string | null>(null);
292
371
  const [externalChangeNotice, setExternalChangeNotice] = useState<
@@ -422,6 +501,18 @@ export default function DocView({ navigate }: PageProps) {
422
501
  ".bash",
423
502
  ].includes(doc.source.ext.toLowerCase());
424
503
 
504
+ const isPdf = Boolean(doc && isPdfDocument(doc.source));
505
+
506
+ // Spec predicate — evaluated per render, never from mime/ext.
507
+ const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc));
508
+
509
+ const pdfAssetUrl = useMemo(() => {
510
+ if (!doc || !isPdf) {
511
+ return null;
512
+ }
513
+ return buildDocAssetUrl(doc.uri, doc.relPath);
514
+ }, [doc, isPdf]);
515
+
425
516
  // Parse frontmatter for markdown files
426
517
  const parsedContent = useMemo(() => {
427
518
  if (!doc?.content || !isMarkdown) {
@@ -439,6 +530,39 @@ export default function DocView({ navigate }: PageProps) {
439
530
  }
440
531
  }, [currentTarget.lineStart, currentTarget.view]);
441
532
 
533
+ // Clear fallback when the loaded document identity changes.
534
+ useEffect(() => {
535
+ setPdfFallbackReason(null);
536
+ }, [doc?.uri]);
537
+
538
+ /**
539
+ * DocView defends the extractedTextAvailable boundary itself: a spurious
540
+ * onFallback while the predicate is false must not switch view or store a
541
+ * reason (viewer/error surface stays mounted).
542
+ */
543
+ const handlePdfFallback = useCallback(
544
+ (reason: PdfFallbackReason) => {
545
+ // Re-evaluate from current doc identity — never trust a stale closure alone.
546
+ if (!doc || !isExtractedTextAvailable(doc)) {
547
+ return;
548
+ }
549
+ setPdfFallbackReason(reason);
550
+ setShowRawView(true);
551
+ },
552
+ [doc]
553
+ );
554
+
555
+ /** Pages/Text for PDFs — clearing notice when returning to Pages. */
556
+ const togglePdfPagesText = useCallback(() => {
557
+ setShowRawView((prev) => {
558
+ if (prev) {
559
+ setPdfFallbackReason(null);
560
+ return false;
561
+ }
562
+ return true;
563
+ });
564
+ }, []);
565
+
442
566
  useEffect(() => {
443
567
  if (!currentHash || showRawView || loading) {
444
568
  return;
@@ -1464,6 +1588,20 @@ export default function DocView({ navigate }: PageProps) {
1464
1588
  Reveal
1465
1589
  </Button>
1466
1590
  )}
1591
+ {isPdf && pdfAssetUrl ? (
1592
+ <Button
1593
+ asChild
1594
+ className="gap-1.5 cursor-pointer"
1595
+ data-testid="pdf-header-download"
1596
+ size="sm"
1597
+ variant="outline"
1598
+ >
1599
+ <a download href={pdfAssetUrl}>
1600
+ <SquareArrowOutUpRightIcon className="size-4" />
1601
+ Download original
1602
+ </a>
1603
+ </Button>
1604
+ ) : null}
1467
1605
  <Button
1468
1606
  className="gap-1.5 text-muted-foreground hover:text-destructive"
1469
1607
  onClick={() => setDeleteDialogOpen(true)}
@@ -1579,8 +1717,8 @@ export default function DocView({ navigate }: PageProps) {
1579
1717
 
1580
1718
  {/* Content */}
1581
1719
  <div className="relative">
1582
- {/* Source/Rendered toggle pill */}
1583
- {isMarkdown && doc.contentAvailable && (
1720
+ {/* Markdown Source/Rendered pill — unchanged for non-PDF */}
1721
+ {isMarkdown && doc.contentAvailable && !isPdf && (
1584
1722
  <button
1585
1723
  className="z-10 flex cursor-pointer items-center gap-1.5 rounded-full border border-border/30 bg-background/80 px-3 py-1 font-mono text-[11px] text-muted-foreground backdrop-blur-sm transition-colors hover:border-primary/30 hover:text-primary"
1586
1724
  onClick={() => setShowRawView(!showRawView)}
@@ -1606,48 +1744,166 @@ export default function DocView({ navigate }: PageProps) {
1606
1744
  </button>
1607
1745
  )}
1608
1746
 
1609
- {!doc.contentAvailable && (
1747
+ {/* PDF Pages/Text pill — DocView sole owner (no PdfToolbar toggle) */}
1748
+ {isPdf && (
1749
+ <button
1750
+ className="z-10 flex cursor-pointer items-center gap-1.5 rounded-full border border-border/30 bg-background/80 px-3 py-1 font-mono text-[11px] text-muted-foreground backdrop-blur-sm transition-colors hover:border-primary/30 hover:text-primary"
1751
+ data-testid="pdf-pages-text-toggle"
1752
+ onClick={togglePdfPagesText}
1753
+ style={{
1754
+ position: "absolute",
1755
+ top: "0.75rem",
1756
+ right: "0.75rem",
1757
+ left: "auto",
1758
+ }}
1759
+ type="button"
1760
+ >
1761
+ {showRawView ? (
1762
+ <>
1763
+ <FileText className="size-3" />
1764
+ Pages
1765
+ </>
1766
+ ) : (
1767
+ <>
1768
+ <TextIcon className="size-3" />
1769
+ Text
1770
+ </>
1771
+ )}
1772
+ </button>
1773
+ )}
1774
+
1775
+ {/* PDF branch: Pages = lazy PdfViewer; Text = extracted + optional notice */}
1776
+ {isPdf && !showRawView && pdfAssetUrl ? (
1777
+ // Reserve the band the absolutely-positioned Pages/Text pill
1778
+ // occupies (top 0.75rem + ~1.75rem tall). Without it the
1779
+ // sticky PdfToolbar (z-10) renders after the pill (also
1780
+ // z-10) and covers it, leaving the toggle invisible and
1781
+ // un-clickable on every PDF that renders. Inline (like the
1782
+ // pill's own positioning above) so the exact clearance is
1783
+ // explicit: the pill occupies 0.75rem + ~1.75rem, and a
1784
+ // `pt-10` utility (2.5rem) would leave it 0.14px short.
1785
+ <div style={{ paddingTop: "2.75rem" }}>
1786
+ <Suspense
1787
+ fallback={
1788
+ <div className="flex items-center gap-2 py-10 text-muted-foreground">
1789
+ <Loader className="size-4" />
1790
+ <span className="font-mono text-xs">
1791
+ Loading viewer…
1792
+ </span>
1793
+ </div>
1794
+ }
1795
+ >
1796
+ <PdfViewer
1797
+ key={doc.uri}
1798
+ assetUrl={pdfAssetUrl}
1799
+ downloadUrl={pdfAssetUrl}
1800
+ extractedTextAvailable={extractedTextAvailable}
1801
+ onFallback={handlePdfFallback}
1802
+ />
1803
+ </Suspense>
1804
+ </div>
1805
+ ) : null}
1806
+
1807
+ {isPdf && showRawView ? (
1808
+ <div className="pt-10">
1809
+ {pdfFallbackReason && extractedTextAvailable ? (
1810
+ <PdfFallbackNotice
1811
+ downloadUrl={pdfAssetUrl ?? ""}
1812
+ reason={pdfFallbackReason}
1813
+ />
1814
+ ) : null}
1815
+ {!doc.contentAvailable ? (
1816
+ <div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
1817
+ <p className="text-muted-foreground">
1818
+ Content not available (document may need re-indexing)
1819
+ </p>
1820
+ </div>
1821
+ ) : null}
1822
+ {doc.contentAvailable && !extractedTextAvailable ? (
1823
+ <div
1824
+ className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center"
1825
+ data-testid="pdf-no-extracted-text"
1826
+ >
1827
+ <p className="text-muted-foreground">
1828
+ No extracted text for this document.
1829
+ </p>
1830
+ {pdfAssetUrl ? (
1831
+ <div className="mt-3">
1832
+ <Button
1833
+ asChild
1834
+ className="cursor-pointer"
1835
+ size="sm"
1836
+ >
1837
+ <a download href={pdfAssetUrl}>
1838
+ Download original
1839
+ </a>
1840
+ </Button>
1841
+ </div>
1842
+ ) : null}
1843
+ </div>
1844
+ ) : null}
1845
+ {extractedTextAvailable ? (
1846
+ <div className="rounded-lg border border-border/50 bg-muted/30 p-6">
1847
+ <pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
1848
+ {doc.content}
1849
+ </pre>
1850
+ </div>
1851
+ ) : null}
1852
+ </div>
1853
+ ) : null}
1854
+
1855
+ {/* Non-PDF branches (byte-identical behavior) */}
1856
+ {!isPdf && !doc.contentAvailable && (
1610
1857
  <div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
1611
1858
  <p className="text-muted-foreground">
1612
1859
  Content not available (document may need re-indexing)
1613
1860
  </p>
1614
1861
  </div>
1615
1862
  )}
1616
- {doc.contentAvailable && isMarkdown && !showRawView && (
1617
- <div className="rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 p-4 shadow-inner">
1618
- <MarkdownPreview
1619
- collection={doc.collection}
1620
- content={parsedContent.body}
1621
- docUri={doc.uri}
1622
- wikiLinks={resolvedWikiLinks}
1623
- />
1624
- </div>
1625
- )}
1626
- {doc.contentAvailable && isMarkdown && showRawView && (
1627
- <CodeBlock
1628
- code={doc.content ?? ""}
1629
- highlightedLines={highlightedLines}
1630
- language={"markdown" as BundledLanguage}
1631
- scrollToLine={currentTarget.lineStart}
1632
- showLineNumbers
1633
- >
1634
- <CodeBlockCopyButton />
1635
- </CodeBlock>
1636
- )}
1637
- {doc.contentAvailable && isCodeFile && !isMarkdown && (
1638
- <CodeBlock
1639
- code={doc.content ?? ""}
1640
- highlightedLines={highlightedLines}
1641
- language={
1642
- getLanguageFromExt(doc.source.ext) as BundledLanguage
1643
- }
1644
- scrollToLine={currentTarget.lineStart}
1645
- showLineNumbers
1646
- >
1647
- <CodeBlockCopyButton />
1648
- </CodeBlock>
1649
- )}
1650
- {doc.contentAvailable && !isCodeFile && (
1863
+ {!isPdf &&
1864
+ doc.contentAvailable &&
1865
+ isMarkdown &&
1866
+ !showRawView && (
1867
+ <div className="rounded-lg border border-border/40 bg-gradient-to-br from-background to-muted/10 p-4 shadow-inner">
1868
+ <MarkdownPreview
1869
+ collection={doc.collection}
1870
+ content={parsedContent.body}
1871
+ docUri={doc.uri}
1872
+ wikiLinks={resolvedWikiLinks}
1873
+ />
1874
+ </div>
1875
+ )}
1876
+ {!isPdf &&
1877
+ doc.contentAvailable &&
1878
+ isMarkdown &&
1879
+ showRawView && (
1880
+ <CodeBlock
1881
+ code={doc.content ?? ""}
1882
+ highlightedLines={highlightedLines}
1883
+ language={"markdown" as BundledLanguage}
1884
+ scrollToLine={currentTarget.lineStart}
1885
+ showLineNumbers
1886
+ >
1887
+ <CodeBlockCopyButton />
1888
+ </CodeBlock>
1889
+ )}
1890
+ {!isPdf &&
1891
+ doc.contentAvailable &&
1892
+ isCodeFile &&
1893
+ !isMarkdown && (
1894
+ <CodeBlock
1895
+ code={doc.content ?? ""}
1896
+ highlightedLines={highlightedLines}
1897
+ language={
1898
+ getLanguageFromExt(doc.source.ext) as BundledLanguage
1899
+ }
1900
+ scrollToLine={currentTarget.lineStart}
1901
+ showLineNumbers
1902
+ >
1903
+ <CodeBlockCopyButton />
1904
+ </CodeBlock>
1905
+ )}
1906
+ {!isPdf && doc.contentAvailable && !isCodeFile && (
1651
1907
  <div className="rounded-lg border border-border/50 bg-muted/30 p-6">
1652
1908
  <pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
1653
1909
  {doc.content}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * DocView-only re-export of PdfViewer.
3
+ *
4
+ * Kept as a separate module so DOM tests can mock.module this path without
5
+ * sticky-mock pollution of the real components/pdf/PdfViewer suite (fn-112.5).
6
+ */
7
+ export { PdfViewer as default } from "../components/pdf/PdfViewer";
@@ -5,8 +5,8 @@
5
5
  * @module src/serve/routes/api
6
6
  */
7
7
 
8
- // node:fs/promises structure ops have no Bun equivalent
9
- import { readdir } from "node:fs/promises";
8
+ // node:fs/promises structure ops + realpath have no Bun equivalent
9
+ import { readdir, realpath } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
11
  import { posix as pathPosix } from "node:path";
12
12
 
@@ -688,15 +688,57 @@ function isAbsoluteFilesystemPath(pathValue: string): boolean {
688
688
  );
689
689
  }
690
690
 
691
- async function isPathWithinRoot(
691
+ export type RealpathFn = (path: string) => Promise<string>;
692
+
693
+ /**
694
+ * Lexical containment, then realpath containment (symlink-escape defense).
695
+ * Only candidate ENOENT falls back to the lexical verdict; other realpath
696
+ * errors fail closed. Both root and candidate are canonicalized when present.
697
+ * Exported for adversarial unit tests (I1-04 non-ENOENT fail-closed).
698
+ */
699
+ export async function isPathWithinRoot(
692
700
  root: string,
693
- candidate: string
701
+ candidate: string,
702
+ realpathFn: RealpathFn = realpath
694
703
  ): Promise<boolean> {
695
704
  const nodePath = await import("node:path"); // no bun equivalent
696
705
  const relative = nodePath.relative(root, candidate);
697
- return (
706
+ const lexicallyWithin =
698
707
  relative === "" ||
699
- (!relative.startsWith("..") && !nodePath.isAbsolute(relative))
708
+ (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
709
+ if (!lexicallyWithin) {
710
+ return false;
711
+ }
712
+
713
+ let resolvedRoot: string;
714
+ try {
715
+ resolvedRoot = await realpathFn(root);
716
+ } catch {
717
+ // Root must resolve; fail closed
718
+ return false;
719
+ }
720
+
721
+ let resolvedCandidate: string;
722
+ try {
723
+ resolvedCandidate = await realpathFn(candidate);
724
+ } catch (error) {
725
+ const code =
726
+ error && typeof error === "object" && "code" in error
727
+ ? (error as { code?: string }).code
728
+ : undefined;
729
+ // Genuinely missing file: accept lexical verdict so callers can 404 later
730
+ if (code === "ENOENT") {
731
+ return true;
732
+ }
733
+ // EACCES, ELOOP, etc. fail closed
734
+ return false;
735
+ }
736
+
737
+ const resolvedRelative = nodePath.relative(resolvedRoot, resolvedCandidate);
738
+ return (
739
+ resolvedRelative === "" ||
740
+ (!resolvedRelative.startsWith("..") &&
741
+ !nodePath.isAbsolute(resolvedRelative))
700
742
  );
701
743
  }
702
744
 
@@ -2056,15 +2098,77 @@ export async function handleDoc(
2056
2098
  }
2057
2099
 
2058
2100
  /**
2059
- * GET /api/doc-asset
2101
+ * Parse a single-range `Range: bytes=…` header per RFC 9110.
2102
+ * Multi-range requests are rejected here; the caller returns 416 with
2103
+ * Content-Range `bytes * / <size>` (RFC unsatisfiable form; see serve path).
2104
+ */
2105
+ function parseSingleByteRange(
2106
+ rangeHeader: string,
2107
+ size: number
2108
+ ):
2109
+ | { ok: true; start: number; end: number }
2110
+ | { ok: false; reason: "malformed" | "unsatisfiable" } {
2111
+ const trimmed = rangeHeader.trim();
2112
+ // Multi-range: not supported — signal so caller can return 416
2113
+ if (trimmed.includes(",")) {
2114
+ return { ok: false, reason: "malformed" };
2115
+ }
2116
+ const match = /^bytes=(\d*)-(\d*)$/u.exec(trimmed);
2117
+ if (!match || (match[1] === "" && match[2] === "")) {
2118
+ return { ok: false, reason: "malformed" };
2119
+ }
2120
+ const startTok = match[1] ?? "";
2121
+ const endTok = match[2] ?? "";
2122
+
2123
+ let start: number;
2124
+ let end: number;
2125
+ if (startTok === "") {
2126
+ // suffix: bytes=-N
2127
+ const suffix = Number.parseInt(endTok, 10);
2128
+ if (!Number.isFinite(suffix) || suffix <= 0) {
2129
+ return { ok: false, reason: "malformed" };
2130
+ }
2131
+ if (size === 0) {
2132
+ return { ok: false, reason: "unsatisfiable" };
2133
+ }
2134
+ start = Math.max(0, size - suffix);
2135
+ end = size - 1;
2136
+ } else {
2137
+ start = Number.parseInt(startTok, 10);
2138
+ if (!Number.isFinite(start) || start < 0) {
2139
+ return { ok: false, reason: "malformed" };
2140
+ }
2141
+ if (endTok === "") {
2142
+ end = size - 1;
2143
+ } else {
2144
+ end = Number.parseInt(endTok, 10);
2145
+ if (!Number.isFinite(end) || end < 0) {
2146
+ return { ok: false, reason: "malformed" };
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ if (size === 0 || start >= size || end < start) {
2152
+ return { ok: false, reason: "unsatisfiable" };
2153
+ }
2154
+ end = Math.min(end, size - 1);
2155
+ return { ok: true, start, end };
2156
+ }
2157
+
2158
+ /**
2159
+ * GET|HEAD /api/doc-asset
2060
2160
  * Query params:
2061
2161
  * - path (required): relative to current doc, or absolute filesystem path
2062
2162
  * - uri (required for relative paths): current document uri
2163
+ *
2164
+ * Supports single-range Range requests (206/416). Multi-range → 416 (I1-03).
2165
+ * HEAD mirrors GET status/headers with empty body (I1-02).
2063
2166
  */
2064
2167
  export async function handleDocAsset(
2065
2168
  store: SqliteAdapter,
2066
2169
  config: Config,
2067
- url: URL
2170
+ url: URL,
2171
+ request?: Request
2068
2172
  ): Promise<Response> {
2069
2173
  const assetPath = url.searchParams.get("path")?.trim();
2070
2174
  if (!assetPath) {
@@ -2139,12 +2243,48 @@ export async function handleDocAsset(
2139
2243
  return errorResponse("NOT_FOUND", "Asset not found", 404);
2140
2244
  }
2141
2245
 
2142
- return new Response(file, {
2143
- headers: {
2144
- "Cache-Control": "no-store",
2145
- "Content-Type": file.type || "application/octet-stream",
2146
- },
2246
+ const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD";
2247
+ const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document";
2248
+ const headers = new Headers({
2249
+ "Accept-Ranges": "bytes",
2250
+ "Cache-Control": "no-store",
2251
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
2252
+ "Content-Type": file.type || "application/octet-stream",
2147
2253
  });
2254
+
2255
+ const rangeHeader = request?.headers.get("Range");
2256
+ if (!rangeHeader) {
2257
+ headers.set("Content-Length", String(file.size));
2258
+ if (isHead) {
2259
+ return new Response(null, { status: 200, headers });
2260
+ }
2261
+ return new Response(file, { headers });
2262
+ }
2263
+
2264
+ // Multi-range: unsupported → 416 with bytes */size (I1-03)
2265
+ if (rangeHeader.includes(",")) {
2266
+ headers.set("Content-Range", `bytes */${file.size}`);
2267
+ // No Content-Length for empty 416 body
2268
+ headers.delete("Content-Length");
2269
+ return new Response(null, { status: 416, headers });
2270
+ }
2271
+
2272
+ const parsed = parseSingleByteRange(rangeHeader, file.size);
2273
+ if (!parsed.ok) {
2274
+ headers.set("Content-Range", `bytes */${file.size}`);
2275
+ headers.delete("Content-Length");
2276
+ return new Response(null, { status: 416, headers });
2277
+ }
2278
+
2279
+ const { start, end } = parsed;
2280
+ const length = end - start + 1;
2281
+ headers.set("Content-Length", String(length));
2282
+ headers.set("Content-Range", `bytes ${start}-${end}/${file.size}`);
2283
+ if (isHead) {
2284
+ // Empty body; do not slice/stream the file for HEAD
2285
+ return new Response(null, { status: 206, headers });
2286
+ }
2287
+ return new Response(file.slice(start, end + 1), { status: 206, headers });
2148
2288
  }
2149
2289
 
2150
2290
  /**
@@ -5258,7 +5398,7 @@ export async function routeApi(
5258
5398
  }
5259
5399
 
5260
5400
  if (path === "/api/doc-asset") {
5261
- return handleDocAsset(store, config, url);
5401
+ return handleDocAsset(store, config, url, req);
5262
5402
  }
5263
5403
 
5264
5404
  if (path === "/api/search" && req.method === "POST") {