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
package/src/http.ts ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Generic HTTP ObjectStore adapter for partial-content.
3
+ *
4
+ * Serves from ANY range-capable HTTP origin using plain `fetch`: Supabase
5
+ * Storage, S3/GCS/Azure presigned URLs, CDN origins, internal file services,
6
+ * or another partial-content server. Zero dependencies, every runtime.
7
+ *
8
+ * Capabilities mapped straight onto HTTP semantics:
9
+ * - `headObject` -> `HEAD` (metadata from Content-Length / ETag / Last-Modified)
10
+ * - `getObject` -> `GET` with `Range: bytes=...`
11
+ * - Pinned reads -> `If-Match` (origin 412 becomes {@link ObjectChangedError})
12
+ * - RFC 9530 -> `Repr-Digest: sha-256=:...:` response header is extracted
13
+ *
14
+ * Caveats:
15
+ * - The origin must send `Content-Length` (range-capable origins always do).
16
+ * - Presigned URLs are signed per-method: a GET-presigned URL will reject the
17
+ * HEAD probe. Presign both methods in `url()` (it receives the key only;
18
+ * return method-agnostic URLs, or front the origin with a service that
19
+ * accepts both).
20
+ * - Origins that ignore `If-Match` are still safe: the web adapter's
21
+ * response-side guard (actual Content-Range + GET validators) remains.
22
+ *
23
+ * @example Supabase Storage (authenticated)
24
+ * ```typescript
25
+ * import { httpStore } from "partial-content/http";
26
+ * import { serveObject } from "partial-content/web";
27
+ *
28
+ * const store = httpStore({
29
+ * url: (key) => `${SUPABASE_URL}/storage/v1/object/documents/${key}`,
30
+ * headers: { Authorization: `Bearer ${serviceRoleKey}` },
31
+ * });
32
+ *
33
+ * export const GET = serveObject(store, { disposition: "inline" });
34
+ * ```
35
+ *
36
+ * @packageDocumentation
37
+ */
38
+
39
+ import {
40
+ ObjectNotFoundError,
41
+ ObjectChangedError,
42
+ StoreUnavailableError,
43
+ resolveServedRange,
44
+ parseRetryAfterSeconds,
45
+ isOpenEndedRange,
46
+ type ObjectStore,
47
+ type ObjectMetadata,
48
+ type ObjectStream,
49
+ type ParsedRange,
50
+ } from "./index.js";
51
+
52
+ // Re-export for convenience
53
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
54
+
55
+ // ─── Options ────────────────────────────────────────────────────────────────
56
+
57
+ export interface HttpStoreOptions {
58
+ /** Build the absolute URL for a storage key. */
59
+ url: (key: string) => string | URL;
60
+ /**
61
+ * Request headers (e.g. Authorization). A static record, or a function of
62
+ * the key for per-object credentials.
63
+ */
64
+ headers?: Record<string, string> | ((key: string) => Record<string, string>);
65
+ /**
66
+ * Fetch implementation. Defaults to the global `fetch`.
67
+ * Inject for testing or to add retries/instrumentation. This is also the
68
+ * SSRF hook: when `url()` incorporates untrusted input, supply a fetch
69
+ * that validates resolved addresses (see SECURITY.md).
70
+ */
71
+ fetch?: typeof globalThis.fetch;
72
+ /**
73
+ * Redirect policy for origin requests. Defaults to `"error"`: a serving layer
74
+ * whose `url()` may incorporate untrusted keys must not let a compromised or
75
+ * misconfigured origin bounce the store to internal endpoints (cloud metadata
76
+ * IPs) via a 3xx. Object-storage origins answer GET/HEAD with a direct 200, so
77
+ * the safe default costs nothing there. Set `"follow"` explicitly for origins
78
+ * that legitimately redirect (some presigned-URL or CDN front-door flows);
79
+ * pair it with a validating `fetch` (see SECURITY.md) when keys are untrusted.
80
+ */
81
+ redirect?: "follow" | "error" | "manual";
82
+ }
83
+
84
+ // ─── Factory ────────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * Create an {@link ObjectStore} backed by any range-capable HTTP origin.
88
+ */
89
+ export function httpStore(opts: HttpStoreOptions): ObjectStore {
90
+ const doFetch = opts.fetch ?? globalThis.fetch;
91
+ const redirect = opts.redirect ?? "error";
92
+
93
+ function requestHeaders(key: string): Record<string, string> {
94
+ const base = typeof opts.headers === "function"
95
+ ? opts.headers(key)
96
+ : opts.headers ?? {};
97
+ return {
98
+ ...base,
99
+ // Byte accounting depends on the origin NOT transparently compressing:
100
+ // a gzip'd body would break Content-Length/Content-Range math. Server
101
+ // runtimes (undici, Bun, Deno, Workers) honor this; identity is also
102
+ // what object-storage origins serve anyway.
103
+ "Accept-Encoding": "identity",
104
+ };
105
+ }
106
+
107
+ return {
108
+ supportsRange: true,
109
+ // 206 bounds/total are parsed from the origin's actual Content-Range
110
+ // (an origin ignoring Range degrades to 200): the orchestrator may skip
111
+ // the validating HEAD for plain range requests.
112
+ authoritativeRange: true,
113
+
114
+ async headObject(key: string, headOpts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
115
+ const response = await doFetch(opts.url(key), {
116
+ method: "HEAD",
117
+ headers: requestHeaders(key),
118
+ signal: headOpts?.signal,
119
+ redirect,
120
+ });
121
+ // HEAD bodies are empty; no need to drain before throwing.
122
+ if (response.status === 404) throw new ObjectNotFoundError(key);
123
+ if (isUnavailableStatus(response.status)) {
124
+ throw new StoreUnavailableError(key, { retryAfterSeconds: parseRetryAfter(response) });
125
+ }
126
+ if (!response.ok) {
127
+ throw new Error(`httpStore HEAD ${key} failed: ${response.status}`);
128
+ }
129
+
130
+ const headEncoding = nonIdentityEncoding(response);
131
+ if (headEncoding) {
132
+ throw new Error(
133
+ `httpStore HEAD ${key}: origin returned Content-Encoding: ${headEncoding}; ` +
134
+ `Content-Length would report the compressed size, so range serving requires an identity-coded body`,
135
+ );
136
+ }
137
+
138
+ const contentLength = parseSize(response.headers.get("content-length"));
139
+ if (contentLength === undefined) {
140
+ throw new Error(
141
+ `httpStore HEAD ${key}: origin sent no Content-Length; range serving requires sized responses`,
142
+ );
143
+ }
144
+
145
+ return {
146
+ contentLength,
147
+ etag: response.headers.get("etag") ?? undefined,
148
+ lastModified: response.headers.get("last-modified") ?? undefined,
149
+ digest: extractSha256Digest(response.headers.get("repr-digest")),
150
+ };
151
+ },
152
+
153
+ async getObject(
154
+ key: string,
155
+ getOpts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string },
156
+ ): Promise<ObjectStream> {
157
+ const { range, signal, ifMatch } = getOpts ?? {};
158
+ const headers = requestHeaders(key);
159
+ // Open-ended fast-path ranges carry the OPEN_ENDED sentinel end; emit
160
+ // the bare `bytes=a-` form rather than a 16-digit last-byte-pos.
161
+ if (range) {
162
+ headers["Range"] = isOpenEndedRange(range)
163
+ ? `bytes=${range.start}-`
164
+ : `bytes=${range.start}-${range.end}`;
165
+ }
166
+ // Pin the read to the validated representation. Origins that honor
167
+ // If-Match (S3, Azure, nginx, another partial-content server) answer
168
+ // 412 when the object changed; origins that ignore it fall through to
169
+ // the caller's response-side guard. Weak validators are never sent:
170
+ // RFC 9110 mandates strong comparison for If-Match, so a `W/` ETag
171
+ // would be rejected by every compliant origin on every attempt.
172
+ if (ifMatch && !ifMatch.startsWith("W/")) {
173
+ headers["If-Match"] = ifMatch;
174
+ }
175
+
176
+ const response = await doFetch(opts.url(key), {
177
+ method: "GET",
178
+ headers,
179
+ signal,
180
+ redirect,
181
+ });
182
+
183
+ if (response.status === 404) {
184
+ await drain(response);
185
+ throw new ObjectNotFoundError(key);
186
+ }
187
+ if (response.status === 412) {
188
+ await drain(response);
189
+ throw new ObjectChangedError(key);
190
+ }
191
+ if (isUnavailableStatus(response.status)) {
192
+ const retryAfterSeconds = parseRetryAfter(response);
193
+ await drain(response);
194
+ throw new StoreUnavailableError(key, { retryAfterSeconds });
195
+ }
196
+ if (response.status !== 200 && response.status !== 206) {
197
+ await drain(response);
198
+ throw new Error(`httpStore GET ${key} failed: ${response.status}`);
199
+ }
200
+
201
+ // The fetch runtime transparently decodes a compressed body while
202
+ // Content-Length / Content-Range still describe the encoded bytes, so
203
+ // every byte count below would be wrong. Refuse rather than stream a
204
+ // body that disagrees with its own headers.
205
+ const getEncoding = nonIdentityEncoding(response);
206
+ if (getEncoding) {
207
+ await drain(response);
208
+ throw new Error(
209
+ `httpStore GET ${key}: origin returned Content-Encoding: ${getEncoding}; ` +
210
+ `range and length accounting require an identity-coded body`,
211
+ );
212
+ }
213
+
214
+ const headerLength = parseSize(response.headers.get("content-length"));
215
+ let served: { start: number; end: number } | undefined;
216
+ let contentLength: number;
217
+ let totalSize: number | undefined;
218
+
219
+ if (response.status === 206) {
220
+ // A 206 MUST carry a parseable single-range Content-Range; without
221
+ // one the body's bounds are unknowable and serving it as anything
222
+ // would silently truncate or corrupt the client's view.
223
+ const contentRangeHeader = response.headers.get("content-range");
224
+ const resolved = contentRangeHeader ? resolveServedRange(contentRangeHeader) : null;
225
+ if (!resolved) {
226
+ await drain(response);
227
+ throw new Error(
228
+ `httpStore GET ${key}: origin sent 206 with ${contentRangeHeader ? `unparseable Content-Range "${contentRangeHeader}"` : "no Content-Range"}`,
229
+ );
230
+ }
231
+ served = resolved.served;
232
+ // Content-Length, or derived from the actual Content-Range when the
233
+ // origin streams chunked 206s.
234
+ contentLength = headerLength ?? (resolved.served.end - resolved.served.start + 1);
235
+ // A proxied origin streaming a generated/transcoded body legitimately
236
+ // does not know its full length and sends `bytes a-b/*` (the resolver's
237
+ // `undefined` total). Propagate that so the served response repeats `*`
238
+ // honestly instead of fabricating a total that would draw a spurious
239
+ // 416 on a later range past the invented end.
240
+ totalSize = resolved.totalSize;
241
+ } else {
242
+ if (headerLength === undefined) {
243
+ await drain(response);
244
+ throw new Error(
245
+ `httpStore GET ${key}: origin sent no Content-Length; range serving requires sized responses`,
246
+ );
247
+ }
248
+ contentLength = headerLength;
249
+ totalSize = contentLength;
250
+ }
251
+
252
+ return {
253
+ body: response.body ?? emptyStream(),
254
+ contentLength,
255
+ totalSize,
256
+ range: served,
257
+ etag: response.headers.get("etag") ?? undefined,
258
+ lastModified: response.headers.get("last-modified") ?? undefined,
259
+ digest: extractSha256Digest(response.headers.get("repr-digest")),
260
+ };
261
+ },
262
+ };
263
+ }
264
+
265
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Return the response's `Content-Encoding` when the origin transfer-compressed
269
+ * the body despite our `Accept-Encoding: identity` request, or `null` when the
270
+ * body is identity-coded. A compressed body is transparently decoded by the
271
+ * fetch runtime, but `Content-Length` keeps reporting the compressed size,
272
+ * which breaks all downstream byte accounting.
273
+ */
274
+ function nonIdentityEncoding(response: Response): string | null {
275
+ const enc = response.headers.get("content-encoding");
276
+ return enc && enc.trim().toLowerCase() !== "identity" ? enc : null;
277
+ }
278
+
279
+ /** Parse a Content-Length header into a non-negative integer, or undefined. */
280
+ function parseSize(value: string | null): number | undefined {
281
+ if (!value || !/^\d+$/.test(value.trim())) return undefined;
282
+ const n = Number(value.trim());
283
+ return Number.isSafeInteger(n) ? n : undefined;
284
+ }
285
+
286
+ /**
287
+ * Whether an origin status means "transiently unavailable, retry" (mapped to
288
+ * a 503, not a 502): `503 Service Unavailable` and `429 Too Many Requests`.
289
+ */
290
+ function isUnavailableStatus(status: number): boolean {
291
+ return status === 503 || status === 429;
292
+ }
293
+
294
+ /**
295
+ * Parse this origin response's `Retry-After` (RFC 9110 Section 10.2.3) into
296
+ * whole seconds via the shared parser: delay-seconds directly, or an HTTP-date
297
+ * as the non-negative delta from now. Returns undefined when absent/unparseable.
298
+ */
299
+ function parseRetryAfter(response: Response): number | undefined {
300
+ return parseRetryAfterSeconds(response.headers.get("retry-after"), { allowHttpDate: true });
301
+ }
302
+
303
+ /**
304
+ * Extract the raw base64 SHA-256 from an RFC 9530 Repr-Digest header
305
+ * (`sha-256=:BASE64:`), or undefined when absent / different algorithm.
306
+ */
307
+ function extractSha256Digest(header: string | null): string | undefined {
308
+ if (!header) return undefined;
309
+ const match = /sha-256=:([A-Za-z0-9+/]+=*):/i.exec(header);
310
+ return match ? match[1] : undefined;
311
+ }
312
+
313
+ /**
314
+ * Consume up to `maxBytes` of an error/refusal body so the connection can be
315
+ * reused, then cancel. A bare `response.arrayBuffer()` would buffer the ENTIRE
316
+ * body -- and these paths include the malformed-206 and encoding-refusal
317
+ * branches whose body is the real (potentially whole-object) payload, plus the
318
+ * fetch runtime transparently INFLATES a compressed body during buffering. A
319
+ * hostile or misconfigured origin could then drive per-request OOM (a
320
+ * decompression bomb) just to make the adapter throw. Reading a small bounded
321
+ * prefix keeps connection reuse for genuine 404/412 error bodies while capping
322
+ * the cost; anything larger sacrifices one pooled connection, which is cheap.
323
+ */
324
+ async function drain(response: Response, maxBytes = 64 * 1024): Promise<void> {
325
+ const body = response.body;
326
+ if (!body) return;
327
+ try {
328
+ const reader = body.getReader();
329
+ let seen = 0;
330
+ for (;;) {
331
+ const { done, value } = await reader.read();
332
+ if (done) return;
333
+ seen += value.byteLength;
334
+ if (seen > maxBytes) {
335
+ await reader.cancel();
336
+ return;
337
+ }
338
+ }
339
+ } catch {
340
+ // Body already consumed or connection gone; nothing to release.
341
+ }
342
+ }
343
+
344
+ /** A closed empty stream for bodyless 200s (zero-byte objects). */
345
+ function emptyStream(): ReadableStream<Uint8Array> {
346
+ return new ReadableStream<Uint8Array>({
347
+ start(controller) {
348
+ controller.close();
349
+ },
350
+ });
351
+ }
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * partial-content
3
+ *
4
+ * RFC-compliant HTTP Range Requests, Conditional Requests, and
5
+ * Content-Disposition. Pure functions, zero dependencies, any runtime.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ // ─── Range Requests (RFC 7233) ──────────────────────────────────────────────
11
+ export {
12
+ parseRangeHeader,
13
+ parseContentRange,
14
+ buildRangeResponseHeaders,
15
+ build416Headers,
16
+ OPEN_ENDED,
17
+ isOpenEndedRange,
18
+ } from "./kernel.js";
19
+
20
+ // ─── Multiple Ranges (multipart/byteranges) ─────────────────────────────────
21
+ export {
22
+ parseRanges,
23
+ MAX_RANGES_DEFAULT,
24
+ generateMultipartBoundary,
25
+ buildMultipartPartHeader,
26
+ multipartEpilogue,
27
+ buildMultipartHeaders,
28
+ } from "./kernel.js";
29
+
30
+ // ─── Header Safety ──────────────────────────────────────────────────────────
31
+ export { sanitizeHeaderValue } from "./kernel.js";
32
+
33
+ // ─── Conditional Requests (RFC 9110 / RFC 7232) ─────────────────────────────
34
+ export {
35
+ isConditionalFresh,
36
+ isPreconditionFailure,
37
+ isRangeFresh,
38
+ build304Headers,
39
+ build412Headers,
40
+ evaluateConditionalRequest,
41
+ evaluateConditionalWrite,
42
+ } from "./kernel.js";
43
+
44
+ // ─── Digest Negotiation (RFC 9530) ──────────────────────────────────────────
45
+ export { clientWantsDigest } from "./kernel.js";
46
+
47
+ // ─── ETag Generation ────────────────────────────────────────────────────────
48
+ export { generateETag } from "./kernel.js";
49
+
50
+ // ─── Adapters ───────────────────────────────────────────────────────────────
51
+ export { fromNodeHeaders } from "./kernel.js";
52
+
53
+ // ─── Types ──────────────────────────────────────────────────────────────────
54
+ export type {
55
+ ParsedRange,
56
+ ParsedContentRange,
57
+ RangeResponseHeaderOpts,
58
+ RangeResponseHeaders,
59
+ EvaluatedRequest,
60
+ EvaluatedWrite,
61
+ ETagSource,
62
+ RangeSet,
63
+ MultipartResponse,
64
+ } from "./kernel.js";
65
+
66
+ // ─── Storage Contract ───────────────────────────────────────────────────────
67
+ export type {
68
+ CancelSignal,
69
+ ObjectStore,
70
+ ObjectMetadata,
71
+ ObjectStream,
72
+ ServedRange,
73
+ ResolvedContentRange,
74
+ HeadObjectOptions,
75
+ GetObjectOptions,
76
+ StoreErrorClassifiers,
77
+ } from "./object-store.js";
78
+
79
+ // ─── Storage Primitives ─────────────────────────────────────────────────────
80
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError, nodeStreamToWeb, guardStreamLength, resolveServedRange, classifyStoreRead, parseRetryAfterSeconds } from "./object-store.js";
81
+
82
+ // ─── Content-Disposition (RFC 6266 / RFC 8187) ──────────────────────────────
83
+ export { buildContentDisposition } from "./content-disposition.js";
84
+ export type { ContentDispositionOptions } from "./content-disposition.js";
85
+