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.
Files changed (81) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +601 -0
  4. package/SECURITY.md +92 -0
  5. package/dist/azure.d.ts +79 -0
  6. package/dist/azure.d.ts.map +1 -0
  7. package/dist/azure.js +251 -0
  8. package/dist/azure.js.map +1 -0
  9. package/dist/content-disposition.d.ts +74 -0
  10. package/dist/content-disposition.d.ts.map +1 -0
  11. package/dist/content-disposition.js +253 -0
  12. package/dist/content-disposition.js.map +1 -0
  13. package/dist/fs.d.ts +86 -0
  14. package/dist/fs.d.ts.map +1 -0
  15. package/dist/fs.js +375 -0
  16. package/dist/fs.js.map +1 -0
  17. package/dist/gcs.d.ts +72 -0
  18. package/dist/gcs.d.ts.map +1 -0
  19. package/dist/gcs.js +202 -0
  20. package/dist/gcs.js.map +1 -0
  21. package/dist/hono.d.ts +92 -0
  22. package/dist/hono.d.ts.map +1 -0
  23. package/dist/hono.js +61 -0
  24. package/dist/hono.js.map +1 -0
  25. package/dist/http.d.ts +70 -0
  26. package/dist/http.d.ts.map +1 -0
  27. package/dist/http.js +281 -0
  28. package/dist/http.js.map +1 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +27 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/kernel.d.ts +541 -0
  34. package/dist/kernel.d.ts.map +1 -0
  35. package/dist/kernel.js +1218 -0
  36. package/dist/kernel.js.map +1 -0
  37. package/dist/memory.d.ts +55 -0
  38. package/dist/memory.d.ts.map +1 -0
  39. package/dist/memory.js +107 -0
  40. package/dist/memory.js.map +1 -0
  41. package/dist/mime.d.ts +49 -0
  42. package/dist/mime.d.ts.map +1 -0
  43. package/dist/mime.js +150 -0
  44. package/dist/mime.js.map +1 -0
  45. package/dist/node.d.ts +84 -0
  46. package/dist/node.d.ts.map +1 -0
  47. package/dist/node.js +215 -0
  48. package/dist/node.js.map +1 -0
  49. package/dist/object-store.d.ts +472 -0
  50. package/dist/object-store.d.ts.map +1 -0
  51. package/dist/object-store.js +335 -0
  52. package/dist/object-store.js.map +1 -0
  53. package/dist/r2.d.ts +94 -0
  54. package/dist/r2.d.ts.map +1 -0
  55. package/dist/r2.js +150 -0
  56. package/dist/r2.js.map +1 -0
  57. package/dist/s3.d.ts +49 -0
  58. package/dist/s3.d.ts.map +1 -0
  59. package/dist/s3.js +263 -0
  60. package/dist/s3.js.map +1 -0
  61. package/dist/web.d.ts +336 -0
  62. package/dist/web.d.ts.map +1 -0
  63. package/dist/web.js +1094 -0
  64. package/dist/web.js.map +1 -0
  65. package/docs/DESIGN.md +426 -0
  66. package/package.json +182 -0
  67. package/src/azure.ts +329 -0
  68. package/src/content-disposition.ts +300 -0
  69. package/src/fs.ts +469 -0
  70. package/src/gcs.ts +290 -0
  71. package/src/hono.ts +123 -0
  72. package/src/http.ts +351 -0
  73. package/src/index.ts +85 -0
  74. package/src/kernel.ts +1498 -0
  75. package/src/memory.ts +148 -0
  76. package/src/mime.ts +160 -0
  77. package/src/node.ts +261 -0
  78. package/src/object-store.ts +665 -0
  79. package/src/r2.ts +232 -0
  80. package/src/s3.ts +324 -0
  81. package/src/web.ts +1603 -0
