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/gcs.ts ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Google Cloud Storage ObjectStore adapter for partial-content.
3
+ *
4
+ * Wraps `@google-cloud/storage` to implement the {@link ObjectStore} interface.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { Storage } from "@google-cloud/storage";
9
+ * import { gcsStore } from "partial-content/gcs";
10
+ *
11
+ * const storage = new Storage();
12
+ * const store = gcsStore({ storage, bucket: "my-bucket" });
13
+ * ```
14
+ *
15
+ * @packageDocumentation
16
+ */
17
+
18
+ import {
19
+ ObjectNotFoundError,
20
+ ObjectChangedError,
21
+ StoreUnavailableError,
22
+ classifyStoreRead,
23
+ nodeStreamToWeb,
24
+ type ObjectStore,
25
+ type ObjectMetadata,
26
+ type ObjectStream,
27
+ type ParsedRange,
28
+ type StoreErrorClassifiers,
29
+ } from "./index.js";
30
+
31
+ // Re-export for convenience
32
+ export { ObjectNotFoundError, ObjectChangedError, StoreUnavailableError };
33
+
34
+ // ─── GCS Types ──────────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Minimal GCS Storage interface.
38
+ *
39
+ * Declared locally to avoid a hard dependency on `@google-cloud/storage`.
40
+ * Users who import `partial-content/gcs` will have it installed as an
41
+ * optional peer dependency.
42
+ */
43
+ interface GcsStorage {
44
+ bucket(name: string): GcsBucket;
45
+ }
46
+
47
+ interface GcsBucket {
48
+ file(name: string, opts?: { generation?: string | number }): GcsFile;
49
+ }
50
+
51
+ interface GcsFile {
52
+ getMetadata(): Promise<[GcsFileMetadata]>;
53
+ createReadStream(opts?: { start?: number; end?: number }): NodeJS.ReadableStream & AsyncIterable<Buffer>;
54
+ }
55
+
56
+ interface GcsFileMetadata {
57
+ size: string; // GCS returns size as a string
58
+ etag?: string;
59
+ /** Object generation: changes on every overwrite. Used to pin reads. */
60
+ generation?: string | number;
61
+ updated?: string; // ISO 8601
62
+ md5Hash?: string; // base64-encoded MD5
63
+ crc32c?: string;
64
+ metadata?: Record<string, string>;
65
+ }
66
+
67
+ // ─── Options ────────────────────────────────────────────────────────────────
68
+
69
+ export interface GcsStoreOptions {
70
+ /** Pre-configured @google-cloud/storage instance. */
71
+ storage: GcsStorage;
72
+ /** GCS bucket name. */
73
+ bucket: string;
74
+ }
75
+
76
+ // ─── Factory ────────────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Create an {@link ObjectStore} backed by a Google Cloud Storage bucket.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * import { Storage } from "@google-cloud/storage";
84
+ * import { gcsStore } from "partial-content/gcs";
85
+ *
86
+ * const storage = new Storage();
87
+ * const store = gcsStore({ storage, bucket: "my-bucket" });
88
+ *
89
+ * const meta = await store.headObject("reports/q4.pdf");
90
+ * ```
91
+ */
92
+ export function gcsStore(opts: GcsStoreOptions): ObjectStore {
93
+ const bucket = opts.storage.bucket(opts.bucket);
94
+
95
+ return {
96
+ supportsRange: true,
97
+
98
+ async headObject(key: string, opts?: { signal?: AbortSignal }): Promise<ObjectMetadata> {
99
+ opts?.signal?.throwIfAborted();
100
+ const [metadata] = await classifyStoreRead(key, () => bucket.file(key).getMetadata(), gcsClassifiers);
101
+
102
+ return {
103
+ contentLength: parseInt(metadata.size, 10),
104
+ etag: metadata.etag,
105
+ lastModified: metadata.updated
106
+ ? new Date(metadata.updated).toUTCString()
107
+ : undefined,
108
+ // A generation names an immutable object version, so it can carry
109
+ // everything getObject would otherwise re-fetch. No generation
110
+ // (emulators, mocks) means no pin: getObject falls back to its own
111
+ // metadata read.
112
+ pin: metadata.generation !== undefined
113
+ ? encodeGcsPin(metadata)
114
+ : undefined,
115
+ };
116
+ },
117
+
118
+ async getObject(key: string, opts?: { range?: ParsedRange; signal?: AbortSignal; ifMatch?: string; pin?: string }): Promise<ObjectStream> {
119
+ const { range, signal, ifMatch, pin } = opts ?? {};
120
+ signal?.throwIfAborted();
121
+
122
+ // A pin from our own headObject carries the generation, size, and
123
+ // validators of the exact representation the caller validated, so the
124
+ // metadata re-fetch is skipped entirely: one backend read per GET.
125
+ // A pin whose etag disagrees with ifMatch (or that fails to decode)
126
+ // is ignored and the metadata path revalidates from scratch. When an
127
+ // ifMatch is present the pin must carry a matching etag to be trusted:
128
+ // a pin lacking the validator cannot silently satisfy the precondition,
129
+ // it falls through to the metadata path that actually enforces ifMatch.
130
+ const pinned = pin ? decodeGcsPin(pin) : null;
131
+ const usablePin = pinned && (!ifMatch || pinned.etag === ifMatch)
132
+ ? pinned
133
+ : null;
134
+
135
+ let generation: string | number | undefined;
136
+ let totalSize: number;
137
+ let etag: string | undefined;
138
+ let lastModified: string | undefined;
139
+
140
+ if (usablePin) {
141
+ generation = usablePin.generation;
142
+ totalSize = usablePin.size;
143
+ etag = usablePin.etag;
144
+ lastModified = usablePin.lastModified;
145
+ } else {
146
+ // GCS streams carry no total size, the ifMatch check needs the
147
+ // current etag, and generation pinning needs the current generation.
148
+ const [metadata] = await classifyStoreRead(key, () => bucket.file(key).getMetadata(), gcsClassifiers);
149
+
150
+ // Caller pin: the object must still match the validator captured at
151
+ // HEAD time. GCS etags change on every overwrite, so a mismatch means
152
+ // the representation the caller validated no longer exists.
153
+ if (ifMatch && metadata.etag && metadata.etag !== ifMatch) {
154
+ throw new ObjectChangedError(key);
155
+ }
156
+
157
+ generation = metadata.generation;
158
+ totalSize = parseInt(metadata.size, 10);
159
+ etag = metadata.etag;
160
+ lastModified = metadata.updated
161
+ ? new Date(metadata.updated).toUTCString()
162
+ : undefined;
163
+ }
164
+
165
+ if (range && range.start >= totalSize) {
166
+ // Unsatisfiable ranges are the orchestrator's job to reject
167
+ // (parseRangeHeader 416s them first); a direct caller gets a loud
168
+ // error instead of a truncated body under an inflated Content-Length.
169
+ throw new RangeError(
170
+ `gcsStore ${key}: range start ${range.start} is beyond object size ${totalSize}`,
171
+ );
172
+ }
173
+ // Clamp the end so contentLength and the reported range match what
174
+ // the backend stream will actually deliver (createReadStream clamps
175
+ // to EOF silently; the reported bounds must not diverge from it).
176
+ const end = range ? Math.min(range.end, totalSize - 1) : totalSize - 1;
177
+ const streamOpts = range
178
+ ? { start: range.start, end }
179
+ : undefined;
180
+
181
+ // Pin the stream to the generation just measured (or the caller's
182
+ // pinned generation): metadata and bytes come from the SAME object
183
+ // version even if it is overwritten between the two calls (reading a
184
+ // specific generation is immutable in GCS).
185
+ const pinnedFile = generation !== undefined
186
+ ? bucket.file(key, { generation })
187
+ : bucket.file(key);
188
+ const nodeStream = pinnedFile.createReadStream(streamOpts);
189
+
190
+ const contentLength = range
191
+ ? (end - range.start + 1)
192
+ : totalSize;
193
+
194
+ // nodeStreamToWeb auto-detects the stream's destroy() capability.
195
+ // expectedBytes guards a graceful short-read: a truncated GCS download
196
+ // that ends cleanly below the committed length errors the body instead
197
+ // of under-running the Content-Length undetected.
198
+ const webStream = nodeStreamToWeb(nodeStream, { signal, expectedBytes: contentLength });
199
+
200
+ return {
201
+ body: webStream,
202
+ contentLength,
203
+ totalSize,
204
+ range: range ? { start: range.start, end } : undefined,
205
+ etag,
206
+ lastModified,
207
+ };
208
+ },
209
+ };
210
+ }
211
+
212
+ // ─── Internal Helpers ───────────────────────────────────────────────────────
213
+
214
+ /** Shape carried inside the opaque {@link ObjectMetadata.pin} token. */
215
+ interface GcsPin {
216
+ generation: string | number;
217
+ size: number;
218
+ etag?: string;
219
+ lastModified?: string;
220
+ }
221
+
222
+ function encodeGcsPin(metadata: GcsFileMetadata): string {
223
+ const pin: GcsPin = {
224
+ generation: metadata.generation!,
225
+ size: parseInt(metadata.size, 10),
226
+ etag: metadata.etag,
227
+ lastModified: metadata.updated
228
+ ? new Date(metadata.updated).toUTCString()
229
+ : undefined,
230
+ };
231
+ return JSON.stringify(pin);
232
+ }
233
+
234
+ /** Decode a pin token; null for foreign/corrupt tokens (fall back to metadata). */
235
+ function decodeGcsPin(pin: string): GcsPin | null {
236
+ try {
237
+ const parsed: unknown = JSON.parse(pin);
238
+ if (
239
+ typeof parsed === "object" && parsed !== null &&
240
+ "generation" in parsed && "size" in parsed &&
241
+ typeof (parsed as GcsPin).size === "number" &&
242
+ Number.isFinite((parsed as GcsPin).size) &&
243
+ // `generation` flows straight into bucket.file(key, { generation }) and
244
+ // selects the immutable version served. Only a non-empty string or a
245
+ // finite number is a real generation; anything else (object, array,
246
+ // null) is a corrupt/hostile token that must revalidate from scratch,
247
+ // never index an arbitrary version.
248
+ isValidGeneration((parsed as GcsPin).generation)
249
+ ) {
250
+ return parsed as GcsPin;
251
+ }
252
+ } catch { /* not our token: revalidate via metadata instead */ }
253
+ return null;
254
+ }
255
+
256
+ /** A GCS generation is a non-empty string or a finite number; nothing else. */
257
+ function isValidGeneration(gen: unknown): gen is string | number {
258
+ return (typeof gen === "string" && gen.length > 0) ||
259
+ (typeof gen === "number" && Number.isFinite(gen));
260
+ }
261
+
262
+ function isGcsNotFound(err: unknown): boolean {
263
+ if (typeof err === "object" && err !== null && "code" in err) {
264
+ return (err as { code: unknown }).code === 404;
265
+ }
266
+ return false;
267
+ }
268
+
269
+ /**
270
+ * Whether a GCS error is a transient throttle/overload the client should retry
271
+ * (mapped to a 503, not a 502). The Cloud Storage client surfaces the HTTP
272
+ * status on `err.code`: 429 (rate limit) or 503 (backend unavailable).
273
+ */
274
+ function isGcsThrottled(err: unknown): boolean {
275
+ if (typeof err === "object" && err !== null && "code" in err) {
276
+ const code = (err as { code: unknown }).code;
277
+ return code === 429 || code === 503;
278
+ }
279
+ return false;
280
+ }
281
+
282
+ /**
283
+ * The error-classification set shared by both metadata reads. GCS has no
284
+ * `changed` predicate: its pin is an etag comparison (handled inline, throwing
285
+ * {@link ObjectChangedError} directly), not a native conditional error.
286
+ */
287
+ const gcsClassifiers: StoreErrorClassifiers = {
288
+ notFound: isGcsNotFound,
289
+ throttled: isGcsThrottled,
290
+ };
package/src/hono.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Hono middleware adapter for partial-content.
3
+ *
4
+ * Wraps an {@link ObjectStore} into a Hono-compatible middleware that handles
5
+ * the full HTTP file-serving protocol (200, 206, 304, 412, 416, HEAD).
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { Hono } from "hono";
10
+ * import { serveObject } from "partial-content/hono";
11
+ * import { s3Store } from "partial-content/s3";
12
+ *
13
+ * const store = s3Store({ client, bucket: "documents" });
14
+ * const app = new Hono();
15
+ *
16
+ * app.get("/files/:key", serveObject(store, {
17
+ * key: (c) => c.req.param("key"),
18
+ * disposition: "inline",
19
+ * }));
20
+ * ```
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+
25
+ import { serveObject as serveObjectWeb, type ServeObjectOptions, type ServeContext } from "./web.js";
26
+ import type { ObjectStore } from "./index.js";
27
+
28
+ // Re-export for convenience
29
+ export type { ServeObjectOptions, ServeContext } from "./web.js";
30
+ export type { ObjectStore } from "./index.js";
31
+
32
+ // ─── Hono Types ─────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Minimal Hono context interface.
36
+ *
37
+ * We declare just enough of the Hono Context shape to avoid a hard
38
+ * dependency on the `hono` package. Users who import `partial-content/hono`
39
+ * will have `hono` installed (it's an optional peer dep), but we don't
40
+ * want to force TypeScript resolution on it at build time for the
41
+ * published package.
42
+ */
43
+ interface HonoContext {
44
+ req: {
45
+ raw: Request;
46
+ param: (name: string) => string;
47
+ header: (name: string) => string | undefined;
48
+ method: string;
49
+ };
50
+ }
51
+
52
+ /** Hono handler return type. */
53
+ type HonoResponse = Response | Promise<Response>;
54
+
55
+ // ─── Options ────────────────────────────────────────────────────────────────
56
+
57
+ export interface HonoServeOptions extends ServeObjectOptions {
58
+ /**
59
+ * Extract the storage key from the Hono context.
60
+ *
61
+ * @example
62
+ * ```ts
63
+ * key: (c) => c.req.param("key")
64
+ * key: (c) => c.req.param("path")
65
+ * ```
66
+ */
67
+ key: (c: HonoContext) => string | Promise<string>;
68
+
69
+ /**
70
+ * Extract the MIME type from the Hono context.
71
+ * When omitted, defaults to "application/octet-stream".
72
+ */
73
+ mime?: (c: HonoContext) => string | undefined;
74
+
75
+ /**
76
+ * Extract the filename from the Hono context (for Content-Disposition).
77
+ * When omitted, no filename is set.
78
+ */
79
+ filename?: (c: HonoContext) => string | undefined;
80
+ }
81
+
82
+ // ─── Factory ────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Create a Hono middleware handler that serves files from an {@link ObjectStore}.
86
+ *
87
+ * Uses the standard Request/Response API under the hood, so it works on
88
+ * all Hono runtimes (Cloudflare Workers, Bun, Deno, Node.js).
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * import { Hono } from "hono";
93
+ * import { serveObject } from "partial-content/hono";
94
+ * import { s3Store } from "partial-content/s3";
95
+ *
96
+ * const app = new Hono();
97
+ * const store = s3Store({ client, bucket: "media" });
98
+ *
99
+ * app.get("/media/:key", serveObject(store, {
100
+ * key: (c) => c.req.param("key"),
101
+ * disposition: "inline",
102
+ * cacheControl: "public, max-age=31536000, immutable",
103
+ * }));
104
+ * ```
105
+ */
106
+ export function serveObject(
107
+ store: ObjectStore,
108
+ opts: HonoServeOptions,
109
+ ): (c: HonoContext) => HonoResponse {
110
+ const { key: keyFn, mime: mimeFn, filename: filenameFn, ...serveOpts } = opts;
111
+ const handler = serveObjectWeb(store, serveOpts);
112
+
113
+ return async function honoHandler(c: HonoContext): Promise<Response> {
114
+ const key = await keyFn(c);
115
+ const ctx: ServeContext = {
116
+ key,
117
+ mime: mimeFn?.(c),
118
+ filename: filenameFn?.(c),
119
+ };
120
+
121
+ return handler(c.req.raw, ctx);
122
+ };
123
+ }