@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/public-dir.ts CHANGED
@@ -12,12 +12,20 @@
12
12
  * Distinct from `publicPath` in `build.ts`, which is the URL prefix for content-hashed bundle chunks.
13
13
  * The names collide and the concepts do not: `publicPath` never covers user-authored files.
14
14
  */
15
+ import { createHash } from "node:crypto"
15
16
  import { type FileHandle, realpath } from "node:fs/promises"
16
- import { extname, normalize, resolve, sep } from "node:path"
17
+ import { normalize, resolve, sep } from "node:path"
17
18
  import { Readable } from "node:stream"
18
19
  import { parseByteRange } from "@nifrajs/core/range"
19
20
  import { pathnameOf } from "@nifrajs/core/server"
20
- import { isHiddenPublicPath } from "./internal/public-url.ts"
21
+ import {
22
+ contentTags,
23
+ contentTypeOf,
24
+ ifRangeAllowsPartial,
25
+ isHiddenPath,
26
+ isNotModified,
27
+ lastModifiedOf,
28
+ } from "@nifrajs/core/static-file"
21
29
  import { openResolved } from "./open-resolved.ts"
22
30
 
23
31
  /** How long each subtree may be cached. Content-hashed bundle output can be immutable; a
@@ -50,65 +58,25 @@ export const IMMUTABLE = "public, max-age=31536000, immutable"
50
58
  export const ONE_DAY = "public, max-age=86400"
51
59
 
52
60
  /**
53
- * Media types by extension. A table rather than `Bun.file(path).type`: this handler also runs on the
54
- * Node and Deno adapters, where `Bun` does not exist and every hit would throw. Unknown extensions
55
- * (and extensionless files such as ACME tokens) are served as `application/octet-stream`, which
56
- * together with `nosniff` keeps the browser from guessing a more dangerous type.
57
- */
58
- export const CONTENT_TYPES: Readonly<Record<string, string>> = {
59
- ".html": "text/html; charset=utf-8",
60
- ".htm": "text/html; charset=utf-8",
61
- ".css": "text/css; charset=utf-8",
62
- ".js": "text/javascript; charset=utf-8",
63
- ".mjs": "text/javascript; charset=utf-8",
64
- ".cjs": "text/javascript; charset=utf-8",
65
- ".json": "application/json; charset=utf-8",
66
- ".map": "application/json; charset=utf-8",
67
- ".webmanifest": "application/manifest+json",
68
- ".txt": "text/plain; charset=utf-8",
69
- ".md": "text/markdown; charset=utf-8",
70
- ".csv": "text/csv; charset=utf-8",
71
- ".xml": "application/xml",
72
- ".rss": "application/rss+xml",
73
- ".atom": "application/atom+xml",
74
- ".svg": "image/svg+xml",
75
- ".png": "image/png",
76
- ".jpg": "image/jpeg",
77
- ".jpeg": "image/jpeg",
78
- ".gif": "image/gif",
79
- ".webp": "image/webp",
80
- ".avif": "image/avif",
81
- ".ico": "image/x-icon",
82
- ".bmp": "image/bmp",
83
- ".woff": "font/woff",
84
- ".woff2": "font/woff2",
85
- ".ttf": "font/ttf",
86
- ".otf": "font/otf",
87
- ".wasm": "application/wasm",
88
- ".pdf": "application/pdf",
89
- ".zip": "application/zip",
90
- ".mp4": "video/mp4",
91
- ".webm": "video/webm",
92
- ".ogg": "audio/ogg",
93
- ".mp3": "audio/mpeg",
94
- ".wav": "audio/wav",
95
- ".m4a": "audio/mp4",
96
- ".vtt": "text/vtt; charset=utf-8",
97
- }
98
-
99
- /**
100
- * Whether a confined path crosses a hidden segment ({@link isHiddenPublicPath}). Checked on the
61
+ * Whether a confined path crosses a hidden segment ({@link isHiddenPath}). Checked on the
101
62
  * resolved path, so `%2e` encodings are covered, and again on the real target, so a visible name
102
63
  * linking to `.env` is refused too.
103
64
  */
