zerobucket 0.0.0-stage → 0.4.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.
@@ -0,0 +1,425 @@
1
+ import { Pool } from 'pg';
2
+
3
+ /** Public data types returned by the ZeroBucket SDK. */
4
+ /** A retrieved image, ready to be served or written to disk. `data` is raw bytes, never Base64. */
5
+ interface Image {
6
+ data: Buffer;
7
+ mimeType: string;
8
+ filename: string | null;
9
+ sizeBytes: number;
10
+ width: number | null;
11
+ height: number | null;
12
+ checksumSha256: string;
13
+ }
14
+ /** Metadata about a stored image, without the pixel data. */
15
+ interface ImageMetadata {
16
+ imageId: string;
17
+ mimeType: string;
18
+ filename: string | null;
19
+ sizeBytes: number;
20
+ width: number | null;
21
+ height: number | null;
22
+ checksumSha256: string;
23
+ }
24
+ /** Outcome of one item in a `putMany()` call. Batches are best-effort, not all-or-nothing. */
25
+ interface BatchPutResult {
26
+ /** Position in the input array. */
27
+ index: number;
28
+ imageId: string | null;
29
+ error: string | null;
30
+ success: boolean;
31
+ }
32
+ /** Outcome of one id in a `getMany()` call. A missing id is not an exception here. */
33
+ interface BatchGetResult {
34
+ imageId: string;
35
+ image: Image | null;
36
+ error: string | null;
37
+ success: boolean;
38
+ }
39
+ /** Outcome of one id in a `deleteMany()` call. `deleted: false` with no error means "did not exist". */
40
+ interface BatchDeleteResult {
41
+ imageId: string;
42
+ deleted: boolean;
43
+ error: string | null;
44
+ success: boolean;
45
+ }
46
+ /** Emitted to `onOperation` after every storage operation (success or failure). */
47
+ interface OperationEvent {
48
+ operation: string;
49
+ durationMs: number;
50
+ success: boolean;
51
+ error: string | null;
52
+ retryCount: number;
53
+ }
54
+ /**
55
+ * Anything with a pg-style `query()`: a `pg.Client`, a `pg.PoolClient`, or a
56
+ * transaction handle from a wrapper. Pass one as `connection` to make a
57
+ * ZeroBucket call participate in YOUR transaction. ZeroBucket then neither
58
+ * commits, rolls back, nor retries.
59
+ */
60
+ interface Queryable {
61
+ query(text: string, values?: unknown[]): Promise<{
62
+ rows: any[];
63
+ rowCount: number | null;
64
+ }>;
65
+ }
66
+ /**
67
+ * The slice of a `better-sqlite3` Database that ZeroBucket's SQLite backend uses. Pass your own
68
+ * Database handle as `connection` to make a call part of YOUR SQLite transaction.
69
+ */
70
+ interface SqliteDatabaseLike {
71
+ prepare(sql: string): {
72
+ all(...params: unknown[]): any[];
73
+ get(...params: unknown[]): any;
74
+ run(...params: unknown[]): {
75
+ changes: number;
76
+ };
77
+ };
78
+ exec(sql: string): unknown;
79
+ }
80
+ /** What `connection` accepts: a `pg` client for the Postgres backend, a `better-sqlite3` Database for SQLite. */
81
+ type Connection = Queryable | SqliteDatabaseLike;
82
+ /** The row shape a storage backend persists (after validation and checksumming). */
83
+ interface PreparedRow {
84
+ data: Buffer;
85
+ mimeType: string;
86
+ originalFilename: string | null;
87
+ sizeBytes: number;
88
+ width: number | null;
89
+ height: number | null;
90
+ checksumSha256: string;
91
+ }
92
+
93
+ /**
94
+ * Storage backend interface.
95
+ *
96
+ * CRITICAL DESIGN RULE (same as the Python package): backends know nothing about
97
+ * images. They store and retrieve rows of bytes + metadata columns. All
98
+ * image-specific logic (validation, optimization, checksums) lives in client.ts.
99
+ *
100
+ * Every method accepts an optional `connection`. When omitted, the backend uses
101
+ * its own pool: each call commits independently and transient failures are
102
+ * retried. When provided, the backend uses it directly and does NOT commit,
103
+ * roll back or retry. That is what lets a call join the caller's transaction.
104
+ */
105
+
106
+ interface StoredRecord {
107
+ id: string;
108
+ data: Buffer;
109
+ mimeType: string;
110
+ originalFilename: string | null;
111
+ sizeBytes: number;
112
+ width: number | null;
113
+ height: number | null;
114
+ checksumSha256: string;
115
+ }
116
+ type StoredRecordMetadata = Omit<StoredRecord, "data">;
117
+ interface CallOptions {
118
+ connection?: Connection;
119
+ }
120
+ interface StreamOptions$1 extends CallOptions {
121
+ chunkSize: number;
122
+ /** Inclusive byte range [start, end]; `end` is clamped to the object size. Omit for the whole object. */
123
+ range?: {
124
+ start: number;
125
+ end: number;
126
+ };
127
+ }
128
+ interface StorageBackend {
129
+ /** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
130
+ ready(): Promise<void>;
131
+ put(row: PreparedRow, opts?: CallOptions): Promise<string>;
132
+ putMany(rows: PreparedRow[], opts?: CallOptions): Promise<string[]>;
133
+ get(id: string, opts?: CallOptions): Promise<StoredRecord | null>;
134
+ /** Missing ids are simply absent. Result order is NOT guaranteed to match input. */
135
+ getMany(ids: string[], opts?: CallOptions): Promise<StoredRecord[]>;
136
+ getMetadata(id: string, opts?: CallOptions): Promise<StoredRecordMetadata | null>;
137
+ /**
138
+ * Resolves to null if the id does not exist (checked eagerly, before any
139
+ * chunk is read). A row deleted mid-stream makes iteration throw
140
+ * StorageError rather than silently truncating.
141
+ */
142
+ getStream(id: string, opts: StreamOptions$1): Promise<AsyncIterable<Buffer> | null>;
143
+ delete(id: string, opts?: CallOptions): Promise<boolean>;
144
+ /** Returns the ids that were actually deleted. */
145
+ deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
146
+ exists(id: string, opts?: CallOptions): Promise<boolean>;
147
+ /** null = no such image, false = already tiered (no-op), true = tiered now. Needs object storage configured. */
148
+ tierToObjectStorage(id: string, opts?: CallOptions): Promise<boolean | null>;
149
+ /** Dedup-mode instances only: copy the classic table into the dedup tables (non-destructive). */
150
+ migrateClassicToDedup?(): Promise<{
151
+ imagesMigrated: number;
152
+ distinctBlobsCreated: number;
153
+ duplicateReferencesFound: number;
154
+ alreadyMigrated: number;
155
+ }>;
156
+ close(): Promise<void>;
157
+ }
158
+
159
+ /** The surface the Postgres adapter needs. Implement this to plug in a non-S3 store (or a test double). */
160
+ interface ObjectStorageLike {
161
+ readonly bucket: string;
162
+ upload(key: string, data: Buffer, opts: {
163
+ mimeType: string;
164
+ }): Promise<void>;
165
+ download(key: string): Promise<Buffer>;
166
+ /** Inclusive byte range, fetched with a real HTTP Range request. */
167
+ downloadRange(key: string, start: number, endInclusive: number): Promise<Buffer>;
168
+ /** Idempotent: deleting a missing key is not an error. */
169
+ delete(key: string): Promise<void>;
170
+ exists(key: string): Promise<boolean>;
171
+ }
172
+ interface ObjectStorageOptions {
173
+ /** Must already exist. */
174
+ bucket: string;
175
+ /** Set for anything that is not AWS S3 itself (MinIO, R2, B2, Spaces, ...). */
176
+ endpoint?: string;
177
+ /** Default: AWS_REGION / AWS_DEFAULT_REGION, else "us-east-1". Many S3-compatible services ignore it but the SDK requires one. */
178
+ region?: string;
179
+ /** Default: the SDK's standard chain (env vars, shared config, IAM role, ...). */
180
+ credentials?: {
181
+ accessKeyId: string;
182
+ secretAccessKey: string;
183
+ sessionToken?: string;
184
+ };
185
+ /** Path-style addressing (`endpoint/bucket/key`). Default: true when `endpoint` is set, else false. */
186
+ forcePathStyle?: boolean;
187
+ /** Bring your own configured S3Client (advanced). */
188
+ client?: unknown;
189
+ }
190
+ declare class ObjectStorage implements ObjectStorageLike {
191
+ private readonly opts;
192
+ readonly bucket: string;
193
+ private clientPromise;
194
+ constructor(opts: ObjectStorageOptions);
195
+ private connection;
196
+ upload(key: string, data: Buffer, { mimeType }: {
197
+ mimeType: string;
198
+ }): Promise<void>;
199
+ download(key: string): Promise<Buffer>;
200
+ downloadRange(key: string, start: number, endInclusive: number): Promise<Buffer>;
201
+ private get;
202
+ delete(key: string): Promise<void>;
203
+ exists(key: string): Promise<boolean>;
204
+ }
205
+
206
+ interface OptimizeOptions {
207
+ /** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
208
+ maxWidth?: number;
209
+ /** "jpeg" | "png" | "webp". Defaults to the source format. */
210
+ format?: string;
211
+ /** 1-100, JPEG/WebP only (ignored for PNG). */
212
+ quality?: number;
213
+ }
214
+ interface OptimizationResult {
215
+ data: Buffer;
216
+ mimeType: string;
217
+ width: number;
218
+ height: number;
219
+ sizeBytes: number;
220
+ originalSizeBytes: number;
221
+ }
222
+ declare function optimizeImage(data: Buffer, opts: OptimizeOptions & {
223
+ maxBytes: number;
224
+ maxPixels?: number;
225
+ }): Promise<OptimizationResult>;
226
+
227
+ /** Pluggable content validation (the npm equivalent of Python's ContentValidator). */
228
+ interface ValidatedContent {
229
+ mimeType: string;
230
+ sizeBytes: number;
231
+ width?: number | null;
232
+ height?: number | null;
233
+ }
234
+ /**
235
+ * Implement this to let `put()` / `putMany()` accept a content type ZeroBucket
236
+ * does not natively understand. It must throw (ideally a
237
+ * `ContentValidationError`) when the content is invalid, and must trust nothing
238
+ * but the bytes themselves.
239
+ */
240
+ interface ContentValidator {
241
+ validate(data: Buffer, opts: {
242
+ maxBytes: number;
243
+ }): ValidatedContent | Promise<ValidatedContent>;
244
+ }
245
+
246
+ /** 8 MiB: a practical ceiling for "small app" images. Override with `maxBytes`. */
247
+ declare const DEFAULT_MAX_BYTES: number;
248
+ /**
249
+ * What put() accepts:
250
+ * - Buffer / Uint8Array / ArrayBuffer: the bytes themselves
251
+ * - string: a filesystem PATH (never raw content)
252
+ * - Blob / File (e.g. from `request.formData()`)
253
+ * - Node Readable or web ReadableStream (read in bounded chunks)
254
+ */
255
+ type ImageInput = Buffer | Uint8Array | ArrayBuffer | string | Blob | AsyncIterable<Uint8Array | string>;
256
+ type BeforeGetHook<C = unknown> = (imageId: string, context: C | undefined) => boolean | Promise<boolean>;
257
+ type BeforePutHook<C = unknown> = (context: C | undefined) => boolean | Promise<boolean>;
258
+ interface ZeroBucketOptions<C = unknown> {
259
+ /** PostgreSQL connection string. */
260
+ connectionString?: string;
261
+ /** Bring your own pg.Pool. It is NOT closed by close(). */
262
+ pool?: Pool;
263
+ /**
264
+ * Use SQLite instead of PostgreSQL: a database file path (or ":memory:"). Needs `npm install better-sqlite3`.
265
+ * `dedup`, `objectStorage`, `autoMigrate` and `onOperation` apply to it too. Same file format as the Python
266
+ * package's SQLiteBackend, so both can share one `.db` file.
267
+ */
268
+ sqlite?: string;
269
+ /** Advanced: inject a custom StorageBackend instead of building one from `connectionString` / `pool` / `sqlite`. */
270
+ backend?: StorageBackend;
271
+ /** Max accepted size in bytes. Default 8 MiB. */
272
+ maxBytes?: number;
273
+ /** Decoded-pixel ceiling that rejects decompression bombs. Default ~89 MP. */
274
+ maxPixels?: number;
275
+ /** Accepted image formats. Default ["jpeg", "png", "webp"]. */
276
+ allowedFormats?: readonly string[];
277
+ autoMigrate?: boolean;
278
+ maxRetries?: number;
279
+ retryBaseDelayMs?: number;
280
+ poolMinSize?: number;
281
+ poolMaxSize?: number;
282
+ poolTimeoutMs?: number;
283
+ /** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
284
+ onOperation?: (event: OperationEvent) => void;
285
+ /**
286
+ * S3-compatible object storage, enabling `tierToObjectStorage()` and transparent
287
+ * reads of images the Python or Node package tiered. Configure it with the same
288
+ * bucket and credentials used to tier. Without it, reading a tiered image throws
289
+ * a clear StorageError.
290
+ */
291
+ objectStorage?: ObjectStorageLike;
292
+ /**
293
+ * Content-addressed mode: identical bytes are stored once and shared by reference, each image
294
+ * keeping its own id and filename. Cannot be combined with `objectStorage`. Uses separate tables
295
+ * (zerobucket_blobs / zerobucket_image_refs); see `migrateClassicToDedup()` for existing data.
296
+ */
297
+ dedup?: boolean;
298
+ /**
299
+ * Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
300
+ * Return false to deny. A hook that THROWS fails closed: the exception
301
+ * propagates (or is captured per item in getMany) and is never treated as allow.
302
+ * NOT called for exists().
303
+ */
304
+ beforeGet?: BeforeGetHook<C>;
305
+ /** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
306
+ beforePut?: BeforePutHook<C>;
307
+ }
308
+ interface PutBase<C> {
309
+ /** Display filename stored in metadata (never used for validation). */
310
+ filename?: string;
311
+ /** `true` = strip metadata only; or pass { maxWidth, format, quality } to resize/re-encode. */
312
+ optimize?: boolean | OptimizeOptions;
313
+ /** Store non-image content through the same machinery. Incompatible with `optimize`. */
314
+ validator?: ContentValidator;
315
+ /** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
316
+ connection?: Connection;
317
+ /** Passed to beforePut. */
318
+ context?: C;
319
+ }
320
+ type PutOptions<C = unknown> = PutBase<C>;
321
+ interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
322
+ /** Same length as `images`; use null/undefined entries to fall back to each input's own name. */
323
+ filenames?: (string | null | undefined)[];
324
+ }
325
+ interface GetOptions<C = unknown> {
326
+ connection?: Connection;
327
+ context?: C;
328
+ }
329
+ interface StreamOptions<C = unknown> extends GetOptions<C> {
330
+ /** Bytes per chunk. Default 1 MiB. */
331
+ chunkSize?: number;
332
+ /** Inclusive byte range, e.g. { start: 0, end: 1023 }. `end` is clamped to the object size. */
333
+ range?: {
334
+ start: number;
335
+ end?: number;
336
+ };
337
+ }
338
+ /** Anything with write(); e.g. fs.WriteStream, http.ServerResponse, or a custom sink. Backpressure is honoured. */
339
+ interface WritableLike {
340
+ write(chunk: Buffer): boolean | void | Promise<void>;
341
+ once?(event: "drain", listener: () => void): unknown;
342
+ }
343
+ declare class ZeroBucket<C = unknown> {
344
+ private readonly backend;
345
+ private readonly maxBytes;
346
+ private readonly maxPixels;
347
+ private readonly allowedFormats;
348
+ private readonly beforeGet;
349
+ private readonly beforePut;
350
+ constructor(options?: ZeroBucketOptions<C>);
351
+ /** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
352
+ static connect<C = unknown>(options?: ZeroBucketOptions<C>): Promise<ZeroBucket<C>>;
353
+ /** Resolves once connected and migrated. Called automatically on first use. */
354
+ ready(): Promise<void>;
355
+ private checkBeforeGet;
356
+ private checkBeforePut;
357
+ private prepareRow;
358
+ /** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
359
+ put(image: ImageInput, options?: PutOptions<C>): Promise<string>;
360
+ /**
361
+ * Store many images. Best-effort, not all-or-nothing: one bad image does not
362
+ * abort the rest. Check each result's `success` / `error`. Settings apply to
363
+ * every item. Valid items are inserted in ONE transaction.
364
+ */
365
+ putMany(images: ImageInput[], options?: PutManyOptions<C>): Promise<BatchPutResult[]>;
366
+ /** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
367
+ get(imageId: string, options?: GetOptions<C>): Promise<Image>;
368
+ /**
369
+ * Retrieve many images in one query. Results come back in input order; a
370
+ * missing id is `error: "not found"`, not an exception. A hook denial or hook
371
+ * exception affects only that id.
372
+ */
373
+ getMany(imageIds: string[], options?: GetOptions<C>): Promise<BatchGetResult[]>;
374
+ /**
375
+ * Stream an image's bytes in chunks without holding the whole image in memory.
376
+ * Not-found is checked eagerly: the returned promise rejects before any chunk
377
+ * is read. A concurrent delete mid-stream makes iteration throw StorageError
378
+ * rather than silently truncating. Pass `range` for partial reads.
379
+ *
380
+ * Honest limitation: reduces Node-side memory, not Postgres-side (the server
381
+ * still handles the full BYTEA value per chunk request).
382
+ */
383
+ getStream(imageId: string, options?: StreamOptions<C>): Promise<AsyncIterable<Buffer>>;
384
+ /** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
385
+ toWebStream(imageId: string, options?: StreamOptions<C>): Promise<ReadableStream<Uint8Array>>;
386
+ /** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
387
+ streamTo(imageId: string, destination: WritableLike, options?: StreamOptions<C>): Promise<number>;
388
+ /** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
389
+ metadata(imageId: string, options?: GetOptions<C>): Promise<ImageMetadata>;
390
+ /** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
391
+ exists(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
392
+ /** Delete an image. Returns true if it existed. */
393
+ delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
394
+ /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
395
+ deleteMany(imageIds: string[], options?: Pick<CallOptions, "connection">): Promise<BatchDeleteResult[]>;
396
+ /**
397
+ * Move an image's bytes from Postgres into object storage; reads, streams and
398
+ * deletes keep working transparently. Returns true if tiered now, false if it was
399
+ * already tiered (safe to re-run in a backfill). Throws ImageNotFoundError if missing.
400
+ *
401
+ * The upload runs inside the database transaction: if it fails, the row is left
402
+ * completely untouched. Explicit and opt-in: put() never tiers automatically.
403
+ * Not gated by beforeGet/beforePut (it is a maintenance operation, as in the Python package).
404
+ */
405
+ tierToObjectStorage(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
406
+ /**
407
+ * One-time, non-destructive copy of classic-mode images into a dedup instance's tables, keeping every
408
+ * id. Call it on an instance created with `dedup: true`. Safe to re-run. See PostgresBackend for details.
409
+ */
410
+ migrateClassicToDedup(): ReturnType<NonNullable<StorageBackend["migrateClassicToDedup"]>>;
411
+ /** Release the pool (if ZeroBucket created it). */
412
+ close(): Promise<void>;
413
+ }
414
+ /**
415
+ * Read any supported input into a Buffer. Streams are consumed in bounded
416
+ * chunks and rejected as soon as they pass maxBytes (reported size is then a
417
+ * lower bound), rather than buffering an arbitrarily large upload first.
418
+ * Files and Blobs are size-checked BEFORE their bytes are read.
419
+ */
420
+ declare function readInput(image: ImageInput, filename: string | undefined, maxBytes: number): Promise<{
421
+ data: Buffer;
422
+ filename: string | undefined;
423
+ }>;
424
+
425
+ export { type BatchDeleteResult as B, type CallOptions as C, DEFAULT_MAX_BYTES as D, type GetOptions as G, type Image as I, type OperationEvent as O, type PreparedRow as P, type Queryable as Q, type StorageBackend as S, type ValidatedContent as V, type WritableLike as W, ZeroBucket as Z, type ObjectStorageLike as a, type StoredRecord as b, type StoredRecordMetadata as c, type StreamOptions$1 as d, type ContentValidator as e, type BatchGetResult as f, type BatchPutResult as g, type BeforeGetHook as h, type BeforePutHook as i, type Connection as j, type ImageInput as k, type ImageMetadata as l, ObjectStorage as m, type ObjectStorageOptions as n, type OptimizationResult as o, type OptimizeOptions as p, type PutManyOptions as q, type PutOptions as r, type SqliteDatabaseLike as s, type StreamOptions as t, type ZeroBucketOptions as u, optimizeImage as v, readInput as w };
package/dist/http.cjs ADDED
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/http.ts
21
+ var http_exports = {};
22
+ __export(http_exports, {
23
+ createImageHandler: () => createImageHandler,
24
+ etagMatches: () => etagMatches,
25
+ parseRange: () => parseRange,
26
+ toNodeHandler: () => toNodeHandler
27
+ });
28
+ module.exports = __toCommonJS(http_exports);
29
+ var import_node_stream = require("stream");
30
+
31
+ // src/errors.ts
32
+ var ZeroBucketError = class extends Error {
33
+ constructor(message, options) {
34
+ super(message, options);
35
+ this.name = new.target.name;
36
+ }
37
+ };
38
+ var ImageNotFoundError = class extends ZeroBucketError {
39
+ constructor(imageId) {
40
+ super(`No image found with id ${JSON.stringify(imageId)}`);
41
+ this.imageId = imageId;
42
+ }
43
+ imageId;
44
+ };
45
+ var AccessDeniedError = class extends ZeroBucketError {
46
+ constructor(operation, imageId) {
47
+ super(
48
+ imageId !== void 0 ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook` : `${operation} denied by the beforePut hook`
49
+ );
50
+ this.operation = operation;
51
+ this.imageId = imageId;
52
+ }
53
+ operation;
54
+ imageId;
55
+ };
56
+
57
+ // src/http.ts
58
+ function createImageHandler(images, options = {}) {
59
+ const cacheControl = options.cacheControl ?? "private, no-cache";
60
+ return async function handle(req, ctx) {
61
+ if (req.method !== "GET" && req.method !== "HEAD") {
62
+ return new Response("Method Not Allowed", {
63
+ status: 405,
64
+ headers: { Allow: "GET, HEAD" }
65
+ });
66
+ }
67
+ const id = ctx?.id ?? (options.getId ?? defaultGetId)(req);
68
+ if (!id) return notFound();
69
+ try {
70
+ const context = options.getContext ? await options.getContext(req) : void 0;
71
+ const meta = await images.metadata(id, { context });
72
+ const etag = `"${meta.checksumSha256}"`;
73
+ const size = meta.sizeBytes;
74
+ const base = {
75
+ ETag: etag,
76
+ "Cache-Control": cacheControl,
77
+ "Accept-Ranges": "bytes",
78
+ "X-Content-Type-Options": "nosniff",
79
+ "Content-Type": meta.mimeType
80
+ };
81
+ if (options.disposition === "attachment") {
82
+ base["Content-Disposition"] = attachmentHeader(
83
+ meta.filename ?? "download"
84
+ );
85
+ }
86
+ if (etagMatches(req.headers.get("if-none-match"), etag)) {
87
+ return new Response(null, {
88
+ status: 304,
89
+ headers: { ETag: etag, "Cache-Control": cacheControl }
90
+ });
91
+ }
92
+ let range = null;
93
+ const rangeHeader = req.headers.get("range");
94
+ const ifRange = req.headers.get("if-range");
95
+ if (rangeHeader && (ifRange === null || ifRange.trim() === etag)) {
96
+ range = parseRange(rangeHeader, size);
97
+ }
98
+ if (range === "unsatisfiable") {
99
+ return new Response(null, {
100
+ status: 416,
101
+ headers: {
102
+ ...base,
103
+ "Content-Range": `bytes */${size}`,
104
+ "Content-Length": "0"
105
+ }
106
+ });
107
+ }
108
+ const status = range ? 206 : 200;
109
+ const length = range ? range.end - range.start + 1 : size;
110
+ const headers = {
111
+ ...base,
112
+ "Content-Length": String(length)
113
+ };
114
+ if (range)
115
+ headers["Content-Range"] = `bytes ${range.start}-${range.end}/${size}`;
116
+ if (req.method === "HEAD") return new Response(null, { status, headers });
117
+ const body = await images.toWebStream(id, {
118
+ context,
119
+ chunkSize: options.chunkSize,
120
+ range: range ? { start: range.start, end: range.end } : void 0
121
+ });
122
+ return new Response(body, { status, headers });
123
+ } catch (exc) {
124
+ if (exc instanceof ImageNotFoundError) return notFound();
125
+ if (exc instanceof AccessDeniedError)
126
+ return new Response("Forbidden", { status: 403 });
127
+ options.onError?.(exc, req);
128
+ return new Response("Internal Server Error", { status: 500 });
129
+ }
130
+ };
131
+ }
132
+ function toNodeHandler(handler) {
133
+ return async function nodeHandler(req, res, ctx) {
134
+ const host = req.headers.host ?? "localhost";
135
+ const headers = new Headers();
136
+ for (const [k, v] of Object.entries(req.headers)) {
137
+ if (Array.isArray(v)) v.forEach((x) => headers.append(k, x));
138
+ else if (v !== void 0) headers.set(k, v);
139
+ }
140
+ const request = new Request(`http://${host}${req.url ?? "/"}`, {
141
+ method: req.method,
142
+ headers
143
+ });
144
+ const response = await handler(request, ctx);
145
+ res.writeHead(response.status, Object.fromEntries(response.headers));
146
+ if (!response.body) {
147
+ res.end();
148
+ return;
149
+ }
150
+ const nodeStream = import_node_stream.Readable.fromWeb(
151
+ response.body
152
+ );
153
+ res.on("close", () => nodeStream.destroy());
154
+ nodeStream.on("error", () => res.destroy());
155
+ nodeStream.pipe(res);
156
+ };
157
+ }
158
+ function notFound() {
159
+ return new Response("Not Found", { status: 404 });
160
+ }
161
+ function defaultGetId(req) {
162
+ const segments = new URL(req.url).pathname.split("/").filter(Boolean);
163
+ const last = segments[segments.length - 1];
164
+ return last ? decodeURIComponent(last) : void 0;
165
+ }
166
+ function etagMatches(header, etag) {
167
+ if (!header) return false;
168
+ if (header.trim() === "*") return true;
169
+ const strip = (t) => t.trim().replace(/^W\//, "");
170
+ return header.split(",").some((t) => strip(t) === etag);
171
+ }
172
+ function parseRange(header, size) {
173
+ const m = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
174
+ if (!m) return null;
175
+ const [, a, b] = m;
176
+ if (a === "" && b === "") return null;
177
+ if (a === "") {
178
+ const n = Number(b);
179
+ if (n === 0) return "unsatisfiable";
180
+ return { start: Math.max(0, size - n), end: size - 1 };
181
+ }
182
+ const start = Number(a);
183
+ if (start >= size) return "unsatisfiable";
184
+ if (b === "") return { start, end: size - 1 };
185
+ const end = Number(b);
186
+ if (end < start) return null;
187
+ return { start, end: Math.min(end, size - 1) };
188
+ }
189
+ function attachmentHeader(filename) {
190
+ const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
191
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
192
+ }
193
+ // Annotate the CommonJS export names for ESM import in node:
194
+ 0 && (module.exports = {
195
+ createImageHandler,
196
+ etagMatches,
197
+ parseRange,
198
+ toNodeHandler
199
+ });
200
+ //# sourceMappingURL=http.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/http.ts","../src/errors.ts"],"sourcesContent":["/**\r\n * HTTP serving helpers: `import { createImageHandler } from \"zerobucket/http\"`.\r\n *\r\n * `createImageHandler()` returns a Web-standard `(Request) => Promise<Response>`,\r\n * which is what Next.js route handlers, Hono, Remix, SvelteKit, Bun, Deno and\r\n * Cloudflare-style runtimes all speak. `toNodeHandler()` adapts it to\r\n * Express / Fastify / plain `node:http`.\r\n *\r\n * Implements: ETag (the content SHA-256) + If-None-Match -> 304; Range /\r\n * If-Range -> 206 (single byte range) with 416 for unsatisfiable ranges;\r\n * HEAD; X-Content-Type-Options: nosniff. Bytes are streamed in chunks, never\r\n * fully buffered.\r\n */\r\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\r\nimport { Readable } from \"node:stream\";\r\nimport { AccessDeniedError, ImageNotFoundError } from \"./errors.js\";\r\nimport type { ZeroBucket } from \"./client.js\";\r\n\r\nexport type ImageHandler = (\r\n req: Request,\r\n ctx?: { id?: string },\r\n) => Promise<Response>;\r\n\r\nexport interface ImageHandlerOptions<C = unknown> {\r\n /** Extract the id from the request. Default: the last non-empty path segment. Ignored if `ctx.id` is passed. */\r\n getId?: (req: Request) => string | undefined;\r\n /** Build the context handed to your beforeGet hook (e.g. the signed-in user from cookies). */\r\n getContext?: (req: Request) => C | undefined | Promise<C | undefined>;\r\n /**\r\n * Cache-Control header. The default, \"private, no-cache\", is safe for\r\n * access-controlled images: browsers always revalidate, and the ETag makes\r\n * that a cheap 304. For public images pass e.g. \"public, max-age=31536000, immutable\"\r\n * (ids are never reused and content never mutates).\r\n */\r\n cacheControl?: string;\r\n /** \"inline\" (default) or \"attachment\" (adds the stored filename). */\r\n disposition?: \"inline\" | \"attachment\";\r\n /** Bytes per DB read while streaming. Default 1 MiB. */\r\n chunkSize?: number;\r\n /** Called with unexpected errors (anything other than not-found / denied) so you can log them. The response is a generic 500. */\r\n onError?: (error: unknown, req: Request) => void;\r\n}\r\n\r\nexport function createImageHandler<C = unknown>(\r\n images: ZeroBucket<C>,\r\n options: ImageHandlerOptions<C> = {},\r\n): ImageHandler {\r\n const cacheControl = options.cacheControl ?? \"private, no-cache\";\r\n\r\n return async function handle(req, ctx) {\r\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\r\n return new Response(\"Method Not Allowed\", {\r\n status: 405,\r\n headers: { Allow: \"GET, HEAD\" },\r\n });\r\n }\r\n\r\n const id = ctx?.id ?? (options.getId ?? defaultGetId)(req);\r\n if (!id) return notFound();\r\n\r\n try {\r\n const context = options.getContext\r\n ? await options.getContext(req)\r\n : undefined;\r\n const meta = await images.metadata(id, { context });\r\n const etag = `\"${meta.checksumSha256}\"`;\r\n const size = meta.sizeBytes;\r\n\r\n const base: Record<string, string> = {\r\n ETag: etag,\r\n \"Cache-Control\": cacheControl,\r\n \"Accept-Ranges\": \"bytes\",\r\n \"X-Content-Type-Options\": \"nosniff\",\r\n \"Content-Type\": meta.mimeType,\r\n };\r\n if (options.disposition === \"attachment\") {\r\n base[\"Content-Disposition\"] = attachmentHeader(\r\n meta.filename ?? \"download\",\r\n );\r\n }\r\n\r\n if (etagMatches(req.headers.get(\"if-none-match\"), etag)) {\r\n return new Response(null, {\r\n status: 304,\r\n headers: { ETag: etag, \"Cache-Control\": cacheControl },\r\n });\r\n }\r\n\r\n // Range is honoured unless If-Range is present and does not match our (strong) ETag.\r\n let range: ReturnType<typeof parseRange> = null;\r\n const rangeHeader = req.headers.get(\"range\");\r\n const ifRange = req.headers.get(\"if-range\");\r\n if (rangeHeader && (ifRange === null || ifRange.trim() === etag)) {\r\n range = parseRange(rangeHeader, size);\r\n }\r\n\r\n if (range === \"unsatisfiable\") {\r\n return new Response(null, {\r\n status: 416,\r\n headers: {\r\n ...base,\r\n \"Content-Range\": `bytes */${size}`,\r\n \"Content-Length\": \"0\",\r\n },\r\n });\r\n }\r\n\r\n const status = range ? 206 : 200;\r\n const length = range ? range.end - range.start + 1 : size;\r\n const headers: Record<string, string> = {\r\n ...base,\r\n \"Content-Length\": String(length),\r\n };\r\n if (range)\r\n headers[\"Content-Range\"] = `bytes ${range.start}-${range.end}/${size}`;\r\n\r\n if (req.method === \"HEAD\") return new Response(null, { status, headers });\r\n\r\n const body = await images.toWebStream(id, {\r\n context,\r\n chunkSize: options.chunkSize,\r\n range: range ? { start: range.start, end: range.end } : undefined,\r\n });\r\n return new Response(body, { status, headers });\r\n } catch (exc) {\r\n if (exc instanceof ImageNotFoundError) return notFound();\r\n if (exc instanceof AccessDeniedError)\r\n return new Response(\"Forbidden\", { status: 403 });\r\n options.onError?.(exc, req);\r\n return new Response(\"Internal Server Error\", { status: 500 });\r\n }\r\n };\r\n}\r\n\r\n// ---- Node adapter --------------------------------------------------------\r\n\r\n/**\r\n * Adapt an ImageHandler to Node's `(req, res)` signature (Express, Fastify's raw\r\n * handler, plain `http.createServer`).\r\n *\r\n * app.get(\"/images/:id\", (req, res) => nodeHandler(req, res, { id: req.params.id }));\r\n */\r\nexport function toNodeHandler(handler: ImageHandler) {\r\n return async function nodeHandler(\r\n req: IncomingMessage,\r\n res: ServerResponse,\r\n ctx?: { id?: string },\r\n ): Promise<void> {\r\n const host = req.headers.host ?? \"localhost\";\r\n const headers = new Headers();\r\n for (const [k, v] of Object.entries(req.headers)) {\r\n if (Array.isArray(v)) v.forEach((x) => headers.append(k, x));\r\n else if (v !== undefined) headers.set(k, v);\r\n }\r\n const request = new Request(`http://${host}${req.url ?? \"/\"}`, {\r\n method: req.method,\r\n headers,\r\n });\r\n const response = await handler(request, ctx);\r\n\r\n res.writeHead(response.status, Object.fromEntries(response.headers));\r\n if (!response.body) {\r\n res.end();\r\n return;\r\n }\r\n const nodeStream = Readable.fromWeb(\r\n response.body as import(\"node:stream/web\").ReadableStream,\r\n );\r\n // If the client disconnects, stop reading from the database.\r\n res.on(\"close\", () => nodeStream.destroy());\r\n nodeStream.on(\"error\", () => res.destroy());\r\n nodeStream.pipe(res);\r\n };\r\n}\r\n\r\n// ---- helpers (exported for tests) ----------------------------------------\r\n\r\nfunction notFound(): Response {\r\n return new Response(\"Not Found\", { status: 404 });\r\n}\r\n\r\nfunction defaultGetId(req: Request): string | undefined {\r\n const segments = new URL(req.url).pathname.split(\"/\").filter(Boolean);\r\n const last = segments[segments.length - 1];\r\n return last ? decodeURIComponent(last) : undefined;\r\n}\r\n\r\n/** Weak comparison, per RFC 9110 for If-None-Match. */\r\nexport function etagMatches(header: string | null, etag: string): boolean {\r\n if (!header) return false;\r\n if (header.trim() === \"*\") return true;\r\n const strip = (t: string) => t.trim().replace(/^W\\//, \"\");\r\n return header.split(\",\").some((t) => strip(t) === etag);\r\n}\r\n\r\n/**\r\n * Parse a single `bytes=` range against a resource of `size` bytes.\r\n * - returns a clamped inclusive range\r\n * - \"unsatisfiable\" -> respond 416\r\n * - null -> ignore the header and serve the full body (syntax we don't handle, incl. multi-range)\r\n */\r\nexport function parseRange(\r\n header: string,\r\n size: number,\r\n): { start: number; end: number } | \"unsatisfiable\" | null {\r\n const m = /^bytes=(\\d*)-(\\d*)$/i.exec(header.trim());\r\n if (!m) return null; // also rejects multi-range (\"bytes=0-1,5-6\")\r\n const [, a, b] = m as unknown as [string, string, string];\r\n if (a === \"\" && b === \"\") return null;\r\n\r\n if (a === \"\") {\r\n const n = Number(b); // suffix: last n bytes\r\n if (n === 0) return \"unsatisfiable\";\r\n return { start: Math.max(0, size - n), end: size - 1 };\r\n }\r\n const start = Number(a);\r\n if (start >= size) return \"unsatisfiable\";\r\n if (b === \"\") return { start, end: size - 1 };\r\n const end = Number(b);\r\n if (end < start) return null; // syntactically invalid -> ignore\r\n return { start, end: Math.min(end, size - 1) };\r\n}\r\n\r\nfunction attachmentHeader(filename: string): string {\r\n const ascii = filename.replace(/[^\\x20-\\x7e]/g, \"_\").replace(/[\"\\\\]/g, \"_\");\r\n return `attachment; filename=\"${ascii}\"; filename*=UTF-8''${encodeURIComponent(filename)}`;\r\n}\r\n","/**\r\n * Error hierarchy for ZeroBucket. Mirrors the Python package's exceptions\r\n * one-to-one so behaviour is portable across languages.\r\n *\r\n * Catch broadly with `instanceof ZeroBucketError`, or narrowly with a\r\n * subclass such as `ImageNotFoundError`.\r\n */\r\n\r\nexport class ZeroBucketError extends Error {\r\n constructor(message: string, options?: { cause?: unknown }) {\r\n super(message, options);\r\n this.name = new.target.name;\r\n }\r\n}\r\n\r\n/** Any content (image or custom-validated) failed validation. */\r\nexport class ContentValidationError extends ZeroBucketError {}\r\n\r\n/** An image failed validation (bad format, too large, corrupted, ...). */\r\nexport class ImageValidationError extends ContentValidationError {}\r\n\r\nexport class ImageTooLargeError extends ImageValidationError {\r\n constructor(\r\n public readonly sizeBytes: number,\r\n public readonly maxBytes: number,\r\n ) {\r\n super(\r\n `Image is ${sizeBytes} bytes, which exceeds the maximum of ${maxBytes} bytes`,\r\n );\r\n }\r\n}\r\n\r\nexport class UnsupportedFormatError extends ImageValidationError {\r\n constructor(\r\n public readonly detectedFormat: string | null,\r\n public readonly allowed: readonly string[],\r\n ) {\r\n super(\r\n `Detected format ${JSON.stringify(detectedFormat)} is not supported. ` +\r\n `Allowed formats: ${JSON.stringify([...allowed].sort())}`,\r\n );\r\n }\r\n}\r\n\r\n/** Image bytes could not be decoded despite a recognizable header. */\r\nexport class CorruptedImageError extends ImageValidationError {}\r\n\r\n/** `get()`, `getStream()` or `metadata()` was called with an id that does not exist. */\r\nexport class ImageNotFoundError extends ZeroBucketError {\r\n constructor(public readonly imageId: string) {\r\n super(`No image found with id ${JSON.stringify(imageId)}`);\r\n }\r\n}\r\n\r\n/** Underlying storage / database failure not covered above. */\r\nexport class StorageError extends ZeroBucketError {}\r\n\r\n/**\r\n * A `beforeGet` / `beforePut` hook denied the operation by returning false.\r\n *\r\n * If the hook itself throws, that exception propagates unchanged: it is NOT\r\n * wrapped in AccessDeniedError and NOT treated as an implicit allow. A broken\r\n * authorization check must fail closed, never open.\r\n */\r\nexport class AccessDeniedError extends ZeroBucketError {\r\n constructor(\r\n public readonly operation: string,\r\n public readonly imageId?: string,\r\n ) {\r\n super(\r\n imageId !== undefined\r\n ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook`\r\n : `${operation} denied by the beforePut hook`,\r\n );\r\n }\r\n}\r\n"],"mappings":"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","names":[]}