@nifrajs/web 5.3.0 → 5.4.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/src/build.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  singleCopyPlugin as declaredSingleCopyPlugin,
26
26
  readSingleCopyDeclaration,
27
27
  } from "@nifrajs/core/single-copy"
28
+ import { isHiddenPath } from "@nifrajs/core/static-file"
28
29
  import { type BunPlugin, Glob } from "bun"
29
30
  import {
30
31
  aggregateSizeReport,
@@ -55,6 +56,7 @@ import { loadCopySplit } from "./internal/copy-split.ts"
55
56
  import { assertDisposableDir } from "./internal/disposable-dir.ts"
56
57
  import { entryChunks } from "./internal/entry-chunks.ts"
57
58
  import { dedupePolicyFor } from "./internal/identity-policy.ts"
59
+ import { nodeAdapterRefusal } from "./internal/node-adapter.ts"
58
60
  import {
59
61
  assertDevelopmentProductionParity,
60
62
  assertIdentityParity,
@@ -65,7 +67,7 @@ import {
65
67
  servablePrerenderedPages,
66
68
  } from "./internal/prerendered-pages.ts"
67
69
  import { privateEnvCheck } from "./internal/private-env.ts"
68
- import { isHiddenPublicPath, publicUrlPath } from "./internal/public-url.ts"
70
+ import { publicUrlPath } from "./internal/public-url.ts"
69
71
  import {
70
72
  assertNoSecrets,
71
73
  buildEnvironment,
@@ -100,7 +102,7 @@ import { assertNoImportGlob, importGlobPlugin } from "./plugins/import-glob.ts"
100
102
  import { zoneGuardPlugin } from "./plugins/zone-guard.ts"
101
103
  // `buildTarget(static)` drives the SSG prerender engine directly (it's also re-exported below).
102
104
  import { type PrerenderOptions, prerenderRoutes } from "./prerender.ts"
103
- import { CONTENT_TYPES, IMMUTABLE, ONE_DAY } from "./public-dir.ts"
105
+ import { IMMUTABLE, ONE_DAY } from "./public-dir.ts"
104
106
  import { createZoneClassifier } from "./zones.ts"
105
107
 
106
108
  export * from "./build-plan.ts"
@@ -360,7 +362,7 @@ export async function copyPublicDir(from: string, to: string): Promise<string[]>
360
362
  const sourceReal = await realpath(source)
361
363
  const copied: string[] = []
362
364
  for await (const rel of new Glob("**/*").scan({ cwd: source, dot: true, onlyFiles: false })) {
363
- if (isHiddenPublicPath(rel)) continue
365
+ if (isHiddenPath(rel)) continue
364
366
  const candidate = join(source, rel)
365
367
  const candidateStat = await lstat(candidate)
366
368
  if (candidateStat.isSymbolicLink()) {
@@ -1379,8 +1381,9 @@ export function generateServerEntry(options: {
1379
1381
  if (target === "static") {
1380
1382
  throw new Error("[nifra/web] generateServerEntry: `static` has no server entry (SSG only)")
1381
1383
  }
1382
- // Bun and Node serve the client bundle through `servePublicDir`. Deno bundles for the browser
1383
- // condition, where `node:fs` is a stub, so its entry runs the same checks on Deno's own file API.
1384
+ // Bun serves the client bundle through `servePublicDir` and Node through @nifrajs/node's own reader,
1385
+ // both on core's static-file policy. Deno bundles for the browser condition, where `node:fs` is a
1386
+ // stub, so its entry runs the same checks on Deno's own file API.
1384
1387
  const nodeFs = target === "bun" || target === "node"
1385
1388
  const prerendered = Object.entries(options.prerenderedPages ?? {})
1386
1389
  if (prerendered.length > 0 && !nodeFs) {
@@ -1388,13 +1391,10 @@ export function generateServerEntry(options: {
1388
1391
  `[nifra/web] generateServerEntry: prerendered pages are served by the bun and node targets, not ${target}`,
1389
1392
  )
1390
1393
  }
1391
- const lines: string[] = [
1392
- !nodeFs
1393
- ? 'import { createWebApp } from "@nifrajs/web"'
1394
- : prerendered.length > 0
1395
- ? 'import { createWebApp, prerenderedPageReader, servePublicDir } from "@nifrajs/web"'
1396
- : 'import { createWebApp, servePublicDir } from "@nifrajs/web"',
1397
- ]
1394
+ const webImports = ["createWebApp"]
1395
+ if (prerendered.length > 0) webImports.push("prerenderedPageReader")
1396
+ if (target === "bun") webImports.push("servePublicDir")
1397
+ const lines: string[] = [`import { ${webImports.join(", ")} } from "@nifrajs/web"`]
1398
1398
  if (backendImport !== undefined) lines.push('import { inProcessClient } from "@nifrajs/client"')
1399
1399
  lines.push(`import { adapter } from ${JSON.stringify(adapterImport)}`)
1400
1400
  if (useImport !== undefined) lines.push(`import { use } from ${JSON.stringify(useImport)}`)
@@ -1413,7 +1413,13 @@ export function generateServerEntry(options: {
1413
1413
  if (target === "cloudflare") lines.push('import { toFetchHandler } from "@nifrajs/core/server"')
1414
1414
  if (target === "node") lines.push('import { serve } from "@nifrajs/node"')
1415
1415
  if (nodeFs) lines.push('import { fileURLToPath } from "node:url"')
1416
- if (target === "deno") lines.push('import { parseByteRange } from "@nifrajs/core/range"')
1416
+ if (target === "deno") {
1417
+ lines.push(
1418
+ 'import { parseByteRange } from "@nifrajs/core/range"',
1419
+ 'import { contentTags, contentTypeOf, ifRangeAllowsPartial, isHiddenPath, isNotModified, lastModifiedOf } from "@nifrajs/core/static-file"',
1420
+ 'import { createHash } from "node:crypto"',
1421
+ )
1422
+ }
1417
1423
  if (prerendered.length > 0) {
1418
1424
  const headers = Object.fromEntries(prerendered.map(([path, page]) => [path, page.headers]))
1419
1425
  const files = Object.fromEntries(prerendered.map(([path, page]) => [path, page.file]))
@@ -1494,6 +1500,34 @@ export function generateServerEntry(options: {
1494
1500
  ...(hashedFiles !== undefined
1495
1501
  ? [`const HASHED_FILES = new Set(${JSON.stringify(hashedFiles)})`]
1496
1502
  : []),
1503
+ )
1504
+ if (target === "node") {
1505
+ // The app itself with the adapter's own reader, not a `{ fetch }` wrapper around app.fetch: the
1506
+ // wrapper hid the node-direct lane, so every page built an undici Request and drained a Response,
1507
+ // and every file went through one too. The root mount serves only the files the build listed.
1508
+ lines.push(
1509
+ "await serve(app, {",
1510
+ " port: Number(process.env.PORT ?? 3000),",
1511
+ " static: [",
1512
+ " {",
1513
+ ' dir: fileURLToPath(new URL("./assets/", import.meta.url)),',
1514
+ ' prefix: "/assets",',
1515
+ ...(hashedFiles !== undefined ? [" hashedFiles: HASHED_FILES,"] : []),
1516
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1517
+ " },",
1518
+ " {",
1519
+ " dir: fileURLToPath(STATIC_ROOT),",
1520
+ ' prefix: "/",',
1521
+ " files: PUBLIC_FILES,",
1522
+ " immutable: false,",
1523
+ ` cacheControl: ${JSON.stringify(ONE_DAY)},`,
1524
+ " },",
1525
+ " ],",
1526
+ "})",
1527
+ )
1528
+ return `${lines.join("\n")}\n`
1529
+ }
1530
+ lines.push(
1497
1531
  "const staticPath = (pathname: string): string | undefined => {",
1498
1532
  " if (PUBLIC_FILES.has(pathname)) return '.' + pathname",
1499
1533
  ' if (!pathname.startsWith("/assets/")) return undefined',
@@ -1505,11 +1539,10 @@ export function generateServerEntry(options: {
1505
1539
  ' segment !== "." && segment !== ".." && /^[A-Za-z0-9._-]+$/.test(segment)',
1506
1540
  " return segments.every(safe) ? '.' + pathname : undefined",
1507
1541
  "}",
1508
- // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, HEAD
1509
- // answered without reading the file, byte ranges and conditional requests, and a descriptor opened
1510
- // only once the real path is inside the root, so a link planted in the deployed tree cannot reach
1511
- // a file outside it.
1512
- ...(nodeFs
1542
+ // The handler `nifra dev` serves public/ with: its type table, nosniff and cache policy, content
1543
+ // tags, byte ranges and conditional requests, and a descriptor opened only once the real path is
1544
+ // inside the root, so a link planted in the deployed tree cannot reach a file outside it.
1545
+ ...(target === "bun"
1513
1546
  ? [
1514
1547
  "const serveStatic = servePublicDir({",
1515
1548
  " dir: fileURLToPath(STATIC_ROOT),",
@@ -1545,21 +1578,6 @@ export function generateServerEntry(options: {
1545
1578
  )
1546
1579
  return `${lines.join("\n")}\n`
1547
1580
  }
1548
- if (target === "node") {
1549
- lines.push(
1550
- "await serve(",
1551
- " {",
1552
- " async fetch(req, platform) {",
1553
- " const { pathname } = new URL(req.url)",
1554
- " if (staticPath(pathname) !== undefined) return staticResponse(req)",
1555
- " return app.fetch(req, platform)",
1556
- " },",
1557
- " },",
1558
- " { port: Number(process.env.PORT ?? 3000) },",
1559
- ")",
1560
- )
1561
- return `${lines.join("\n")}\n`
1562
- }
1563
1581
  // deno
1564
1582
  lines.push(
1565
1583
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
@@ -1577,34 +1595,39 @@ export function generateServerEntry(options: {
1577
1595
  }
1578
1596
 
1579
1597
  /**
1580
- * The Deno entry's static handler: `servePublicDir`'s checks on Deno's file API. The real path must sit
1581
- * inside the root and cross no hidden segment, and the opened file must still be the one that path
1582
- * names, so a link planted in the deployed tree cannot reach a file outside it or a `.env` inside it.
1583
- * HEAD answers from `stat` without reading; one byte range is served from a seek, several as the
1584
- * whole file.
1598
+ * The Deno entry's static handler: `servePublicDir`'s checks and answers on Deno's file API. The real
1599
+ * path must sit inside the root and cross no hidden segment, and the opened file must still be the one
1600
+ * that path names, so a link planted in the deployed tree cannot reach a file outside it or a `.env`
1601
+ * inside it. The file is typed by its real path, tagged by its content and dated only by an mtime its
1602
+ * own last write set, through the same policy as servePublicDir: a current cached copy is a 304, and a
1603
+ * range survives only an `If-Range` naming that tag. A file is read once to tag it, and one small
1604
+ * enough is then served from those bytes; otherwise one byte range is served from a seek, several as
1605
+ * the whole file.
1585
1606
  */
1586
1607
  function denoStaticResponse(hashed: boolean): string[] {
1587
1608
  return [
1588
- `const TYPES: Record<string, string> = ${JSON.stringify(CONTENT_TYPES)}`,
1589
- "const staticHeaders = (pathname: string): Record<string, string> => ({",
1590
- ' "content-type": TYPES[pathname.slice(pathname.lastIndexOf(".")).toLowerCase()] ?? "application/octet-stream",',
1609
+ "const staticHeaders = (pathname: string, file: string): Record<string, string> => ({",
1610
+ ' "content-type": contentTypeOf(file),',
1591
1611
  ` "cache-control": ${hashed ? "HASHED_FILES.has(pathname)" : 'pathname.startsWith("/assets/")'} ? ${JSON.stringify(IMMUTABLE)} : ${JSON.stringify(ONE_DAY)},`,
1592
1612
  ' "x-content-type-options": "nosniff",',
1593
1613
  ' "accept-ranges": "bytes",',
1594
1614
  "})",
1595
1615
  "// @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1596
1616
  "const STATIC_REAL: string = await Deno.realPath(STATIC_ROOT)",
1597
- "const hiddenSegment = (rel: string): boolean =>",
1598
- ' rel.split(/[\\\\/]/).some((segment) => segment.startsWith(".") && segment !== ".well-known")',
1617
+ "// One per process: the tag of a file is remembered by its real path.",
1618
+ "const STATIC_TAGS = contentTags(createHash)",
1619
+ "// Up to this many bytes are read whole to tag a file, and the response is then served from them.",
1620
+ "const STATIC_INLINE_MAX = 256 * 1024",
1599
1621
  "const staticResponse = async (req: Request, filePath: string, pathname: string): Promise<Response> => {",
1600
1622
  ' const missing = () => new Response("not found", { status: 404 })',
1601
1623
  ' if (req.method !== "GET" && req.method !== "HEAD") return missing()',
1602
1624
  " let file, info",
1625
+ ' let real = ""',
1603
1626
  " try {",
1604
1627
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1605
- " const real: string = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1628
+ " real = await Deno.realPath(new URL(filePath, STATIC_ROOT))",
1606
1629
  ' if (!real.startsWith(STATIC_REAL + (STATIC_REAL.includes("\\\\") ? "\\\\" : "/"))) return missing()',
1607
- " if (hiddenSegment(real.slice(STATIC_REAL.length + 1))) return missing()",
1630
+ " if (isHiddenPath(real.slice(STATIC_REAL.length + 1))) return missing()",
1608
1631
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1609
1632
  " file = await Deno.open(real)",
1610
1633
  " // Deno.open follows links, so a directory swapped for one after the check above redirects it.",
@@ -1622,12 +1645,61 @@ function denoStaticResponse(hashed: boolean): string[] {
1622
1645
  " file?.close()",
1623
1646
  " return missing()",
1624
1647
  " }",
1648
+ " if (!info.isFile) {",
1649
+ " file.close()",
1650
+ " return missing()",
1651
+ " }",
1625
1652
  " const size: number = info.size",
1626
- " const headers = new Headers(staticHeaders(pathname))",
1627
- ' const range = req.method === "GET" ? parseByteRange(req.headers.get("range"), size) : { kind: "none" as const }',
1628
- ' if (!info.isFile || req.method === "HEAD" || range.kind === "unsatisfiable") {',
1653
+ " const headers = new Headers(staticHeaders(pathname, real))",
1654
+ " // Deno reports a field the platform does not keep as null; NaN never matches a remembered version.",
1655
+ " const version = {",
1656
+ " dev: info.dev,",
1657
+ " ino: info.ino ?? Number.NaN,",
1658
+ " size,",
1659
+ " mtimeMs: info.mtime?.getTime() ?? Number.NaN,",
1660
+ " ctimeMs: info.ctime?.getTime() ?? Number.NaN,",
1661
+ " }",
1662
+ " let whole: Uint8Array<ArrayBuffer> | undefined",
1663
+ " let moved = false",
1664
+ " let etag: string | undefined",
1665
+ " try {",
1666
+ " etag = STATIC_TAGS.cached(real, version)",
1667
+ " if (etag === undefined && size <= STATIC_INLINE_MAX) {",
1668
+ " whole = new Uint8Array(size)",
1669
+ " let filled = 0",
1670
+ " while (filled < size) {",
1671
+ " const read: number | null = await file.read(whole.subarray(filled))",
1672
+ " if (read === null || read === 0) break",
1673
+ " filled += read",
1674
+ " }",
1675
+ " if (filled < size) whole = whole.subarray(0, filled)",
1676
+ " etag = STATIC_TAGS.ofBytes(real, version, whole)",
1677
+ " }",
1678
+ " etag ??= await STATIC_TAGS.ofFile(real, version, async (bytes: Uint8Array, position: number) => {",
1679
+ " moved = true",
1680
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1681
+ " await file.seek(position, Deno.SeekMode.Start)",
1682
+ " return (await file.read(bytes)) ?? 0",
1683
+ " })",
1684
+ " } catch {",
1685
+ " file.close()",
1686
+ " return missing()",
1687
+ " }",
1688
+ ' if (etag !== undefined) headers.set("etag", etag)',
1689
+ " const lastModified = lastModifiedOf(version)",
1690
+ ' if (lastModified !== undefined) headers.set("last-modified", new Date(lastModified).toUTCString())',
1691
+ ' if (isNotModified(req.headers.get("if-none-match"), req.headers.get("if-modified-since"), etag, lastModified)) {',
1692
+ " file.close()",
1693
+ ' headers.delete("content-type")',
1694
+ " return new Response(null, { status: 304, headers })",
1695
+ " }",
1696
+ ' const rangeHeader = req.method === "GET" ? req.headers.get("range") : null',
1697
+ " const range =",
1698
+ ' rangeHeader !== null && ifRangeAllowsPartial(req.headers.get("if-range"), etag, lastModified)',
1699
+ " ? parseByteRange(rangeHeader, size)",
1700
+ ' : { kind: "none" as const }',
1701
+ ' if (req.method === "HEAD" || range.kind === "unsatisfiable") {',
1629
1702
  " file.close()",
1630
- " if (!info.isFile) return missing()",
1631
1703
  ' if (range.kind !== "unsatisfiable") {',
1632
1704
  ' headers.set("content-length", String(size))',
1633
1705
  " return new Response(null, { headers })",
@@ -1638,10 +1710,16 @@ function denoStaticResponse(hashed: boolean): string[] {
1638
1710
  " }",
1639
1711
  ' if (range.kind === "satisfiable" && range.ranges.length === 1) {',
1640
1712
  " const { start, end } = range.ranges[0]!",
1713
+ ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1714
+ " if (whole !== undefined) {",
1715
+ " file.close()",
1716
+ " const part = whole.subarray(start, end + 1)",
1717
+ ' headers.set("content-length", String(part.byteLength))',
1718
+ " return new Response(part, { status: 206, headers })",
1719
+ " }",
1641
1720
  " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1642
1721
  " await file.seek(start, Deno.SeekMode.Start)",
1643
1722
  " let left = end - start + 1",
1644
- ' headers.set("content-range", "bytes " + start + "-" + end + "/" + size)',
1645
1723
  ' headers.set("content-length", String(left))',
1646
1724
  " const body = new ReadableStream<Uint8Array>({",
1647
1725
  " async pull(controller) {",
@@ -1663,6 +1741,13 @@ function denoStaticResponse(hashed: boolean): string[] {
1663
1741
  " })",
1664
1742
  " return new Response(body, { status: 206, headers })",
1665
1743
  " }",
1744
+ " if (whole !== undefined) {",
1745
+ " file.close()",
1746
+ ' headers.set("content-length", String(whole.byteLength))',
1747
+ " return new Response(whole, { headers })",
1748
+ " }",
1749
+ " // @ts-ignore - Deno global is present on the Deno runtime this output targets.",
1750
+ " if (moved) await file.seek(0, Deno.SeekMode.Start)",
1666
1751
  ' headers.set("content-length", String(size))',
1667
1752
  " return new Response(file.readable, { headers })",
1668
1753
  "}",
@@ -1924,6 +2009,11 @@ export async function buildTargetWith(
1924
2009
  ]
1925
2010
  assertDisposableDir("outDir", outDir, sources)
1926
2011
  assertDisposableDir("workDir", workDir, sources)
2012
+ // Before anything is cleared, so a refused build leaves the last deploy dir in place.
2013
+ if (targetPlan.target === "node") {
2014
+ const refusal = nodeAdapterRefusal(workDir)
2015
+ if (refusal !== undefined) throw new Error(refusal)
2016
+ }
1927
2017
  const { rmSync } = await import("node:fs")
1928
2018
  rmSync(outDir, { recursive: true, force: true })
1929
2019
  rmSync(workDir, { recursive: true, force: true })
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+
4
+ /** A package as installed: its directory and the version its package.json declares. */
5
+ export interface InstalledPackage {
6
+ readonly dir: string
7
+ readonly version: string
8
+ }
9
+
10
+ /** The `version` a package.json declares, or `undefined` when it declares none. */
11
+ function manifestVersion(path: string | URL): string | undefined {
12
+ const manifest: unknown = JSON.parse(readFileSync(path, "utf8"))
13
+ return typeof manifest === "object" &&
14
+ manifest !== null &&
15
+ "version" in manifest &&
16
+ typeof manifest.version === "string"
17
+ ? manifest.version
18
+ : undefined
19
+ }
20
+
21
+ /**
22
+ * The copy of `name` a module in `fromDir` imports: the first `node_modules/<name>` walking up, as
23
+ * both bundlers resolve a bare specifier. Walked rather than `Bun.resolveSync`, which keeps its first
24
+ * answer for the life of the process.
25
+ */
26
+ export function installedPackage(name: string, fromDir: string): InstalledPackage | undefined {
27
+ for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
28
+ const manifest = join(dir, "node_modules", name, "package.json")
29
+ if (existsSync(manifest)) {
30
+ const version = manifestVersion(manifest)
31
+ return version === undefined ? undefined : { dir: dirname(manifest), version }
32
+ }
33
+ if (dirname(dir) === dir) return undefined
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Why a `--target node` build whose server entry is generated in `workDir` must stop before
39
+ * bundling, or `undefined`. The entry is written for the `@nifrajs/node` of this package's release and
40
+ * the bundle inlines whichever copy is installed: a 5.3 adapter built cleanly, then threw at startup
41
+ * on the static mounts.
42
+ */
43
+ export function nodeAdapterRefusal(
44
+ workDir: string,
45
+ // src/internal/ and dist/internal/ both sit two levels below the package root.
46
+ webVersion = manifestVersion(new URL("../../package.json", import.meta.url)),
47
+ ): string | undefined {
48
+ const adapter = installedPackage("@nifrajs/node", workDir)
49
+ if (adapter === undefined || webVersion === undefined) return undefined
50
+ if (Bun.semver.order(adapter.version, webVersion) >= 0) return undefined
51
+ return (
52
+ `[nifra/web] --target node would bundle @nifrajs/node ${adapter.version} (${adapter.dir}), older ` +
53
+ `than @nifrajs/web ${webVersion}. The server entry is generated for the adapter of the same ` +
54
+ "release, and an older one can fail when the server starts. Upgrade the nifra packages " +
55
+ `together: nifra upgrade ${webVersion}`
56
+ )
57
+ }
@@ -16,11 +16,12 @@ import {
16
16
  SINGLE_COPY_REGISTER_SPECIFIER,
17
17
  type SingleCopyRegistration,
18
18
  } from "@nifrajs/core/single-copy"
19
+ import { isHiddenPath } from "@nifrajs/core/static-file"
19
20
  import { discoverRoutes } from "../fs.ts"
20
21
  import { codeUnitOrder } from "./code-unit-order.ts"
21
22
  import { withoutComments } from "./html-spans.ts"
22
23
  import { isIdentitySensitivePackage } from "./identity-policy.ts"
23
- import { isHiddenPublicPath, publicUrlPath } from "./public-url.ts"
24
+ import { publicUrlPath } from "./public-url.ts"
24
25
 
25
26
  const DEPENDENCY_FIELDS = [
26
27
  "dependencies",
@@ -1077,7 +1078,7 @@ const publicFilesUnder = (publicDir: string | false | undefined): readonly strin
1077
1078
  const files: string[] = []
1078
1079
  const walk = (current: string): void => {
1079
1080
  for (const entry of readdirSync(current, { withFileTypes: true })) {
1080
- if (isHiddenPublicPath(entry.name)) continue // the build skips it too
1081
+ if (isHiddenPath(entry.name)) continue // the build skips it too
1081
1082
  const path = join(current, entry.name)
1082
1083
  if (entry.isDirectory()) walk(path)
1083
1084
  else if (entry.isFile()) files.push(publicUrlPath(relative(root, path)))
@@ -20,16 +20,3 @@ function encodePathSegment(segment: string): string {
20
20
  export function publicUrlPath(rel: string): string {
21
21
  return `/${rel.split(sep).map(encodePathSegment).join("/")}`
22
22
  }
23
-
24
- /**
25
- * Whether a path under the public directory crosses a dot-segment other than `.well-known`. Serving,
26
- * the build copy and the parity listing all apply it, so an editor or VCS artifact left in `public/`
27
- * (`.env`, `.git/`, `.DS_Store`) is neither served in development nor shipped by a build. Both
28
- * separators split: a request path uses `/`, a relative file path the OS separator.
29
- */
30
- export function isHiddenPublicPath(rel: string): boolean {
31
- for (const segment of rel.split(/[\\/]/)) {
32
- if (segment.startsWith(".") && segment !== ".well-known") return true
33
- }
34
- return false
35
- }