@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/CHANGELOG.md +40 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +120 -41
- package/dist/build.js.map +1 -1
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -2
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/public-url.d.ts +0 -7
- package/dist/internal/public-url.d.ts.map +1 -1
- package/dist/internal/public-url.js +0 -13
- package/dist/internal/public-url.js.map +1 -1
- package/dist/public-dir.d.ts +4 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +47 -95
- package/dist/public-dir.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +3 -3
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +140 -50
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/parity.ts +3 -2
- package/src/internal/public-url.ts +0 -13
- package/src/public-dir.ts +62 -96
- package/src/vite.ts +3 -3
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 {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
105
|
-
return abs !== root &&
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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 (
|
|
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 &&
|
|
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 <=
|
|
285
|
-
const bytes =
|
|
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 =
|
|
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) ||
|
|
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 (
|
|
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) &&
|
|
481
|
+
return real.startsWith(realRoot + sep) && isHiddenPath(real.slice(realRoot.length + 1))
|
|
482
482
|
} catch {
|
|
483
483
|
return false
|
|
484
484
|
}
|