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/dist/index.cjs ADDED
@@ -0,0 +1,1245 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ AccessDeniedError: () => AccessDeniedError,
34
+ ContentValidationError: () => ContentValidationError,
35
+ CorruptedImageError: () => CorruptedImageError,
36
+ DEFAULT_MAX_BYTES: () => DEFAULT_MAX_BYTES,
37
+ DEFAULT_MAX_PIXELS: () => DEFAULT_MAX_PIXELS,
38
+ DEFAULT_STREAM_CHUNK_SIZE: () => DEFAULT_STREAM_CHUNK_SIZE,
39
+ ImageNotFoundError: () => ImageNotFoundError,
40
+ ImageTooLargeError: () => ImageTooLargeError,
41
+ ImageValidationError: () => ImageValidationError,
42
+ PDFValidator: () => PDFValidator,
43
+ PostgresBackend: () => PostgresBackend,
44
+ SUPPORTED_FORMATS: () => SUPPORTED_FORMATS,
45
+ StorageError: () => StorageError,
46
+ UnsupportedFormatError: () => UnsupportedFormatError,
47
+ ZeroBucket: () => ZeroBucket,
48
+ ZeroBucketError: () => ZeroBucketError,
49
+ optimizeImage: () => optimizeImage,
50
+ readInput: () => readInput,
51
+ validateImage: () => validateImage
52
+ });
53
+ module.exports = __toCommonJS(src_exports);
54
+
55
+ // src/client.ts
56
+ var import_node_crypto = require("crypto");
57
+ var import_promises = require("fs/promises");
58
+ var import_node_path = require("path");
59
+
60
+ // src/errors.ts
61
+ var ZeroBucketError = class extends Error {
62
+ constructor(message, options) {
63
+ super(message, options);
64
+ this.name = new.target.name;
65
+ }
66
+ };
67
+ var ContentValidationError = class extends ZeroBucketError {
68
+ };
69
+ var ImageValidationError = class extends ContentValidationError {
70
+ };
71
+ var ImageTooLargeError = class extends ImageValidationError {
72
+ constructor(sizeBytes, maxBytes) {
73
+ super(
74
+ `Image is ${sizeBytes} bytes, which exceeds the maximum of ${maxBytes} bytes`
75
+ );
76
+ this.sizeBytes = sizeBytes;
77
+ this.maxBytes = maxBytes;
78
+ }
79
+ sizeBytes;
80
+ maxBytes;
81
+ };
82
+ var UnsupportedFormatError = class extends ImageValidationError {
83
+ constructor(detectedFormat, allowed) {
84
+ super(
85
+ `Detected format ${JSON.stringify(detectedFormat)} is not supported. Allowed formats: ${JSON.stringify([...allowed].sort())}`
86
+ );
87
+ this.detectedFormat = detectedFormat;
88
+ this.allowed = allowed;
89
+ }
90
+ detectedFormat;
91
+ allowed;
92
+ };
93
+ var CorruptedImageError = class extends ImageValidationError {
94
+ };
95
+ var ImageNotFoundError = class extends ZeroBucketError {
96
+ constructor(imageId) {
97
+ super(`No image found with id ${JSON.stringify(imageId)}`);
98
+ this.imageId = imageId;
99
+ }
100
+ imageId;
101
+ };
102
+ var StorageError = class extends ZeroBucketError {
103
+ };
104
+ var AccessDeniedError = class extends ZeroBucketError {
105
+ constructor(operation, imageId) {
106
+ super(
107
+ imageId !== void 0 ? `${operation} denied for image ${JSON.stringify(imageId)} by the beforeGet hook` : `${operation} denied by the beforePut hook`
108
+ );
109
+ this.operation = operation;
110
+ this.imageId = imageId;
111
+ }
112
+ operation;
113
+ imageId;
114
+ };
115
+
116
+ // src/adapters/postgres.ts
117
+ var DEFAULT_STREAM_CHUNK_SIZE = 1024 * 1024;
118
+ var SCHEMA = `
119
+ CREATE TABLE IF NOT EXISTS zerobucket_images (
120
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
121
+ data BYTEA NOT NULL,
122
+ mime_type TEXT NOT NULL,
123
+ original_filename TEXT,
124
+ size_bytes INTEGER NOT NULL,
125
+ width INTEGER,
126
+ height INTEGER,
127
+ checksum_sha256 CHAR(64) NOT NULL,
128
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
129
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
130
+ );
131
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_checksum ON zerobucket_images (checksum_sha256);
132
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_created_at ON zerobucket_images (created_at);
133
+
134
+ -- Object-storage tiering columns (additive; identical to the Python package).
135
+ ALTER TABLE zerobucket_images ALTER COLUMN data DROP NOT NULL;
136
+ ALTER TABLE zerobucket_images
137
+ ADD COLUMN IF NOT EXISTS storage_backend TEXT NOT NULL DEFAULT 'postgres';
138
+ ALTER TABLE zerobucket_images ADD COLUMN IF NOT EXISTS object_storage_bucket TEXT;
139
+ ALTER TABLE zerobucket_images ADD COLUMN IF NOT EXISTS object_storage_key TEXT;
140
+ DO $$
141
+ BEGIN
142
+ IF NOT EXISTS (
143
+ SELECT 1 FROM pg_constraint WHERE conname = 'zerobucket_storage_location_check'
144
+ ) THEN
145
+ ALTER TABLE zerobucket_images ADD CONSTRAINT zerobucket_storage_location_check
146
+ CHECK (
147
+ (storage_backend = 'postgres'
148
+ AND data IS NOT NULL
149
+ AND object_storage_key IS NULL
150
+ AND object_storage_bucket IS NULL)
151
+ OR
152
+ (storage_backend = 'object_storage'
153
+ AND data IS NULL
154
+ AND object_storage_key IS NOT NULL
155
+ AND object_storage_bucket IS NOT NULL)
156
+ );
157
+ END IF;
158
+ END $$;
159
+ CREATE INDEX IF NOT EXISTS idx_zerobucket_storage_backend
160
+ ON zerobucket_images (storage_backend) WHERE storage_backend != 'postgres';
161
+ `;
162
+ var INSERT = `
163
+ INSERT INTO zerobucket_images
164
+ (data, mime_type, original_filename, size_bytes, width, height, checksum_sha256)
165
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
166
+ RETURNING id;`;
167
+ var SELECT_FULL = `
168
+ SELECT id, data, mime_type, original_filename, size_bytes, width, height,
169
+ checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
170
+ FROM zerobucket_images WHERE id = $1;`;
171
+ var SELECT_MANY = `
172
+ SELECT id, data, mime_type, original_filename, size_bytes, width, height,
173
+ checksum_sha256, storage_backend, object_storage_bucket, object_storage_key
174
+ FROM zerobucket_images WHERE id = ANY($1::uuid[]);`;
175
+ var SELECT_METADATA = `
176
+ SELECT id, mime_type, original_filename, size_bytes, width, height, checksum_sha256
177
+ FROM zerobucket_images WHERE id = $1;`;
178
+ var SELECT_CHUNK = `SELECT substring(data FROM $1::int FOR $2::int) AS chunk FROM zerobucket_images WHERE id = $3;`;
179
+ var SELECT_STREAM_INFO = `SELECT size_bytes, storage_backend, object_storage_key FROM zerobucket_images WHERE id = $1;`;
180
+ var DELETE_RETURNING = `DELETE FROM zerobucket_images WHERE id = $1 RETURNING storage_backend, object_storage_key;`;
181
+ var DELETE_MANY = `DELETE FROM zerobucket_images WHERE id = ANY($1::uuid[]) RETURNING id, storage_backend, object_storage_key;`;
182
+ var EXISTS = `SELECT 1 FROM zerobucket_images WHERE id = $1;`;
183
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
184
+ function isValidId(id) {
185
+ return typeof id === "string" && UUID_RE.test(id);
186
+ }
187
+ var RETRYABLE_SQLSTATES = /* @__PURE__ */ new Set([
188
+ "40001",
189
+ // serialization_failure
190
+ "40P01",
191
+ // deadlock_detected
192
+ "08000",
193
+ // connection_exception
194
+ "08003",
195
+ // connection_does_not_exist
196
+ "08006",
197
+ // connection_failure
198
+ "08001",
199
+ // sqlclient_unable_to_establish_sqlconnection
200
+ "08004",
201
+ // sqlserver_rejected_establishment_of_sqlconnection
202
+ "57P01",
203
+ // admin_shutdown
204
+ "57P02",
205
+ // crash_shutdown
206
+ "57P03",
207
+ // cannot_connect_now
208
+ "53000",
209
+ // insufficient_resources
210
+ "53300"
211
+ // too_many_connections
212
+ ]);
213
+ var RETRYABLE_NODE_CODES = /* @__PURE__ */ new Set([
214
+ "ECONNRESET",
215
+ "ECONNREFUSED",
216
+ "ETIMEDOUT",
217
+ "EPIPE",
218
+ "ECONNABORTED"
219
+ ]);
220
+ var CONNECTION_MESSAGE_RE = /connection terminated|connection timeout|timeout exceeded when trying to connect|client has encountered a connection error/i;
221
+ function isRetryable(exc) {
222
+ if (!(exc instanceof Error)) return false;
223
+ const code = exc.code;
224
+ if (typeof code === "string") {
225
+ if (RETRYABLE_SQLSTATES.has(code) || RETRYABLE_NODE_CODES.has(code))
226
+ return true;
227
+ }
228
+ return CONNECTION_MESSAGE_RE.test(exc.message);
229
+ }
230
+ function isConnectionLevel(exc) {
231
+ if (!(exc instanceof Error)) return false;
232
+ const code = exc.code;
233
+ if (typeof code === "string" && (RETRYABLE_NODE_CODES.has(code) || code.startsWith("08") || code.startsWith("57"))) {
234
+ return true;
235
+ }
236
+ return CONNECTION_MESSAGE_RE.test(exc.message);
237
+ }
238
+ var MAX_BACKOFF_MS = 2e3;
239
+ function backoffDelayMs(attempt, baseDelayMs) {
240
+ const exponential = baseDelayMs * 2 ** (attempt - 1);
241
+ const jitter = Math.random() * baseDelayMs;
242
+ return Math.min(exponential + jitter, MAX_BACKOFF_MS);
243
+ }
244
+ var PostgresBackend = class {
245
+ constructor(opts) {
246
+ this.opts = opts;
247
+ if (!opts.connectionString && !opts.pool) {
248
+ throw new TypeError(
249
+ "PostgresBackend requires either `connectionString` or `pool`"
250
+ );
251
+ }
252
+ this.ownsPool = !opts.pool;
253
+ this.pool = opts.pool;
254
+ this.maxRetries = opts.maxRetries ?? 3;
255
+ this.retryBaseDelayMs = opts.retryBaseDelayMs ?? 100;
256
+ this.autoMigrate = opts.autoMigrate ?? true;
257
+ }
258
+ opts;
259
+ pool;
260
+ ownsPool;
261
+ readyPromise;
262
+ closed = false;
263
+ maxRetries;
264
+ retryBaseDelayMs;
265
+ autoMigrate;
266
+ // ---- lifecycle -------------------------------------------------------
267
+ ready() {
268
+ if (this.closed)
269
+ return Promise.reject(
270
+ new StorageError("This ZeroBucket instance has been closed")
271
+ );
272
+ this.readyPromise ??= this.init().catch((exc) => {
273
+ this.readyPromise = void 0;
274
+ throw exc;
275
+ });
276
+ return this.readyPromise;
277
+ }
278
+ async init() {
279
+ if (!this.pool) {
280
+ let pg;
281
+ try {
282
+ const mod = await import("pg");
283
+ pg = mod.default ?? mod;
284
+ } catch (cause) {
285
+ throw new StorageError(
286
+ "PostgresBackend requires the 'pg' package. Install it with: npm install pg",
287
+ { cause }
288
+ );
289
+ }
290
+ const config = {
291
+ connectionString: this.opts.connectionString,
292
+ min: this.opts.poolMinSize ?? 1,
293
+ max: this.opts.poolMaxSize ?? 5,
294
+ connectionTimeoutMillis: this.opts.poolTimeoutMs ?? 1e4
295
+ };
296
+ this.pool = new pg.Pool(config);
297
+ this.pool.on("error", () => {
298
+ });
299
+ }
300
+ try {
301
+ if (this.autoMigrate) await this.migrate();
302
+ else await this.pool.query("SELECT 1");
303
+ } catch (exc) {
304
+ if (this.ownsPool) {
305
+ await this.pool.end().catch(() => {
306
+ });
307
+ this.pool = void 0;
308
+ }
309
+ throw exc instanceof StorageError ? exc : new StorageError(`Could not connect to PostgreSQL: ${msg(exc)}`, {
310
+ cause: exc
311
+ });
312
+ }
313
+ }
314
+ /** Create the schema if missing. Serialized across processes with an advisory lock. */
315
+ async migrate() {
316
+ const pool = this.pool;
317
+ let client;
318
+ try {
319
+ client = await pool.connect();
320
+ } catch (exc) {
321
+ throw new StorageError(`Could not connect to PostgreSQL: ${msg(exc)}`, {
322
+ cause: exc
323
+ });
324
+ }
325
+ try {
326
+ await client.query("BEGIN");
327
+ await client.query(
328
+ "SELECT pg_advisory_xact_lock(hashtext('zerobucket_migrate'))"
329
+ );
330
+ await client.query(SCHEMA);
331
+ await client.query("COMMIT");
332
+ client.release();
333
+ } catch (exc) {
334
+ await client.query("ROLLBACK").catch(() => {
335
+ });
336
+ client.release(true);
337
+ throw new StorageError(`Migration failed: ${msg(exc)}`, { cause: exc });
338
+ }
339
+ }
340
+ async close() {
341
+ this.closed = true;
342
+ const pool = this.pool;
343
+ this.pool = void 0;
344
+ this.readyPromise = void 0;
345
+ if (pool && this.ownsPool) await pool.end();
346
+ }
347
+ // ---- execution core --------------------------------------------------
348
+ emit(operation, start, success, error, retryCount) {
349
+ const cb = this.opts.onOperation;
350
+ if (!cb) return;
351
+ try {
352
+ cb({
353
+ operation,
354
+ durationMs: performance.now() - start,
355
+ success,
356
+ error,
357
+ retryCount
358
+ });
359
+ } catch {
360
+ }
361
+ }
362
+ /**
363
+ * Run `work` and return its result, emitting exactly one OperationEvent.
364
+ *
365
+ * connection given -> run once on it, no BEGIN/COMMIT, no retry.
366
+ * connection absent -> pooled; `tx` wraps the work in BEGIN/COMMIT; transient
367
+ * failures retry the WHOLE closure on a fresh connection.
368
+ */
369
+ async run(operation, connection, work, { tx = false } = {}) {
370
+ if (connection) {
371
+ const start2 = performance.now();
372
+ try {
373
+ const result = await work(connection);
374
+ this.emit(operation, start2, true, null, 0);
375
+ return result;
376
+ } catch (exc) {
377
+ this.emit(operation, start2, false, msg(exc), 0);
378
+ throw exc;
379
+ }
380
+ }
381
+ await this.ready();
382
+ const start = performance.now();
383
+ let attempt = 0;
384
+ for (; ; ) {
385
+ let client;
386
+ let destroy = false;
387
+ try {
388
+ client = await this.pool.connect();
389
+ if (tx) await client.query("BEGIN");
390
+ const result = await work(client);
391
+ if (tx) await client.query("COMMIT");
392
+ this.emit(operation, start, true, null, attempt);
393
+ return result;
394
+ } catch (exc) {
395
+ if (client && tx) {
396
+ try {
397
+ await client.query("ROLLBACK");
398
+ } catch {
399
+ destroy = true;
400
+ }
401
+ }
402
+ if (isConnectionLevel(exc)) destroy = true;
403
+ attempt += 1;
404
+ if (attempt > this.maxRetries || !isRetryable(exc)) {
405
+ this.emit(operation, start, false, msg(exc), attempt - 1);
406
+ throw exc;
407
+ }
408
+ } finally {
409
+ client?.release(destroy);
410
+ }
411
+ await sleep(backoffDelayMs(attempt, this.retryBaseDelayMs));
412
+ }
413
+ }
414
+ // ---- StorageBackend --------------------------------------------------
415
+ async put(row, { connection } = {}) {
416
+ try {
417
+ return await this.run("put", connection, async (q) => {
418
+ const res = await q.query(INSERT, rowParams(row));
419
+ return String(res.rows[0].id);
420
+ });
421
+ } catch (exc) {
422
+ throw wrap("Failed to store image", exc);
423
+ }
424
+ }
425
+ async putMany(rows, { connection } = {}) {
426
+ if (rows.length === 0) return [];
427
+ try {
428
+ return await this.run(
429
+ "put_many",
430
+ connection,
431
+ async (q) => {
432
+ const ids = [];
433
+ for (const r of rows)
434
+ ids.push(String((await q.query(INSERT, rowParams(r))).rows[0].id));
435
+ return ids;
436
+ },
437
+ { tx: true }
438
+ );
439
+ } catch (exc) {
440
+ throw wrap("Failed to store image batch", exc);
441
+ }
442
+ }
443
+ async get(id, { connection } = {}) {
444
+ if (!isValidId(id)) return null;
445
+ let row;
446
+ try {
447
+ row = await this.run(
448
+ "get",
449
+ connection,
450
+ async (q) => (await q.query(SELECT_FULL, [id])).rows[0]
451
+ );
452
+ } catch (exc) {
453
+ throw wrap("Failed to retrieve image", exc);
454
+ }
455
+ return row ? this.toRecord(row, id) : null;
456
+ }
457
+ async getMany(ids, { connection } = {}) {
458
+ const valid = ids.filter(isValidId);
459
+ if (valid.length === 0) return [];
460
+ let rows;
461
+ try {
462
+ rows = await this.run(
463
+ "get_many",
464
+ connection,
465
+ async (q) => (await q.query(SELECT_MANY, [valid])).rows
466
+ );
467
+ } catch (exc) {
468
+ throw wrap("Failed to retrieve image batch", exc);
469
+ }
470
+ return rows.map((r) => this.toRecord(r, String(r.id)));
471
+ }
472
+ async getMetadata(id, { connection } = {}) {
473
+ if (!isValidId(id)) return null;
474
+ let row;
475
+ try {
476
+ row = await this.run(
477
+ "get_metadata",
478
+ connection,
479
+ async (q) => (await q.query(SELECT_METADATA, [id])).rows[0]
480
+ );
481
+ } catch (exc) {
482
+ throw wrap("Failed to retrieve image metadata", exc);
483
+ }
484
+ if (!row) return null;
485
+ return {
486
+ id: String(row.id),
487
+ mimeType: row.mime_type,
488
+ originalFilename: row.original_filename,
489
+ sizeBytes: row.size_bytes,
490
+ width: row.width,
491
+ height: row.height,
492
+ checksumSha256: row.checksum_sha256
493
+ };
494
+ }
495
+ async getStream(id, opts) {
496
+ if (!isValidId(id)) return null;
497
+ const { connection, chunkSize } = opts;
498
+ if (!Number.isInteger(chunkSize) || chunkSize <= 0)
499
+ throw new RangeError("chunkSize must be a positive integer");
500
+ let info;
501
+ try {
502
+ info = await this.run(
503
+ "get_metadata",
504
+ connection,
505
+ async (q) => (await q.query(SELECT_STREAM_INFO, [id])).rows[0]
506
+ );
507
+ } catch (exc) {
508
+ throw wrap("Failed to retrieve image metadata", exc);
509
+ }
510
+ if (!info) return null;
511
+ if (info.storage_backend === "object_storage")
512
+ throw this.tieredWithoutStorage(id, info.object_storage_key);
513
+ const totalSize = info.size_bytes;
514
+ const start = opts.range?.start ?? 0;
515
+ const end = Math.min(opts.range?.end ?? totalSize - 1, totalSize - 1);
516
+ const self = this;
517
+ async function* generate() {
518
+ let offset = start;
519
+ let delivered = 0;
520
+ const wanted = Math.max(0, end - start + 1);
521
+ while (delivered < wanted) {
522
+ const length = Math.min(chunkSize, wanted - delivered);
523
+ let row;
524
+ try {
525
+ row = await self.run(
526
+ "get_stream",
527
+ connection,
528
+ async (q) => (await q.query(SELECT_CHUNK, [offset + 1, length, id])).rows[0]
529
+ );
530
+ } catch (exc) {
531
+ throw wrap("Failed to stream image", exc);
532
+ }
533
+ if (!row) {
534
+ throw new StorageError(
535
+ `Image ${JSON.stringify(id)} was deleted while streaming (delivered ${delivered} of ${wanted} bytes). Pass connection with your own open transaction if you need a consistent read across concurrent writers.`
536
+ );
537
+ }
538
+ const chunk = row.chunk;
539
+ if (!chunk || chunk.length === 0) {
540
+ throw new StorageError(
541
+ `Image ${JSON.stringify(id)} ended early while streaming (delivered ${delivered} of ${wanted} bytes).`
542
+ );
543
+ }
544
+ yield chunk;
545
+ offset += chunk.length;
546
+ delivered += chunk.length;
547
+ }
548
+ }
549
+ return generate();
550
+ }
551
+ async delete(id, { connection } = {}) {
552
+ if (!isValidId(id)) return false;
553
+ try {
554
+ const row = await this.run(
555
+ "delete",
556
+ connection,
557
+ async (q) => (await q.query(DELETE_RETURNING, [id])).rows[0]
558
+ );
559
+ return row !== void 0;
560
+ } catch (exc) {
561
+ throw wrap("Failed to delete image", exc);
562
+ }
563
+ }
564
+ async deleteMany(ids, { connection } = {}) {
565
+ const valid = ids.filter(isValidId);
566
+ if (valid.length === 0) return [];
567
+ try {
568
+ const rows = await this.run(
569
+ "delete_many",
570
+ connection,
571
+ async (q) => (await q.query(DELETE_MANY, [valid])).rows
572
+ );
573
+ return rows.map((r) => String(r.id));
574
+ } catch (exc) {
575
+ throw wrap("Failed to delete image batch", exc);
576
+ }
577
+ }
578
+ async exists(id, { connection } = {}) {
579
+ if (!isValidId(id)) return false;
580
+ try {
581
+ return await this.run(
582
+ "exists",
583
+ connection,
584
+ async (q) => ((await q.query(EXISTS, [id])).rowCount ?? 0) > 0
585
+ );
586
+ } catch (exc) {
587
+ throw wrap("Failed to check image existence", exc);
588
+ }
589
+ }
590
+ // ---- helpers ---------------------------------------------------------
591
+ toRecord(row, id) {
592
+ if (row.storage_backend === "object_storage")
593
+ throw this.tieredWithoutStorage(id, row.object_storage_key);
594
+ return {
595
+ id: String(row.id),
596
+ data: row.data,
597
+ mimeType: row.mime_type,
598
+ originalFilename: row.original_filename,
599
+ sizeBytes: row.size_bytes,
600
+ width: row.width,
601
+ height: row.height,
602
+ checksumSha256: row.checksum_sha256
603
+ };
604
+ }
605
+ tieredWithoutStorage(id, key) {
606
+ return new StorageError(
607
+ `Image ${JSON.stringify(id)} is stored in object storage (key=${JSON.stringify(key)}) but object-storage tiering is not available in this version of the npm package yet. Read it from the Python package (configured with the same bucket), or wait for npm tiering support.`
608
+ );
609
+ }
610
+ };
611
+ function rowParams(r) {
612
+ return [
613
+ r.data,
614
+ r.mimeType,
615
+ r.originalFilename,
616
+ r.sizeBytes,
617
+ r.width,
618
+ r.height,
619
+ r.checksumSha256
620
+ ];
621
+ }
622
+ function wrap(prefix, exc) {
623
+ return exc instanceof StorageError ? exc : new StorageError(`${prefix}: ${msg(exc)}`, { cause: exc });
624
+ }
625
+ function msg(exc) {
626
+ return exc instanceof Error ? exc.message : String(exc);
627
+ }
628
+ function sleep(ms) {
629
+ return new Promise((resolve) => setTimeout(resolve, ms));
630
+ }
631
+
632
+ // src/validation.ts
633
+ var FORMAT_TO_MIME = {
634
+ jpeg: "image/jpeg",
635
+ png: "image/png",
636
+ webp: "image/webp"
637
+ };
638
+ var SUPPORTED_FORMATS = Object.keys(FORMAT_TO_MIME);
639
+ var DEFAULT_MAX_PIXELS = 89e6;
640
+ var sharpPromise;
641
+ function loadSharp() {
642
+ sharpPromise ??= import("sharp").then(
643
+ // ESM gives { default: sharp }; some CJS interop gives sharp directly.
644
+ (m) => m.default ?? m,
645
+ (cause) => {
646
+ sharpPromise = void 0;
647
+ throw new StorageError(
648
+ "Image validation requires the optional dependency 'sharp'. Install it with: npm install sharp",
649
+ { cause }
650
+ );
651
+ }
652
+ );
653
+ return sharpPromise;
654
+ }
655
+ async function validateImage(data, opts) {
656
+ const {
657
+ maxBytes,
658
+ maxPixels = DEFAULT_MAX_PIXELS,
659
+ allowedFormats = SUPPORTED_FORMATS
660
+ } = opts;
661
+ const sizeBytes = data.length;
662
+ if (sizeBytes > maxBytes) throw new ImageTooLargeError(sizeBytes, maxBytes);
663
+ if (sizeBytes === 0) throw new CorruptedImageError("Image data is empty");
664
+ const sharp = await loadSharp();
665
+ const make = () => sharp(data, { limitInputPixels: maxPixels, failOn: "error" });
666
+ let format;
667
+ let width;
668
+ let height;
669
+ try {
670
+ const meta = await make().metadata();
671
+ format = meta.format;
672
+ width = meta.width;
673
+ height = meta.height;
674
+ } catch (exc) {
675
+ if (isPixelLimit(exc)) throw new ImageTooLargeError(sizeBytes, maxBytes);
676
+ throw new CorruptedImageError(
677
+ `Could not decode image: ${errMessage(exc)}`,
678
+ { cause: exc }
679
+ );
680
+ }
681
+ if (!format || !allowedFormats.includes(format) || !(format in FORMAT_TO_MIME)) {
682
+ throw new UnsupportedFormatError(format ?? null, allowedFormats);
683
+ }
684
+ if (!width || !height)
685
+ throw new CorruptedImageError("Image has no readable dimensions");
686
+ try {
687
+ await make().stats();
688
+ } catch (exc) {
689
+ if (isPixelLimit(exc)) throw new ImageTooLargeError(sizeBytes, maxBytes);
690
+ throw new CorruptedImageError(
691
+ `Image data is truncated or corrupted: ${errMessage(exc)}`,
692
+ {
693
+ cause: exc
694
+ }
695
+ );
696
+ }
697
+ return { mimeType: FORMAT_TO_MIME[format], width, height, sizeBytes };
698
+ }
699
+ function isPixelLimit(exc) {
700
+ return /pixel limit/i.test(errMessage(exc));
701
+ }
702
+ function errMessage(exc) {
703
+ return exc instanceof Error ? exc.message : String(exc);
704
+ }
705
+
706
+ // src/optimize.ts
707
+ var DEFAULT_JPEG_QUALITY = 90;
708
+ var DEFAULT_WEBP_QUALITY = 88;
709
+ var FORMAT_ALIASES = { jpg: "jpeg" };
710
+ async function optimizeImage(data, opts) {
711
+ const { maxWidth, quality, maxBytes, maxPixels = DEFAULT_MAX_PIXELS } = opts;
712
+ const sharp = await loadSharp();
713
+ const originalSizeBytes = data.length;
714
+ const base = sharp(data, { limitInputPixels: maxPixels, failOn: "error" });
715
+ const meta = await base.metadata();
716
+ const requested = (opts.format ?? meta.format ?? "jpeg").toLowerCase();
717
+ const target = FORMAT_ALIASES[requested] ?? requested;
718
+ if (!SUPPORTED_FORMATS.includes(target)) {
719
+ throw new ImageValidationError(
720
+ `Cannot re-encode to unsupported format ${JSON.stringify(target)}. Supported: ${JSON.stringify(
721
+ [...SUPPORTED_FORMATS].sort()
722
+ )}`
723
+ );
724
+ }
725
+ let pipeline = sharp(data, {
726
+ limitInputPixels: maxPixels,
727
+ failOn: "error"
728
+ }).rotate();
729
+ if (maxWidth !== void 0) {
730
+ pipeline = pipeline.resize({
731
+ width: maxWidth,
732
+ fit: "inside",
733
+ withoutEnlargement: true,
734
+ kernel: "lanczos3"
735
+ });
736
+ }
737
+ if (target === "jpeg") {
738
+ pipeline = pipeline.flatten({ background: "#ffffff" }).jpeg({
739
+ quality: quality ?? DEFAULT_JPEG_QUALITY,
740
+ mozjpeg: true,
741
+ progressive: true
742
+ });
743
+ } else if (target === "webp") {
744
+ pipeline = pipeline.webp({
745
+ quality: quality ?? DEFAULT_WEBP_QUALITY,
746
+ effort: 6
747
+ });
748
+ } else {
749
+ pipeline = pipeline.png({ compressionLevel: 9 });
750
+ }
751
+ const out = await pipeline.toBuffer();
752
+ const revalidated = await validateImage(out, { maxBytes, maxPixels });
753
+ return {
754
+ data: out,
755
+ mimeType: revalidated.mimeType,
756
+ width: revalidated.width,
757
+ height: revalidated.height,
758
+ sizeBytes: revalidated.sizeBytes,
759
+ originalSizeBytes
760
+ };
761
+ }
762
+
763
+ // src/client.ts
764
+ var DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
765
+ var PREPARE_CONCURRENCY = 4;
766
+ var ZeroBucket = class _ZeroBucket {
767
+ backend;
768
+ maxBytes;
769
+ maxPixels;
770
+ allowedFormats;
771
+ beforeGet;
772
+ beforePut;
773
+ constructor(options = {}) {
774
+ this.backend = options.backend ?? new PostgresBackend({
775
+ connectionString: options.connectionString,
776
+ pool: options.pool,
777
+ autoMigrate: options.autoMigrate,
778
+ maxRetries: options.maxRetries,
779
+ retryBaseDelayMs: options.retryBaseDelayMs,
780
+ poolMinSize: options.poolMinSize,
781
+ poolMaxSize: options.poolMaxSize,
782
+ poolTimeoutMs: options.poolTimeoutMs,
783
+ onOperation: options.onOperation
784
+ });
785
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
786
+ this.maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
787
+ this.allowedFormats = options.allowedFormats ?? SUPPORTED_FORMATS;
788
+ this.beforeGet = options.beforeGet;
789
+ this.beforePut = options.beforePut;
790
+ }
791
+ /** Construct AND connect/migrate immediately, so a bad DATABASE_URL fails at startup, not on first request. */
792
+ static async connect(options = {}) {
793
+ const zb = new _ZeroBucket(options);
794
+ await zb.ready();
795
+ return zb;
796
+ }
797
+ /** Resolves once connected and migrated. Called automatically on first use. */
798
+ ready() {
799
+ return this.backend.ready();
800
+ }
801
+ // ---- hooks -----------------------------------------------------------
802
+ async checkBeforeGet(imageId, context) {
803
+ if (!this.beforeGet) return;
804
+ if (!await this.beforeGet(imageId, context))
805
+ throw new AccessDeniedError("get", imageId);
806
+ }
807
+ async checkBeforePut(context) {
808
+ if (!this.beforePut) return;
809
+ if (!await this.beforePut(context)) throw new AccessDeniedError("put");
810
+ }
811
+ // ---- write path ------------------------------------------------------
812
+ async prepareRow(image, o) {
813
+ const { data, filename } = await readInput(
814
+ image,
815
+ o.filename ?? void 0,
816
+ this.maxBytes
817
+ );
818
+ let finalData;
819
+ let mimeType;
820
+ let width;
821
+ let height;
822
+ let sizeBytes;
823
+ if (o.validator) {
824
+ if (o.optimize) {
825
+ throw new ImageValidationError(
826
+ "optimize is not supported together with a custom validator: the optimize pipeline (resize/re-encode) is image-specific. Validate/transform custom content types yourself before put()."
827
+ );
828
+ }
829
+ const v = await o.validator.validate(data, { maxBytes: this.maxBytes });
830
+ finalData = data;
831
+ mimeType = v.mimeType;
832
+ width = v.width ?? null;
833
+ height = v.height ?? null;
834
+ sizeBytes = v.sizeBytes;
835
+ } else {
836
+ const validated = await validateImage(data, {
837
+ maxBytes: this.maxBytes,
838
+ maxPixels: this.maxPixels,
839
+ allowedFormats: this.allowedFormats
840
+ });
841
+ if (o.optimize) {
842
+ const opts = o.optimize === true ? {} : o.optimize;
843
+ const r = await optimizeImage(data, {
844
+ ...opts,
845
+ maxBytes: this.maxBytes,
846
+ maxPixels: this.maxPixels
847
+ });
848
+ finalData = r.data;
849
+ mimeType = r.mimeType;
850
+ width = r.width;
851
+ height = r.height;
852
+ sizeBytes = r.sizeBytes;
853
+ } else {
854
+ finalData = data;
855
+ mimeType = validated.mimeType;
856
+ width = validated.width;
857
+ height = validated.height;
858
+ sizeBytes = validated.sizeBytes;
859
+ }
860
+ }
861
+ return {
862
+ data: finalData,
863
+ mimeType,
864
+ originalFilename: filename ?? null,
865
+ sizeBytes,
866
+ width,
867
+ height,
868
+ checksumSha256: (0, import_node_crypto.createHash)("sha256").update(finalData).digest("hex")
869
+ };
870
+ }
871
+ /** Validate, optionally optimize, checksum and store one image. Returns its id (a UUID). */
872
+ async put(image, options = {}) {
873
+ await this.checkBeforePut(options.context);
874
+ const row = await this.prepareRow(image, options);
875
+ return this.backend.put(row, { connection: options.connection });
876
+ }
877
+ /**
878
+ * Store many images. Best-effort, not all-or-nothing: one bad image does not
879
+ * abort the rest. Check each result's `success` / `error`. Settings apply to
880
+ * every item. Valid items are inserted in ONE transaction.
881
+ */
882
+ async putMany(images, options = {}) {
883
+ const { filenames } = options;
884
+ if (filenames && filenames.length !== images.length) {
885
+ throw new RangeError(
886
+ "filenames must be the same length as images if provided"
887
+ );
888
+ }
889
+ const fail = (index, error) => ({
890
+ index,
891
+ imageId: null,
892
+ error,
893
+ success: false
894
+ });
895
+ if (this.beforePut) {
896
+ try {
897
+ if (!await this.beforePut(options.context))
898
+ return images.map((_, i) => fail(i, "access denied"));
899
+ } catch (exc) {
900
+ return images.map((_, i) => fail(i, errText(exc)));
901
+ }
902
+ }
903
+ const results = new Array(images.length);
904
+ const prepared = [];
905
+ await mapLimit(images, PREPARE_CONCURRENCY, async (image, i) => {
906
+ try {
907
+ const row = await this.prepareRow(image, {
908
+ ...options,
909
+ filename: filenames?.[i] ?? void 0
910
+ });
911
+ prepared.push({ index: i, row });
912
+ } catch (exc) {
913
+ results[i] = fail(i, errText(exc));
914
+ }
915
+ });
916
+ prepared.sort((a, b) => a.index - b.index);
917
+ if (prepared.length > 0) {
918
+ try {
919
+ const ids = await this.backend.putMany(
920
+ prepared.map((p) => p.row),
921
+ { connection: options.connection }
922
+ );
923
+ prepared.forEach((p, n) => {
924
+ results[p.index] = {
925
+ index: p.index,
926
+ imageId: ids[n],
927
+ error: null,
928
+ success: true
929
+ };
930
+ });
931
+ } catch (exc) {
932
+ for (const p of prepared)
933
+ results[p.index] = fail(p.index, errText(exc));
934
+ }
935
+ }
936
+ return results;
937
+ }
938
+ // ---- read path -------------------------------------------------------
939
+ /** Retrieve a full image including bytes. Throws ImageNotFoundError if missing. */
940
+ async get(imageId, options = {}) {
941
+ await this.checkBeforeGet(imageId, options.context);
942
+ const record = await this.backend.get(imageId, {
943
+ connection: options.connection
944
+ });
945
+ if (!record) throw new ImageNotFoundError(imageId);
946
+ return {
947
+ data: record.data,
948
+ mimeType: record.mimeType,
949
+ filename: record.originalFilename,
950
+ sizeBytes: record.sizeBytes,
951
+ width: record.width,
952
+ height: record.height,
953
+ checksumSha256: record.checksumSha256
954
+ };
955
+ }
956
+ /**
957
+ * Retrieve many images in one query. Results come back in input order; a
958
+ * missing id is `error: "not found"`, not an exception. A hook denial or hook
959
+ * exception affects only that id.
960
+ */
961
+ async getMany(imageIds, options = {}) {
962
+ const results = new Array(imageIds.length);
963
+ const allowedIdx = [];
964
+ for (let i = 0; i < imageIds.length; i++) {
965
+ const imageId = imageIds[i];
966
+ if (this.beforeGet) {
967
+ try {
968
+ if (!await this.beforeGet(imageId, options.context)) {
969
+ results[i] = {
970
+ imageId,
971
+ image: null,
972
+ error: "access denied",
973
+ success: false
974
+ };
975
+ continue;
976
+ }
977
+ } catch (exc) {
978
+ results[i] = {
979
+ imageId,
980
+ image: null,
981
+ error: errText(exc),
982
+ success: false
983
+ };
984
+ continue;
985
+ }
986
+ }
987
+ allowedIdx.push(i);
988
+ }
989
+ if (allowedIdx.length > 0) {
990
+ const records = await this.backend.getMany(
991
+ allowedIdx.map((i) => imageIds[i]),
992
+ { connection: options.connection }
993
+ );
994
+ const byId = new Map(records.map((r) => [r.id.toLowerCase(), r]));
995
+ for (const i of allowedIdx) {
996
+ const imageId = imageIds[i];
997
+ const r = byId.get(imageId.toLowerCase());
998
+ results[i] = r ? {
999
+ imageId,
1000
+ image: {
1001
+ data: r.data,
1002
+ mimeType: r.mimeType,
1003
+ filename: r.originalFilename,
1004
+ sizeBytes: r.sizeBytes,
1005
+ width: r.width,
1006
+ height: r.height,
1007
+ checksumSha256: r.checksumSha256
1008
+ },
1009
+ error: null,
1010
+ success: true
1011
+ } : { imageId, image: null, error: "not found", success: false };
1012
+ }
1013
+ }
1014
+ return results;
1015
+ }
1016
+ /**
1017
+ * Stream an image's bytes in chunks without holding the whole image in memory.
1018
+ * Not-found is checked eagerly: the returned promise rejects before any chunk
1019
+ * is read. A concurrent delete mid-stream makes iteration throw StorageError
1020
+ * rather than silently truncating. Pass `range` for partial reads.
1021
+ *
1022
+ * Honest limitation: reduces Node-side memory, not Postgres-side (the server
1023
+ * still handles the full BYTEA value per chunk request).
1024
+ */
1025
+ async getStream(imageId, options = {}) {
1026
+ await this.checkBeforeGet(imageId, options.context);
1027
+ const { range } = options;
1028
+ if (range) {
1029
+ if (!Number.isInteger(range.start) || range.start < 0 || range.end !== void 0 && (!Number.isInteger(range.end) || range.end < range.start)) {
1030
+ throw new RangeError(
1031
+ "range must be { start >= 0, end >= start } with integer values"
1032
+ );
1033
+ }
1034
+ }
1035
+ const stream = await this.backend.getStream(imageId, {
1036
+ chunkSize: options.chunkSize ?? DEFAULT_STREAM_CHUNK_SIZE,
1037
+ connection: options.connection,
1038
+ range: range ? { start: range.start, end: range.end ?? Number.MAX_SAFE_INTEGER } : void 0
1039
+ });
1040
+ if (!stream) throw new ImageNotFoundError(imageId);
1041
+ return stream;
1042
+ }
1043
+ /** Same as getStream(), as a Web `ReadableStream` you can hand straight to `new Response(...)`. */
1044
+ async toWebStream(imageId, options = {}) {
1045
+ const iterable = await this.getStream(imageId, options);
1046
+ const iterator = iterable[Symbol.asyncIterator]();
1047
+ return new ReadableStream({
1048
+ async pull(controller) {
1049
+ try {
1050
+ const { value, done } = await iterator.next();
1051
+ if (done) controller.close();
1052
+ else
1053
+ controller.enqueue(
1054
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
1055
+ );
1056
+ } catch (exc) {
1057
+ controller.error(exc);
1058
+ }
1059
+ },
1060
+ async cancel() {
1061
+ await iterator.return?.();
1062
+ }
1063
+ });
1064
+ }
1065
+ /** Write an image to `destination` chunk by chunk (honouring backpressure). Returns bytes written. Does not end the destination. */
1066
+ async streamTo(imageId, destination, options = {}) {
1067
+ let total = 0;
1068
+ for await (const chunk of await this.getStream(imageId, options)) {
1069
+ const result = destination.write(chunk);
1070
+ if (result === false && typeof destination.once === "function") {
1071
+ await new Promise(
1072
+ (resolve) => destination.once("drain", resolve)
1073
+ );
1074
+ } else if (result && typeof result.then === "function") {
1075
+ await result;
1076
+ }
1077
+ total += chunk.length;
1078
+ }
1079
+ return total;
1080
+ }
1081
+ /** Metadata without pulling the bytes. Gated by the same beforeGet hook as get(). */
1082
+ async metadata(imageId, options = {}) {
1083
+ await this.checkBeforeGet(imageId, options.context);
1084
+ const r = await this.backend.getMetadata(imageId, {
1085
+ connection: options.connection
1086
+ });
1087
+ if (!r) throw new ImageNotFoundError(imageId);
1088
+ return {
1089
+ imageId: r.id,
1090
+ mimeType: r.mimeType,
1091
+ filename: r.originalFilename,
1092
+ sizeBytes: r.sizeBytes,
1093
+ width: r.width,
1094
+ height: r.height,
1095
+ checksumSha256: r.checksumSha256
1096
+ };
1097
+ }
1098
+ /** Whether an image exists. Deliberately NOT gated by beforeGet (returns no data or metadata). */
1099
+ exists(imageId, options = {}) {
1100
+ return this.backend.exists(imageId, options);
1101
+ }
1102
+ /** Delete an image. Returns true if it existed. */
1103
+ delete(imageId, options = {}) {
1104
+ return this.backend.delete(imageId, options);
1105
+ }
1106
+ /** Delete many in one query. Results are in input order; a missing id is `deleted: false`, not an error. */
1107
+ async deleteMany(imageIds, options = {}) {
1108
+ if (imageIds.length === 0) return [];
1109
+ const deleted = new Set(
1110
+ (await this.backend.deleteMany(imageIds, options)).map(
1111
+ (id) => id.toLowerCase()
1112
+ )
1113
+ );
1114
+ return imageIds.map((imageId) => ({
1115
+ imageId,
1116
+ deleted: deleted.has(imageId.toLowerCase()),
1117
+ error: null,
1118
+ success: true
1119
+ }));
1120
+ }
1121
+ /** Release the pool (if ZeroBucket created it). */
1122
+ close() {
1123
+ return this.backend.close();
1124
+ }
1125
+ };
1126
+ async function readInput(image, filename, maxBytes) {
1127
+ if (typeof image === "string") {
1128
+ const size = (await (0, import_promises.stat)(image)).size;
1129
+ if (size > maxBytes) throw new ImageTooLargeError(size, maxBytes);
1130
+ return {
1131
+ data: await (0, import_promises.readFile)(image),
1132
+ filename: filename ?? (0, import_node_path.basename)(image)
1133
+ };
1134
+ }
1135
+ if (Buffer.isBuffer(image)) return { data: image, filename };
1136
+ if (image instanceof Uint8Array)
1137
+ return {
1138
+ data: Buffer.from(image.buffer, image.byteOffset, image.byteLength),
1139
+ filename
1140
+ };
1141
+ if (image instanceof ArrayBuffer)
1142
+ return { data: Buffer.from(image), filename };
1143
+ if (typeof Blob !== "undefined" && image instanceof Blob) {
1144
+ if (image.size > maxBytes)
1145
+ throw new ImageTooLargeError(image.size, maxBytes);
1146
+ const name = image.name;
1147
+ return {
1148
+ data: Buffer.from(await image.arrayBuffer()),
1149
+ filename: filename ?? (typeof name === "string" ? name : void 0)
1150
+ };
1151
+ }
1152
+ if (image && typeof image[Symbol.asyncIterator] === "function") {
1153
+ const chunks = [];
1154
+ let total = 0;
1155
+ for await (const chunk of image) {
1156
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
1157
+ total += buf.length;
1158
+ if (total > maxBytes) {
1159
+ image.destroy?.();
1160
+ throw new ImageTooLargeError(total, maxBytes);
1161
+ }
1162
+ chunks.push(buf);
1163
+ }
1164
+ const p = image.path;
1165
+ return {
1166
+ data: Buffer.concat(chunks, total),
1167
+ filename: filename ?? (typeof p === "string" ? (0, import_node_path.basename)(p) : void 0)
1168
+ };
1169
+ }
1170
+ throw new TypeError(
1171
+ "Unsupported image input. Pass a Buffer, Uint8Array, file path string, Blob/File, or a readable stream."
1172
+ );
1173
+ }
1174
+ async function mapLimit(items, limit, fn) {
1175
+ let next = 0;
1176
+ const workers = Array.from(
1177
+ { length: Math.min(limit, items.length) },
1178
+ async () => {
1179
+ while (next < items.length) {
1180
+ const i = next++;
1181
+ await fn(items[i], i);
1182
+ }
1183
+ }
1184
+ );
1185
+ await Promise.all(workers);
1186
+ }
1187
+ function errText(exc) {
1188
+ return exc instanceof Error ? exc.message : String(exc);
1189
+ }
1190
+
1191
+ // src/validators/pdf.ts
1192
+ var DEFAULT_MAX_PDF_BYTES = 20 * 1024 * 1024;
1193
+ var PDFValidator = class {
1194
+ constructor(opts = {}) {
1195
+ this.opts = opts;
1196
+ }
1197
+ opts;
1198
+ validate(data, { maxBytes }) {
1199
+ const effectiveMax = Math.min(
1200
+ maxBytes,
1201
+ this.opts.maxBytes ?? DEFAULT_MAX_PDF_BYTES
1202
+ );
1203
+ if (data.length === 0)
1204
+ throw new ContentValidationError("PDF data is empty");
1205
+ if (data.length > effectiveMax) {
1206
+ throw new ContentValidationError(
1207
+ `PDF is ${data.length} bytes, exceeds the maximum of ${effectiveMax} bytes`
1208
+ );
1209
+ }
1210
+ if (!data.subarray(0, 1024).includes("%PDF-")) {
1211
+ throw new ContentValidationError(
1212
+ "This does not look like a valid PDF (missing '%PDF-' header). Content is checked by inspecting the actual bytes, not the filename or any client-supplied content type."
1213
+ );
1214
+ }
1215
+ return {
1216
+ mimeType: "application/pdf",
1217
+ sizeBytes: data.length,
1218
+ width: null,
1219
+ height: null
1220
+ };
1221
+ }
1222
+ };
1223
+ // Annotate the CommonJS export names for ESM import in node:
1224
+ 0 && (module.exports = {
1225
+ AccessDeniedError,
1226
+ ContentValidationError,
1227
+ CorruptedImageError,
1228
+ DEFAULT_MAX_BYTES,
1229
+ DEFAULT_MAX_PIXELS,
1230
+ DEFAULT_STREAM_CHUNK_SIZE,
1231
+ ImageNotFoundError,
1232
+ ImageTooLargeError,
1233
+ ImageValidationError,
1234
+ PDFValidator,
1235
+ PostgresBackend,
1236
+ SUPPORTED_FORMATS,
1237
+ StorageError,
1238
+ UnsupportedFormatError,
1239
+ ZeroBucket,
1240
+ ZeroBucketError,
1241
+ optimizeImage,
1242
+ readInput,
1243
+ validateImage
1244
+ });
1245
+ //# sourceMappingURL=index.cjs.map