@@ -0,0 +1,541 @@
1
+ /**
2
+ * HTTP Range header parser and response builder (RFC 7233 + RFC 9110).
3
+ *
4
+ * Pure functions for building RFC-compliant HTTP responses that serve
5
+ * files from any storage backend (S3, R2, GCS, local disk). Handles
6
+ * range parsing, conditional request evaluation, and response header
7
+ * construction.
8
+ *
9
+ * Supports:
10
+ * - `If-None-Match` -> 304 Not Modified (bandwidth savings on revisits)
11
+ * - `If-Match` / `If-Unmodified-Since` -> 412 Precondition Failed
12
+ * - `If-Range` -> ETag/date validation (prevents data corruption)
13
+ * - `Range: bytes=...` -> 206 Partial Content (seeking in video/PDF)
14
+ * - 416 Range Not Satisfiable (proper error for unsatisfiable ranges)
15
+ *
16
+ * Supports both single byte ranges and multiple ranges (`parseRanges` +
17
+ * the `multipart/byteranges` builders below). Multi-range coalesces
18
+ * overlapping/adjacent parts and caps the count (amplification defense); the
19
+ * single-range fast path is kept separate so the common seek pays no
20
+ * multi-range overhead.
21
+ */
22
+ /** A validated, clamped byte range with inclusive bounds. */
23
+ export interface ParsedRange {
24
+ /** First byte position (0-indexed). */
25
+ start: number;
26
+ /**
27
+ * Last byte position (inclusive). May equal {@link OPEN_ENDED} on the
28
+ * single-round-trip fast path, meaning "to end of object" -- the range's
29
+ * true end is unknown until the backend responds. Adapters MUST treat
30
+ * `end === OPEN_ENDED` as an open-ended read (`bytes=start-`, or omit the
31
+ * count) rather than emitting the sentinel as a literal last-byte-pos.
32
+ */
33
+ end: number;
34
+ }
35
+ /**
36
+ * Sentinel `ParsedRange.end` meaning "to the end of the object". Used only
37
+ * by the fast path for a `bytes=a-` request, where the total size is not yet
38
+ * known. It is deliberately NOT `Number.MAX_SAFE_INTEGER`-as-a-magic-number
39
+ * at call sites: adapters compare against this named constant and emit the
40
+ * idiomatic open-ended wire form, so no 16-digit last-byte-pos ever reaches
41
+ * a backend or proxy that might reject it.
42
+ */
43
+ export declare const OPEN_ENDED: number;
44
+ /** True when a range is the fast-path open-ended form (`bytes=start-`). */
45
+ export declare function isOpenEndedRange(range: {
46
+ end: number;
47
+ } | undefined | null): boolean;
48
+ /** Options for building range response headers. */
49
+ export interface RangeResponseHeaderOpts {
50
+ /**
51
+ * Total object size in bytes, or `undefined` when the total is unknown --
52
+ * a `bytes a-b/*` partial response from a streaming origin that does not
53
+ * know its full length (RFC 7233 Section 4.2). Unknown is valid ONLY
54
+ * alongside a `range`: a 206's `Content-Length` is the range span, so the
55
+ * total is not needed to size the body and is emitted as `*`. A full (200)
56
+ * response has no length without it and rejects `undefined`.
57
+ */
58
+ totalSize: number | undefined;
59
+ /** Parsed range, or null for a full-content response. */
60
+ range: ParsedRange | null;
61
+ /** MIME type from storage metadata. */
62
+ contentType: string | undefined;
63
+ /** ETag from storage metadata, for conditional caching. */
64
+ etag: string | undefined;
65
+ /**
66
+ * Last-Modified date from storage metadata.
67
+ *
68
+ * Accepts any string parseable by `Date.parse()` (ISO 8601, IMF-fixdate,
69
+ * RFC 850, asctime). The library normalizes it to IMF-fixdate
70
+ * (e.g. "Sun, 29 Jun 2025 12:00:00 GMT") before emitting the
71
+ * `Last-Modified` response header, because ISO strings are not valid
72
+ * HTTP-dates and would break `If-Modified-Since` revalidation.
73
+ */
74
+ lastModified: string | undefined;
75
+ /**
76
+ * RFC 9530 representation digest for end-to-end integrity.
77
+ *
78
+ * When provided, emitted as `Repr-Digest: sha-256=:<base64>:` on 200/206
79
+ * responses. The digest covers the *full representation* (not the partial
80
+ * range), so it remains stable across range requests.
81
+ *
82
+ * Storage backends typically provide this:
83
+ * - S3: `x-amz-checksum-sha256`
84
+ * - GCS: `x-goog-hash: crc32c=...,md5=...`
85
+ *
86
+ * Must be a raw base64-encoded SHA-256 hash (no prefix, no colons).
87
+ */
88
+ digest?: string;
89
+ /**
90
+ * Cache-Control directive to include in the response.
91
+ *
92
+ * The library never generates cache directives on its own. It only echoes
93
+ * a value you provide. Common patterns:
94
+ * - `"private, no-cache"` (revalidate every request)
95
+ * - `"private, max-age=3600"` (cache 1 hour)
96
+ * - `"public, max-age=31536000, immutable"` (content-addressed)
97
+ */
98
+ cacheControl?: string;
99
+ }
100
+ /** Result of building range response headers. */
101
+ export interface RangeResponseHeaders {
102
+ /** HTTP status code: 200 for full content, 206 for partial, 304 for not modified, 412 for precondition failure, 416 for unsatisfiable. */
103
+ status: 200 | 206 | 304 | 412 | 416;
104
+ /** Headers to set on the response. */
105
+ headers: Record<string, string>;
106
+ }
107
+ /** Result of the full conditional request evaluation chain. */
108
+ export interface EvaluatedRequest extends RangeResponseHeaders {
109
+ /** Parsed range, or null if full content or an error status. */
110
+ range: ParsedRange | null;
111
+ }
112
+ /**
113
+ * Parse an HTTP Range header value into a validated byte range.
114
+ *
115
+ * Supports three RFC 7233 forms:
116
+ * - `bytes=0-499` (first 500 bytes)
117
+ * - `bytes=500-` (everything from byte 500 onward)
118
+ * - `bytes=-500` (last 500 bytes, suffix range)
119
+ *
120
+ * Returns `null` for:
121
+ * - Missing or empty header
122
+ * - Malformed syntax
123
+ * - Multi-range requests (e.g. `bytes=0-100,200-300`)
124
+ * - Non-byte range units
125
+ *
126
+ * Returns `"unsatisfiable"` for:
127
+ * - Start >= totalSize (valid syntax but out of bounds)
128
+ * - Suffix of 0 bytes
129
+ * - Start > end after clamping
130
+ *
131
+ * This distinction matters: `null` means "not a range request" (serve 200),
132
+ * while `"unsatisfiable"` means "valid range syntax but cannot be honored" (serve 416).
133
+ *
134
+ * @param rangeHeader - The raw `Range` header value (e.g. "bytes=0-499")
135
+ * @param totalSize - Total file size in bytes
136
+ */
137
+ export declare function parseRangeHeader(rangeHeader: string | null | undefined, totalSize: number): ParsedRange | "unsatisfiable" | null;
138
+ /** Default cap on distinct coalesced ranges before a request degrades to 200. */
139
+ export declare const MAX_RANGES_DEFAULT = 50;
140
+ /** A validated, coalesced set of satisfiable ranges. */
141
+ export interface RangeSet {
142
+ /**
143
+ * Coalesced satisfiable ranges in ascending order (length >= 1). A length
144
+ * of 1 is served as a normal single 206; length > 1 as multipart/byteranges.
145
+ */
146
+ ranges: ParsedRange[];
147
+ }
148
+ /**
149
+ * Parse a possibly-multi-range `Range` header into a coalesced, satisfiable
150
+ * range set, applying range-amplification defenses.
151
+ *
152
+ * Return contract mirrors {@link parseRangeHeader} but for a set:
153
+ * - `null` -- not a byte range, ignorable syntax, or a defense
154
+ * tripped: serve the full 200.
155
+ * - `"unsatisfiable"` -- every element was valid syntax but out of bounds:
156
+ * serve 416.
157
+ * - {@link RangeSet} -- 1+ satisfiable ranges (overlapping/adjacent already
158
+ * coalesced): serve 206 (single) or multipart (>1).
159
+ *
160
+ * Amplification defenses (informed by Go `net/http.ServeContent`'s
161
+ * sum-of-ranges check and nginx `max_ranges` + adjacent coalescing):
162
+ * 1. Overlapping/adjacent ranges are coalesced, so a client cannot force
163
+ * redundant bytes by requesting the same region many times.
164
+ * 2. If the coalesced set still exceeds `maxRanges` distinct parts, or the
165
+ * bytes it covers reach or exceed the whole representation, the ranges
166
+ * are ignored and the full 200 is served -- multipart framing over the
167
+ * entire file is pure overhead and a classic amplification vector.
168
+ *
169
+ * Empty list elements (`bytes=0-1,,2-3`) are skipped per the RFC 9110
170
+ * Section 5.6.1 list rule; a genuinely malformed element voids the whole
171
+ * header (serve 200), matching single-range leniency.
172
+ */
173
+ export declare function parseRanges(rangeHeader: string | null | undefined, totalSize: number, maxRanges?: number): RangeSet | "unsatisfiable" | null;
174
+ /**
175
+ * Generate a multipart/byteranges boundary token. Hyphen-free so the token
176
+ * itself can never contain the `--` delimiter run; random so it cannot appear
177
+ * in body content by construction.
178
+ */
179
+ export declare function generateMultipartBoundary(): string;
180
+ /**
181
+ * Build the per-part header block for one multipart/byteranges part:
182
+ * `--BOUNDARY CRLF [Content-Type CRLF] Content-Range CRLF CRLF`. The part's
183
+ * body bytes and a trailing CRLF follow (the caller emits those).
184
+ */
185
+ export declare function buildMultipartPartHeader(boundary: string, range: ParsedRange, totalSize: number, contentType: string | undefined): string;
186
+ /** The closing delimiter for a multipart/byteranges body: `--BOUNDARY-- CRLF`. */
187
+ export declare function multipartEpilogue(boundary: string): string;
188
+ /** Result of {@link buildMultipartHeaders}. */
189
+ export interface MultipartResponse {
190
+ status: 206;
191
+ headers: Record<string, string>;
192
+ /** Exact byte length of the full multipart body (framing + all part bytes). */
193
+ contentLength: number;
194
+ }
195
+ /**
196
+ * Build the top-level headers and exact Content-Length for a
197
+ * multipart/byteranges (206) response. The per-representation Content-Type
198
+ * lives in each part; the top-level type is `multipart/byteranges`.
199
+ *
200
+ * Content-Length is computed deterministically from the framing and the
201
+ * range spans, so the response is never chunked and the client gets an exact
202
+ * length up front. `contentType` is the representation's own MIME (placed in
203
+ * every part header).
204
+ */
205
+ export declare function buildMultipartHeaders(opts: {
206
+ boundary: string;
207
+ ranges: ParsedRange[];
208
+ totalSize: number;
209
+ contentType: string | undefined;
210
+ etag?: string;
211
+ lastModified?: string;
212
+ cacheControl?: string;
213
+ digest?: string;
214
+ }): MultipartResponse;
215
+ /**
216
+ * Check if a conditional GET request is "fresh" (resource not modified).
217
+ *
218
+ * Implements RFC 7232 Section 3.2 (If-None-Match) and Section 3.3
219
+ * (If-Modified-Since). `If-None-Match` takes precedence per the spec.
220
+ *
221
+ * When this returns true, the server should respond with 304 Not Modified
222
+ * and skip streaming the body entirely.
223
+ *
224
+ * @param reqHeaders - Request headers (must support `.get(name)`)
225
+ * @param etag - Current resource ETag
226
+ * @param lastModified - Current resource Last-Modified date string
227
+ */
228
+ export declare function isConditionalFresh(reqHeaders: {
229
+ get(name: string): string | null;
230
+ }, etag: string | undefined, lastModified: string | undefined): boolean;
231
+ /**
232
+ * Check if a request's preconditions have failed.
233
+ *
234
+ * Implements RFC 7232 Section 3.1 (If-Match) and Section 3.4
235
+ * (If-Unmodified-Since). `If-Match` takes precedence per the spec.
236
+ *
237
+ * When this returns true, the server MUST respond with 412 Precondition
238
+ * Failed and skip processing the request body.
239
+ *
240
+ * Evaluation order per RFC 7232 Section 6:
241
+ * 1. isPreconditionFailure -> 412 (this function)
242
+ * 2. isConditionalFresh -> 304
243
+ * 3. Process range / serve content
244
+ *
245
+ * @param reqHeaders - Request headers (must support `.get(name)`)
246
+ * @param etag - Current resource ETag
247
+ * @param lastModified - Current resource Last-Modified date string
248
+ * @param exists - Whether the resource currently exists. When omitted,
249
+ * inferred from `etag !== undefined`. Callers that have already fetched
250
+ * metadata (e.g. the read orchestrator) should pass `true` explicitly.
251
+ */
252
+ export declare function isPreconditionFailure(reqHeaders: {
253
+ get(name: string): string | null;
254
+ }, etag: string | undefined, lastModified: string | undefined, exists?: boolean): boolean;
255
+ /**
256
+ * Check if a Range request's `If-Range` precondition is "fresh".
257
+ *
258
+ * RFC 7233 Section 3.2: If the client sends `If-Range`, the server MUST
259
+ * only honor the Range when the validator matches. If it doesn't match,
260
+ * the resource has changed since the client cached it, so serving a partial
261
+ * response would result in data corruption (new bytes appended to old content).
262
+ *
263
+ * Returns `true` if the Range should be honored (validator matches or no If-Range).
264
+ * Returns `false` if the Range should be ignored (serve full 200 instead).
265
+ *
266
+ * @param reqHeaders - Request headers
267
+ * @param etag - Current resource ETag
268
+ * @param lastModified - Current resource Last-Modified date string
269
+ */
270
+ export declare function isRangeFresh(reqHeaders: {
271
+ get(name: string): string | null;
272
+ }, etag: string | undefined, lastModified: string | undefined): boolean;
273
+ /**
274
+ * Build HTTP response headers for a full or partial content response.
275
+ *
276
+ * Always includes `Accept-Ranges: bytes` to advertise range support
277
+ * (even on 200 responses, per RFC 7233 Section 2.3).
278
+ *
279
+ * Handles:
280
+ * - 200 OK (full content, when range is null)
281
+ * - 206 Partial Content (valid range)
282
+ *
283
+ * For error statuses (304, 412, 416), use the dedicated builders
284
+ * (`build304Headers`, `build412Headers`, `build416Headers`).
285
+ *
286
+ * @returns Status code and headers dict ready to pass to `new Response()`.
287
+ */
288
+ export declare function buildRangeResponseHeaders(opts: RangeResponseHeaderOpts): RangeResponseHeaders;
289
+ /**
290
+ * Build 416 Range Not Satisfiable response headers.
291
+ *
292
+ * Per RFC 7233 Section 4.4, the response MUST include a Content-Range
293
+ * header with the unsatisfied-range syntax: `bytes * /totalSize`.
294
+ *
295
+ * Deliberately omits ETag, Last-Modified, and Content-Type. These are
296
+ * representation metadata for the successful response; including them
297
+ * on error responses can poison shared caches.
298
+ */
299
+ export declare function build416Headers(totalSize: number): RangeResponseHeaders;
300
+ /** Result of parsing a Content-Range response header. */
301
+ export interface ParsedContentRange {
302
+ /** First byte of the range (inclusive, 0-indexed). */
303
+ start: number;
304
+ /** Last byte of the range (inclusive). */
305
+ end: number;
306
+ /** Total size of the complete representation. */
307
+ totalSize: number;
308
+ }
309
+ /**
310
+ * Parse a `Content-Range` response header into structured fields.
311
+ *
312
+ * Understands the two RFC 7233 Section 4.2 forms:
313
+ * - `bytes 0-499/1000` (byte range with known total)
314
+ * - `bytes 0-499/*` (byte range with unknown total, returns `totalSize: -1`)
315
+ *
316
+ * The unsatisfied-range form (`bytes * /1000`) is NOT parsed because it carries
317
+ * no range information -- callers should check the HTTP status (416) instead.
318
+ *
319
+ * Returns `null` for:
320
+ * - Missing or empty header
321
+ * - Non-byte range units
322
+ * - Malformed syntax
323
+ * - Unsatisfied range form (`bytes * /...`)
324
+ *
325
+ * Storage adapters use this to extract `totalSize` from a 206 response when
326
+ * the backend serves a range slice. Without it, each adapter reimplements the
327
+ * same regex inline, with varying correctness on edge cases.
328
+ *
329
+ * @example
330
+ * ```typescript
331
+ * import { parseContentRange } from "partial-content";
332
+ *
333
+ * const cr = parseContentRange("bytes 0-499/1000");
334
+ * // => { start: 0, end: 499, totalSize: 1000 }
335
+ *
336
+ * const unknown = parseContentRange("bytes 0-499/*");
337
+ * // => { start: 0, end: 499, totalSize: -1 }
338
+ * ```
339
+ */
340
+ export declare function parseContentRange(header: string | null | undefined): ParsedContentRange | null;
341
+ /**
342
+ * Build 412 Precondition Failed response headers.
343
+ *
344
+ * Per RFC 7232 Section 4.2, a 412 response indicates that one or more
345
+ * conditions in the request headers (If-Match, If-Unmodified-Since)
346
+ * evaluated to false.
347
+ *
348
+ * Deliberately omits representation metadata (ETag, Last-Modified,
349
+ * Content-Type) to prevent cache poisoning.
350
+ */
351
+ export declare function build412Headers(): RangeResponseHeaders;
352
+ /**
353
+ * Build 304 Not Modified response headers.
354
+ *
355
+ * Per RFC 7232 Section 4.1, a 304 response MUST NOT contain a message
356
+ * body and SHOULD NOT include representation headers (Content-Type,
357
+ * Content-Length, Content-Encoding, Content-Language, Content-Range).
358
+ *
359
+ * Only includes: ETag, Last-Modified, and Cache-Control.
360
+ */
361
+ export declare function build304Headers(etag: string | undefined, lastModified: string | undefined, cacheControl?: string): RangeResponseHeaders;
362
+ /**
363
+ * Evaluate a conditional request and return the correct HTTP response.
364
+ *
365
+ * Implements the full RFC 7232 Section 6 evaluation chain in the correct
366
+ * order. This is the recommended entry point for most consumers -- it
367
+ * eliminates the risk of misordering the evaluation steps.
368
+ *
369
+ * Evaluation order:
370
+ * 1. `isPreconditionFailure` -> 412 Precondition Failed
371
+ * 2. `isConditionalFresh` -> 304 Not Modified
372
+ * 3. `isRangeFresh` -> Should we honor the Range?
373
+ * 4. `parseRangeHeader` -> Parse and validate the Range
374
+ * 5. `buildRangeResponseHeaders` or `build416Headers`
375
+ *
376
+ * @returns Status, headers, and the parsed range (null if full content or error status).
377
+ */
378
+ export declare function evaluateConditionalRequest(reqHeaders: {
379
+ get(name: string): string | null;
380
+ }, meta: {
381
+ totalSize: number;
382
+ contentType?: string;
383
+ etag?: string;
384
+ lastModified?: string;
385
+ cacheControl?: string;
386
+ /** RFC 9530 SHA-256 digest (raw base64, no prefix). Emitted as `Repr-Digest` on 200/206. */
387
+ digest?: string;
388
+ }): EvaluatedRequest;
389
+ /**
390
+ * Result of a conditional-write evaluation.
391
+ *
392
+ * Discriminated union on `proceed`:
393
+ * - `{ proceed: true }` -- safe to execute the write.
394
+ * - `{ proceed: false, status: 412, headers }` -- return 412 to the client.
395
+ */
396
+ export type EvaluatedWrite = {
397
+ proceed: true;
398
+ } | {
399
+ proceed: false;
400
+ status: 412;
401
+ headers: Record<string, string>;
402
+ };
403
+ /**
404
+ * Evaluate conditional headers for a **write** request (PUT, PATCH, DELETE).
405
+ *
406
+ * On read requests (GET/HEAD), `If-None-Match` match triggers 304 Not Modified.
407
+ * On write requests, the same match triggers **412 Precondition Failed**
408
+ * (RFC 9110 Section 13.1.2). This function implements the write-side
409
+ * evaluation so consumers don't accidentally use the read-side orchestrator
410
+ * for OCC flows.
411
+ *
412
+ * Evaluation order (RFC 9110 Section 13.2.2):
413
+ * 1. `If-Match` / `If-Unmodified-Since` -> 412 (same as reads)
414
+ * 2. `If-None-Match` -> 412 (differs from reads: 412, not 304)
415
+ *
416
+ * ### OCC pattern (If-Match)
417
+ *
418
+ * ```typescript
419
+ * const result = evaluateConditionalWrite(req.headers, {
420
+ * etag: '"v2"',
421
+ * lastModified: "2025-06-28T12:00:00.000Z",
422
+ * });
423
+ * if (!result.proceed) {
424
+ * return new Response(null, { status: result.status, headers: result.headers });
425
+ * }
426
+ * // Safe to apply the mutation
427
+ * ```
428
+ *
429
+ * ### PUT-if-absent pattern (If-None-Match: *)
430
+ *
431
+ * ```typescript
432
+ * const result = evaluateConditionalWrite(req.headers, {
433
+ * etag: existingEtag, // undefined if resource doesn't exist yet
434
+ * exists: resourceExists,
435
+ * });
436
+ * ```
437
+ *
438
+ * @param reqHeaders - Request headers (must support `.get(name)`)
439
+ * @param meta - Current resource state
440
+ */
441
+ export declare function evaluateConditionalWrite(reqHeaders: {
442
+ get(name: string): string | null;
443
+ }, meta: {
444
+ etag?: string;
445
+ lastModified?: string;
446
+ /**
447
+ * Whether the target resource currently exists. Used to evaluate
448
+ * `If-None-Match: *` (PUT-if-absent / create-only pattern).
449
+ *
450
+ * When omitted, existence is inferred from `etag !== undefined`.
451
+ */
452
+ exists?: boolean;
453
+ }): EvaluatedWrite;
454
+ /**
455
+ * Adapter for Node.js HTTP servers (Express, Fastify, raw `http`).
456
+ *
457
+ * Converts a plain Node headers object (`IncomingHttpHeaders`) into the
458
+ * `{ get(name) }` interface expected by `partial-content` functions.
459
+ *
460
+ * @example
461
+ * ```typescript
462
+ * import { fromNodeHeaders, isConditionalFresh } from "partial-content";
463
+ *
464
+ * // Express
465
+ * app.get("/file", (req, res) => {
466
+ * const headers = fromNodeHeaders(req.headers);
467
+ * if (isConditionalFresh(headers, etag, lastModified)) {
468
+ * return res.status(304).end();
469
+ * }
470
+ * });
471
+ * ```
472
+ */
473
+ export declare function fromNodeHeaders(headers: Record<string, string | string[] | undefined>): {
474
+ get(name: string): string | null;
475
+ };
476
+ /** Metadata from a storage backend used to derive an entity-tag. */
477
+ export interface ETagSource {
478
+ /**
479
+ * Content digest from the backend (S3/Azure/GCS object hash).
480
+ * Yields a STRONG validator. May include surrounding quotes or `W/` prefix,
481
+ * both of which are normalized.
482
+ */
483
+ hash?: string;
484
+ /** Object size in bytes. With `mtime`, yields a WEAK validator when no hash exists. */
485
+ size?: number;
486
+ /**
487
+ * Last-modified: `Date`, epoch-ms number, or any `Date.parse()`-able string.
488
+ * Floor to whole seconds for consistency with emitted `Last-Modified` headers.
489
+ */
490
+ mtime?: Date | number | string;
491
+ }
492
+ /**
493
+ * Derive an entity-tag from storage metadata.
494
+ *
495
+ * This is a **formatter**, not a hasher. It classifies what the storage backend
496
+ * already provides into the correct validator strength per RFC 9110 Section 8.8.1:
497
+ *
498
+ * - `hash` present -> strong `"<hash>"` (a content digest asserts byte equality)
499
+ * - `size` + `mtime` only -> weak `W/"<size>-<mtime>"` (cannot assert byte equality;
500
+ * mtime resolution is coarse, collisions exist)
501
+ * - insufficient metadata -> `undefined` (caller omits ETag; never fabricate a
502
+ * validator we cannot stand behind)
503
+ *
504
+ * The dangerous failure mode is emitting a strong or size-only validator that ignores
505
+ * modification, which would serve stale 304s. Every `undefined` return guards that.
506
+ *
507
+ * @param source - Storage metadata to derive the ETag from
508
+ * @returns A correctly-typed ETag string, or `undefined` if insufficient metadata
509
+ */
510
+ export declare function generateETag(source: ETagSource): string | undefined;
511
+ /**
512
+ * Strip bytes outside RFC 9110 field-value grammar to prevent HTTP header
513
+ * injection (CWE-113) and header-write crashes.
514
+ *
515
+ * Storage backends may pass through untrusted ETag, Last-Modified,
516
+ * Content-Type, digest, Cache-Control, or Location values. Exported so
517
+ * framework adapters can sanitize backend-derived headers the kernel does
518
+ * not build itself (e.g. the web adapter's signed-URL `Location`).
519
+ */
520
+ export declare function sanitizeHeaderValue(s: string): string;
521
+ /**
522
+ * RFC 9530 Section 4: Check if the client wants a `sha-256` digest.
523
+ *
524
+ * `Want-Repr-Digest` uses Structured Fields Dictionary syntax:
525
+ * Want-Repr-Digest: sha-256=5, sha-512=3
526
+ *
527
+ * Each key is a hash algorithm, each value is a preference weight (0-10).
528
+ * Weight 0 means "explicitly unwanted." If the header is absent or contains
529
+ * `sha-256` with weight > 0, we emit the digest. If it only lists algorithms
530
+ * we don't support (e.g. `sha-512=5`), we skip emission entirely.
531
+ *
532
+ * We also check `Want-Content-Digest` since our Content-Digest uses the
533
+ * same algorithm.
534
+ *
535
+ * Exported so framework adapters apply the same negotiation the orchestrator
536
+ * uses internally -- digest emission behaves identically at every layer.
537
+ */
538
+ export declare function clientWantsDigest(reqHeaders: {
539
+ get(name: string): string | null;
540
+ }): boolean;
541
+ //# sourceMappingURL=kernel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kernel.d.ts","sourceRoot":"","sources":["../src/kernel.ts"],"names":[],"mappings":"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"}