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/web.ts
ADDED
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web / Fetch API adapter for partial-content.
|
|
3
|
+
*
|
|
4
|
+
* Composes a partial-content {@link ObjectStore} with the kernel's conditional
|
|
5
|
+
* request evaluation to produce a standards-compliant file-serving handler.
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic: works with Next.js App Router, Hono, SvelteKit, Remix,
|
|
8
|
+
* Cloudflare Workers, Bun.serve, Deno.serve, or any runtime that uses the
|
|
9
|
+
* standard Request/Response API.
|
|
10
|
+
*
|
|
11
|
+
* Handles: 200 (full), 206 (partial), 304 (not modified), 412 (precondition
|
|
12
|
+
* failed), 416 (range not satisfiable), and HEAD (headers only).
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { serveObject } from "partial-content/web";
|
|
17
|
+
* import { s3Store } from "partial-content/s3";
|
|
18
|
+
*
|
|
19
|
+
* const store = s3Store({ client, bucket: "documents" });
|
|
20
|
+
*
|
|
21
|
+
* export const GET = serveObject(store, {
|
|
22
|
+
* key: (req) => req.url.split("/").pop()!,
|
|
23
|
+
* disposition: "inline",
|
|
24
|
+
* });
|
|
25
|
+
* export const HEAD = GET;
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
evaluateConditionalRequest,
|
|
33
|
+
buildRangeResponseHeaders,
|
|
34
|
+
buildContentDisposition,
|
|
35
|
+
generateETag,
|
|
36
|
+
clientWantsDigest,
|
|
37
|
+
sanitizeHeaderValue,
|
|
38
|
+
isRangeFresh,
|
|
39
|
+
parseRanges,
|
|
40
|
+
ObjectChangedError,
|
|
41
|
+
build416Headers,
|
|
42
|
+
buildMultipartHeaders,
|
|
43
|
+
buildMultipartPartHeader,
|
|
44
|
+
multipartEpilogue,
|
|
45
|
+
generateMultipartBoundary,
|
|
46
|
+
MAX_RANGES_DEFAULT,
|
|
47
|
+
OPEN_ENDED,
|
|
48
|
+
parseRetryAfterSeconds,
|
|
49
|
+
type ObjectStore,
|
|
50
|
+
type ObjectMetadata,
|
|
51
|
+
type ParsedRange,
|
|
52
|
+
} from "./index.js";
|
|
53
|
+
|
|
54
|
+
// Re-export kernel types so consumers only need one import
|
|
55
|
+
export type {
|
|
56
|
+
CancelSignal,
|
|
57
|
+
ObjectStore,
|
|
58
|
+
ObjectMetadata,
|
|
59
|
+
ObjectStream,
|
|
60
|
+
ParsedRange,
|
|
61
|
+
ETagSource,
|
|
62
|
+
} from "./index.js";
|
|
63
|
+
|
|
64
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One reusable UTF-8 encoder for the module (multipart part headers, plain-text
|
|
68
|
+
* error bodies), mirroring the kernel's hoisted encoder. `TextEncoder` is
|
|
69
|
+
* stateless, so a single instance is safe to share and avoids a per-response
|
|
70
|
+
* allocation.
|
|
71
|
+
*/
|
|
72
|
+
const UTF8_ENCODER = new TextEncoder();
|
|
73
|
+
|
|
74
|
+
/** Reason phrases for HTTP statuses used by this adapter. */
|
|
75
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
76
|
+
200: "OK",
|
|
77
|
+
206: "Partial Content",
|
|
78
|
+
304: "Not Modified",
|
|
79
|
+
412: "Precondition Failed",
|
|
80
|
+
416: "Range Not Satisfiable",
|
|
81
|
+
499: "Client Closed Request",
|
|
82
|
+
502: "Bad Gateway",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The request surface the orchestrator actually consumes. A standard fetch
|
|
89
|
+
* `Request` satisfies it structurally; server adapters may pass a
|
|
90
|
+
* lightweight view instead, avoiding per-request construction of fetch
|
|
91
|
+
* primitives (an undici `Request` + `Headers` pair costs a measurable
|
|
92
|
+
* fraction of a small-file serve).
|
|
93
|
+
*/
|
|
94
|
+
export interface ServableRequest {
|
|
95
|
+
/** HTTP method. GET and HEAD are served; anything else gets a 405. */
|
|
96
|
+
method: string;
|
|
97
|
+
/** Case-insensitive header lookup: a `Headers` object or any equivalent view. */
|
|
98
|
+
headers: { get(name: string): string | null };
|
|
99
|
+
/** Client-disconnect signal, forwarded to the storage backend. */
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Metadata resolved by the consumer before the handler runs. */
|
|
104
|
+
export interface ServeContext {
|
|
105
|
+
/** Storage key (path within the bucket). */
|
|
106
|
+
key: string;
|
|
107
|
+
/** Override MIME type. When omitted, defaults to "application/octet-stream". */
|
|
108
|
+
mime?: string;
|
|
109
|
+
/** Filename for Content-Disposition. When omitted, no disposition header is set. */
|
|
110
|
+
filename?: string;
|
|
111
|
+
/**
|
|
112
|
+
* Per-request Cache-Control, overriding {@link ServeObjectOptions.cacheControl}.
|
|
113
|
+
* Lets one handler serve mixed cacheability (immutable content-addressed
|
|
114
|
+
* blobs next to private user uploads) without instantiating N handlers.
|
|
115
|
+
* Used verbatim: the `immutable` option is NOT appended to it.
|
|
116
|
+
*/
|
|
117
|
+
cacheControl?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ServeObjectOptions {
|
|
121
|
+
/**
|
|
122
|
+
* Content-Disposition strategy.
|
|
123
|
+
* - `"inline"` -- render in the browser (PDF, images, video)
|
|
124
|
+
* - `"attachment"` -- force download
|
|
125
|
+
* - A function mapping MIME to disposition type (for per-type policy)
|
|
126
|
+
*
|
|
127
|
+
* @default "attachment"
|
|
128
|
+
*/
|
|
129
|
+
disposition?: "inline" | "attachment" | ((mime: string) => "inline" | "attachment");
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Cache-Control value for 200/206 responses.
|
|
133
|
+
* @default "private, no-cache"
|
|
134
|
+
*/
|
|
135
|
+
cacheControl?: string;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* When true, appends `immutable` to the Cache-Control directive.
|
|
139
|
+
* Use for content-addressed storage where the key contains a hash
|
|
140
|
+
* (e.g. `<sha256>.pdf`) and the resource will never change.
|
|
141
|
+
*
|
|
142
|
+
* @default false
|
|
143
|
+
*/
|
|
144
|
+
immutable?: boolean;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Security headers applied to every response that carries a body (200, 206).
|
|
148
|
+
* Receives the MIME type so the policy can vary per format (e.g. relaxed
|
|
149
|
+
* CSP for PDF.js, strict sandbox for images).
|
|
150
|
+
*
|
|
151
|
+
* @default No extra security headers.
|
|
152
|
+
*/
|
|
153
|
+
securityHeaders?: (mime: string) => Record<string, string>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Cross-Origin-Resource-Policy value for success responses.
|
|
157
|
+
*
|
|
158
|
+
* Controls which origins can embed this resource:
|
|
159
|
+
* - `"same-origin"` -- only your origin (most secure)
|
|
160
|
+
* - `"same-site"` -- same registrable domain
|
|
161
|
+
* - `"cross-origin"` -- any origin (for public CDN assets)
|
|
162
|
+
*
|
|
163
|
+
* Required for pages with `Cross-Origin-Embedder-Policy: require-corp`.
|
|
164
|
+
* When omitted, no CORP header is set (caller decides).
|
|
165
|
+
*/
|
|
166
|
+
crossOriginResourcePolicy?: "same-origin" | "same-site" | "cross-origin";
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Timing-Allow-Origin value for success responses.
|
|
170
|
+
*
|
|
171
|
+
* Allows cross-origin pages to read Server-Timing and Resource Timing data
|
|
172
|
+
* via the PerformanceObserver API. Without this, browser security policy
|
|
173
|
+
* zeros out timing metrics for cross-origin resources.
|
|
174
|
+
*
|
|
175
|
+
* Set to `"*"` for public assets, or a specific origin for private APIs.
|
|
176
|
+
*/
|
|
177
|
+
timingAllowOrigin?: string;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* When true, emits a `Server-Timing` header with storage and evaluation
|
|
181
|
+
* latency metrics. Also calls `onTiming` if provided.
|
|
182
|
+
*
|
|
183
|
+
* Caution: timing data may leak internal architecture details.
|
|
184
|
+
* Enable only when you control the deployment environment.
|
|
185
|
+
*
|
|
186
|
+
* @default false
|
|
187
|
+
*/
|
|
188
|
+
timing?: boolean;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Timing callback for observability.
|
|
192
|
+
*
|
|
193
|
+
* Called with structured timing data for every request. Use to ship
|
|
194
|
+
* metrics to your RUM/APM backend.
|
|
195
|
+
*/
|
|
196
|
+
onTiming?: (metrics: { storeMs: number; evaluateMs: number; totalMs: number }) => void;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fallback filename used by buildContentDisposition when no filename is
|
|
200
|
+
* provided and the disposition needs an ASCII fallback.
|
|
201
|
+
*
|
|
202
|
+
* @default "download"
|
|
203
|
+
*/
|
|
204
|
+
fallbackFilename?: string;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Error callback for observability.
|
|
208
|
+
*
|
|
209
|
+
* Called when the storage backend throws during HEAD or GET operations.
|
|
210
|
+
* Use this to log errors with request IDs, correlation tokens, and
|
|
211
|
+
* structured metadata for production monitoring.
|
|
212
|
+
*
|
|
213
|
+
* The error is NOT exposed to the client (the response body is generic).
|
|
214
|
+
*
|
|
215
|
+
* @param error - The original error from the storage backend
|
|
216
|
+
* @param context - Additional context about the failed operation
|
|
217
|
+
* (`audit` = the consumer's own onServe hook threw; the response was
|
|
218
|
+
* still served, the hook failure is surfaced here instead of crashing).
|
|
219
|
+
*/
|
|
220
|
+
onError?: (error: unknown, context: { key: string; operation: "head" | "get" | "audit" }) => void;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Audit callback for compliance logging (SOC 2 CC7.2, ISO 27001 A.8.15).
|
|
224
|
+
*
|
|
225
|
+
* Called on every response that grants access (200, 206, 304, and 302
|
|
226
|
+
* signed-URL redirects) with structured metadata suitable for audit trail
|
|
227
|
+
* ingestion. Not called on errors (use `onError` for those).
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```ts
|
|
231
|
+
* onServe: (event) => logger.info({ ...event }, "file.served")
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
onServe?: (event: ServeAuditEvent) => void;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Transfer-completion callback for true egress accounting and truncation
|
|
238
|
+
* detection.
|
|
239
|
+
*
|
|
240
|
+
* `onServe` fires when headers are committed and reports bytes *granted*
|
|
241
|
+
* (the response Content-Length). This fires once when the response body
|
|
242
|
+
* reaches its terminal state and reports bytes *actually transferred*
|
|
243
|
+
* through it, plus whether it drained fully or the client disconnected
|
|
244
|
+
* early. Use it for egress billing (`bytesTransferred`) or abandonment
|
|
245
|
+
* analytics (`completed === false`).
|
|
246
|
+
*
|
|
247
|
+
* Zero-cost when unset: the body is returned untouched, so byte bodies keep
|
|
248
|
+
* the runtime's static-body fast path. When set, the body is routed through
|
|
249
|
+
* a counting stream (byte bodies are wrapped too, so measurement is uniform
|
|
250
|
+
* across stores) -- a deliberate cost you opt into for the metering.
|
|
251
|
+
*
|
|
252
|
+
* Fires only for 200/206 GET responses (a body was served). Never fires for
|
|
253
|
+
* HEAD, 304, 302, 412, 416, or error responses. A throwing callback cannot
|
|
254
|
+
* corrupt the transfer: its error is routed to `onError` (operation
|
|
255
|
+
* `"audit"`).
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* ```ts
|
|
259
|
+
* onTransfer: (e) => {
|
|
260
|
+
* meter.recordEgress(e.key, e.bytesTransferred);
|
|
261
|
+
* if (!e.completed) log.info({ ...e }, "download.abandoned");
|
|
262
|
+
* }
|
|
263
|
+
* ```
|
|
264
|
+
*/
|
|
265
|
+
onTransfer?: (event: TransferEvent) => void;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Maximum number of distinct (coalesced) byte ranges to serve as
|
|
269
|
+
* multipart/byteranges before a multi-range request degrades to a full 200.
|
|
270
|
+
*
|
|
271
|
+
* A range-amplification defense: a client sending thousands of tiny or
|
|
272
|
+
* overlapping ranges would otherwise force a large multipart response.
|
|
273
|
+
* Overlapping/adjacent ranges are coalesced first, and if the result still
|
|
274
|
+
* exceeds this cap (or already covers the whole object) the full 200 is
|
|
275
|
+
* served instead. Matches the intent of nginx `max_ranges` and Go's
|
|
276
|
+
* sum-of-ranges check.
|
|
277
|
+
*
|
|
278
|
+
* @default 50
|
|
279
|
+
*/
|
|
280
|
+
maxRanges?: number;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* When true, appends `; charset=utf-8` to textual Content-Type values
|
|
284
|
+
* (text/*, application/json, application/xml, etc.) if not already present.
|
|
285
|
+
*
|
|
286
|
+
* Prevents UTF-7 encoding-sniffing XSS in legacy browsers that probe for
|
|
287
|
+
* a charset declaration and fall back to auto-detection when none is found.
|
|
288
|
+
*
|
|
289
|
+
* @default true
|
|
290
|
+
*/
|
|
291
|
+
enforceCharset?: boolean;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Structured audit event for compliance logging. */
|
|
295
|
+
export interface ServeAuditEvent {
|
|
296
|
+
/** Storage key that was served. */
|
|
297
|
+
key: string;
|
|
298
|
+
/**
|
|
299
|
+
* Request method. Distinguishes a HEAD metadata probe (no bytes transferred)
|
|
300
|
+
* from a GET, which otherwise both surface as `status: 200, bytesServed: 0`
|
|
301
|
+
* for an empty object.
|
|
302
|
+
*/
|
|
303
|
+
method: "GET" | "HEAD";
|
|
304
|
+
/**
|
|
305
|
+
* HTTP status code. 302 = access granted via signed-URL redirect.
|
|
306
|
+
* 412/416 = access DENIED (failed precondition / unsatisfiable range):
|
|
307
|
+
* emitted so compliance trails capture denials, not only grants -- a 412
|
|
308
|
+
* is an optimistic-concurrency conflict signal auditors ask for.
|
|
309
|
+
*/
|
|
310
|
+
status: 200 | 206 | 302 | 304 | 412 | 416;
|
|
311
|
+
/** MIME type of the served content. */
|
|
312
|
+
mime: string;
|
|
313
|
+
/**
|
|
314
|
+
* Body bytes GRANTED, not confirmed transferred: the body's
|
|
315
|
+
* Content-Length on a 200/206 GET (the event fires when headers are
|
|
316
|
+
* committed, before the stream drains, so a client disconnect can
|
|
317
|
+
* receive fewer bytes). 0 for HEAD, 304, 302, 412, and 416. Treat as
|
|
318
|
+
* access volume, never as exfiltration volume.
|
|
319
|
+
*/
|
|
320
|
+
bytesServed: number;
|
|
321
|
+
/** Range start (inclusive), present only on 206. */
|
|
322
|
+
rangeStart?: number;
|
|
323
|
+
/** Range end (inclusive), present only on 206. */
|
|
324
|
+
rangeEnd?: number;
|
|
325
|
+
/** ETag of the served representation, if available. */
|
|
326
|
+
etag?: string;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Structured transfer-completion event ({@link ServeObjectOptions.onTransfer}). */
|
|
330
|
+
export interface TransferEvent {
|
|
331
|
+
/** Storage key that was served. */
|
|
332
|
+
key: string;
|
|
333
|
+
/** Always GET: only GET responses carry a body to transfer. */
|
|
334
|
+
method: "GET";
|
|
335
|
+
/** HTTP status of the served body. */
|
|
336
|
+
status: 200 | 206;
|
|
337
|
+
/**
|
|
338
|
+
* Bytes GRANTED: the response body's Content-Length (206 range span or 200
|
|
339
|
+
* full size). Compare against {@link bytesTransferred} to detect truncation.
|
|
340
|
+
*/
|
|
341
|
+
bytesExpected: number;
|
|
342
|
+
/**
|
|
343
|
+
* Bytes ACTUALLY read through the response body before it reached its
|
|
344
|
+
* terminal state. Equals {@link bytesExpected} on a fully-drained transfer;
|
|
345
|
+
* less when the client disconnected or cancelled early.
|
|
346
|
+
*/
|
|
347
|
+
bytesTransferred: number;
|
|
348
|
+
/**
|
|
349
|
+
* `true` if the body drained completely, `false` if it was cancelled /
|
|
350
|
+
* the client disconnected before the last byte. The honest signal for
|
|
351
|
+
* egress billing and abandonment analytics.
|
|
352
|
+
*/
|
|
353
|
+
completed: boolean;
|
|
354
|
+
/** Range start (inclusive), present only on 206. */
|
|
355
|
+
rangeStart?: number;
|
|
356
|
+
/** Range end (inclusive), present only on 206. */
|
|
357
|
+
rangeEnd?: number;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Handler ────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Serve an object from an {@link ObjectStore} with full RFC 7232/7233 support.
|
|
364
|
+
*
|
|
365
|
+
* Returns a function that takes a standard `Request` plus {@link ServeContext}
|
|
366
|
+
* and returns a `Response`. This is framework-agnostic: it works with Next.js
|
|
367
|
+
* App Router, Hono, SvelteKit, Remix, or any runtime that uses the Fetch API.
|
|
368
|
+
*
|
|
369
|
+
* The evaluation chain:
|
|
370
|
+
* 1. HEAD -> get metadata (ETag, Last-Modified, size)
|
|
371
|
+
* 2. Preconditions (If-Match / If-Unmodified-Since) -> 412
|
|
372
|
+
* 3. Freshness (If-None-Match / If-Modified-Since) -> 304
|
|
373
|
+
* 4. If-Range validation -> honor or ignore Range
|
|
374
|
+
* 5. Range parsing -> 206 or 416
|
|
375
|
+
* 6. Stream bytes -> 200 or 206
|
|
376
|
+
*
|
|
377
|
+
* TOCTOU guard: ETag/Last-Modified from the GET response are preferred over
|
|
378
|
+
* HEAD values. If the GET response omits Content-Range for a range request,
|
|
379
|
+
* the handler degrades to 200 (never emits a lying 206).
|
|
380
|
+
*/
|
|
381
|
+
export interface RawResponseParts {
|
|
382
|
+
status: number;
|
|
383
|
+
/** Reason phrase (e.g. "Partial Content", "Client Closed Request"). */
|
|
384
|
+
statusText: string;
|
|
385
|
+
headers: Record<string, string>;
|
|
386
|
+
/**
|
|
387
|
+
* `null` for bodyless statuses (HEAD, 304, 412, 416, 302, 405).
|
|
388
|
+
* `<ArrayBuffer>`-backed so `new Response(parts.body)` compiles under DOM lib.
|
|
389
|
+
*/
|
|
390
|
+
body: ReadableStream<Uint8Array<ArrayBuffer>> | Uint8Array<ArrayBuffer> | null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function serveObject(
|
|
394
|
+
store: ObjectStore,
|
|
395
|
+
opts: ServeObjectOptions = {},
|
|
396
|
+
) {
|
|
397
|
+
const raw = serveObjectRaw(store, opts);
|
|
398
|
+
return async function handleServe(
|
|
399
|
+
req: ServableRequest,
|
|
400
|
+
ctx: ServeContext,
|
|
401
|
+
): Promise<Response> {
|
|
402
|
+
const parts = await raw(req, ctx);
|
|
403
|
+
// BodyInit accepts both body forms; byte bodies take the runtime's
|
|
404
|
+
// static-body fast path (no stream machinery).
|
|
405
|
+
return new Response(parts.body, {
|
|
406
|
+
status: parts.status,
|
|
407
|
+
statusText: parts.statusText,
|
|
408
|
+
headers: parts.headers,
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The engine behind serveObject, exposed for server adapters: identical
|
|
415
|
+
* protocol behavior, but the result is RawResponseParts instead of a
|
|
416
|
+
* Response. Use this when your server writes status/headers/body natively
|
|
417
|
+
* (the bundled node adapter does) -- constructing a Response + Headers pair
|
|
418
|
+
* per request just to immediately deconstruct them is measurable overhead
|
|
419
|
+
* on hot paths.
|
|
420
|
+
*/
|
|
421
|
+
export function serveObjectRaw(
|
|
422
|
+
store: ObjectStore,
|
|
423
|
+
opts: ServeObjectOptions = {},
|
|
424
|
+
) {
|
|
425
|
+
const {
|
|
426
|
+
disposition: dispositionOpt = "attachment",
|
|
427
|
+
cacheControl: rawCacheControl = "private, no-cache",
|
|
428
|
+
immutable: immutableOpt = false,
|
|
429
|
+
securityHeaders,
|
|
430
|
+
crossOriginResourcePolicy,
|
|
431
|
+
timingAllowOrigin,
|
|
432
|
+
timing: timingEnabled = false,
|
|
433
|
+
onTiming,
|
|
434
|
+
fallbackFilename = "download",
|
|
435
|
+
onError,
|
|
436
|
+
onServe: rawOnServe,
|
|
437
|
+
onTransfer: rawOnTransfer,
|
|
438
|
+
maxRanges = MAX_RANGES_DEFAULT,
|
|
439
|
+
enforceCharset = true,
|
|
440
|
+
} = opts;
|
|
441
|
+
|
|
442
|
+
// A consumer's audit hook must never break the never-throw contract: the
|
|
443
|
+
// bodyless emissions (302/304/412/416/HEAD) fire outside any guard, so a
|
|
444
|
+
// throwing hook would escape the handler. Route hook failures to onError;
|
|
445
|
+
// the response itself is unaffected.
|
|
446
|
+
const onServe = rawOnServe
|
|
447
|
+
? (event: ServeAuditEvent): void => {
|
|
448
|
+
try {
|
|
449
|
+
rawOnServe(event);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
onError?.(err, { key: event.key, operation: "audit" });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
: undefined;
|
|
455
|
+
|
|
456
|
+
// Same guard for the transfer hook, which fires from inside the body's
|
|
457
|
+
// stream machinery (during the runtime's consumption, after the handler has
|
|
458
|
+
// returned). A throw there would error the response stream mid-flight;
|
|
459
|
+
// route it to onError instead so the transfer completes cleanly.
|
|
460
|
+
const onTransfer = rawOnTransfer
|
|
461
|
+
? (event: TransferEvent): void => {
|
|
462
|
+
try {
|
|
463
|
+
rawOnTransfer(event);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
onError?.(err, { key: event.key, operation: "audit" });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
: undefined;
|
|
469
|
+
|
|
470
|
+
// Resolve Cache-Control once (append immutable if configured)
|
|
471
|
+
const cacheControl = immutableOpt && !rawCacheControl.includes("immutable")
|
|
472
|
+
? `${rawCacheControl}, immutable`
|
|
473
|
+
: rawCacheControl;
|
|
474
|
+
|
|
475
|
+
return async function handleServeRaw(
|
|
476
|
+
req: ServableRequest,
|
|
477
|
+
ctx: ServeContext,
|
|
478
|
+
): Promise<RawResponseParts> {
|
|
479
|
+
// Method validation: only GET and HEAD are valid for file serving.
|
|
480
|
+
// Other methods get 405 with Allow header per RFC 9110 Section 15.5.6.
|
|
481
|
+
const method = req.method;
|
|
482
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
483
|
+
return {
|
|
484
|
+
status: 405,
|
|
485
|
+
statusText: "Method Not Allowed",
|
|
486
|
+
headers: {
|
|
487
|
+
Allow: "GET, HEAD",
|
|
488
|
+
"Content-Length": "0",
|
|
489
|
+
"Cache-Control": "no-store",
|
|
490
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
491
|
+
"X-Content-Type-Options": "nosniff",
|
|
492
|
+
},
|
|
493
|
+
body: null,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const { key } = ctx;
|
|
498
|
+
const isHead = method === "HEAD";
|
|
499
|
+
const mime = ctx.mime ?? "application/octet-stream";
|
|
500
|
+
|
|
501
|
+
// Resolve Content-Disposition
|
|
502
|
+
const dispositionType = typeof dispositionOpt === "function"
|
|
503
|
+
? dispositionOpt(mime)
|
|
504
|
+
: dispositionOpt;
|
|
505
|
+
const disposition = ctx.filename
|
|
506
|
+
? buildContentDisposition(ctx.filename, { type: dispositionType, fallback: fallbackFilename })
|
|
507
|
+
: dispositionType;
|
|
508
|
+
|
|
509
|
+
// Extra security headers for body responses
|
|
510
|
+
const extraHeaders = securityHeaders ? securityHeaders(mime) : {};
|
|
511
|
+
|
|
512
|
+
// Response-building context shared by all response paths
|
|
513
|
+
const rctx: ResponseContext = {
|
|
514
|
+
mime, disposition, extraHeaders,
|
|
515
|
+
cacheControl: ctx.cacheControl ?? cacheControl,
|
|
516
|
+
crossOriginResourcePolicy, timingAllowOrigin, enforceCharset,
|
|
517
|
+
digestWanted: clientWantsDigest(req.headers),
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// ── Signed-URL fallback for backends that cannot stream ranges ───────
|
|
521
|
+
if (store.supportsRange === false) {
|
|
522
|
+
if (store.createSignedUrl) {
|
|
523
|
+
const result = await store.createSignedUrl(key, {
|
|
524
|
+
expiresInSeconds: 60,
|
|
525
|
+
downloadFilename: ctx.filename,
|
|
526
|
+
});
|
|
527
|
+
if ("url" in result) {
|
|
528
|
+
// A signed-URL redirect grants file access: audit it like a serve.
|
|
529
|
+
onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 302, mime, bytesServed: 0 });
|
|
530
|
+
return {
|
|
531
|
+
status: 302,
|
|
532
|
+
statusText: "Found",
|
|
533
|
+
headers: {
|
|
534
|
+
// The signed URL is backend-derived: sanitize it like every
|
|
535
|
+
// other metadata-sourced header so a malformed provider
|
|
536
|
+
// response cannot inject a header or crash the writer.
|
|
537
|
+
Location: sanitizeHeaderValue(result.url),
|
|
538
|
+
"Cache-Control": "no-store, no-cache",
|
|
539
|
+
"Accept-Ranges": "none",
|
|
540
|
+
"Content-Length": "0",
|
|
541
|
+
},
|
|
542
|
+
body: null,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return plainTextError(502, "Bad Gateway", "Storage backend does not support streaming");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Detect request characteristics ──────────────────────────────────
|
|
550
|
+
const headers = req.headers;
|
|
551
|
+
const hasConditional = Boolean(
|
|
552
|
+
headers.get("if-none-match") || headers.get("if-modified-since")
|
|
553
|
+
|| headers.get("if-match") || headers.get("if-unmodified-since"),
|
|
554
|
+
);
|
|
555
|
+
const rangeHeader = headers.get("range");
|
|
556
|
+
const hasIfRange = Boolean(headers.get("if-range"));
|
|
557
|
+
// Multi-range (comma-separated) is served as multipart/byteranges. It
|
|
558
|
+
// always needs the HEAD-resolved total to clamp bounds and cannot use the
|
|
559
|
+
// single-range fast path.
|
|
560
|
+
const isMultiRange = !isHead && Boolean(rangeHeader && rangeHeader.includes(","));
|
|
561
|
+
// Range requests need HEAD to resolve: parseRangeHeader requires
|
|
562
|
+
// totalSize to clamp bounds and detect unsatisfiable ranges.
|
|
563
|
+
const needsHead = hasConditional || hasIfRange || isHead || Boolean(rangeHeader);
|
|
564
|
+
|
|
565
|
+
// ── Path B: plain range on an authoritative-range store ──────────────
|
|
566
|
+
// A range GET with no conditionals and no If-Range needs nothing from a
|
|
567
|
+
// HEAD: stores whose 206 bounds/total come from the backend's actual
|
|
568
|
+
// Content-Range can serve the seek in ONE round-trip (validators,
|
|
569
|
+
// bounds, and digest all come from the GET itself -- inherently
|
|
570
|
+
// TOCTOU-atomic). This halves latency on the hottest media path
|
|
571
|
+
// (video seeking, PDF.js chunked loading). Suffix ranges (`bytes=-N`)
|
|
572
|
+
// and anything the strict parser rejects fall through to Path A, whose
|
|
573
|
+
// HEAD-resolved evaluation handles them.
|
|
574
|
+
if (rangeHeader && !hasConditional && !hasIfRange && !isHead && store.authoritativeRange) {
|
|
575
|
+
const fastRange = parseFastRange(rangeHeader);
|
|
576
|
+
if (fastRange) {
|
|
577
|
+
let parts: RawResponseParts | null = null;
|
|
578
|
+
// Capture (don't report yet) the speculative failure. A 502 falls
|
|
579
|
+
// through to Path A, which re-runs and reports authoritatively --
|
|
580
|
+
// reporting here too would double-count. But a terminal 404/503 is
|
|
581
|
+
// served straight from Path B and never re-runs, so on the hottest
|
|
582
|
+
// path (every authoritative-range seek) those failures would be
|
|
583
|
+
// invisible to onError -- exactly the telemetry a monitoring
|
|
584
|
+
// consumer wires up. Capture here, report once below if terminal.
|
|
585
|
+
let speculativeErr: unknown;
|
|
586
|
+
try {
|
|
587
|
+
parts = await streamFromStore({
|
|
588
|
+
store, key, range: fastRange, ctx: rctx, signal: req.signal,
|
|
589
|
+
onError: onError ? (err) => { speculativeErr = err; } : undefined,
|
|
590
|
+
timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
|
|
591
|
+
auditCtx: onServe ? { onServe, mime } : undefined,
|
|
592
|
+
// A speculative success IS the served response, so it must meter.
|
|
593
|
+
// A failure returns 502 (no store body) and falls to Path A, which
|
|
594
|
+
// meters the real transfer -- no double-fire.
|
|
595
|
+
onTransfer,
|
|
596
|
+
});
|
|
597
|
+
} catch {
|
|
598
|
+
// ObjectChangedError without a pin, or any other escape: fall
|
|
599
|
+
// through to Path A, which reports and responds correctly.
|
|
600
|
+
}
|
|
601
|
+
// A 502 here usually means the backend rejected the range natively
|
|
602
|
+
// (e.g. start beyond EOF -> S3 InvalidRange). Path A's
|
|
603
|
+
// HEAD-resolved evaluation turns that into the correct 416 with
|
|
604
|
+
// real bounds (or surfaces the genuine store failure with full
|
|
605
|
+
// error reporting). The retry only ever costs an extra attempt on
|
|
606
|
+
// error paths.
|
|
607
|
+
if (parts && parts.status !== 502) {
|
|
608
|
+
// Terminal outcome served from Path B (200/206 success, or a 404/503
|
|
609
|
+
// that Path A would never revisit): surface a captured 404/503 error
|
|
610
|
+
// exactly once so throttle/not-found storms stay visible to onError.
|
|
611
|
+
if ((parts.status === 404 || parts.status === 503) && speculativeErr !== undefined) {
|
|
612
|
+
onError?.(speculativeErr, { key, operation: "get" });
|
|
613
|
+
}
|
|
614
|
+
return parts;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ── Path A: HEAD required ───────────────────────────────────────────
|
|
620
|
+
// The GET is pinned to the HEAD's raw ETag (GetObjectOptions.ifMatch),
|
|
621
|
+
// making the HEAD->GET pair atomic on backends with conditional reads.
|
|
622
|
+
// If the object changes in that window the store throws
|
|
623
|
+
// ObjectChangedError; re-validate ONCE against the new state so the
|
|
624
|
+
// client gets a coherent answer (e.g. a stale If-Range now correctly
|
|
625
|
+
// yields a full 200 of the new bytes) instead of an error.
|
|
626
|
+
for (let attempt = 0; needsHead; attempt++) {
|
|
627
|
+
let meta: ObjectMetadata;
|
|
628
|
+
const t0 = timingEnabled ? performance.now() : 0;
|
|
629
|
+
try {
|
|
630
|
+
meta = await store.headObject(key, { signal: req.signal });
|
|
631
|
+
} catch (err) {
|
|
632
|
+
if (isAbortError(err)) return clientClosed();
|
|
633
|
+
onError?.(err, { key, operation: "head" });
|
|
634
|
+
return storeErrorResponse(err);
|
|
635
|
+
}
|
|
636
|
+
const storeMs = timingEnabled ? performance.now() - t0 : 0;
|
|
637
|
+
const etag = deriveETag(meta);
|
|
638
|
+
|
|
639
|
+
// RFC 7232/7233 full evaluation chain. Conditionals apply to HEAD
|
|
640
|
+
// exactly as to GET (RFC 9110 Section 13.1) -- a conditional HEAD must
|
|
641
|
+
// still yield 304/412. Range, however, is only defined for GET
|
|
642
|
+
// (RFC 9110 Section 14.2), so it is masked out for HEAD requests.
|
|
643
|
+
const evalHeaders = isHead ? withoutRangeHeaders(headers) : headers;
|
|
644
|
+
const t1 = timingEnabled ? performance.now() : 0;
|
|
645
|
+
let evaluation: ReturnType<typeof evaluateConditionalRequest>;
|
|
646
|
+
try {
|
|
647
|
+
evaluation = evaluateConditionalRequest(evalHeaders, {
|
|
648
|
+
totalSize: meta.contentLength,
|
|
649
|
+
contentType: mime,
|
|
650
|
+
etag,
|
|
651
|
+
lastModified: meta.lastModified,
|
|
652
|
+
cacheControl: rctx.cacheControl,
|
|
653
|
+
digest: meta.digest,
|
|
654
|
+
});
|
|
655
|
+
} catch (err) {
|
|
656
|
+
// The kernel rejects corrupt store metadata (NaN/negative sizes)
|
|
657
|
+
// with a RangeError. That is an adapter bug, but a handler must
|
|
658
|
+
// always produce a Response -- an escaping throw becomes an
|
|
659
|
+
// unhandled rejection (and a process crash under Express 4).
|
|
660
|
+
onError?.(err, { key, operation: "head" });
|
|
661
|
+
return storeErrorResponse(err);
|
|
662
|
+
}
|
|
663
|
+
const evaluateMs = timingEnabled ? performance.now() - t1 : 0;
|
|
664
|
+
|
|
665
|
+
// Early exits: 412, 304, 416
|
|
666
|
+
if (evaluation.status === 304) {
|
|
667
|
+
onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 304, mime, bytesServed: 0, etag });
|
|
668
|
+
return {
|
|
669
|
+
status: 304,
|
|
670
|
+
statusText: STATUS_TEXT[304],
|
|
671
|
+
headers: evaluation.headers,
|
|
672
|
+
body: null,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (evaluation.status === 412) {
|
|
676
|
+
// Denials are audit events too: a 412 is an optimistic-concurrency
|
|
677
|
+
// conflict (failed If-Match), exactly what SOC 2 change-control
|
|
678
|
+
// trails want captured alongside grants.
|
|
679
|
+
onServe?.({ key, method: isHead ? "HEAD" : "GET", status: 412, mime, bytesServed: 0, etag });
|
|
680
|
+
return {
|
|
681
|
+
status: 412,
|
|
682
|
+
statusText: STATUS_TEXT[412],
|
|
683
|
+
headers: {
|
|
684
|
+
...evaluation.headers,
|
|
685
|
+
...DENY_HEADERS,
|
|
686
|
+
},
|
|
687
|
+
body: null,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (evaluation.status === 416) {
|
|
692
|
+
onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
|
|
693
|
+
return {
|
|
694
|
+
status: evaluation.status,
|
|
695
|
+
statusText: "Range Not Satisfiable",
|
|
696
|
+
headers: {
|
|
697
|
+
...evaluation.headers,
|
|
698
|
+
...DENY_HEADERS,
|
|
699
|
+
},
|
|
700
|
+
body: null,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// HEAD method: preconditions passed -- return headers only, no body
|
|
705
|
+
// (PDF.js size probing, cache priming).
|
|
706
|
+
if (isHead) {
|
|
707
|
+
onServe?.({ key, method: "HEAD", status: 200, mime, bytesServed: 0, etag });
|
|
708
|
+
return buildHeadResponse(meta, etag, rctx);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Stream bytes from store, pinned to the representation just validated.
|
|
712
|
+
// Only STRONG validators pin: RFC 9110 Section 13.1.1 mandates strong
|
|
713
|
+
// comparison for If-Match, so a weak `W/` ETag would fail the
|
|
714
|
+
// precondition on every attempt (guaranteed 412 -> retry -> 502).
|
|
715
|
+
// Weak validators cannot assert byte equality anyway; the response-side
|
|
716
|
+
// guard still protects those reads.
|
|
717
|
+
const pinEtag = meta.etag && !meta.etag.startsWith("W/") ? meta.etag : undefined;
|
|
718
|
+
|
|
719
|
+
// ── Multi-range: serve multipart/byteranges ──────────────────────────
|
|
720
|
+
// evaluateConditionalRequest already settled 412/304 above (conditionals
|
|
721
|
+
// are range-independent) and, because parseRangeHeader rejects comma
|
|
722
|
+
// ranges, left this as a would-be 200. Honor the ranges here. A stale
|
|
723
|
+
// If-Range (validator moved) means ignore the Range -> fall through to
|
|
724
|
+
// the full 200 below; parseRanges returning null (amplification, or the
|
|
725
|
+
// ranges cover the whole file) does the same.
|
|
726
|
+
if (isMultiRange && isRangeFresh(headers, etag, meta.lastModified)) {
|
|
727
|
+
const set = parseRanges(rangeHeader, meta.contentLength, maxRanges);
|
|
728
|
+
if (set === "unsatisfiable") {
|
|
729
|
+
onServe?.({ key, method: "GET", status: 416, mime, bytesServed: 0, etag });
|
|
730
|
+
return {
|
|
731
|
+
status: 416,
|
|
732
|
+
statusText: "Range Not Satisfiable",
|
|
733
|
+
headers: {
|
|
734
|
+
...build416Headers(meta.contentLength).headers,
|
|
735
|
+
...DENY_HEADERS,
|
|
736
|
+
},
|
|
737
|
+
body: null,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
if (set !== null) {
|
|
741
|
+
try {
|
|
742
|
+
if (set.ranges.length === 1) {
|
|
743
|
+
// Overlapping ranges coalesced to one: a normal single 206.
|
|
744
|
+
return await streamFromStore({
|
|
745
|
+
store, key, range: set.ranges[0],
|
|
746
|
+
ifMatch: pinEtag, pin: meta.pin,
|
|
747
|
+
headEtag: etag, headLastModified: meta.lastModified,
|
|
748
|
+
reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
|
|
749
|
+
timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
|
|
750
|
+
auditCtx: onServe ? { onServe, mime } : undefined,
|
|
751
|
+
onTransfer,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return await serveMultipart({
|
|
755
|
+
store, key, ranges: set.ranges, totalSize: meta.contentLength,
|
|
756
|
+
mime, etag, lastModified: meta.lastModified, digest: meta.digest,
|
|
757
|
+
ifMatch: pinEtag, pin: meta.pin, ctx: rctx, signal: req.signal,
|
|
758
|
+
onError, auditCtx: onServe ? { onServe, mime } : undefined, onTransfer,
|
|
759
|
+
});
|
|
760
|
+
} catch (err) {
|
|
761
|
+
// A pinned first-part read lost its race: re-validate once, exactly
|
|
762
|
+
// like single-range serving.
|
|
763
|
+
if (isObjectChangedError(err) && attempt === 0) continue;
|
|
764
|
+
onError?.(err, { key, operation: "get" });
|
|
765
|
+
return storeErrorResponse(err);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// set === null: serve the full 200 (fall through).
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
try {
|
|
772
|
+
return await streamFromStore({
|
|
773
|
+
store, key, range: evaluation.range ?? undefined,
|
|
774
|
+
ifMatch: pinEtag, pin: meta.pin,
|
|
775
|
+
headEtag: etag, headLastModified: meta.lastModified,
|
|
776
|
+
reprDigest: meta.digest, ctx: rctx, signal: req.signal, onError,
|
|
777
|
+
timingCtx: timingEnabled ? { storeMs, evaluateMs, onTiming } : undefined,
|
|
778
|
+
auditCtx: onServe ? { onServe, mime } : undefined,
|
|
779
|
+
onTransfer,
|
|
780
|
+
});
|
|
781
|
+
} catch (err) {
|
|
782
|
+
// Re-validate ONCE, and only for a pinned-read race. Any other error
|
|
783
|
+
// that escapes streamFromStore -- e.g. a corrupt-metadata RangeError
|
|
784
|
+
// rethrown from buildHeaders -- is deterministic, so a retry just wastes
|
|
785
|
+
// a HEAD+GET and drops the first failure from onError. Report it now.
|
|
786
|
+
// Mirrors the multipart catch above.
|
|
787
|
+
if (isObjectChangedError(err) && attempt === 0) continue;
|
|
788
|
+
onError?.(err, { key, operation: "get" });
|
|
789
|
+
return storeErrorResponse(err);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// ── Path C: No Range, no conditional headers ─────────────────────────
|
|
794
|
+
// Same never-throw contract as Path A: corrupt GET metadata (header
|
|
795
|
+
// builder RangeError), a throwing onServe hook, or a store throwing
|
|
796
|
+
// ObjectChangedError without a pin must become a 502, not a rejected
|
|
797
|
+
// handler (which crashes Express 4 processes).
|
|
798
|
+
try {
|
|
799
|
+
return await streamFromStore({
|
|
800
|
+
store, key, ctx: rctx, signal: req.signal, onError,
|
|
801
|
+
timingCtx: timingEnabled ? { storeMs: 0, evaluateMs: 0, onTiming } : undefined,
|
|
802
|
+
auditCtx: onServe ? { onServe, mime } : undefined,
|
|
803
|
+
onTransfer,
|
|
804
|
+
});
|
|
805
|
+
} catch (err) {
|
|
806
|
+
onError?.(err, { key, operation: "get" });
|
|
807
|
+
return storeErrorResponse(err);
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ─── Internal Helpers ───────────────────────────────────────────────────────
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Wrap a headers view so Range and If-Range read as absent.
|
|
816
|
+
*
|
|
817
|
+
* Used for HEAD requests: RFC 9110 Section 14.2 defines range handling only
|
|
818
|
+
* for GET, so the evaluation chain must not see the Range header (a 206 or
|
|
819
|
+
* 416 for HEAD would be wrong), while conditionals still apply.
|
|
820
|
+
*/
|
|
821
|
+
function withoutRangeHeaders(
|
|
822
|
+
headers: { get(name: string): string | null },
|
|
823
|
+
): { get(name: string): string | null } {
|
|
824
|
+
return {
|
|
825
|
+
get(name: string): string | null {
|
|
826
|
+
const lower = name.toLowerCase();
|
|
827
|
+
if (lower === "range" || lower === "if-range") return null;
|
|
828
|
+
return headers.get(lower);
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Parse a Range header for the single-round-trip fast path (no totalSize
|
|
835
|
+
* available yet). Accepts only `bytes=a-b` and `bytes=a-` (open end becomes
|
|
836
|
+
* MAX_SAFE_INTEGER; RFC 9110 Section 14.1.2 lets the server clamp a
|
|
837
|
+
* last-byte-pos past EOF, and authoritative-range backends do). Everything
|
|
838
|
+
* else -- suffix ranges, multi-range, malformed specs -- returns null so the
|
|
839
|
+
* validating HEAD path evaluates it with the real object size.
|
|
840
|
+
*/
|
|
841
|
+
function parseFastRange(header: string): { start: number; end: number } | null {
|
|
842
|
+
const m = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
|
|
843
|
+
if (!m) return null;
|
|
844
|
+
const start = Number(m[1]);
|
|
845
|
+
if (!Number.isSafeInteger(start)) return null;
|
|
846
|
+
const end = m[2] ? Number(m[2]) : OPEN_ENDED;
|
|
847
|
+
if (!Number.isSafeInteger(end) || end < start) return null;
|
|
848
|
+
return { start, end };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Derive an ETag from storage metadata using the kernel's formatter. */
|
|
852
|
+
function deriveETag(meta: ObjectMetadata): string | undefined {
|
|
853
|
+
return generateETag({
|
|
854
|
+
hash: meta.etag,
|
|
855
|
+
size: meta.contentLength,
|
|
856
|
+
mtime: meta.lastModified,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Shared response-building context resolved once per request. */
|
|
861
|
+
interface ResponseContext {
|
|
862
|
+
readonly mime: string;
|
|
863
|
+
readonly disposition: string;
|
|
864
|
+
readonly extraHeaders: Record<string, string>;
|
|
865
|
+
readonly cacheControl: string;
|
|
866
|
+
readonly crossOriginResourcePolicy?: string;
|
|
867
|
+
readonly timingAllowOrigin?: string;
|
|
868
|
+
readonly enforceCharset: boolean;
|
|
869
|
+
/** RFC 9530 Section 4: whether the client's Want-* headers accept sha-256. */
|
|
870
|
+
readonly digestWanted: boolean;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Protocol metadata for building response headers. */
|
|
874
|
+
interface ProtocolMeta {
|
|
875
|
+
/** Full size, or `undefined` for an unknown-total partial (`bytes a-b/*`). */
|
|
876
|
+
totalSize: number | undefined;
|
|
877
|
+
range?: ParsedRange;
|
|
878
|
+
etag?: string;
|
|
879
|
+
lastModified?: string;
|
|
880
|
+
digest?: string;
|
|
881
|
+
serverTiming?: string;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Locked-down headers for bodyless denial responses (304 excepted, which
|
|
886
|
+
* carries only validators). A `default-src 'none'` CSP plus `nosniff` so a
|
|
887
|
+
* 412/416 body can never be sniffed or execute anything, and `no-store` so a
|
|
888
|
+
* transient denial is never cached (a 416 in particular advertises
|
|
889
|
+
* `Accept-Ranges: bytes` per RFC 7233 and must not linger in a shared cache).
|
|
890
|
+
* Spread onto the protocol headers the kernel produced for the status.
|
|
891
|
+
*/
|
|
892
|
+
const DENY_HEADERS = {
|
|
893
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
894
|
+
"X-Content-Type-Options": "nosniff",
|
|
895
|
+
"Cache-Control": "no-store",
|
|
896
|
+
} as const;
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Append `; charset=utf-8` to a textual Content-Type when charset enforcement
|
|
900
|
+
* is on and none is present. Shared by the single-range/200 path and each
|
|
901
|
+
* multipart part so their Content-Type (and thus the precomputed
|
|
902
|
+
* Content-Length) is derived identically.
|
|
903
|
+
*/
|
|
904
|
+
function withCharset(mime: string, ctx: ResponseContext): string {
|
|
905
|
+
return ctx.enforceCharset && isTextualMime(mime) && !mime.includes("charset")
|
|
906
|
+
? `${mime}; charset=utf-8`
|
|
907
|
+
: mime;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Layer the adapter's success-response header tail (Content-Disposition,
|
|
912
|
+
* nosniff, caller `extraHeaders`, CORP, TAO) onto a protocol header base.
|
|
913
|
+
* Shared by the single-range/200 and multipart paths so a future security
|
|
914
|
+
* header can never be added to one and silently forgotten on the other.
|
|
915
|
+
*/
|
|
916
|
+
function applyAdapterHeaders(base: Record<string, string>, ctx: ResponseContext): Record<string, string> {
|
|
917
|
+
const headers: Record<string, string> = {
|
|
918
|
+
...base,
|
|
919
|
+
"Content-Disposition": ctx.disposition,
|
|
920
|
+
"X-Content-Type-Options": "nosniff",
|
|
921
|
+
...ctx.extraHeaders,
|
|
922
|
+
};
|
|
923
|
+
if (ctx.crossOriginResourcePolicy) headers["Cross-Origin-Resource-Policy"] = ctx.crossOriginResourcePolicy;
|
|
924
|
+
if (ctx.timingAllowOrigin) headers["Timing-Allow-Origin"] = ctx.timingAllowOrigin;
|
|
925
|
+
return headers;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Build response headers by composing the kernel's protocol headers with
|
|
930
|
+
* adapter-specific extras.
|
|
931
|
+
*/
|
|
932
|
+
function buildHeaders(
|
|
933
|
+
ctx: ResponseContext,
|
|
934
|
+
meta: ProtocolMeta,
|
|
935
|
+
): Record<string, string> {
|
|
936
|
+
const { headers: protocol } = buildRangeResponseHeaders({
|
|
937
|
+
totalSize: meta.totalSize,
|
|
938
|
+
range: meta.range ?? null,
|
|
939
|
+
contentType: withCharset(ctx.mime, ctx),
|
|
940
|
+
etag: meta.etag,
|
|
941
|
+
lastModified: meta.lastModified,
|
|
942
|
+
// RFC 9530 Section 4: respect Want-Repr-Digest negotiation. Weight 0 or
|
|
943
|
+
// an algorithm list without sha-256 means "do not send"; honoring that
|
|
944
|
+
// here keeps adapter responses consistent with the kernel orchestrator.
|
|
945
|
+
digest: ctx.digestWanted ? meta.digest : undefined,
|
|
946
|
+
cacheControl: ctx.cacheControl,
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const headers = applyAdapterHeaders(protocol, ctx);
|
|
950
|
+
if (meta.serverTiming) headers["Server-Timing"] = meta.serverTiming;
|
|
951
|
+
return headers;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** Build a HEAD-only response (no body). */
|
|
955
|
+
function buildHeadResponse(meta: ObjectMetadata, etag: string | undefined, ctx: ResponseContext): RawResponseParts {
|
|
956
|
+
return {
|
|
957
|
+
status: 200,
|
|
958
|
+
statusText: "OK",
|
|
959
|
+
headers: buildHeaders(ctx, {
|
|
960
|
+
totalSize: meta.contentLength,
|
|
961
|
+
etag,
|
|
962
|
+
lastModified: meta.lastModified,
|
|
963
|
+
digest: meta.digest,
|
|
964
|
+
}),
|
|
965
|
+
body: null,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/** Timing context passed to streamFromStore when timing is enabled. */
|
|
970
|
+
interface TimingContext {
|
|
971
|
+
storeMs: number;
|
|
972
|
+
evaluateMs: number;
|
|
973
|
+
onTiming?: (metrics: { storeMs: number; evaluateMs: number; totalMs: number }) => void;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Audit context passed to streamFromStore when onServe is configured. */
|
|
977
|
+
interface AuditContext {
|
|
978
|
+
onServe: (event: ServeAuditEvent) => void;
|
|
979
|
+
mime: string;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Options for streamFromStore. */
|
|
983
|
+
interface StreamOpts {
|
|
984
|
+
store: ObjectStore;
|
|
985
|
+
key: string;
|
|
986
|
+
range?: { start: number; end: number };
|
|
987
|
+
/** Opaque adapter pin token from the HEAD metadata (GetObjectOptions.pin). */
|
|
988
|
+
pin?: string;
|
|
989
|
+
/**
|
|
990
|
+
* Raw backend ETag to pin the read to (GetObjectOptions.ifMatch). When the
|
|
991
|
+
* store supports it and the object changed, getObject throws
|
|
992
|
+
* ObjectChangedError, which propagates to the caller for re-validation.
|
|
993
|
+
*/
|
|
994
|
+
ifMatch?: string;
|
|
995
|
+
headEtag?: string;
|
|
996
|
+
headLastModified?: string;
|
|
997
|
+
reprDigest?: string;
|
|
998
|
+
ctx: ResponseContext;
|
|
999
|
+
signal?: AbortSignal;
|
|
1000
|
+
onError?: (error: unknown, context: { key: string; operation: "head" | "get" }) => void;
|
|
1001
|
+
timingCtx?: TimingContext;
|
|
1002
|
+
auditCtx?: AuditContext;
|
|
1003
|
+
/**
|
|
1004
|
+
* Guarded transfer-completion hook. When present, the served body is routed
|
|
1005
|
+
* through a counting stream that fires this once on terminal state with the
|
|
1006
|
+
* true bytes transferred.
|
|
1007
|
+
*/
|
|
1008
|
+
onTransfer?: (event: TransferEvent) => void;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Stream bytes from the store, building a proper 200/206 response.
|
|
1013
|
+
*
|
|
1014
|
+
* Maps store failures to responses internally, with one exception:
|
|
1015
|
+
* ObjectChangedError (a pinned read losing its race) is rethrown so the
|
|
1016
|
+
* caller can re-validate against the object's new state.
|
|
1017
|
+
*/
|
|
1018
|
+
async function streamFromStore(opts: StreamOpts): Promise<RawResponseParts> {
|
|
1019
|
+
const {
|
|
1020
|
+
store, key, range, ifMatch, pin, headEtag, headLastModified, reprDigest,
|
|
1021
|
+
ctx, signal, onError, timingCtx, auditCtx, onTransfer,
|
|
1022
|
+
} = opts;
|
|
1023
|
+
|
|
1024
|
+
const t0 = timingCtx ? performance.now() : 0;
|
|
1025
|
+
let result: Awaited<ReturnType<ObjectStore["getObject"]>>;
|
|
1026
|
+
try {
|
|
1027
|
+
result = await store.getObject(key, { range, signal, ifMatch, pin });
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
if (isObjectChangedError(err)) throw err;
|
|
1030
|
+
if (isAbortError(err)) return clientClosed();
|
|
1031
|
+
onError?.(err, { key, operation: "get" });
|
|
1032
|
+
return storeErrorResponse(err);
|
|
1033
|
+
}
|
|
1034
|
+
const getMs = timingCtx ? performance.now() - t0 : 0;
|
|
1035
|
+
|
|
1036
|
+
let serverTiming: string | undefined;
|
|
1037
|
+
if (timingCtx) {
|
|
1038
|
+
const totalMs = (timingCtx.storeMs + timingCtx.evaluateMs + getMs);
|
|
1039
|
+
serverTiming = `store;dur=${(timingCtx.storeMs + getMs).toFixed(1)},eval;dur=${timingCtx.evaluateMs.toFixed(1)}`;
|
|
1040
|
+
timingCtx.onTiming?.({
|
|
1041
|
+
storeMs: timingCtx.storeMs + getMs,
|
|
1042
|
+
evaluateMs: timingCtx.evaluateMs,
|
|
1043
|
+
totalMs,
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Derive the response ETag from the GET result itself: strong from the
|
|
1048
|
+
// backend hash when present, weak from size + mtime otherwise. This keeps
|
|
1049
|
+
// validators consistent between Path A (HEAD-derived) and Path C (GET-only),
|
|
1050
|
+
// so plain 200s from hash-less stores (fs) still carry a revalidator.
|
|
1051
|
+
const finalEtag = generateETag({
|
|
1052
|
+
hash: result.etag,
|
|
1053
|
+
size: result.totalSize,
|
|
1054
|
+
mtime: result.lastModified,
|
|
1055
|
+
}) ?? headEtag;
|
|
1056
|
+
const finalLastModified = result.lastModified ?? headLastModified;
|
|
1057
|
+
const finalDigest = result.digest ?? reprDigest;
|
|
1058
|
+
|
|
1059
|
+
// TOCTOU guard: the range the backend ACTUALLY served is the source of
|
|
1060
|
+
// truth for 206 vs 200 AND for the emitted byte bounds. If a range was
|
|
1061
|
+
// requested but the GET result carries none, the store served full
|
|
1062
|
+
// content: emit 200, never a lying 206. Incoherent bounds (a custom-store
|
|
1063
|
+
// bug) cannot be trusted -- emitting them would corrupt client caches,
|
|
1064
|
+
// so fail loudly instead.
|
|
1065
|
+
const actualRange = range ? result.range ?? null : null;
|
|
1066
|
+
if (actualRange && !isServableRange(actualRange, result.totalSize)) {
|
|
1067
|
+
const err = new Error(
|
|
1068
|
+
`Store returned invalid served range for ${key}: ` +
|
|
1069
|
+
`${actualRange.start}-${actualRange.end}/${result.totalSize}`,
|
|
1070
|
+
);
|
|
1071
|
+
onError?.(err, { key, operation: "get" });
|
|
1072
|
+
// The body was never handed to a response: cancel a stream form or the
|
|
1073
|
+
// backing resource (fs file handle, pooled HTTP socket) stays open
|
|
1074
|
+
// until GC. Byte bodies hold nothing.
|
|
1075
|
+
cancelBody(result.body);
|
|
1076
|
+
return storeErrorResponse(err);
|
|
1077
|
+
}
|
|
1078
|
+
// Byte-count coherence: the emitted Content-Length is derived from the range
|
|
1079
|
+
// span (206) or totalSize (200), but the body streams result.contentLength
|
|
1080
|
+
// bytes. A store that reports a contentLength disagreeing with those would
|
|
1081
|
+
// commit a Content-Length that over- or under-runs the body -- a truncated
|
|
1082
|
+
// response the client cannot distinguish from a complete one. The multipart
|
|
1083
|
+
// path enforces this per part (servedSpanMatches); the hotter single-range
|
|
1084
|
+
// and 200 paths must guarantee it too. (A 200 with undefined totalSize is an
|
|
1085
|
+
// adapter bug the header builder already rejects, so only the defined case
|
|
1086
|
+
// is checked here.)
|
|
1087
|
+
const incoherentByteCount = actualRange
|
|
1088
|
+
? result.contentLength !== actualRange.end - actualRange.start + 1
|
|
1089
|
+
: result.totalSize !== undefined && result.contentLength !== result.totalSize;
|
|
1090
|
+
if (incoherentByteCount) {
|
|
1091
|
+
const err = new Error(
|
|
1092
|
+
`Store returned incoherent byte count for ${key}: contentLength=${result.contentLength} ` +
|
|
1093
|
+
(actualRange ? `range=${actualRange.start}-${actualRange.end}` : `totalSize=${result.totalSize}`),
|
|
1094
|
+
);
|
|
1095
|
+
onError?.(err, { key, operation: "get" });
|
|
1096
|
+
cancelBody(result.body);
|
|
1097
|
+
return storeErrorResponse(err);
|
|
1098
|
+
}
|
|
1099
|
+
const isPartial = actualRange !== null;
|
|
1100
|
+
const status = isPartial ? 206 : 200;
|
|
1101
|
+
const responseRange = actualRange ?? undefined;
|
|
1102
|
+
const totalSize = result.totalSize;
|
|
1103
|
+
|
|
1104
|
+
// Between here and the returned parts the body has an owner only on the
|
|
1105
|
+
// happy path: a throw from the audit hook or header builder would
|
|
1106
|
+
// otherwise leak a stream form.
|
|
1107
|
+
try {
|
|
1108
|
+
const headers = buildHeaders(ctx, {
|
|
1109
|
+
totalSize,
|
|
1110
|
+
range: responseRange,
|
|
1111
|
+
etag: finalEtag,
|
|
1112
|
+
lastModified: finalLastModified,
|
|
1113
|
+
digest: finalDigest,
|
|
1114
|
+
serverTiming,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Audit AFTER the headers commit, never before. onServe is the "grant"
|
|
1118
|
+
// event, so it must not fire for a response that never materializes: if
|
|
1119
|
+
// buildHeaders throws on corrupt metadata this streamFromStore call is
|
|
1120
|
+
// discarded (Path A re-runs, or a 502 is returned), and an onServe fired
|
|
1121
|
+
// up front would double-count the retry or log a phantom grant on a 502.
|
|
1122
|
+
if (auditCtx) {
|
|
1123
|
+
// streamFromStore only runs for GET: HEAD returns early via
|
|
1124
|
+
// buildHeadResponse, and Path C is unreachable when isHead forces needsHead.
|
|
1125
|
+
auditCtx.onServe({
|
|
1126
|
+
key, method: "GET", status: status as 200 | 206, mime: auditCtx.mime,
|
|
1127
|
+
bytesServed: result.contentLength, etag: finalEtag,
|
|
1128
|
+
...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Metering is opt-in: only wrap the body when a transfer hook is present,
|
|
1133
|
+
// so the common path keeps the runtime's static-body fast path. The wrap
|
|
1134
|
+
// happens AFTER buildHeaders and the audit hook, so the catch below still
|
|
1135
|
+
// cancels the untouched original on any earlier throw (once wrapped, the
|
|
1136
|
+
// source reader is locked and owned by the returned stream).
|
|
1137
|
+
const body = onTransfer
|
|
1138
|
+
? meterBody(result.body, (bytesTransferred, completed) => onTransfer({
|
|
1139
|
+
key,
|
|
1140
|
+
method: "GET",
|
|
1141
|
+
status: status as 200 | 206,
|
|
1142
|
+
bytesExpected: result.contentLength,
|
|
1143
|
+
bytesTransferred,
|
|
1144
|
+
completed,
|
|
1145
|
+
...(responseRange ? { rangeStart: responseRange.start, rangeEnd: responseRange.end } : {}),
|
|
1146
|
+
}))
|
|
1147
|
+
: result.body;
|
|
1148
|
+
|
|
1149
|
+
return {
|
|
1150
|
+
status,
|
|
1151
|
+
statusText: STATUS_TEXT[status],
|
|
1152
|
+
headers,
|
|
1153
|
+
body,
|
|
1154
|
+
};
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
cancelBody(result.body);
|
|
1157
|
+
throw err;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Options for {@link serveMultipart}. */
|
|
1162
|
+
interface MultipartOpts {
|
|
1163
|
+
store: ObjectStore;
|
|
1164
|
+
key: string;
|
|
1165
|
+
/** Coalesced, satisfiable ranges (length >= 2), clamped to the object size. */
|
|
1166
|
+
ranges: ParsedRange[];
|
|
1167
|
+
totalSize: number;
|
|
1168
|
+
/** The representation's own MIME (goes in every part's Content-Type). */
|
|
1169
|
+
mime: string;
|
|
1170
|
+
etag?: string;
|
|
1171
|
+
lastModified?: string;
|
|
1172
|
+
digest?: string;
|
|
1173
|
+
ifMatch?: string;
|
|
1174
|
+
pin?: string;
|
|
1175
|
+
ctx: ResponseContext;
|
|
1176
|
+
signal?: AbortSignal;
|
|
1177
|
+
onError?: (error: unknown, context: { key: string; operation: "head" | "get" }) => void;
|
|
1178
|
+
auditCtx?: AuditContext;
|
|
1179
|
+
onTransfer?: (event: TransferEvent) => void;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Serve multiple byte ranges as a `multipart/byteranges` (206) response.
|
|
1184
|
+
*
|
|
1185
|
+
* The Content-Length is computed exactly by the kernel from the framing and
|
|
1186
|
+
* the range spans, so the response is never chunked. The first part is fetched
|
|
1187
|
+
* EAGERLY so a pinned-read `ObjectChangedError` surfaces before headers commit
|
|
1188
|
+
* (giving the caller the same one-shot re-validation single-range serving
|
|
1189
|
+
* gets); the remaining parts stream lazily, one `getObject` per range, each
|
|
1190
|
+
* pinned to the same representation via `ifMatch`.
|
|
1191
|
+
*
|
|
1192
|
+
* Relies on each store serving exactly the requested (already size-clamped)
|
|
1193
|
+
* span per range -- which every bundled adapter does -- so the precomputed
|
|
1194
|
+
* Content-Length matches the streamed body byte-for-byte.
|
|
1195
|
+
*/
|
|
1196
|
+
async function serveMultipart(opts: MultipartOpts): Promise<RawResponseParts> {
|
|
1197
|
+
const {
|
|
1198
|
+
store, key, ranges, totalSize, mime, etag, lastModified, digest,
|
|
1199
|
+
ifMatch, pin, ctx, signal, onError, auditCtx, onTransfer,
|
|
1200
|
+
} = opts;
|
|
1201
|
+
|
|
1202
|
+
const boundary = generateMultipartBoundary();
|
|
1203
|
+
// Each part carries the representation's own Content-Type; apply the same
|
|
1204
|
+
// charset enforcement single-range responses use so the value (and thus the
|
|
1205
|
+
// precomputed Content-Length) is identical to what the framing emits.
|
|
1206
|
+
const partContentType = withCharset(mime, ctx);
|
|
1207
|
+
|
|
1208
|
+
// Fetch the first part up front: a pinned read losing its race throws
|
|
1209
|
+
// ObjectChangedError HERE, before any headers are committed, so the caller
|
|
1210
|
+
// can re-validate once. Abort and store errors map to responses.
|
|
1211
|
+
let firstStream: Awaited<ReturnType<ObjectStore["getObject"]>>;
|
|
1212
|
+
try {
|
|
1213
|
+
firstStream = await store.getObject(key, { range: ranges[0], signal, ifMatch, pin });
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
if (isObjectChangedError(err)) throw err;
|
|
1216
|
+
if (isAbortError(err)) return clientClosed();
|
|
1217
|
+
onError?.(err, { key, operation: "get" });
|
|
1218
|
+
return storeErrorResponse(err);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Validate the first part's served span BEFORE headers commit. On a
|
|
1222
|
+
// non-pinning store (one that cannot honor ifMatch/pin), a concurrent
|
|
1223
|
+
// overwrite between parts would otherwise splice bytes across
|
|
1224
|
+
// representations or under-run the precomputed Content-Length. A first-part
|
|
1225
|
+
// mismatch re-validates once via the orchestrator's retry loop.
|
|
1226
|
+
if (!servedSpanMatches(firstStream, ranges[0]!)) {
|
|
1227
|
+
cancelBody(firstStream.body);
|
|
1228
|
+
throw new ObjectChangedError(key);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const multipart = buildMultipartHeaders({
|
|
1232
|
+
boundary, ranges, totalSize, contentType: partContentType,
|
|
1233
|
+
etag, lastModified, digest, cacheControl: ctx.cacheControl,
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Ownership of the eagerly-fetched first part transfers to the generator the
|
|
1237
|
+
// moment it takes the part's reader (or yields its bytes). Until then, a
|
|
1238
|
+
// consumer cancel would strand firstStream: gen.return() only runs the
|
|
1239
|
+
// reader's finally if control already entered the try. Track the handoff so
|
|
1240
|
+
// the outer cancel can release it in the pre-handoff window (suspendedStart,
|
|
1241
|
+
// or suspended at the part-header yield).
|
|
1242
|
+
let firstPartOwned = false;
|
|
1243
|
+
const enc = UTF8_ENCODER;
|
|
1244
|
+
async function* multipartChunks(): AsyncGenerator<Uint8Array> {
|
|
1245
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
1246
|
+
const range = ranges[i]!;
|
|
1247
|
+
yield enc.encode(buildMultipartPartHeader(boundary, range, totalSize, partContentType));
|
|
1248
|
+
const stream = i === 0
|
|
1249
|
+
? firstStream
|
|
1250
|
+
: await store.getObject(key, { range, signal, ifMatch, pin });
|
|
1251
|
+
// Lazy parts settle AFTER headers commit, so a mismatch can only be
|
|
1252
|
+
// surfaced as a stream error (a reset). That still beats splicing bytes
|
|
1253
|
+
// from a changed representation or under-running the committed length.
|
|
1254
|
+
// The span check alone would miss a SAME-SIZE overwrite (right byte count,
|
|
1255
|
+
// different bytes); comparing each part's validator against the first
|
|
1256
|
+
// catches that too, since any overwrite changes the fs weak ETag (mtime)
|
|
1257
|
+
// and the S3 strong ETag (content hash). Stores that return no GET
|
|
1258
|
+
// validator fall back to the span check.
|
|
1259
|
+
if (i > 0 && (!servedSpanMatches(stream, range) || !sameRepresentation(firstStream, stream))) {
|
|
1260
|
+
cancelBody(stream.body);
|
|
1261
|
+
throw new ObjectChangedError(key);
|
|
1262
|
+
}
|
|
1263
|
+
const body = stream.body;
|
|
1264
|
+
// Handoff point: cleanup is now guaranteed by the reader's finally below
|
|
1265
|
+
// (stream body) or is unneeded (byte body holds no resource). No await or
|
|
1266
|
+
// yield sits between here and getReader(), so a cancel cannot interleave.
|
|
1267
|
+
if (i === 0) firstPartOwned = true;
|
|
1268
|
+
if (body instanceof Uint8Array) {
|
|
1269
|
+
yield body;
|
|
1270
|
+
} else {
|
|
1271
|
+
const reader = body.getReader();
|
|
1272
|
+
try {
|
|
1273
|
+
for (;;) {
|
|
1274
|
+
const { done, value } = await reader.read();
|
|
1275
|
+
if (done) break;
|
|
1276
|
+
yield value;
|
|
1277
|
+
}
|
|
1278
|
+
} finally {
|
|
1279
|
+
// Release the backend resource on normal completion AND on an early
|
|
1280
|
+
// generator return (client cancelled mid-part).
|
|
1281
|
+
reader.cancel().catch(() => { /* already-settled reader */ });
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
yield enc.encode("\r\n");
|
|
1285
|
+
}
|
|
1286
|
+
yield enc.encode(multipartEpilogue(boundary));
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const gen = multipartChunks();
|
|
1290
|
+
const rawBody = new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
1291
|
+
async pull(controller) {
|
|
1292
|
+
try {
|
|
1293
|
+
const { done, value } = await gen.next();
|
|
1294
|
+
if (done) controller.close();
|
|
1295
|
+
// Part headers (encoder output) and part bodies are ArrayBuffer-backed;
|
|
1296
|
+
// narrow so the multipart body stays Response-assignable under DOM (F5).
|
|
1297
|
+
else controller.enqueue(value as Uint8Array<ArrayBuffer>);
|
|
1298
|
+
} catch (err) {
|
|
1299
|
+
controller.error(err);
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
async cancel(reason) {
|
|
1303
|
+
await gen.return?.(reason as undefined);
|
|
1304
|
+
// Cancelled before the generator took ownership of the eagerly-fetched
|
|
1305
|
+
// first part (suspendedStart, or suspended at the part-header yield):
|
|
1306
|
+
// gen.return ran no finally, so release it here. A byte-body first part
|
|
1307
|
+
// is a no-op in cancelBody; a stream part's file handle/socket is freed.
|
|
1308
|
+
if (!firstPartOwned) cancelBody(firstStream.body);
|
|
1309
|
+
},
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const headers = applyAdapterHeaders(multipart.headers, ctx);
|
|
1313
|
+
|
|
1314
|
+
// Audit reports the multipart body's granted length (framing + all parts).
|
|
1315
|
+
auditCtx?.onServe({
|
|
1316
|
+
key, method: "GET", status: 206, mime: auditCtx.mime,
|
|
1317
|
+
bytesServed: multipart.contentLength, etag,
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const body = onTransfer
|
|
1321
|
+
? meterBody(rawBody, (bytesTransferred, completed) => onTransfer({
|
|
1322
|
+
key, method: "GET", status: 206,
|
|
1323
|
+
bytesExpected: multipart.contentLength, bytesTransferred, completed,
|
|
1324
|
+
}))
|
|
1325
|
+
: rawBody;
|
|
1326
|
+
|
|
1327
|
+
return { status: 206, statusText: STATUS_TEXT[206], headers, body };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Wrap a response body in a counting stream that reports the true bytes
|
|
1332
|
+
* transferred exactly once when the body reaches its terminal state.
|
|
1333
|
+
*
|
|
1334
|
+
* `report(bytesTransferred, completed)` fires on:
|
|
1335
|
+
* - full drain (`completed: true`) -- the consumer read every byte,
|
|
1336
|
+
* - cancel / client disconnect (`completed: false`) -- fewer bytes reached
|
|
1337
|
+
* the client than the Content-Length promised,
|
|
1338
|
+
* - source error (`completed: false`) -- a mid-transfer backend failure.
|
|
1339
|
+
*
|
|
1340
|
+
* A byte body is wrapped into a one-shot stream so metering is uniform across
|
|
1341
|
+
* stream and byte stores (the caller only pays this when a transfer hook is
|
|
1342
|
+
* registered; unmetered byte bodies keep the static-body fast path). The
|
|
1343
|
+
* `settled` latch guarantees the report fires once even if the consumer
|
|
1344
|
+
* cancels after the stream already closed.
|
|
1345
|
+
*/
|
|
1346
|
+
function meterBody(
|
|
1347
|
+
source: ReadableStream<Uint8Array> | Uint8Array,
|
|
1348
|
+
report: (bytesTransferred: number, completed: boolean) => void,
|
|
1349
|
+
): ReadableStream<Uint8Array<ArrayBuffer>> {
|
|
1350
|
+
let transferred = 0;
|
|
1351
|
+
let settled = false;
|
|
1352
|
+
const settle = (completed: boolean): void => {
|
|
1353
|
+
if (settled) return;
|
|
1354
|
+
settled = true;
|
|
1355
|
+
report(transferred, completed);
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
// Normalize a byte body to a one-shot stream and meter it through the SAME
|
|
1359
|
+
// reader path, so `completed:true` fires only when the consumer has pulled
|
|
1360
|
+
// the terminal (done) result -- i.e. after it read the last chunk -- never
|
|
1361
|
+
// merely because the chunk was buffered ahead of a read. A dedicated
|
|
1362
|
+
// byte-path that settled inside its enqueue would over-report a disconnect
|
|
1363
|
+
// that lands after the chunk was queued but before it was consumed.
|
|
1364
|
+
const stream = source instanceof Uint8Array
|
|
1365
|
+
? new ReadableStream<Uint8Array>({
|
|
1366
|
+
start(controller) {
|
|
1367
|
+
if (source.byteLength > 0) controller.enqueue(source);
|
|
1368
|
+
controller.close();
|
|
1369
|
+
},
|
|
1370
|
+
})
|
|
1371
|
+
: source;
|
|
1372
|
+
|
|
1373
|
+
const reader = stream.getReader();
|
|
1374
|
+
return new ReadableStream<Uint8Array<ArrayBuffer>>({
|
|
1375
|
+
async pull(controller) {
|
|
1376
|
+
let res: Awaited<ReturnType<typeof reader.read>>;
|
|
1377
|
+
try {
|
|
1378
|
+
res = await reader.read();
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
settle(false);
|
|
1381
|
+
controller.error(err);
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (res.done) {
|
|
1385
|
+
settle(true);
|
|
1386
|
+
controller.close();
|
|
1387
|
+
} else {
|
|
1388
|
+
transferred += res.value.byteLength;
|
|
1389
|
+
// Backend byte chunks are ArrayBuffer-backed; narrow so the metered
|
|
1390
|
+
// body stays `new Response(...)`-assignable under DOM lib (F5).
|
|
1391
|
+
controller.enqueue(res.value as Uint8Array<ArrayBuffer>);
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
async cancel(reason) {
|
|
1395
|
+
settle(false);
|
|
1396
|
+
await reader.cancel(reason);
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* True when a store served EXACTLY the requested span: same inclusive bounds
|
|
1403
|
+
* and matching byte count. A mismatch means the object changed under a
|
|
1404
|
+
* non-pinning store (or the store's accounting is broken), so the committed
|
|
1405
|
+
* multipart framing and Content-Length no longer describe the bytes.
|
|
1406
|
+
*/
|
|
1407
|
+
function servedSpanMatches(
|
|
1408
|
+
stream: { range?: { start: number; end: number }; contentLength: number },
|
|
1409
|
+
range: ParsedRange,
|
|
1410
|
+
): boolean {
|
|
1411
|
+
const served = stream.range;
|
|
1412
|
+
return !!served
|
|
1413
|
+
&& served.start === range.start
|
|
1414
|
+
&& served.end === range.end
|
|
1415
|
+
&& stream.contentLength === range.end - range.start + 1;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* True when two parts of a multipart response came from the same
|
|
1420
|
+
* representation, judged by the strongest validator both expose. Any overwrite
|
|
1421
|
+
* changes the fs weak ETag (mtime) and the S3 strong ETag (content hash), so an
|
|
1422
|
+
* ETag disagreement means the object changed mid-stream. When a store returns
|
|
1423
|
+
* no GET ETag (or no Last-Modified) on one side, there is nothing to compare
|
|
1424
|
+
* and the caller's span check is the only guard -- so this returns `true` and
|
|
1425
|
+
* does not manufacture a mismatch from missing metadata.
|
|
1426
|
+
*/
|
|
1427
|
+
function sameRepresentation(
|
|
1428
|
+
a: { etag?: string; lastModified?: string },
|
|
1429
|
+
b: { etag?: string; lastModified?: string },
|
|
1430
|
+
): boolean {
|
|
1431
|
+
if (a.etag && b.etag) return a.etag === b.etag;
|
|
1432
|
+
if (a.lastModified && b.lastModified) return a.lastModified === b.lastModified;
|
|
1433
|
+
return true;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** Release a body that will never reach a response (stream forms only). */
|
|
1437
|
+
function cancelBody(body: ReadableStream<Uint8Array> | Uint8Array): void {
|
|
1438
|
+
if (!(body instanceof ReadableStream)) return;
|
|
1439
|
+
try {
|
|
1440
|
+
// A locked stream (a reader was taken) throws SYNCHRONOUSLY here rather
|
|
1441
|
+
// than rejecting, so .catch alone would let it escape; an already-errored
|
|
1442
|
+
// stream rejects. Swallow both -- teardown is best-effort by definition.
|
|
1443
|
+
body.cancel().catch(() => { /* already-errored streams reject cancel */ });
|
|
1444
|
+
} catch { /* locked: the reader owns teardown */ }
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/** Bodyless 499 parts, built per return (headers object is caller-owned). */
|
|
1448
|
+
function clientClosed(): RawResponseParts {
|
|
1449
|
+
return {
|
|
1450
|
+
status: 499,
|
|
1451
|
+
statusText: "Client Closed Request",
|
|
1452
|
+
headers: { "Content-Length": "0" },
|
|
1453
|
+
body: null,
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// ─── Error Helpers ──────────────────────────────────────────────────────────
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Validate a store-reported served range against the representation size:
|
|
1461
|
+
* inclusive integer bounds, ordered, and inside the object. Anything else
|
|
1462
|
+
* means the store's byte accounting is broken.
|
|
1463
|
+
*
|
|
1464
|
+
* When `totalSize` is `undefined` the backend served an unknown-total partial
|
|
1465
|
+
* (`bytes a-b/*`): there is no EOF to bound-check against, so the ordered
|
|
1466
|
+
* bounds the authoritative backend reported are trusted as-is.
|
|
1467
|
+
*/
|
|
1468
|
+
function isServableRange(r: { start: number; end: number }, totalSize: number | undefined): boolean {
|
|
1469
|
+
if (!Number.isSafeInteger(r.start) || !Number.isSafeInteger(r.end)) return false;
|
|
1470
|
+
if (r.start < 0 || r.start > r.end) return false;
|
|
1471
|
+
if (totalSize === undefined) return true;
|
|
1472
|
+
return Number.isSafeInteger(totalSize) && r.end < totalSize;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Check if an error is an AbortError (client disconnected).
|
|
1477
|
+
* Works across runtimes: DOMException in browsers/Workers, AbortError in Node.
|
|
1478
|
+
*/
|
|
1479
|
+
function isAbortError(err: unknown): boolean {
|
|
1480
|
+
if (err instanceof DOMException && err.name === "AbortError") return true;
|
|
1481
|
+
if (err instanceof Error && err.name === "AbortError") return true;
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Check if an error is a pinned-read ObjectChangedError.
|
|
1487
|
+
*
|
|
1488
|
+
* Matched by name rather than instanceof so third-party stores can throw
|
|
1489
|
+
* their own equivalently-named error without importing the kernel class.
|
|
1490
|
+
*/
|
|
1491
|
+
function isObjectChangedError(err: unknown): boolean {
|
|
1492
|
+
return err instanceof Error && err.name === "ObjectChangedError";
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Build a plain-text error response with a computed Content-Length.
|
|
1497
|
+
*
|
|
1498
|
+
* The body is encoded once and Content-Length comes from the encoded byte
|
|
1499
|
+
* count, so the header stays truthful even if a message ever gains
|
|
1500
|
+
* non-ASCII characters (String.length counts UTF-16 units, not bytes).
|
|
1501
|
+
*/
|
|
1502
|
+
function plainTextError(
|
|
1503
|
+
status: number,
|
|
1504
|
+
statusText: string,
|
|
1505
|
+
body: string,
|
|
1506
|
+
extraHeaders?: Record<string, string>,
|
|
1507
|
+
): RawResponseParts {
|
|
1508
|
+
const bytes = UTF8_ENCODER.encode(body);
|
|
1509
|
+
return {
|
|
1510
|
+
status,
|
|
1511
|
+
statusText,
|
|
1512
|
+
headers: {
|
|
1513
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
1514
|
+
"Content-Length": String(bytes.byteLength),
|
|
1515
|
+
"Accept-Ranges": "none",
|
|
1516
|
+
// 404 is heuristically cacheable (RFC 9111 Section 4.2.2): without
|
|
1517
|
+
// this, a CDN can cache a transient miss and keep serving it after
|
|
1518
|
+
// the object appears. Errors must never outlive their cause.
|
|
1519
|
+
"Cache-Control": "no-store",
|
|
1520
|
+
"X-Content-Type-Options": "nosniff",
|
|
1521
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
1522
|
+
...extraHeaders,
|
|
1523
|
+
},
|
|
1524
|
+
body: bytes,
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Build an error response for store failures.
|
|
1530
|
+
*
|
|
1531
|
+
* Discriminates three cases: "object not found" (404), "backend transiently
|
|
1532
|
+
* unavailable" (503, retryable), and everything else (502, the upstream
|
|
1533
|
+
* returned something invalid). A 404 hint comes from a `status` of 404 or an
|
|
1534
|
+
* error named `ObjectNotFoundError`/`NotFound`; a 503 hint from a `status` of
|
|
1535
|
+
* 503 or an error named `StoreUnavailableError`, which additionally carries an
|
|
1536
|
+
* optional `retryAfterSeconds` echoed as `Retry-After`.
|
|
1537
|
+
*/
|
|
1538
|
+
function storeErrorResponse(err: unknown): RawResponseParts {
|
|
1539
|
+
if (isNotFoundStoreError(err)) {
|
|
1540
|
+
return plainTextError(404, "Not Found", "Not Found");
|
|
1541
|
+
}
|
|
1542
|
+
if (isUnavailableStoreError(err)) {
|
|
1543
|
+
const secs = retryAfterSeconds(err);
|
|
1544
|
+
return plainTextError(
|
|
1545
|
+
503,
|
|
1546
|
+
"Service Unavailable",
|
|
1547
|
+
"Storage backend unavailable",
|
|
1548
|
+
secs !== undefined ? { "Retry-After": String(secs) } : undefined,
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
return plainTextError(502, "Bad Gateway", "Storage backend error");
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Check if a store error represents a missing object (404).
|
|
1556
|
+
*/
|
|
1557
|
+
function isNotFoundStoreError(err: unknown): boolean {
|
|
1558
|
+
if (typeof err === "object" && err !== null && "status" in err) {
|
|
1559
|
+
return (err as { status: unknown }).status === 404;
|
|
1560
|
+
}
|
|
1561
|
+
if (err instanceof Error) {
|
|
1562
|
+
return err.name === "ObjectNotFoundError" || err.name === "NotFound";
|
|
1563
|
+
}
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Check if a store error signals a transient, retryable backend condition
|
|
1569
|
+
* (throttling/overload/timeout). Matched by `status` 503 or by name so a
|
|
1570
|
+
* third-party store can throw an equivalently-named error without importing
|
|
1571
|
+
* the kernel class.
|
|
1572
|
+
*/
|
|
1573
|
+
function isUnavailableStoreError(err: unknown): boolean {
|
|
1574
|
+
if (typeof err === "object" && err !== null && "status" in err) {
|
|
1575
|
+
if ((err as { status: unknown }).status === 503) return true;
|
|
1576
|
+
}
|
|
1577
|
+
return err instanceof Error && err.name === "StoreUnavailableError";
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Extract a non-negative integer `Retry-After` (delay-seconds, RFC 9110
|
|
1582
|
+
* Section 10.2.3) from a store error's `retryAfterSeconds`, or `undefined`
|
|
1583
|
+
* when absent or malformed. Delegates to the shared parser: fractional hints
|
|
1584
|
+
* are floored, 0 is kept, and a huge finite hint that would serialize as
|
|
1585
|
+
* `1e+21` (a duck-typed third-party error that skipped the StoreUnavailableError
|
|
1586
|
+
* constructor's normalization) is rejected rather than emitted as a malformed
|
|
1587
|
+
* header.
|
|
1588
|
+
*/
|
|
1589
|
+
function retryAfterSeconds(err: unknown): number | undefined {
|
|
1590
|
+
if (typeof err !== "object" || err === null || !("retryAfterSeconds" in err)) return undefined;
|
|
1591
|
+
return parseRetryAfterSeconds((err as { retryAfterSeconds: unknown }).retryAfterSeconds);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Check if a MIME type is textual and should have charset=utf-8 enforced.
|
|
1596
|
+
*/
|
|
1597
|
+
function isTextualMime(mime: string): boolean {
|
|
1598
|
+
if (mime.startsWith("text/")) return true;
|
|
1599
|
+
if (mime === "application/json" || mime === "application/xml") return true;
|
|
1600
|
+
if (mime.endsWith("+json") || mime.endsWith("+xml")) return true;
|
|
1601
|
+
if (mime === "application/javascript" || mime === "application/ecmascript") return true;
|
|
1602
|
+
return false;
|
|
1603
|
+
}
|