104
- function isHiddenPath(root: string, abs: string): boolean {
105
- return abs !== root && isHiddenPublicPath(abs.slice(root.length + 1))
65
+ function hiddenUnder(root: string, abs: string): boolean {
66
+ return abs !== root && isHiddenPath(abs.slice(root.length + 1))
106
67
  }
107
68
 
108
69
  // Up to this many bytes are read whole through the pinned descriptor, which then closes before the
109
70
  // response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
110
- // stream. A larger body streams, so a video holds 64 KiB per request rather than the file.
71
+ // stream. A larger body streams, so a video holds a stream's buffer per request rather than the file.
111
72
  const BUFFERED_BODY_MAX = 256 * 1024
73
+ // Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
74
+ // would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
75
+ // is read whole as well: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
76
+ const BUN_BUFFERED_BODY_MAX = 1024 * 1024
77
+
78
+ // One per process: the tag of a file served by any handler is remembered by its real path.
79
+ const TAGS = contentTags(createHash)
112
80
 
113
81
  /** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
114
82
  async function readPinned(
@@ -126,35 +94,6 @@ async function readPinned(
126
94
  return offset === length ? bytes : bytes.subarray(0, offset)
127
95
  }
128
96
 
129
- /** HTTP dates carry second precision; comparing raw milliseconds makes every file look stale. */
130
- function seconds(time: number): number {
131
- return Math.floor(time / 1000)
132
- }
133
-
134
- /**
135
- * `If-Range` decides whether a range request is still safe to answer partially. The only validator
136
- * this handler publishes is `last-modified`, so an entity-tag form can never match and the whole
137
- * representation is sent instead - which is the conformant outcome, not a fallback. Dates use strong
138
- * comparison (RFC 9110 13.1.5): a file modified since the client's copy invalidates its byte offsets.
139
- */
140
- function ifRangeMatches(value: string | null, lastModified: number | undefined): boolean {
141
- if (value === null) return true
142
- if (lastModified === undefined) return false
143
- const item = value.trim()
144
- if (item.startsWith('"') || item.startsWith("W/")) return false
145
- const parsed = Date.parse(item)
146
- return Number.isFinite(parsed) && seconds(parsed) === seconds(lastModified)
147
- }
148
-
149
- /** `If-Modified-Since` freshness. No ETag is published, so there is no `If-None-Match` precedence. */
150
- function isNotModified(request: Request, lastModified: number | undefined): boolean {
151
- if (lastModified === undefined) return false
152
- const since = request.headers.get("if-modified-since")
153
- if (since === null) return false
154
- const parsed = Date.parse(since)
155
- return Number.isFinite(parsed) && seconds(parsed) >= seconds(lastModified)
156
- }
157
-
158
97
  /**
159
98
  * Resolve a URL pathname to an absolute path **confined** to `root`, or `undefined` if it escapes.
160
99
  *
@@ -185,6 +124,10 @@ export function resolvePublicPath(root: string, pathname: string): string | unde
185
124
  * never shadow a route. That ordering is also why a `routes/robots.txt.tsx` beats a
186
125
  * `public/robots.txt` only if the caller checks routes first; the documented precedence is that the
187
126
  * static probe runs first, so `public/` wins, and an app wanting the route should not ship both.
127
+ *
128
+ * A file is sent with a strong `ETag` from its bytes, the tag `@nifrajs/node`'s `static` reader and
129
+ * the Deno entry send, and with `Last-Modified` only while its mtime is that of its own last change,
130
+ * so a replacement that keeps a file's size and timestamps is never judged current or resumed into.
188
131
  */
189
132
  export function servePublicDir(
190
133
  options: ServePublicDirOptions,
@@ -195,6 +138,7 @@ export function servePublicDir(
195
138
  const hashedFiles = options.hashedFiles
196
139
  const hashed = options.cache?.hashed ?? IMMUTABLE
197
140
  const assets = options.cache?.assets ?? ONE_DAY
141
+ const bufferedMax = typeof Bun === "undefined" ? BUFFERED_BODY_MAX : BUN_BUFFERED_BODY_MAX
198
142
 
199
143
  return async (request: Request): Promise<Response | undefined> => {
200
144
  if (request.method !== "GET" && request.method !== "HEAD") return undefined
@@ -205,7 +149,7 @@ export function servePublicDir(
205
149
  // A production manifest can reject page routes without touching the filesystem.
206
150
  if (options.files !== undefined && !options.files.has(pathname)) return undefined
207
151
  const abs = resolvePublicPath(root, pathname)
208
- if (abs === undefined || isHiddenPath(root, abs)) return undefined
152
+ if (abs === undefined || hiddenUnder(root, abs)) return undefined
209
153
  const [resolvedRoot, resolvedFile] = await Promise.all([
210
154
  rootReal,
211
155
  realpath(abs).catch(() => undefined),
@@ -214,7 +158,7 @@ export function servePublicDir(
214
158
  resolvedRoot === undefined ||
215
159
  resolvedFile === undefined ||
216
160
  (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(resolvedRoot + sep)) ||
217
- isHiddenPath(resolvedRoot, resolvedFile)
161
+ hiddenUnder(resolvedRoot, resolvedFile)
218
162
  ) {
219
163
  return undefined
220
164
  }
@@ -224,13 +168,15 @@ export function servePublicDir(
224
168
  // symlink; the descriptor then pins the bytes for the lifetime of the response.
225
169
  let handle: Awaited<ReturnType<typeof openResolved>> | undefined
226
170
  try {
171
+ let file: FileHandle
227
172
  try {
228
- handle = await openResolved(resolvedFile)
173
+ file = await openResolved(resolvedFile)
229
174
  } catch {
230
175
  // A name refused or gone since realpath falls through, as a failed realpath does.
231
176
  return undefined
232
177
  }
233
- const stat = await handle.stat()
178
+ handle = file
179
+ const stat = await file.stat()
234
180
  if (!stat.isFile()) return undefined
235
181
  const size = stat.size
236
182
  const headers = new Headers({
@@ -248,17 +194,33 @@ export function servePublicDir(
248
194
  // rendered as HTML, say). The declared type below is authoritative.
249
195
  "x-content-type-options": "nosniff",
250
196
  // Set explicitly rather than inferred from the body, so HEAD (a null body) carries it too.
251
- "content-type":
252
- CONTENT_TYPES[extname(resolvedFile).toLowerCase()] ?? "application/octet-stream",
197
+ "content-type": contentTypeOf(resolvedFile),
198
+ })
199
+ // The whole file, when tagging it had to read it: the response is then served from these bytes.
200
+ let whole: Uint8Array<ArrayBuffer> | undefined
201
+ let etag = TAGS.cached(resolvedFile, stat)
202
+ if (etag === undefined && size <= bufferedMax) {
203
+ whole = await readPinned(file, 0, size)
204
+ etag = TAGS.ofBytes(resolvedFile, stat, whole)
205
+ }
206
+ etag ??= await TAGS.ofFile(resolvedFile, stat, async (bytes, position) => {
207
+ return (await file.read(bytes, 0, bytes.byteLength, position)).bytesRead
253
208
  })
254
- const lastModified =
255
- Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0 ? stat.mtimeMs : undefined
209
+ if (etag !== undefined) headers.set("etag", etag)
210
+ const lastModified = lastModifiedOf(stat)
256
211
  if (lastModified !== undefined) {
257
212
  headers.set("last-modified", new Date(lastModified).toUTCString())
258
213
  }
259
214
  const head = request.method === "HEAD"
260
215
 
261
- if (isNotModified(request, lastModified)) {
216
+ if (
217
+ isNotModified(
218
+ request.headers.get("if-none-match"),
219
+ request.headers.get("if-modified-since"),
220
+ etag,
221
+ lastModified,
222
+ )
223
+ ) {
262
224
  headers.delete("content-type")
263
225
  return new Response(null, { status: 304, headers })
264
226
  }
@@ -266,7 +228,8 @@ export function servePublicDir(
266
228
  // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
267
229
  const rangeHeader = head ? null : request.headers.get("range")
268
230
  const range =
269
- rangeHeader !== null && ifRangeMatches(request.headers.get("if-range"), lastModified)
231
+ rangeHeader !== null &&
232
+ ifRangeAllowsPartial(request.headers.get("if-range"), etag, lastModified)
270
233
  ? parseByteRange(rangeHeader, size)
271
234
  : ({ kind: "none" } as const)
272
235
 
@@ -281,12 +244,15 @@ export function servePublicDir(
281
244
  end: number,
282
245
  ): Promise<Uint8Array<ArrayBuffer> | ReadableStream<Uint8Array>> => {
283
246
  const length = end - start + 1
284
- if (length <= BUFFERED_BODY_MAX) {
285
- const bytes = await readPinned(handle!, start, length)
247
+ if (length <= bufferedMax) {
248
+ const bytes =
249
+ whole === undefined
250
+ ? await readPinned(file, start, length)
251
+ : whole.subarray(start, start + length)
286
252
  headers.set("content-length", String(bytes.byteLength))
287
253
  return bytes
288
254
  }
289
- const stream = handle!.createReadStream({ start, end })
255
+ const stream = file.createReadStream({ start, end })
290
256
  // FileHandle.createReadStream owns closing the descriptor after EOF/abort.
291
257
  handle = undefined
292
258
  return Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
@@ -346,7 +312,7 @@ export function prerenderedPageReader(
346
312
  const rel = files.get(pathname)
347
313
  if (rel === undefined) return undefined
348
314
  const abs = resolve(root, rel)
349
- if (!abs.startsWith(root + sep) || isHiddenPath(root, abs)) return undefined
315
+ if (!abs.startsWith(root + sep) || hiddenUnder(root, abs)) return undefined
350
316
  const [resolvedRoot, resolvedFile] = await Promise.all([
351
317
  rootReal,
352
318
  realpath(abs).catch(() => undefined),
package/src/vite.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "node:http"
20
20
  import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"
21
21
  import { singleCopyPackageNames } from "@nifrajs/core/single-copy"
22
+ import { isHiddenPath } from "@nifrajs/core/static-file"
22
23
  import { CLIENT_BATCH_MAX_BYTES } from "./dev-client.ts"
23
24
  import { DEV_REQUEST_ID_HEADER, DEV_SERVER_FILES } from "./dev-feed.ts"
24
25
  import { listenOrExplain } from "./dev-port.ts"
@@ -33,7 +34,6 @@ import {
33
34
  formatIdentityParityFindings,
34
35
  identityParityHeadline,
35
36
  } from "./internal/parity.ts"
36
- import { isHiddenPublicPath } from "./internal/public-url.ts"
37
37
  import { vitePublicEnvPrefix } from "./internal/server-boundary.ts"
38
38
  import { importVite } from "./internal/vite-import.ts"
39
39
  import { normalizeRolldownPlugins } from "./internal/vite-plugin-compat.ts"
@@ -473,12 +473,12 @@ export async function createViteDevServer(options: ViteDevServerOptions): Promis
473
473
  }
474
474
  const file = resolvePath(publicDir, rel)
475
475
  if (!file.startsWith(publicDir + sep) || !existsSync(file)) return false
476
- if (isHiddenPublicPath(rel)) return true
476
+ if (isHiddenPath(rel)) return true
477
477
  // A visible name linking to a hidden file is refused too, as `servePublicDir` refuses it.
478
478
  try {
479
479
  const real = realpathSync(file)
480
480
  const realRoot = realpathSync(publicDir)
481
- return real.startsWith(realRoot + sep) && isHiddenPublicPath(real.slice(realRoot.length + 1))
481
+ return real.startsWith(realRoot + sep) && isHiddenPath(real.slice(realRoot.length + 1))
482
482
  } catch {
483
483
  return false
484
484
  }