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/memory.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory ObjectStore for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* A complete, spec-faithful store over a plain object map. Built for:
|
|
5
|
+
* - **Consumer test suites** -- exercise your serving routes (ranges,
|
|
6
|
+
* conditionals, pinned-read retries) without a storage backend.
|
|
7
|
+
* - **Demos and examples** -- a working store in three lines.
|
|
8
|
+
* - **Small embedded assets** -- serve a handful of in-process files with
|
|
9
|
+
* the full 200/206/304 protocol.
|
|
10
|
+
*
|
|
11
|
+
* Faithful to the contract: real Content-Range fabrication, `ifMatch`
|
|
12
|
+
* pinning (mismatch throws {@link ObjectChangedError}, so retry logic is
|
|
13
|
+
* testable), and per-object validators.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import { memoryStore } from "partial-content/memory";
|
|
18
|
+
* import { serveObject } from "partial-content/web";
|
|
19
|
+
*
|
|
20
|
+
* const store = memoryStore({
|
|
21
|
+
* objects: {
|
|
22
|
+
* "hello.txt": { body: "Hello, world!", etag: '"v1"' },
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* const handler = serveObject(store);
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
ObjectNotFoundError,
|
|
33
|
+
ObjectChangedError,
|
|
34
|
+
type ObjectStore,
|
|
35
|
+
type ObjectMetadata,
|
|
36
|
+
type ObjectStream,
|
|
37
|
+
type ParsedRange,
|
|
38
|
+
} from "./index.js";
|
|
39
|
+
|
|
40
|
+
// Re-export for convenience
|
|
41
|
+
export { ObjectNotFoundError, ObjectChangedError };
|
|
42
|
+
|
|
43
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** A stored object: body plus optional validators and integrity metadata. */
|
|
46
|
+
export interface MemoryObject {
|
|
47
|
+
/** Object content. Strings are encoded as UTF-8. */
|
|
48
|
+
body: Uint8Array | string;
|
|
49
|
+
/** Raw ETag (quoted, like a backend would return). */
|
|
50
|
+
etag?: string;
|
|
51
|
+
/** Last-Modified (any `Date.parse`-able string). */
|
|
52
|
+
lastModified?: string;
|
|
53
|
+
/** RFC 9530 raw base64 SHA-256 digest of the body. */
|
|
54
|
+
digest?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MemoryStoreOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Key -> object map. Held BY REFERENCE: mutate it between requests to
|
|
60
|
+
* simulate uploads, overwrites (change `etag` to trigger pinned-read
|
|
61
|
+
* retries), and deletions.
|
|
62
|
+
*/
|
|
63
|
+
objects: Record<string, MemoryObject>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an {@link ObjectStore} over an in-memory object map.
|
|
70
|
+
*/
|
|
71
|
+
export function memoryStore(opts: MemoryStoreOptions): ObjectStore {
|
|
72
|
+
const encoder = new TextEncoder();
|
|
73
|
+
// Cache the UTF-8 encoding of string bodies so a hot-served asset is not
|
|
74
|
+
// re-encoded on every head + get. The map is keyed by the entry and holds
|
|
75
|
+
// the exact string it encoded: the objects map is mutable by reference
|
|
76
|
+
// (the documented way to simulate overwrites), so a changed body misses
|
|
77
|
+
// the cache and re-encodes rather than serving stale bytes.
|
|
78
|
+
const encodeCache = new WeakMap<MemoryObject, { source: string; bytes: Uint8Array }>();
|
|
79
|
+
|
|
80
|
+
function lookup(key: string): { entry: MemoryObject; bytes: Uint8Array } {
|
|
81
|
+
// Own-property check: the map is a caller-supplied plain object, so an
|
|
82
|
+
// inherited key ("constructor", "__proto__", "toString") would otherwise
|
|
83
|
+
// return a truthy prototype member and crash downstream as a 502 instead
|
|
84
|
+
// of answering the honest 404.
|
|
85
|
+
const entry = Object.hasOwn(opts.objects, key) ? opts.objects[key] : undefined;
|
|
86
|
+
if (!entry) throw new ObjectNotFoundError(key);
|
|
87
|
+
if (typeof entry.body !== "string") return { entry, bytes: entry.body };
|
|
88
|
+
const cached = encodeCache.get(entry);
|
|
89
|
+
if (cached && cached.source === entry.body) return { entry, bytes: cached.bytes };
|
|
90
|
+
const bytes = encoder.encode(entry.body);
|
|
91
|
+
encodeCache.set(entry, { source: entry.body, bytes });
|
|
92
|
+
return { entry, bytes };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
supportsRange: true,
|
|
97
|
+
|
|
98
|
+
async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
|
|
99
|
+
opts?.signal?.throwIfAborted();
|
|
100
|
+
const { entry, bytes } = lookup(key);
|
|
101
|
+
return {
|
|
102
|
+
contentLength: bytes.length,
|
|
103
|
+
etag: entry.etag,
|
|
104
|
+
lastModified: entry.lastModified,
|
|
105
|
+
digest: entry.digest,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async getObject(
|
|
110
|
+
key: string,
|
|
111
|
+
getOpts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string },
|
|
112
|
+
): Promise<ObjectStream> {
|
|
113
|
+
const { range, signal, ifMatch } = getOpts ?? {};
|
|
114
|
+
signal?.throwIfAborted();
|
|
115
|
+
const { entry, bytes } = lookup(key);
|
|
116
|
+
|
|
117
|
+
// Pinned read: the object must still match the caller's validator.
|
|
118
|
+
if (ifMatch && entry.etag !== ifMatch) {
|
|
119
|
+
throw new ObjectChangedError(key);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (range && range.start >= bytes.length) {
|
|
123
|
+
// Unsatisfiable ranges are the orchestrator's job to reject
|
|
124
|
+
// (parseRangeHeader 416s them first); a direct caller gets a loud
|
|
125
|
+
// error instead of an empty body with lying bounds.
|
|
126
|
+
throw new RangeError(
|
|
127
|
+
`memoryStore ${key}: range start ${range.start} is beyond object size ${bytes.length}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
// subarray() clamps to the object size and returns a view (no copy);
|
|
131
|
+
// the served bytes are read-only, so a view is safe. Derive the reported
|
|
132
|
+
// range from the actual slice so ObjectStream.range is the SERVED bounds.
|
|
133
|
+
const slice = range ? bytes.subarray(range.start, range.end + 1) : bytes;
|
|
134
|
+
return {
|
|
135
|
+
// In-memory bytes are ArrayBuffer-backed (TextEncoder output or the
|
|
136
|
+
// caller's view); narrow so the body is `new Response(...)`-assignable
|
|
137
|
+
// under DOM lib (F5).
|
|
138
|
+
body: slice as Uint8Array<ArrayBuffer>,
|
|
139
|
+
contentLength: slice.length,
|
|
140
|
+
totalSize: bytes.length,
|
|
141
|
+
range: range ? { start: range.start, end: range.start + slice.length - 1 } : undefined,
|
|
142
|
+
etag: entry.etag,
|
|
143
|
+
lastModified: entry.lastModified,
|
|
144
|
+
digest: entry.digest,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal MIME type lookup for file serving.
|
|
3
|
+
*
|
|
4
|
+
* A curated extension -> MIME map covering the formats that actually flow
|
|
5
|
+
* through document/media serving: office documents, images, audio/video,
|
|
6
|
+
* archives, fonts, and web assets. Deliberately small and auditable rather
|
|
7
|
+
* than exhaustive -- for the long tail, pass an explicit `mime` to the
|
|
8
|
+
* serve handler or bring a full database (`mime-types`, `mrmime`).
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies, aligned with IANA registrations and the WHATWG
|
|
11
|
+
* mimesniff living standard.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { serveObject } from "partial-content/node";
|
|
16
|
+
* import { fsStore } from "partial-content/fs";
|
|
17
|
+
* import { lookupMime } from "partial-content/mime";
|
|
18
|
+
*
|
|
19
|
+
* const handler = serveObject(fsStore({ root: "/data" }), {
|
|
20
|
+
* key: (req) => req.params.key,
|
|
21
|
+
* mime: (req) => lookupMime(req.params.key),
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// ─── Extension Map ──────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extension (lowercase, no dot) -> MIME type.
|
|
32
|
+
*
|
|
33
|
+
* Security-relevant choices:
|
|
34
|
+
* - `html`/`htm`/`xhtml` are intentionally ABSENT: serving stored user
|
|
35
|
+
* uploads as `text/html` is stored XSS. If you serve trusted HTML, pass
|
|
36
|
+
* the MIME explicitly so the decision is visible at the call site.
|
|
37
|
+
* - Several mapped types are ACTIVE CONTENT that can execute script when
|
|
38
|
+
* rendered `inline` from your own origin. For untrusted uploads serve
|
|
39
|
+
* them `attachment` (the library's default disposition) or behind a
|
|
40
|
+
* strict CSP -- never plain `inline`:
|
|
41
|
+
* - `svg` -> `image/svg+xml` (embedded `<script>`, event handlers)
|
|
42
|
+
* - `pdf` -> `application/pdf` (embedded JavaScript in the viewer)
|
|
43
|
+
* - `xml` -> `application/xml` (`<?xml-stylesheet?>` can run XSLT)
|
|
44
|
+
* `X-Content-Type-Options: nosniff` and the default `attachment`
|
|
45
|
+
* disposition already protect the built-in serve path; the warning
|
|
46
|
+
* matters when a caller overrides `disposition: "inline"`.
|
|
47
|
+
*/
|
|
48
|
+
const MIME_TYPES: Record<string, string> = {
|
|
49
|
+
// Documents
|
|
50
|
+
pdf: "application/pdf",
|
|
51
|
+
txt: "text/plain",
|
|
52
|
+
md: "text/markdown",
|
|
53
|
+
csv: "text/csv",
|
|
54
|
+
json: "application/json",
|
|
55
|
+
xml: "application/xml",
|
|
56
|
+
rtf: "application/rtf",
|
|
57
|
+
epub: "application/epub+zip",
|
|
58
|
+
ics: "text/calendar",
|
|
59
|
+
vcf: "text/vcard",
|
|
60
|
+
|
|
61
|
+
// Office (OOXML + legacy + OpenDocument)
|
|
62
|
+
doc: "application/msword",
|
|
63
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
64
|
+
xls: "application/vnd.ms-excel",
|
|
65
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
66
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
67
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
68
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
69
|
+
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
70
|
+
odp: "application/vnd.oasis.opendocument.presentation",
|
|
71
|
+
|
|
72
|
+
// Images
|
|
73
|
+
png: "image/png",
|
|
74
|
+
jpg: "image/jpeg",
|
|
75
|
+
jpeg: "image/jpeg",
|
|
76
|
+
gif: "image/gif",
|
|
77
|
+
webp: "image/webp",
|
|
78
|
+
avif: "image/avif",
|
|
79
|
+
svg: "image/svg+xml",
|
|
80
|
+
ico: "image/x-icon",
|
|
81
|
+
bmp: "image/bmp",
|
|
82
|
+
tif: "image/tiff",
|
|
83
|
+
tiff: "image/tiff",
|
|
84
|
+
heic: "image/heic",
|
|
85
|
+
|
|
86
|
+
// Audio
|
|
87
|
+
mp3: "audio/mpeg",
|
|
88
|
+
wav: "audio/wav",
|
|
89
|
+
ogg: "audio/ogg",
|
|
90
|
+
oga: "audio/ogg",
|
|
91
|
+
m4a: "audio/mp4",
|
|
92
|
+
aac: "audio/aac",
|
|
93
|
+
flac: "audio/flac",
|
|
94
|
+
opus: "audio/opus",
|
|
95
|
+
|
|
96
|
+
// Video
|
|
97
|
+
mp4: "video/mp4",
|
|
98
|
+
m4v: "video/mp4",
|
|
99
|
+
webm: "video/webm",
|
|
100
|
+
mov: "video/quicktime",
|
|
101
|
+
avi: "video/x-msvideo",
|
|
102
|
+
mkv: "video/x-matroska",
|
|
103
|
+
ogv: "video/ogg",
|
|
104
|
+
|
|
105
|
+
// Archives
|
|
106
|
+
zip: "application/zip",
|
|
107
|
+
gz: "application/gzip",
|
|
108
|
+
tar: "application/x-tar",
|
|
109
|
+
"7z": "application/x-7z-compressed",
|
|
110
|
+
rar: "application/vnd.rar",
|
|
111
|
+
|
|
112
|
+
// Fonts
|
|
113
|
+
woff: "font/woff",
|
|
114
|
+
woff2: "font/woff2",
|
|
115
|
+
ttf: "font/ttf",
|
|
116
|
+
otf: "font/otf",
|
|
117
|
+
|
|
118
|
+
// Web assets
|
|
119
|
+
js: "text/javascript",
|
|
120
|
+
mjs: "text/javascript",
|
|
121
|
+
css: "text/css",
|
|
122
|
+
wasm: "application/wasm",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Look up the MIME type for a filename, path, storage key, or bare extension.
|
|
129
|
+
*
|
|
130
|
+
* Matching is case-insensitive and uses the LAST dot segment, so keys like
|
|
131
|
+
* `2025/reports/Q4 Report.PDF` and `archive.tar.gz` resolve correctly
|
|
132
|
+
* (`application/pdf`, `application/gzip`).
|
|
133
|
+
*
|
|
134
|
+
* Returns `undefined` for unknown or missing extensions so the caller
|
|
135
|
+
* controls the fallback (the serve handlers default to
|
|
136
|
+
* `application/octet-stream`, the safe choice for unknown content).
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```typescript
|
|
140
|
+
* lookupMime("report.pdf"); // "application/pdf"
|
|
141
|
+
* lookupMime("Q4 Report.PDF"); // "application/pdf"
|
|
142
|
+
* lookupMime("archive.tar.gz"); // "application/gzip"
|
|
143
|
+
* lookupMime("pdf"); // "application/pdf" (bare extension)
|
|
144
|
+
* lookupMime("unknown.xyz"); // undefined
|
|
145
|
+
* lookupMime("no-extension"); // undefined (unless it IS an extension)
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export function lookupMime(filenameOrExt: string | null | undefined): string | undefined {
|
|
149
|
+
if (!filenameOrExt) return undefined;
|
|
150
|
+
|
|
151
|
+
const lastDot = filenameOrExt.lastIndexOf(".");
|
|
152
|
+
// No dot: treat the whole input as a bare extension ("pdf" -> pdf).
|
|
153
|
+
// Trailing dot ("file.") yields an empty extension -> undefined.
|
|
154
|
+
const ext = lastDot === -1
|
|
155
|
+
? filenameOrExt
|
|
156
|
+
: filenameOrExt.slice(lastDot + 1);
|
|
157
|
+
|
|
158
|
+
if (!ext) return undefined;
|
|
159
|
+
return MIME_TYPES[ext.toLowerCase()];
|
|
160
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node.js HTTP adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the gap between Node.js `IncomingMessage`/`ServerResponse` and the
|
|
5
|
+
* Fetch-based `partial-content/web` handler. Works with Express, Fastify
|
|
6
|
+
* (in compatibility mode), Koa, raw `http.createServer`, and any Node.js
|
|
7
|
+
* HTTP framework that exposes the standard request/response objects.
|
|
8
|
+
*
|
|
9
|
+
* @example Express
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import express from "express";
|
|
12
|
+
* import { serveObject } from "partial-content/node";
|
|
13
|
+
* import { fsStore } from "partial-content/fs";
|
|
14
|
+
*
|
|
15
|
+
* const store = fsStore({ root: "./uploads" });
|
|
16
|
+
* const app = express();
|
|
17
|
+
*
|
|
18
|
+
* app.get("/files/:key", serveObject(store, {
|
|
19
|
+
* key: (req) => req.params.key,
|
|
20
|
+
* disposition: "inline",
|
|
21
|
+
* }));
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example raw http.createServer
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { createServer } from "node:http";
|
|
27
|
+
* import { serveObject } from "partial-content/node";
|
|
28
|
+
* import { s3Store } from "partial-content/s3";
|
|
29
|
+
*
|
|
30
|
+
* const store = s3Store({ client, bucket: "docs" });
|
|
31
|
+
* const handler = serveObject(store, {
|
|
32
|
+
* key: (req) => new URL(req.url!, `http://${req.headers.host}`).pathname.slice(1),
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* createServer((req, res) => handler(req, res)).listen(3000);
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* @packageDocumentation
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
42
|
+
import { serveObjectRaw, type RawResponseParts, type ServeObjectOptions, type ServeContext } from "./web.js";
|
|
43
|
+
import type { ObjectStore } from "./index.js";
|
|
44
|
+
|
|
45
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface NodeServeOptions extends ServeObjectOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Extract the storage key from the incoming request.
|
|
50
|
+
* The request object is the raw Node.js IncomingMessage, so you have
|
|
51
|
+
* access to URL, params (if using Express/Fastify), headers, etc.
|
|
52
|
+
*/
|
|
53
|
+
key: (req: IncomingMessage) => string | Promise<string>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract the MIME type from the request.
|
|
57
|
+
* When omitted, defaults to "application/octet-stream".
|
|
58
|
+
*/
|
|
59
|
+
mime?: (req: IncomingMessage) => string | undefined;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Extract the filename from the request (for Content-Disposition).
|
|
63
|
+
* When omitted, no filename is set.
|
|
64
|
+
*/
|
|
65
|
+
filename?: (req: IncomingMessage) => string | undefined;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Max milliseconds to wait for a single backpressure `drain` while streaming
|
|
69
|
+
* before treating the client as stalled and tearing the transfer down.
|
|
70
|
+
*
|
|
71
|
+
* A client that stops reading but holds its socket open (a slow-read attack)
|
|
72
|
+
* fills the send buffer, so `res.write()` returns false and `drain` never
|
|
73
|
+
* fires. Without a bound the stream pump would wait forever, pinning the
|
|
74
|
+
* backend storage socket (and its connection-pool slot). A stall reliably
|
|
75
|
+
* tears down via the existing error path (cancel the reader, destroy the
|
|
76
|
+
* response). Set to `0` to disable and rely on an upstream proxy / socket
|
|
77
|
+
* timeout instead.
|
|
78
|
+
*
|
|
79
|
+
* @default 60000
|
|
80
|
+
*/
|
|
81
|
+
writeStallTimeoutMs?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Default backpressure stall bound: 60s of zero drain is a stalled reader. */
|
|
85
|
+
const DEFAULT_WRITE_STALL_MS = 60_000;
|
|
86
|
+
|
|
87
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a Node.js request handler that serves files from an {@link ObjectStore}.
|
|
91
|
+
*
|
|
92
|
+
* Converts Node.js `IncomingMessage` to a Fetch `Request`, delegates to the
|
|
93
|
+
* web adapter's `serveObject`, then writes the Fetch `Response` back to the
|
|
94
|
+
* Node.js `ServerResponse`.
|
|
95
|
+
*/
|
|
96
|
+
export function serveObject(
|
|
97
|
+
store: ObjectStore,
|
|
98
|
+
opts: NodeServeOptions,
|
|
99
|
+
) {
|
|
100
|
+
const { key: keyFn, mime: mimeFn, filename: filenameFn, writeStallTimeoutMs, ...serveOpts } = opts;
|
|
101
|
+
const handler = serveObjectRaw(store, serveOpts);
|
|
102
|
+
const stallMs = writeStallTimeoutMs ?? DEFAULT_WRITE_STALL_MS;
|
|
103
|
+
|
|
104
|
+
return async function handleNodeServe(
|
|
105
|
+
req: IncomingMessage,
|
|
106
|
+
res: ServerResponse,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
// Wire up client-disconnect detection: when the Node.js request closes
|
|
109
|
+
// (client navigates away, connection drops), signal the web adapter to
|
|
110
|
+
// cancel the storage stream and stop transferring bytes.
|
|
111
|
+
//
|
|
112
|
+
// The listener MUST be detached once the response is fully written:
|
|
113
|
+
// IncomingMessage emits "close" after every NORMAL completion too, and
|
|
114
|
+
// an abort() there constructs a DOMException and notifies the store's
|
|
115
|
+
// abort listener on every request -- measured at ~8% of serve CPU.
|
|
116
|
+
const ac = new AbortController();
|
|
117
|
+
const onClientGone = () => ac.abort();
|
|
118
|
+
req.once("close", onClientGone);
|
|
119
|
+
|
|
120
|
+
// Lightweight ServableRequest view: the orchestrator only reads method,
|
|
121
|
+
// headers.get, and signal, so constructing real fetch primitives
|
|
122
|
+
// (undici Request + Headers) per request would be pure overhead. Node
|
|
123
|
+
// already lower-cases incoming header names, so reads go straight to
|
|
124
|
+
// req.headers with no per-request copy; join() matches how Node folds
|
|
125
|
+
// repeated headers (request headers are never Set-Cookie).
|
|
126
|
+
const nodeHeaders = req.headers;
|
|
127
|
+
const fetchRequest = {
|
|
128
|
+
method: req.method ?? "GET",
|
|
129
|
+
headers: {
|
|
130
|
+
get(name: string): string | null {
|
|
131
|
+
const value = nodeHeaders[name.toLowerCase()];
|
|
132
|
+
if (value === undefined) return null;
|
|
133
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
signal: ac.signal,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Resolve context and delegate to the web adapter. Guarded: a throwing
|
|
140
|
+
// key/mime/filename extractor (caller code) must become a 500 response,
|
|
141
|
+
// not a rejected handler -- in Express 4 an async handler rejection is
|
|
142
|
+
// an unhandled promise rejection, which kills the process.
|
|
143
|
+
let parts: RawResponseParts;
|
|
144
|
+
try {
|
|
145
|
+
const key = await keyFn(req);
|
|
146
|
+
const ctx: ServeContext = {
|
|
147
|
+
key,
|
|
148
|
+
mime: mimeFn?.(req),
|
|
149
|
+
filename: filenameFn?.(req),
|
|
150
|
+
};
|
|
151
|
+
parts = await handler(fetchRequest, ctx);
|
|
152
|
+
} catch {
|
|
153
|
+
if (!res.headersSent) {
|
|
154
|
+
const body = "Internal Server Error";
|
|
155
|
+
res.writeHead(500, {
|
|
156
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
157
|
+
"Content-Length": String(body.length),
|
|
158
|
+
"X-Content-Type-Options": "nosniff",
|
|
159
|
+
});
|
|
160
|
+
res.end(body);
|
|
161
|
+
} else if (!res.destroyed) {
|
|
162
|
+
res.destroy();
|
|
163
|
+
}
|
|
164
|
+
req.off("close", onClientGone);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Write the raw parts straight to the ServerResponse: no fetch
|
|
169
|
+
// primitives were constructed anywhere on this path. Preserves custom
|
|
170
|
+
// reason phrases (e.g. 499 Client Closed Request). The outer try/finally
|
|
171
|
+
// is load-bearing: writeHead and end can throw SYNCHRONOUSLY (socket
|
|
172
|
+
// destroyed in the await window, a header value the runtime rejects),
|
|
173
|
+
// and that throw must not leak the storage stream, leave the disconnect
|
|
174
|
+
// listener attached, or escape as an unhandled rejection (which kills
|
|
175
|
+
// Express 4 processes).
|
|
176
|
+
try {
|
|
177
|
+
res.writeHead(parts.status, parts.statusText || undefined, parts.headers);
|
|
178
|
+
|
|
179
|
+
if (!parts.body) {
|
|
180
|
+
res.end();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Byte bodies (in-memory stores, fs small-transfer fast path, error
|
|
185
|
+
// pages): a single write, no reader machinery.
|
|
186
|
+
if (parts.body instanceof Uint8Array) {
|
|
187
|
+
res.end(parts.body);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Stream the response body with backpressure. Client disconnects abort
|
|
192
|
+
// the storage stream (via the signal above), which rejects reader.read();
|
|
193
|
+
// that rejection must be contained here, not escape the handler.
|
|
194
|
+
const reader = parts.body.getReader();
|
|
195
|
+
try {
|
|
196
|
+
while (true) {
|
|
197
|
+
const { done, value } = await reader.read();
|
|
198
|
+
if (done) break;
|
|
199
|
+
if (res.destroyed) {
|
|
200
|
+
// Client is gone; stop pulling and release the storage stream.
|
|
201
|
+
await reader.cancel();
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
const canContinue = res.write(value);
|
|
205
|
+
if (!canContinue) {
|
|
206
|
+
// Backpressure: wait for drain, or for the response to close
|
|
207
|
+
// (client disconnect) so a stalled socket cannot hang this loop.
|
|
208
|
+
// A stall timeout bounds a reader that stops consuming but holds
|
|
209
|
+
// the socket open: it rejects into the catch below, which cancels
|
|
210
|
+
// the storage reader and destroys the response.
|
|
211
|
+
await new Promise<void>((resolve, reject) => {
|
|
212
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
const cleanup = () => {
|
|
214
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
215
|
+
res.off("drain", onDone);
|
|
216
|
+
res.off("close", onDone);
|
|
217
|
+
};
|
|
218
|
+
const onDone = () => {
|
|
219
|
+
cleanup();
|
|
220
|
+
resolve();
|
|
221
|
+
};
|
|
222
|
+
if (stallMs > 0) {
|
|
223
|
+
timer = setTimeout(() => {
|
|
224
|
+
cleanup();
|
|
225
|
+
reject(new Error("partial-content: response write stalled"));
|
|
226
|
+
}, stallMs);
|
|
227
|
+
// Do not keep the event loop alive solely for this timer.
|
|
228
|
+
timer.unref?.();
|
|
229
|
+
}
|
|
230
|
+
res.once("drain", onDone);
|
|
231
|
+
res.once("close", onDone);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
res.end();
|
|
236
|
+
} catch {
|
|
237
|
+
// Transfer failed mid-stream (disconnect or storage error). Headers are
|
|
238
|
+
// already sent, so no error response is possible; tear the socket down
|
|
239
|
+
// so the client sees a truncated transfer instead of a hang.
|
|
240
|
+
await reader.cancel().catch(() => {
|
|
241
|
+
// Stream may already be errored; cancel is best-effort
|
|
242
|
+
});
|
|
243
|
+
if (!res.destroyed) res.destroy();
|
|
244
|
+
} finally {
|
|
245
|
+
reader.releaseLock();
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// writeHead/end threw synchronously. The pump above never rethrows, so
|
|
249
|
+
// this only catches pre-stream failures: release an unconsumed stream
|
|
250
|
+
// body (fs handle / backend socket) and tear the connection down.
|
|
251
|
+
if (parts.body instanceof ReadableStream) {
|
|
252
|
+
await parts.body.cancel().catch(() => {
|
|
253
|
+
// Already locked or errored; best-effort release
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (!res.destroyed) res.destroy();
|
|
257
|
+
} finally {
|
|
258
|
+
req.off("close", onClientGone);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|