partial-content 1.0.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
package/src/fs.ts
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem ObjectStore adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Implements the read-only {@link ObjectStore} interface using Node.js
|
|
5
|
+
* `fs.stat()` and `fs.createReadStream()`. Suitable for:
|
|
6
|
+
* - Development servers
|
|
7
|
+
* - Small/medium deployments
|
|
8
|
+
* - Hybrid architectures (local cache + cloud primary)
|
|
9
|
+
* - Testing and CI pipelines
|
|
10
|
+
*
|
|
11
|
+
* Security: all keys are resolved relative to a fixed root directory.
|
|
12
|
+
* Path traversal attempts (`..`), absolute paths, and null bytes are
|
|
13
|
+
* rejected with ObjectNotFoundError. Symbolic links inside the root ARE
|
|
14
|
+
* followed (matching nginx/caddy defaults); do not place untrusted
|
|
15
|
+
* symlinks under the root if it must be a strict sandbox.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* import { fsStore } from "partial-content/fs";
|
|
20
|
+
* import { serveObject } from "partial-content/web";
|
|
21
|
+
*
|
|
22
|
+
* const store = fsStore({ root: "/var/data/uploads" });
|
|
23
|
+
*
|
|
24
|
+
* // Use with the web adapter:
|
|
25
|
+
* const handler = serveObject(store, { disposition: "inline" });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { open, stat } from "node:fs/promises";
|
|
32
|
+
import { resolve, relative, sep, isAbsolute } from "node:path";
|
|
33
|
+
import {
|
|
34
|
+
ObjectNotFoundError,
|
|
35
|
+
nodeStreamToWeb,
|
|
36
|
+
type ObjectStore,
|
|
37
|
+
type ObjectMetadata,
|
|
38
|
+
type ObjectStream,
|
|
39
|
+
type ParsedRange,
|
|
40
|
+
} from "./index.js";
|
|
41
|
+
|
|
42
|
+
// Re-export for convenience (consumers can catch without importing the kernel)
|
|
43
|
+
export { ObjectNotFoundError };
|
|
44
|
+
|
|
45
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface FsStoreOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Root directory. All keys are resolved relative to this path.
|
|
50
|
+
* Must be an absolute path.
|
|
51
|
+
*/
|
|
52
|
+
root: string;
|
|
53
|
+
/**
|
|
54
|
+
* Opt-in hot-object cache (metadata + small bodies), following the
|
|
55
|
+
* nginx `open_file_cache` model: entries revalidate on a TTL rather
|
|
56
|
+
* than a change watcher, so a served representation can lag a
|
|
57
|
+
* filesystem overwrite by up to `ttlMs`. Metadata and bytes are always
|
|
58
|
+
* captured together from one read, so responses are internally
|
|
59
|
+
* coherent (headers always describe the bytes actually sent).
|
|
60
|
+
*
|
|
61
|
+
* Off by default: correctness-first. Enable for hot small files
|
|
62
|
+
* (thumbnails, documents, content-addressed assets -- use a long TTL
|
|
63
|
+
* for immutable keys). Bodies at or below the single-read limit
|
|
64
|
+
* (128 KiB) are cached; larger objects always stream fresh from disk.
|
|
65
|
+
*
|
|
66
|
+
* Memory bound: `maxEntries` caps the entry count and `maxBytes` caps
|
|
67
|
+
* the total cached BODY bytes, so the worst case is
|
|
68
|
+
* `min(maxEntries * 128 KiB, maxBytes)` plus per-entry metadata.
|
|
69
|
+
*/
|
|
70
|
+
cache?: {
|
|
71
|
+
/** How long an entry may serve before revalidating against disk. */
|
|
72
|
+
ttlMs: number;
|
|
73
|
+
/**
|
|
74
|
+
* Entry cap, evicted least-recently-used.
|
|
75
|
+
* @default 1024
|
|
76
|
+
*/
|
|
77
|
+
maxEntries?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Total byte budget for cached bodies, evicted least-recently-used.
|
|
80
|
+
* Metadata-only entries cost nothing against it. A single body larger
|
|
81
|
+
* than the budget is served normally but cached metadata-only, so an
|
|
82
|
+
* oversized object can never flush the whole cache to still not fit.
|
|
83
|
+
* `0` keeps the cache metadata-only (stat elision without body memory).
|
|
84
|
+
* @default 67108864 (64 MiB)
|
|
85
|
+
*/
|
|
86
|
+
maxBytes?: number;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One coherent snapshot of a small object: metadata + bytes from one read. */
|
|
91
|
+
interface CacheEntry {
|
|
92
|
+
expiresAt: number;
|
|
93
|
+
totalSize: number;
|
|
94
|
+
lastModified: string;
|
|
95
|
+
/** Weak validator derived once at stat time (size + mtime). */
|
|
96
|
+
etag: string | undefined;
|
|
97
|
+
/** Present only for objects at or below the single-read limit. */
|
|
98
|
+
bytes?: Buffer;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create an {@link ObjectStore} backed by the local filesystem.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```typescript
|
|
108
|
+
* import { fsStore } from "partial-content/fs";
|
|
109
|
+
*
|
|
110
|
+
* const store = fsStore({ root: "/var/data/uploads" });
|
|
111
|
+
* const meta = await store.headObject("reports/q4.pdf");
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
114
|
+
export function fsStore(opts: FsStoreOptions): ObjectStore {
|
|
115
|
+
const root = resolve(opts.root);
|
|
116
|
+
const cacheTtl = opts.cache?.ttlMs ?? 0;
|
|
117
|
+
const cacheMax = opts.cache?.maxEntries ?? 1024;
|
|
118
|
+
const cacheMaxBytes = opts.cache?.maxBytes ?? DEFAULT_CACHE_MAX_BYTES;
|
|
119
|
+
const cache = cacheTtl > 0 ? new Map<string, CacheEntry>() : null;
|
|
120
|
+
// Total bytes held by cached bodies, maintained by cacheDelete/cacheSet.
|
|
121
|
+
// Metadata-only entries contribute zero.
|
|
122
|
+
let cacheBytes = 0;
|
|
123
|
+
|
|
124
|
+
/** Remove an entry, keeping the body byte accounting exact. */
|
|
125
|
+
function cacheDelete(key: string): boolean {
|
|
126
|
+
const prev = cache!.get(key);
|
|
127
|
+
if (prev === undefined) return false;
|
|
128
|
+
cacheBytes -= prev.bytes ? prev.bytes.byteLength : 0;
|
|
129
|
+
cache!.delete(key);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Evict the least recently used entry (first in insertion order). */
|
|
134
|
+
function evictOldest(): void {
|
|
135
|
+
const oldest = cache!.keys().next();
|
|
136
|
+
if (!oldest.done) cacheDelete(oldest.value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cacheGet(key: string): CacheEntry | null {
|
|
140
|
+
if (!cache) return null;
|
|
141
|
+
const entry = cache.get(key);
|
|
142
|
+
if (!entry) return null;
|
|
143
|
+
if (entry.expiresAt < Date.now()) {
|
|
144
|
+
cacheDelete(key);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
// LRU touch: Map preserves insertion order; re-insert marks as recent.
|
|
148
|
+
// Same entry out and in, so the byte accounting is untouched.
|
|
149
|
+
cache.delete(key);
|
|
150
|
+
cache.set(key, entry);
|
|
151
|
+
return entry;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cacheSet(key: string, entry: Omit<CacheEntry, "expiresAt">): void {
|
|
155
|
+
if (!cache) return;
|
|
156
|
+
const prev = cache.get(key);
|
|
157
|
+
// Preserve a cached body across a metadata-only write-back: a range GET
|
|
158
|
+
// refreshes metadata but carries no bytes, and blindly replacing the
|
|
159
|
+
// entry would evict a still-valid full body (same representation) and
|
|
160
|
+
// force the next full GET back to disk. Keep the bytes when the
|
|
161
|
+
// representation is unchanged (same weak/strong validator); drop them
|
|
162
|
+
// when the etag moved (the file changed -> old bytes are stale).
|
|
163
|
+
const carried = (entry.bytes === undefined && prev?.bytes && prev.etag === entry.etag)
|
|
164
|
+
? { ...entry, bytes: prev.bytes }
|
|
165
|
+
: entry;
|
|
166
|
+
// A body that alone exceeds the byte budget can never fit: store the
|
|
167
|
+
// entry metadata-only rather than evicting every other body and still
|
|
168
|
+
// failing. The object itself is unaffected (this GET already served it).
|
|
169
|
+
const stored = carried.bytes && carried.bytes.byteLength > cacheMaxBytes
|
|
170
|
+
? { totalSize: carried.totalSize, lastModified: carried.lastModified, etag: carried.etag }
|
|
171
|
+
: carried;
|
|
172
|
+
// Delete-before-set: Map.set on an existing key keeps its old insertion
|
|
173
|
+
// order, so a refresh must re-insert to reach the most-recent LRU slot.
|
|
174
|
+
// It also makes eviction conditional on genuinely growing the map --
|
|
175
|
+
// refreshing a hot key at capacity must not evict an unrelated entry.
|
|
176
|
+
const existed = cacheDelete(key);
|
|
177
|
+
if (!existed && cache.size >= cacheMax) {
|
|
178
|
+
evictOldest();
|
|
179
|
+
}
|
|
180
|
+
// Byte budget: evict least-recent entries until the new body fits.
|
|
181
|
+
const newBytes = stored.bytes ? stored.bytes.byteLength : 0;
|
|
182
|
+
while (newBytes > 0 && cacheBytes + newBytes > cacheMaxBytes && cache.size > 0) {
|
|
183
|
+
evictOldest();
|
|
184
|
+
}
|
|
185
|
+
cache.set(key, { ...stored, expiresAt: Date.now() + cacheTtl });
|
|
186
|
+
cacheBytes += newBytes;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Resolve a key to an absolute path within the root directory.
|
|
191
|
+
* Rejects path traversal attempts that escape the root.
|
|
192
|
+
*/
|
|
193
|
+
function safePath(key: string): string {
|
|
194
|
+
// Null bytes are invalid in every filesystem and are a classic probe for
|
|
195
|
+
// C-string truncation bugs in lower layers. Treat as "no such object"
|
|
196
|
+
// rather than letting fs throw ERR_INVALID_ARG_VALUE (which would map
|
|
197
|
+
// to a misleading 502 upstream).
|
|
198
|
+
if (key.includes("\0")) {
|
|
199
|
+
throw new ObjectNotFoundError(key);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Windows-only hardening (sep === "\\"). ':' is illegal in a normal
|
|
203
|
+
// Windows filename -- it only appears in a drive designator ("D:\x")
|
|
204
|
+
// or an NTFS alternate-data-stream name ("secret.txt::$DATA"). Rejecting
|
|
205
|
+
// it early stops a cross-volume escape (resolve() would turn "D:\x" into
|
|
206
|
+
// an absolute path on another drive) and hidden-stream access. Reserved
|
|
207
|
+
// device names (NUL, CON, COM1...) map to hardware from ANY directory,
|
|
208
|
+
// so open() on them never touches a file under the root.
|
|
209
|
+
if (sep === "\\") {
|
|
210
|
+
if (key.includes(":")) {
|
|
211
|
+
throw new ObjectNotFoundError(key);
|
|
212
|
+
}
|
|
213
|
+
const base = key.slice(
|
|
214
|
+
Math.max(key.lastIndexOf("/"), key.lastIndexOf("\\")) + 1,
|
|
215
|
+
);
|
|
216
|
+
if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/i.test(base)) {
|
|
217
|
+
throw new ObjectNotFoundError(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const resolved = resolve(root, key);
|
|
222
|
+
const rel = relative(root, resolved);
|
|
223
|
+
|
|
224
|
+
// rel escapes the root when it climbs out ("..") or is itself absolute.
|
|
225
|
+
// A cross-drive key resolves onto another volume and relative() then
|
|
226
|
+
// returns that absolute path unchanged, which a bare startsWith("..")
|
|
227
|
+
// check would wave through.
|
|
228
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
|
|
229
|
+
throw new ObjectNotFoundError(key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return resolved;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
supportsRange: true,
|
|
237
|
+
|
|
238
|
+
async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
|
|
239
|
+
opts?.signal?.throwIfAborted();
|
|
240
|
+
const cached = cacheGet(key);
|
|
241
|
+
if (cached) {
|
|
242
|
+
return {
|
|
243
|
+
contentLength: cached.totalSize,
|
|
244
|
+
lastModified: cached.lastModified,
|
|
245
|
+
etag: cached.etag,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const filePath = safePath(key);
|
|
249
|
+
|
|
250
|
+
let stats;
|
|
251
|
+
try {
|
|
252
|
+
stats = await stat(filePath, { bigint: true });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (isFileNotFound(err)) throw new ObjectNotFoundError(key, err);
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!stats.isFile()) {
|
|
259
|
+
throw new ObjectNotFoundError(key);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const meta = {
|
|
263
|
+
totalSize: Number(stats.size),
|
|
264
|
+
lastModified: stats.mtime.toUTCString(),
|
|
265
|
+
// Derive the weak validator once per stat instead of letting the
|
|
266
|
+
// orchestrator re-parse the Last-Modified string on every request
|
|
267
|
+
// (Date.parse on the serve hot path is measurable at 10k req/s).
|
|
268
|
+
etag: weakFsETag(stats.size, stats.mtimeNs),
|
|
269
|
+
};
|
|
270
|
+
cacheSet(key, meta);
|
|
271
|
+
return {
|
|
272
|
+
contentLength: meta.totalSize,
|
|
273
|
+
lastModified: meta.lastModified,
|
|
274
|
+
etag: meta.etag,
|
|
275
|
+
};
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal }): Promise<ObjectStream> {
|
|
279
|
+
const { range, signal } = opts ?? {};
|
|
280
|
+
signal?.throwIfAborted();
|
|
281
|
+
|
|
282
|
+
// Hot path: a cached small body serves ranges and full reads as
|
|
283
|
+
// zero-copy subarray views -- no syscalls at all. Metadata-only
|
|
284
|
+
// entries (from headObject) cannot serve bytes and fall through.
|
|
285
|
+
const cached = cacheGet(key);
|
|
286
|
+
if (cached?.bytes) {
|
|
287
|
+
if (range && range.start >= cached.totalSize) {
|
|
288
|
+
// Unsatisfiable ranges are the orchestrator's job to reject
|
|
289
|
+
// (parseRangeHeader 416s them first); a direct caller gets a loud
|
|
290
|
+
// error instead of an empty body with lying bounds.
|
|
291
|
+
throw new RangeError(
|
|
292
|
+
`fsStore ${key}: range start ${range.start} is beyond object size ${cached.totalSize}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
// Clamp so ObjectStream.range always reports the bounds actually
|
|
296
|
+
// served, even for a direct caller passing an unclamped range.
|
|
297
|
+
const bytes = range
|
|
298
|
+
? cached.bytes.subarray(range.start, Math.min(range.end + 1, cached.totalSize))
|
|
299
|
+
: cached.bytes;
|
|
300
|
+
return {
|
|
301
|
+
// Node fs read buffers are ArrayBuffer-backed; narrow so the body is
|
|
302
|
+
// `new Response(...)`-assignable under DOM lib (F5).
|
|
303
|
+
body: bytes as Uint8Array<ArrayBuffer>,
|
|
304
|
+
contentLength: bytes.length,
|
|
305
|
+
totalSize: cached.totalSize,
|
|
306
|
+
range: range ? { start: range.start, end: range.start + bytes.length - 1 } : undefined,
|
|
307
|
+
lastModified: cached.lastModified,
|
|
308
|
+
etag: cached.etag,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const filePath = safePath(key);
|
|
313
|
+
|
|
314
|
+
// Open once and both stat and stream from the same file handle. A
|
|
315
|
+
// stat-then-reopen sequence races against file replacement: the stream
|
|
316
|
+
// could read a different (shorter) file than the one measured, producing
|
|
317
|
+
// a Content-Length that doesn't match the bytes sent.
|
|
318
|
+
let handle;
|
|
319
|
+
try {
|
|
320
|
+
handle = await open(filePath, "r");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (isFileNotFound(err)) throw new ObjectNotFoundError(key, err);
|
|
323
|
+
throw err;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let stats;
|
|
327
|
+
try {
|
|
328
|
+
stats = await handle.stat({ bigint: true });
|
|
329
|
+
if (!stats.isFile()) throw new ObjectNotFoundError(key);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
await handle.close().catch(() => {
|
|
332
|
+
// Best-effort close; the stat/isFile error is the one that matters
|
|
333
|
+
});
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const totalSize = Number(stats.size);
|
|
338
|
+
const lastModified = stats.mtime.toUTCString();
|
|
339
|
+
const etag = weakFsETag(stats.size, stats.mtimeNs);
|
|
340
|
+
|
|
341
|
+
if (range && range.start >= totalSize) {
|
|
342
|
+
await handle.close().catch(() => {
|
|
343
|
+
// Best-effort close; the range error is the one that matters
|
|
344
|
+
});
|
|
345
|
+
throw new RangeError(
|
|
346
|
+
`fsStore ${key}: range start ${range.start} is beyond object size ${totalSize}`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
// Clamp the end so served bounds always reflect the file's actual
|
|
350
|
+
// size (the orchestrator pre-clamps; direct callers may not).
|
|
351
|
+
const end = range ? Math.min(range.end, totalSize - 1) : totalSize - 1;
|
|
352
|
+
const contentLength = range ? end - range.start + 1 : totalSize;
|
|
353
|
+
|
|
354
|
+
// Small transfers: one exact-length positional read, close, and a
|
|
355
|
+
// single-chunk stream. Skips the ReadStream + async-iterator bridge
|
|
356
|
+
// machinery entirely, which dominates per-request cost for small
|
|
357
|
+
// files (the memory bound is SMALL_READ_LIMIT per in-flight request).
|
|
358
|
+
if (contentLength <= SMALL_READ_LIMIT) {
|
|
359
|
+
// Cache-destined buffers are allocated off-pool: for small sizes
|
|
360
|
+
// allocUnsafe returns a view into Node's shared 8 KiB slab, and a
|
|
361
|
+
// long-lived cache entry would pin the whole slab and expose
|
|
362
|
+
// adjacent slab bytes to any consumer that reads `.buffer` without
|
|
363
|
+
// honoring byteOffset. Only full-object reads populate bytes: a
|
|
364
|
+
// range slice is not the whole body, and mixing partial bytes into
|
|
365
|
+
// the cache would serve wrong content for other ranges.
|
|
366
|
+
const willCache = cache !== null && !range && contentLength === totalSize;
|
|
367
|
+
const buffer = willCache
|
|
368
|
+
? Buffer.allocUnsafeSlow(contentLength)
|
|
369
|
+
: Buffer.allocUnsafe(contentLength);
|
|
370
|
+
try {
|
|
371
|
+
const { bytesRead } = await handle.read(buffer, 0, contentLength, range?.start ?? 0);
|
|
372
|
+
// The handle's own stat promised these bytes; a short read means
|
|
373
|
+
// the file was truncated mid-request. Failing here (502, before
|
|
374
|
+
// headers) beats the streaming path's only option of a torn body.
|
|
375
|
+
if (bytesRead < contentLength) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`fsStore ${key}: file shrank during read (expected ${contentLength} bytes, got ${bytesRead})`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
await handle.close().catch(() => {
|
|
382
|
+
// Best-effort close; the bytes (or the read error) already decide the outcome
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
if (willCache) {
|
|
386
|
+
cacheSet(key, { totalSize, lastModified, etag, bytes: buffer });
|
|
387
|
+
} else {
|
|
388
|
+
// Metadata write-back: every disk read refreshes the entry so
|
|
389
|
+
// HEAD/conditional traffic converges on the representation this
|
|
390
|
+
// GET actually served instead of a stale headObject snapshot.
|
|
391
|
+
cacheSet(key, { totalSize, lastModified, etag });
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
body: buffer,
|
|
395
|
+
contentLength,
|
|
396
|
+
totalSize,
|
|
397
|
+
range: range ? { start: range.start, end } : undefined,
|
|
398
|
+
lastModified,
|
|
399
|
+
etag,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// autoClose (default true) closes the handle when the stream ends or
|
|
404
|
+
// is destroyed, covering completion, cancel, and abort paths.
|
|
405
|
+
const nodeStream = handle.createReadStream(
|
|
406
|
+
range ? { start: range.start, end } : {},
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// expectedBytes mirrors the small-read path's short-read guard onto the
|
|
410
|
+
// streaming path: an in-place truncation mid-serve ends the ReadStream
|
|
411
|
+
// early, and without this the body would under-run the committed
|
|
412
|
+
// Content-Length silently. The atomic write-temp+rename overwrite pattern
|
|
413
|
+
// does not trip it (the open handle keeps reading the original inode).
|
|
414
|
+
const webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
|
|
415
|
+
|
|
416
|
+
// Metadata write-back (bytes stay uncached above the single-read
|
|
417
|
+
// limit): keeps HEAD/conditional responses coherent with the
|
|
418
|
+
// representation this GET is serving.
|
|
419
|
+
cacheSet(key, { totalSize, lastModified, etag });
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
body: webStream,
|
|
423
|
+
contentLength,
|
|
424
|
+
totalSize,
|
|
425
|
+
range: range ? { start: range.start, end } : undefined,
|
|
426
|
+
lastModified,
|
|
427
|
+
etag,
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Transfers at or below this size are served by a single positional read
|
|
437
|
+
* instead of a ReadStream. 128 KiB covers typical documents, thumbnails,
|
|
438
|
+
* and range chunks while bounding per-request buffering.
|
|
439
|
+
*/
|
|
440
|
+
const SMALL_READ_LIMIT = 128 * 1024;
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Default total byte budget for cached bodies (64 MiB). Together with the
|
|
444
|
+
* entry cap this bounds the cache's worst-case footprint explicitly, instead
|
|
445
|
+
* of leaving it implied by `maxEntries * SMALL_READ_LIMIT`.
|
|
446
|
+
*/
|
|
447
|
+
const DEFAULT_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Weak validator from size + NANOSECOND mtime (bigint stat).
|
|
451
|
+
*
|
|
452
|
+
* The kernel's generic formatter floors mtime to whole seconds; on a
|
|
453
|
+
* filesystem store that recreates the classic weak-ETag hazard: two
|
|
454
|
+
* same-length writes within one second are indistinguishable, and a
|
|
455
|
+
* revalidating client gets a false-fresh 304 for changed bytes. Nanosecond
|
|
456
|
+
* resolution closes that window on ext4/APFS/NTFS; filesystems with coarse
|
|
457
|
+
* timestamps (FAT: 2 s) keep a residual window, documented in DESIGN.md.
|
|
458
|
+
*/
|
|
459
|
+
function weakFsETag(size: bigint, mtimeNs: bigint): string {
|
|
460
|
+
return `W/"${size.toString(16)}-${mtimeNs.toString(16)}"`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function isFileNotFound(err: unknown): boolean {
|
|
464
|
+
if (err instanceof Error && "code" in err) {
|
|
465
|
+
const code = (err as { code: string }).code;
|
|
466
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
469
|
+
}
|