zerobucket 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +228 -3
- package/dist/chunk-W2C37XYV.js +68 -0
- package/dist/chunk-W2C37XYV.js.map +1 -0
- package/dist/client-C_W0t8My.d.cts +319 -0
- package/dist/client-C_W0t8My.d.ts +319 -0
- package/dist/http.cjs +200 -0
- package/dist/http.cjs.map +1 -0
- package/dist/http.d.cts +64 -0
- package/dist/http.d.ts +64 -0
- package/dist/http.js +149 -0
- package/dist/http.js.map +1 -0
- package/dist/index.cjs +1245 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +164 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +1146 -0
- package/dist/index.js.map +1 -0
- package/package.json +88 -6
|
@@ -0,0 +1,319 @@
|
|
|
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
|
+
/** The row shape a storage backend persists (after validation and checksumming). */
|
|
67
|
+
interface PreparedRow {
|
|
68
|
+
data: Buffer;
|
|
69
|
+
mimeType: string;
|
|
70
|
+
originalFilename: string | null;
|
|
71
|
+
sizeBytes: number;
|
|
72
|
+
width: number | null;
|
|
73
|
+
height: number | null;
|
|
74
|
+
checksumSha256: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Storage backend interface.
|
|
79
|
+
*
|
|
80
|
+
* CRITICAL DESIGN RULE (same as the Python package): backends know nothing about
|
|
81
|
+
* images. They store and retrieve rows of bytes + metadata columns. All
|
|
82
|
+
* image-specific logic (validation, optimization, checksums) lives in client.ts.
|
|
83
|
+
*
|
|
84
|
+
* Every method accepts an optional `connection`. When omitted, the backend uses
|
|
85
|
+
* its own pool: each call commits independently and transient failures are
|
|
86
|
+
* retried. When provided, the backend uses it directly and does NOT commit,
|
|
87
|
+
* roll back or retry. That is what lets a call join the caller's transaction.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
interface StoredRecord {
|
|
91
|
+
id: string;
|
|
92
|
+
data: Buffer;
|
|
93
|
+
mimeType: string;
|
|
94
|
+
originalFilename: string | null;
|
|
95
|
+
sizeBytes: number;
|
|
96
|
+
width: number | null;
|
|
97
|
+
height: number | null;
|
|
98
|
+
checksumSha256: string;
|
|
99
|
+
}
|
|
100
|
+
type StoredRecordMetadata = Omit<StoredRecord, "data">;
|
|
101
|
+
interface CallOptions {
|
|
102
|
+
connection?: Queryable;
|
|
103
|
+
}
|
|
104
|
+
interface StreamOptions$1 extends CallOptions {
|
|
105
|
+
chunkSize: number;
|
|
106
|
+
/** Inclusive byte range [start, end]; `end` is clamped to the object size. Omit for the whole object. */
|
|
107
|
+
range?: {
|
|
108
|
+
start: number;
|
|
109
|
+
end: number;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
interface StorageBackend {
|
|
113
|
+
/** Resolve once the backend is connected and migrated. Safe to call repeatedly. */
|
|
114
|
+
ready(): Promise<void>;
|
|
115
|
+
put(row: PreparedRow, opts?: CallOptions): Promise<string>;
|
|
116
|
+
putMany(rows: PreparedRow[], opts?: CallOptions): Promise<string[]>;
|
|
117
|
+
get(id: string, opts?: CallOptions): Promise<StoredRecord | null>;
|
|
118
|
+
/** Missing ids are simply absent. Result order is NOT guaranteed to match input. */
|
|
119
|
+
getMany(ids: string[], opts?: CallOptions): Promise<StoredRecord[]>;
|
|
120
|
+
getMetadata(id: string, opts?: CallOptions): Promise<StoredRecordMetadata | null>;
|
|
121
|
+
/**
|
|
122
|
+
* Resolves to null if the id does not exist (checked eagerly, before any
|
|
123
|
+
* chunk is read). A row deleted mid-stream makes iteration throw
|
|
124
|
+
* StorageError rather than silently truncating.
|
|
125
|
+
*/
|
|
126
|
+
getStream(id: string, opts: StreamOptions$1): Promise<AsyncIterable<Buffer> | null>;
|
|
127
|
+
delete(id: string, opts?: CallOptions): Promise<boolean>;
|
|
128
|
+
/** Returns the ids that were actually deleted. */
|
|
129
|
+
deleteMany(ids: string[], opts?: CallOptions): Promise<string[]>;
|
|
130
|
+
exists(id: string, opts?: CallOptions): Promise<boolean>;
|
|
131
|
+
close(): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface OptimizeOptions {
|
|
135
|
+
/** Downscale (aspect ratio preserved, Lanczos) if wider than this. Never upscales. */
|
|
136
|
+
maxWidth?: number;
|
|
137
|
+
/** "jpeg" | "png" | "webp". Defaults to the source format. */
|
|
138
|
+
format?: string;
|
|
139
|
+
/** 1-100, JPEG/WebP only (ignored for PNG). */
|
|
140
|
+
quality?: number;
|
|
141
|
+
}
|
|
142
|
+
interface OptimizationResult {
|
|
143
|
+
data: Buffer;
|
|
144
|
+
mimeType: string;
|
|
145
|
+
width: number;
|
|
146
|
+
height: number;
|
|
147
|
+
sizeBytes: number;
|
|
148
|
+
originalSizeBytes: number;
|
|
149
|
+
}
|
|
150
|
+
declare function optimizeImage(data: Buffer, opts: OptimizeOptions & {
|
|
151
|
+
maxBytes: number;
|
|
152
|
+
maxPixels?: number;
|
|
153
|
+
}): Promise<OptimizationResult>;
|
|
154
|
+
|
|
155
|
+
/** Pluggable content validation (the npm equivalent of Python's ContentValidator). */
|
|
156
|
+
interface ValidatedContent {
|
|
157
|
+
mimeType: string;
|
|
158
|
+
sizeBytes: number;
|
|
159
|
+
width?: number | null;
|
|
160
|
+
height?: number | null;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Implement this to let `put()` / `putMany()` accept a content type ZeroBucket
|
|
164
|
+
* does not natively understand. It must throw (ideally a
|
|
165
|
+
* `ContentValidationError`) when the content is invalid, and must trust nothing
|
|
166
|
+
* but the bytes themselves.
|
|
167
|
+
*/
|
|
168
|
+
interface ContentValidator {
|
|
169
|
+
validate(data: Buffer, opts: {
|
|
170
|
+
maxBytes: number;
|
|
171
|
+
}): ValidatedContent | Promise<ValidatedContent>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 8 MiB: a practical ceiling for "small app" images. Override with `maxBytes`. */
|
|
175
|
+
declare const DEFAULT_MAX_BYTES: number;
|
|
176
|
+
/**
|
|
177
|
+
* What put() accepts:
|
|
178
|
+
* - Buffer / Uint8Array / ArrayBuffer: the bytes themselves
|
|
179
|
+
* - string: a filesystem PATH (never raw content)
|
|
180
|
+
* - Blob / File (e.g. from `request.formData()`)
|
|
181
|
+
* - Node Readable or web ReadableStream (read in bounded chunks)
|
|
182
|
+
*/
|
|
183
|
+
type ImageInput = Buffer | Uint8Array | ArrayBuffer | string | Blob | AsyncIterable<Uint8Array | string>;
|
|
184
|
+
type BeforeGetHook<C = unknown> = (imageId: string, context: C | undefined) => boolean | Promise<boolean>;
|
|
185
|
+
type BeforePutHook<C = unknown> = (context: C | undefined) => boolean | Promise<boolean>;
|
|
186
|
+
interface ZeroBucketOptions<C = unknown> {
|
|
187
|
+
/** PostgreSQL connection string. */
|
|
188
|
+
connectionString?: string;
|
|
189
|
+
/** Bring your own pg.Pool. It is NOT closed by close(). */
|
|
190
|
+
pool?: Pool;
|
|
191
|
+
/** Advanced: inject a custom StorageBackend instead of building a PostgresBackend. */
|
|
192
|
+
backend?: StorageBackend;
|
|
193
|
+
/** Max accepted size in bytes. Default 8 MiB. */
|
|
194
|
+
maxBytes?: number;
|
|
195
|
+
/** Decoded-pixel ceiling that rejects decompression bombs. Default ~89 MP. */
|
|
196
|
+
maxPixels?: number;
|
|
197
|
+
/** Accepted image formats. Default ["jpeg", "png", "webp"]. */
|
|
198
|
+
allowedFormats?: readonly string[];
|
|
199
|
+
autoMigrate?: boolean;
|
|
200
|
+
maxRetries?: number;
|
|
201
|
+
retryBaseDelayMs?: number;
|
|
202
|
+
poolMinSize?: number;
|
|
203
|
+
poolMaxSize?: number;
|
|
204
|
+
poolTimeoutMs?: number;
|
|
205
|
+
/** Fire-and-forget observability. Exceptions thrown inside it are swallowed. */
|
|
206
|
+
onOperation?: (event: OperationEvent) => void;
|
|
207
|
+
/**
|
|
208
|
+
* Authorization hook for get / getMany (per id) / getStream / streamTo / metadata.
|
|
209
|
+
* Return false to deny. A hook that THROWS fails closed: the exception
|
|
210
|
+
* propagates (or is captured per item in getMany) and is never treated as allow.
|
|
211
|
+
* NOT called for exists().
|
|
212
|
+
*/
|
|
213
|
+
beforeGet?: BeforeGetHook<C>;
|
|
214
|
+
/** Authorization hook for put / putMany (evaluated once per call). Same fail-closed rule. */
|
|
215
|
+
beforePut?: BeforePutHook<C>;
|
|
216
|
+
}
|
|
217
|
+
interface PutBase<C> {
|
|
218
|
+
/** Display filename stored in metadata (never used for validation). */
|
|
219
|
+
filename?: string;
|
|
220
|
+
/** `true` = strip metadata only; or pass { maxWidth, format, quality } to resize/re-encode. */
|
|
221
|
+
optimize?: boolean | OptimizeOptions;
|
|
222
|
+
/** Store non-image content through the same machinery. Incompatible with `optimize`. */
|
|
223
|
+
validator?: ContentValidator;
|
|
224
|
+
/** Join your own transaction. ZeroBucket then does not commit, roll back or retry. */
|
|
225
|
+
connection?: Queryable;
|
|
226
|
+
/** Passed to beforePut. */
|
|
227
|
+
context?: C;
|
|
228
|
+
}
|
|
229
|
+
type PutOptions<C = unknown> = PutBase<C>;
|
|
230
|
+
interface PutManyOptions<C = unknown> extends Omit<PutBase<C>, "filename"> {
|
|
231
|
+
/** Same length as `images`; use null/undefined entries to fall back to each input's own name. */
|
|
232
|
+
filenames?: (string | null | undefined)[];
|
|
233
|
+
}
|
|
234
|
+
interface GetOptions<C = unknown> {
|
|
235
|
+
connection?: Queryable;
|
|
236
|
+
context?: C;
|
|
237
|
+
}
|
|
238
|
+
interface StreamOptions<C = unknown> extends GetOptions<C> {
|
|
239
|
+
/** Bytes per chunk. Default 1 MiB. */
|
|
240
|
+
chunkSize?: number;
|
|
241
|
+
/** Inclusive byte range, e.g. { start: 0, end: 1023 }. `end` is clamped to the object size. */
|
|
242
|
+
range?: {
|
|
243
|
+
start: number;
|
|
244
|
+
end?: number;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** Anything with write(); e.g. fs.WriteStream, http.ServerResponse, or a custom sink. Backpressure is honoured. */
|
|
248
|
+
interface WritableLike {
|
|
249
|
+
write(chunk: Buffer): boolean | void | Promise<void>;
|
|
250
|
+
once?(event: "drain", listener: () => void): unknown;
|
|
251
|
+
}
|
|
252
|
+
declare class ZeroBucket<C = unknown> {
|
|
253
|
+
private readonly backend;
|
|
254
|
+
private readonly maxBytes;
|
|
255
|
+
private readonly maxPixels;
|
|
256
|
+
private readonly allowedFormats;
|
|
257
|
+
private readonly beforeGet;
|
|
258
|
+
private readonly beforePut;
|
|
259
|
+
constructor(options?: ZeroBucketOptions<C>);
|
|
260
|
+
/** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
|
|
261
|
+
static connect<C = unknown>(options?: ZeroBucketOptions<C>): Promise<ZeroBucket<C>>;
|
|
262
|
+
/** Resolves once connected and migrated. Called automatically on first use. */
|
|
263
|
+
ready(): Promise<void>;
|
|
264
|
+
private checkBeforeGet;
|
|
265
|
+
private checkBeforePut;
|
|
266
|
+
private prepareRow;
|
|
267
|
+
/** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
|
|
268
|
+
put(image: ImageInput, options?: PutOptions<C>): Promise<string>;
|
|
269
|
+
/**
|
|
270
|
+
* Store many images. Best-effort, not all-or-nothing: one bad image does not
|
|
271
|
+
* abort the rest. Check each result's `success` / `error`. Settings apply to
|
|
272
|
+
* every item. Valid items are inserted in ONE transaction.
|
|
273
|
+
*/
|
|
274
|
+
putMany(images: ImageInput[], options?: PutManyOptions<C>): Promise<BatchPutResult[]>;
|
|
275
|
+
/** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
|
|
276
|
+
get(imageId: string, options?: GetOptions<C>): Promise<Image>;
|
|
277
|
+
/**
|
|
278
|
+
* Retrieve many images in one query. Results come back in input order; a
|
|
279
|
+
* missing id is `error: "not found"`, not an exception. A hook denial or hook
|
|
280
|
+
* exception affects only that id.
|
|
281
|
+
*/
|
|
282
|
+
getMany(imageIds: string[], options?: GetOptions<C>): Promise<BatchGetResult[]>;
|
|
283
|
+
/**
|
|
284
|
+
* Stream an image's bytes in chunks without holding the whole image in memory.
|
|
285
|
+
* Not-found is checked eagerly: the returned promise rejects before any chunk
|
|
286
|
+
* is read. A concurrent delete mid-stream makes iteration throw StorageError
|
|
287
|
+
* rather than silently truncating. Pass `range` for partial reads.
|
|
288
|
+
*
|
|
289
|
+
* Honest limitation: reduces Node-side memory, not Postgres-side (the server
|
|
290
|
+
* still handles the full BYTEA value per chunk request).
|
|
291
|
+
*/
|
|
292
|
+
getStream(imageId: string, options?: StreamOptions<C>): Promise<AsyncIterable<Buffer>>;
|
|
293
|
+
/** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
|
|
294
|
+
toWebStream(imageId: string, options?: StreamOptions<C>): Promise<ReadableStream<Uint8Array>>;
|
|
295
|
+
/** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
|
|
296
|
+
streamTo(imageId: string, destination: WritableLike, options?: StreamOptions<C>): Promise<number>;
|
|
297
|
+
/** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
|
|
298
|
+
metadata(imageId: string, options?: GetOptions<C>): Promise<ImageMetadata>;
|
|
299
|
+
/** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
|
|
300
|
+
exists(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
|
|
301
|
+
/** Delete an image. Returns true if it existed. */
|
|
302
|
+
delete(imageId: string, options?: Pick<CallOptions, "connection">): Promise<boolean>;
|
|
303
|
+
/** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
|
|
304
|
+
deleteMany(imageIds: string[], options?: Pick<CallOptions, "connection">): Promise<BatchDeleteResult[]>;
|
|
305
|
+
/** Release the pool (if ZeroBucket created it). */
|
|
306
|
+
close(): Promise<void>;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Read any supported input into a Buffer. Streams are consumed in bounded
|
|
310
|
+
* chunks and rejected as soon as they pass maxBytes (reported size is then a
|
|
311
|
+
* lower bound), rather than buffering an arbitrarily large upload first.
|
|
312
|
+
* Files and Blobs are size-checked BEFORE their bytes are read.
|
|
313
|
+
*/
|
|
314
|
+
declare function readInput(image: ImageInput, filename: string | undefined, maxBytes: number): Promise<{
|
|
315
|
+
data: Buffer;
|
|
316
|
+
filename: string | undefined;
|
|
317
|
+
}>;
|
|
318
|
+
|
|
319
|
+
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 StoredRecord as a, type StoredRecordMetadata as b, type StreamOptions$1 as c, type ContentValidator as d, type BatchGetResult as e, type BatchPutResult as f, type BeforeGetHook as g, type BeforePutHook as h, type ImageInput as i, type ImageMetadata as j, type OptimizationResult as k, type OptimizeOptions as l, type PutManyOptions as m, type PutOptions as n, type StreamOptions as o, type ZeroBucketOptions as p, optimizeImage as q, readInput as r };
|
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":[]}
|
package/dist/http.d.cts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { Z as ZeroBucket } from './client-C_W0t8My.cjs';
|
|
3
|
+
import 'pg';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTTP serving helpers: `import { createImageHandler } from "zerobucket/http"`.
|
|
7
|
+
*
|
|
8
|
+
* `createImageHandler()` returns a Web-standard `(Request) => Promise<Response>`,
|
|
9
|
+
* which is what Next.js route handlers, Hono, Remix, SvelteKit, Bun, Deno and
|
|
10
|
+
* Cloudflare-style runtimes all speak. `toNodeHandler()` adapts it to
|
|
11
|
+
* Express / Fastify / plain `node:http`.
|
|
12
|
+
*
|
|
13
|
+
* Implements: ETag (the content SHA-256) + If-None-Match -> 304; Range /
|
|
14
|
+
* If-Range -> 206 (single byte range) with 416 for unsatisfiable ranges;
|
|
15
|
+
* HEAD; X-Content-Type-Options: nosniff. Bytes are streamed in chunks, never
|
|
16
|
+
* fully buffered.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type ImageHandler = (req: Request, ctx?: {
|
|
20
|
+
id?: string;
|
|
21
|
+
}) => Promise<Response>;
|
|
22
|
+
interface ImageHandlerOptions<C = unknown> {
|
|
23
|
+
/** Extract the id from the request. Default: the last non-empty path segment. Ignored if `ctx.id` is passed. */
|
|
24
|
+
getId?: (req: Request) => string | undefined;
|
|
25
|
+
/** Build the context handed to your beforeGet hook (e.g. the signed-in user from cookies). */
|
|
26
|
+
getContext?: (req: Request) => C | undefined | Promise<C | undefined>;
|
|
27
|
+
/**
|
|
28
|
+
* Cache-Control header. The default, "private, no-cache", is safe for
|
|
29
|
+
* access-controlled images: browsers always revalidate, and the ETag makes
|
|
30
|
+
* that a cheap 304. For public images pass e.g. "public, max-age=31536000, immutable"
|
|
31
|
+
* (ids are never reused and content never mutates).
|
|
32
|
+
*/
|
|
33
|
+
cacheControl?: string;
|
|
34
|
+
/** "inline" (default) or "attachment" (adds the stored filename). */
|
|
35
|
+
disposition?: "inline" | "attachment";
|
|
36
|
+
/** Bytes per DB read while streaming. Default 1 MiB. */
|
|
37
|
+
chunkSize?: number;
|
|
38
|
+
/** Called with unexpected errors (anything other than not-found / denied) so you can log them. The response is a generic 500. */
|
|
39
|
+
onError?: (error: unknown, req: Request) => void;
|
|
40
|
+
}
|
|
41
|
+
declare function createImageHandler<C = unknown>(images: ZeroBucket<C>, options?: ImageHandlerOptions<C>): ImageHandler;
|
|
42
|
+
/**
|
|
43
|
+
* Adapt an ImageHandler to Node's `(req, res)` signature (Express, Fastify's raw
|
|
44
|
+
* handler, plain `http.createServer`).
|
|
45
|
+
*
|
|
46
|
+
* app.get("/images/:id", (req, res) => nodeHandler(req, res, { id: req.params.id }));
|
|
47
|
+
*/
|
|
48
|
+
declare function toNodeHandler(handler: ImageHandler): (req: IncomingMessage, res: ServerResponse, ctx?: {
|
|
49
|
+
id?: string;
|
|
50
|
+
}) => Promise<void>;
|
|
51
|
+
/** Weak comparison, per RFC 9110 for If-None-Match. */
|
|
52
|
+
declare function etagMatches(header: string | null, etag: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Parse a single `bytes=` range against a resource of `size` bytes.
|
|
55
|
+
* - returns a clamped inclusive range
|
|
56
|
+
* - "unsatisfiable" -> respond 416
|
|
57
|
+
* - null -> ignore the header and serve the full body (syntax we don't handle, incl. multi-range)
|
|
58
|
+
*/
|
|
59
|
+
declare function parseRange(header: string, size: number): {
|
|
60
|
+
start: number;
|
|
61
|
+
end: number;
|
|
62
|
+
} | "unsatisfiable" | null;
|
|
63
|
+
|
|
64
|
+
export { type ImageHandler, type ImageHandlerOptions, createImageHandler, etagMatches, parseRange, toNodeHandler };
|