@ultimat3/storage 23.0.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +39 -8
- package/README.md +87 -6
- package/package.json +2 -2
- package/src/accept.ts +3 -6
- package/src/attachment.ts +40 -7
- package/src/driver-local-read.ts +104 -0
- package/src/driver-local-sidecar.ts +38 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +85 -172
- package/src/driver-memory.ts +36 -11
- package/src/driver-s3.ts +103 -30
- package/src/driver.ts +63 -1
- package/src/errors.ts +94 -12
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +14 -9
- package/src/iso-bmff.ts +50 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -8
- package/src/signing-secret.ts +71 -0
- package/src/upload.ts +31 -5
package/src/driver-s3.ts
CHANGED
|
@@ -4,8 +4,17 @@
|
|
|
4
4
|
// The client is built lazily on first use so importing this module never opens a socket, and
|
|
5
5
|
// credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
|
|
6
6
|
|
|
7
|
-
import { ConfigInvalidError, EnvMissingError, finiteCount, stringField } from '@ultimat3/core';
|
|
8
7
|
import {
|
|
8
|
+
ConfigInvalidError,
|
|
9
|
+
EnvMissingError,
|
|
10
|
+
finiteCount,
|
|
11
|
+
NotImplementedError,
|
|
12
|
+
renderFixShellArg,
|
|
13
|
+
stringField,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
import {
|
|
16
|
+
assertListOptions,
|
|
17
|
+
assertPutOptions,
|
|
9
18
|
DEFAULT_CONTENT_TYPE,
|
|
10
19
|
type ListOptions,
|
|
11
20
|
type ListPage,
|
|
@@ -24,9 +33,12 @@ import { regionMismatch } from './driver-s3-region';
|
|
|
24
33
|
import {
|
|
25
34
|
checksumMismatch,
|
|
26
35
|
deleteFailed,
|
|
36
|
+
getTooLarge,
|
|
37
|
+
isStorageError,
|
|
27
38
|
listFailed,
|
|
28
39
|
objectNotFound,
|
|
29
|
-
|
|
40
|
+
putFailed,
|
|
41
|
+
readFailed,
|
|
30
42
|
} from './errors';
|
|
31
43
|
import { assertSafeKey } from './path';
|
|
32
44
|
import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
|
|
@@ -102,6 +114,12 @@ export interface S3DriverOptions {
|
|
|
102
114
|
* and S3's single-PUT limit is 5GB regardless of what this says.
|
|
103
115
|
*/
|
|
104
116
|
readonly maxPutBytes?: number | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Ceiling on ONE `get()`, because `get()` buffers the whole object — and on this disk nothing
|
|
119
|
+
* bounds what a presigned PUT stored (`SignedUrlOptions.maxBytes`). Defaults to `maxPutBytes`;
|
|
120
|
+
* an object past it is read with `stream()`.
|
|
121
|
+
*/
|
|
122
|
+
readonly maxGetBytes?: number | undefined;
|
|
105
123
|
}
|
|
106
124
|
|
|
107
125
|
interface S3ClientConstructor {
|
|
@@ -157,8 +175,12 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
|
157
175
|
});
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
|
|
161
|
-
|
|
178
|
+
/**
|
|
179
|
+
* A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
|
|
180
|
+
* `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
|
|
181
|
+
*/
|
|
182
|
+
const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
|
|
183
|
+
value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
|
|
162
184
|
|
|
163
185
|
/** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
|
|
164
186
|
function numberField(value: unknown, key: string): number | undefined {
|
|
@@ -196,12 +218,12 @@ function isAbsentObject(error: unknown): boolean {
|
|
|
196
218
|
*/
|
|
197
219
|
function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
|
|
198
220
|
if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
|
|
199
|
-
const uri = `s3://${bucket}/${key}
|
|
200
|
-
throw
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
);
|
|
221
|
+
const uri = renderFixShellArg(`s3://${bucket}/${key}`, '<s3-uri>');
|
|
222
|
+
throw new NotImplementedError({
|
|
223
|
+
cause:
|
|
224
|
+
'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet) is not implemented by this driver — drop metadata/cacheControl from put(), or set them out of band with the command in fix',
|
|
225
|
+
fix: `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
|
|
226
|
+
});
|
|
205
227
|
}
|
|
206
228
|
const sse = putOptions?.serverSideEncryption;
|
|
207
229
|
if (sse === undefined) return;
|
|
@@ -209,10 +231,11 @@ function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptio
|
|
|
209
231
|
sse.algorithm === 'aws:kms'
|
|
210
232
|
? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
|
|
211
233
|
: '{"SSEAlgorithm":"AES256"}';
|
|
212
|
-
throw
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
throw new NotImplementedError({
|
|
235
|
+
cause:
|
|
236
|
+
'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
|
|
237
|
+
fix: `aws s3api put-bucket-encryption --bucket ${renderFixShellArg(bucket, '<bucket>')} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
|
|
238
|
+
});
|
|
216
239
|
}
|
|
217
240
|
|
|
218
241
|
export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
@@ -224,6 +247,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
224
247
|
options.maxPutBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : options.maxPutBytes,
|
|
225
248
|
1,
|
|
226
249
|
);
|
|
250
|
+
const maxGetBytes = finiteCount(
|
|
251
|
+
'the s3 driver',
|
|
252
|
+
'maxGetBytes',
|
|
253
|
+
options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
|
|
254
|
+
1,
|
|
255
|
+
);
|
|
227
256
|
let client: S3ClientLike | undefined;
|
|
228
257
|
const conn = (): S3ClientLike => {
|
|
229
258
|
client ??= buildClient(options);
|
|
@@ -240,19 +269,51 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
240
269
|
const mismatch = regionMismatch(options.bucket, error);
|
|
241
270
|
if (mismatch !== undefined) throw mismatch;
|
|
242
271
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
272
|
+
/**
|
|
273
|
+
* A write the provider refused, coded: a denied `s3:PutObject`, a throttle, an expired
|
|
274
|
+
* credential. The bare `S3Error` used to escape `put()` and `copy()` exactly as it once escaped
|
|
275
|
+
* `list()` — no code, no fix, an anonymous 500.
|
|
276
|
+
*/
|
|
277
|
+
const refusedWrite =
|
|
278
|
+
(key: string) =>
|
|
279
|
+
(error: unknown): never => {
|
|
280
|
+
throwIfMisconfigured(error);
|
|
281
|
+
throw putFailed(
|
|
282
|
+
DRIVER_NAME,
|
|
283
|
+
key,
|
|
284
|
+
error,
|
|
285
|
+
`aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, '<bucket>')}`,
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* A read the provider refused, coded — `refusedWrite`'s twin. A 404 is "not found" wherever it
|
|
291
|
+
* arrives: an object deleted between `exists()` and the read is absent, not unreadable.
|
|
292
|
+
*/
|
|
293
|
+
const refusedRead =
|
|
294
|
+
(key: string) =>
|
|
295
|
+
(error: unknown): never => {
|
|
296
|
+
if (isStorageError(error)) throw error;
|
|
297
|
+
if (isAbsentObject(error)) throw objectNotFound(DRIVER_NAME, key);
|
|
298
|
+
throwIfMisconfigured(error);
|
|
299
|
+
throw readFailed(
|
|
300
|
+
DRIVER_NAME,
|
|
301
|
+
key,
|
|
302
|
+
error,
|
|
303
|
+
`aws s3api head-object --bucket ${renderFixShellArg(options.bucket, '<bucket>')} --key ${renderFixShellArg(key, '<key>')}`,
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
const present = (key: string): Promise<boolean> =>
|
|
307
|
+
conn().file(key).exists().catch(refusedRead(key));
|
|
247
308
|
|
|
248
309
|
const statObject = async (key: string): Promise<StorageObject> => {
|
|
249
|
-
const stat = await conn().file(key).stat();
|
|
310
|
+
const stat = await conn().file(key).stat().catch(refusedRead(key));
|
|
250
311
|
return {
|
|
251
312
|
key,
|
|
252
313
|
size: stat.size,
|
|
253
314
|
contentType: stat.type ?? DEFAULT_CONTENT_TYPE,
|
|
254
315
|
etag: stat.etag ?? '',
|
|
255
|
-
|
|
316
|
+
...dated(stat.lastModified),
|
|
256
317
|
};
|
|
257
318
|
};
|
|
258
319
|
|
|
@@ -262,6 +323,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
262
323
|
async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
|
|
263
324
|
const safe = assertSafeKey(key);
|
|
264
325
|
refuseUnsupportedPut(options.bucket, safe, putOptions);
|
|
326
|
+
assertPutOptions(DRIVER_NAME, putOptions);
|
|
265
327
|
// Buffered on purpose: size and checksum must be known before the object exists — so this
|
|
266
328
|
// path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
|
|
267
329
|
// rather than a hope. User uploads never come through here: they go direct to the bucket
|
|
@@ -275,22 +337,32 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
275
337
|
await conn()
|
|
276
338
|
.file(safe)
|
|
277
339
|
.write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
|
|
278
|
-
.catch(
|
|
340
|
+
.catch(refusedWrite(safe));
|
|
279
341
|
return statObject(safe);
|
|
280
342
|
},
|
|
281
343
|
|
|
344
|
+
async stat(key: string): Promise<StorageObject | undefined> {
|
|
345
|
+
const safe = assertSafeKey(key);
|
|
346
|
+
return (await present(safe)) ? statObject(safe) : undefined;
|
|
347
|
+
},
|
|
348
|
+
|
|
282
349
|
async get(key: string): Promise<StorageRead> {
|
|
283
350
|
const safe = assertSafeKey(key);
|
|
284
351
|
const file = conn().file(safe);
|
|
285
|
-
if (!(await
|
|
286
|
-
|
|
287
|
-
|
|
352
|
+
if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
|
|
353
|
+
// The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
|
|
354
|
+
// bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
|
|
355
|
+
// the uploader chose. Refused on the provider's own size before a byte is read.
|
|
356
|
+
const object = await statObject(safe);
|
|
357
|
+
if (object.size > maxGetBytes) throw getTooLarge(DRIVER_NAME, safe, object.size, maxGetBytes);
|
|
358
|
+
const bytes = new Uint8Array(await file.arrayBuffer().catch(refusedRead(safe)));
|
|
359
|
+
return { object, bytes };
|
|
288
360
|
},
|
|
289
361
|
|
|
290
362
|
async stream(key: string): Promise<ReadableStream<Uint8Array>> {
|
|
291
363
|
const safe = assertSafeKey(key);
|
|
292
364
|
const file = conn().file(safe);
|
|
293
|
-
if (!(await
|
|
365
|
+
if (!(await present(safe))) throw objectNotFound(DRIVER_NAME, safe);
|
|
294
366
|
return file.stream();
|
|
295
367
|
},
|
|
296
368
|
|
|
@@ -305,12 +377,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
305
377
|
const source = assertSafeKey(from);
|
|
306
378
|
const destination = assertSafeKey(to);
|
|
307
379
|
const file = conn().file(source);
|
|
308
|
-
if (!(await
|
|
309
|
-
const stat = await file.stat();
|
|
380
|
+
if (!(await present(source))) throw objectNotFound(DRIVER_NAME, source);
|
|
381
|
+
const stat = await file.stat().catch(refusedRead(source));
|
|
310
382
|
await conn()
|
|
311
383
|
.file(destination)
|
|
312
384
|
.write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE })
|
|
313
|
-
.catch(
|
|
385
|
+
.catch(refusedWrite(destination));
|
|
314
386
|
return statObject(destination);
|
|
315
387
|
},
|
|
316
388
|
|
|
@@ -333,13 +405,14 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
333
405
|
},
|
|
334
406
|
|
|
335
407
|
async exists(key: string): Promise<boolean> {
|
|
336
|
-
return
|
|
408
|
+
return present(assertSafeKey(key));
|
|
337
409
|
},
|
|
338
410
|
|
|
339
411
|
async list(listOptions?: ListOptions): Promise<ListPage> {
|
|
340
412
|
const prefix = listOptions?.prefix ?? '';
|
|
341
413
|
// Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
|
|
342
414
|
// go straight through, while the local disk answered a complete-looking empty page.
|
|
415
|
+
assertListOptions(listOptions);
|
|
343
416
|
const maxKeys = resolveListLimit(listOptions?.limit);
|
|
344
417
|
// `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
|
|
345
418
|
// misconfigured, and it already answers with its own code and its own fix.
|
|
@@ -375,7 +448,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
375
448
|
key: entry.key,
|
|
376
449
|
size: entry.size ?? 0,
|
|
377
450
|
etag: entry.eTag ?? '',
|
|
378
|
-
|
|
451
|
+
...dated(entry.lastModified),
|
|
379
452
|
});
|
|
380
453
|
}
|
|
381
454
|
const cursor = result.nextContinuationToken;
|
package/src/driver.ts
CHANGED
|
@@ -23,7 +23,12 @@ export interface StorageListEntry {
|
|
|
23
23
|
/** Absent means the driver's listing does not carry it. Never a fabricated default. */
|
|
24
24
|
readonly contentType?: string | undefined;
|
|
25
25
|
readonly etag: string;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* ABSENT when the provider did not say — never epoch 0, which every age test reads as "older
|
|
28
|
+
* than any window", and never an invalid `Date`, which is a value a reader has to remember to
|
|
29
|
+
* test. A reader that needs an age handles `undefined`; `sweepOrphans` spares it.
|
|
30
|
+
*/
|
|
31
|
+
readonly lastModified?: Date | undefined;
|
|
27
32
|
/** Present only when the driver actually stored what `put()` was handed — never invented. */
|
|
28
33
|
readonly cacheControl?: string | undefined;
|
|
29
34
|
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
@@ -115,6 +120,17 @@ export interface StorageDriver {
|
|
|
115
120
|
*/
|
|
116
121
|
registerAs?(diskName: string): void;
|
|
117
122
|
put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
|
|
123
|
+
/**
|
|
124
|
+
* What a read would say about the object, WITHOUT its bytes — `undefined` when nothing is there.
|
|
125
|
+
* `promoteAttachment` decides on `stat().size`, because a presigned PUT lands on the disk
|
|
126
|
+
* unmeasured and `get()` is the one call that must not be how its size is learned. Required: a
|
|
127
|
+
* driver that cannot measure cannot be promoted from, and that is a compile error, not a refusal.
|
|
128
|
+
*/
|
|
129
|
+
stat(key: string): Promise<StorageObject | undefined>;
|
|
130
|
+
/**
|
|
131
|
+
* The whole object, BUFFERED — so bounded: an object over the driver's `maxGetBytes` is
|
|
132
|
+
* `X_STORAGE_TOO_LARGE`, exactly as `put()` refuses a body over `maxPutBytes`. Past it, `stream()`.
|
|
133
|
+
*/
|
|
118
134
|
get(key: string): Promise<StorageRead>;
|
|
119
135
|
/** Bytes without buffering — the only safe path for anything over a few MB. */
|
|
120
136
|
stream(key: string): Promise<ReadableStream<Uint8Array>>;
|
|
@@ -181,6 +197,45 @@ export function resolveListLimit(limit: number | undefined): number {
|
|
|
181
197
|
return limit;
|
|
182
198
|
}
|
|
183
199
|
|
|
200
|
+
const isStringRecord = (value: unknown): boolean =>
|
|
201
|
+
typeof value === 'object' &&
|
|
202
|
+
value !== null &&
|
|
203
|
+
!Array.isArray(value) &&
|
|
204
|
+
Object.values(value).every((entry) => typeof entry === 'string');
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* What `put()` was told ABOUT the bytes, held to its type before a byte moves. An untyped caller's
|
|
208
|
+
* `metadata: { n: 1n }` reached `JSON.stringify` on the local disk as a bare `TypeError`, and the
|
|
209
|
+
* memory disk stored it — against the `Record<string, string>` every reader is typed on.
|
|
210
|
+
*/
|
|
211
|
+
export function assertPutOptions(driver: string, options: PutOptions | undefined): void {
|
|
212
|
+
if (options === undefined) return;
|
|
213
|
+
for (const name of ['contentType', 'cacheControl'] as const) {
|
|
214
|
+
assert(
|
|
215
|
+
options[name] === undefined || typeof options[name] === 'string',
|
|
216
|
+
`put() on the ${driver} disk was given a ${name} that is not a string`,
|
|
217
|
+
`put(key, body, { ${name}: 'a string' }) — or omit ${name}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
assert(
|
|
221
|
+
options.metadata === undefined || isStringRecord(options.metadata),
|
|
222
|
+
`put() on the ${driver} disk was given metadata that is not a plain object of string values`,
|
|
223
|
+
"put(key, body, { metadata: { name: 'value' } }) — every value a string: String(n) a number, JSON.stringify anything nested",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A `prefix` or a `cursor` that is not a string reached `startsWith` and `<=` as whatever it was. */
|
|
228
|
+
export function assertListOptions(options: ListOptions | undefined): void {
|
|
229
|
+
for (const name of ['prefix', 'cursor'] as const) {
|
|
230
|
+
const value = options?.[name];
|
|
231
|
+
assert(
|
|
232
|
+
value === undefined || typeof value === 'string',
|
|
233
|
+
`list() was given a ${name} that is not a string`,
|
|
234
|
+
`disk.list({ ${name}: 'a string' }) — or omit ${name}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
184
239
|
/** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
|
|
185
240
|
export interface ByteLimit {
|
|
186
241
|
readonly driver: string;
|
|
@@ -212,6 +267,13 @@ export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint
|
|
|
212
267
|
}
|
|
213
268
|
return new Uint8Array(await body.arrayBuffer());
|
|
214
269
|
}
|
|
270
|
+
// The type says so; an untyped caller's `put(key, 'a string')` reached `getReader` as a
|
|
271
|
+
// bare `TypeError`.
|
|
272
|
+
assert(
|
|
273
|
+
body instanceof ReadableStream,
|
|
274
|
+
`put() on the ${limit.driver} disk was given a body that is not a Uint8Array, a Blob or a ReadableStream`,
|
|
275
|
+
"put(key, new TextEncoder().encode(text)) — bytes, never a string: encode it, or pass the request's own body stream",
|
|
276
|
+
);
|
|
215
277
|
return readBounded(body, limit);
|
|
216
278
|
}
|
|
217
279
|
|
package/src/errors.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// rejected upload must tell the caller which constraint fired and where that constraint is
|
|
3
3
|
// configured, or the caller retries the same bytes forever.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
registerErrorCodes,
|
|
7
|
+
renderFixLiteral,
|
|
8
|
+
renderThrowable,
|
|
9
|
+
UltimateError,
|
|
10
|
+
} from '@ultimat3/core';
|
|
6
11
|
|
|
7
12
|
/** Codes this package declares and owns. */
|
|
8
13
|
export const STORAGE_OWNED_ERROR_CODES = [
|
|
@@ -21,11 +26,14 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
21
26
|
'X_STORAGE_LIST_FAILED',
|
|
22
27
|
'X_STORAGE_QUARANTINED',
|
|
23
28
|
'X_STORAGE_NOT_PENDING',
|
|
29
|
+
'X_STORAGE_KEY_CONFLICT',
|
|
30
|
+
'X_STORAGE_PUT_FAILED',
|
|
31
|
+
'X_STORAGE_READ_FAILED',
|
|
24
32
|
] as const;
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
|
-
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s.
|
|
28
|
-
* keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
|
|
35
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. The drivers throw core's own `NotImplementedError`
|
|
36
|
+
* and this package keeps no title for it — one code, one owner, one title, or the two copies drift apart in silence.
|
|
29
37
|
* `X_IMAGE_UNSUPPORTED` / `X_IMAGE_DECODE_FAILED` are core's too and surface unwrapped (`image.ts`).
|
|
30
38
|
* `X_ENV_MISSING` is core's for the same reason: an unset `STORAGE_SIGNING_SECRET` outside
|
|
31
39
|
* development is a missing environment variable, not a storage concept needing its own code.
|
|
@@ -57,6 +65,9 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
57
65
|
X_STORAGE_LIST_FAILED: 'the objects could not be listed',
|
|
58
66
|
X_STORAGE_QUARANTINED: 'the object is still in quarantine',
|
|
59
67
|
X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
|
|
68
|
+
X_STORAGE_KEY_CONFLICT: 'the key collides with another key on the local disk',
|
|
69
|
+
X_STORAGE_PUT_FAILED: 'the object could not be written',
|
|
70
|
+
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
60
71
|
};
|
|
61
72
|
|
|
62
73
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -161,6 +172,49 @@ export const putTooLarge = (
|
|
|
161
172
|
meta: { disk, key, bytes, maxBytes },
|
|
162
173
|
});
|
|
163
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The SERVER-side `get()` ceiling — `putTooLarge`'s twin. `get()` buffers the whole object, and an
|
|
177
|
+
* object's size is not this process's to choose: a presigned PUT lands in the bucket unmeasured,
|
|
178
|
+
* so a later `get()` of it was heap growth the uploader picked.
|
|
179
|
+
*/
|
|
180
|
+
export const getTooLarge = (
|
|
181
|
+
disk: string,
|
|
182
|
+
key: string,
|
|
183
|
+
bytes: number,
|
|
184
|
+
maxBytes: number,
|
|
185
|
+
): StorageError =>
|
|
186
|
+
new StorageError({
|
|
187
|
+
code: 'X_STORAGE_TOO_LARGE',
|
|
188
|
+
cause: `"${key}" is ${bytes}B against the ${disk} disk's get ceiling of ${maxBytes}B, and get() buffers the whole object`,
|
|
189
|
+
fix:
|
|
190
|
+
`read it with disk.stream(key) instead of get(), or raise the ceiling: ` +
|
|
191
|
+
(disk === 's3'
|
|
192
|
+
? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
|
|
193
|
+
: disk === 'memory'
|
|
194
|
+
? `memoryDriver({ maxGetBytes: ${bytes} })`
|
|
195
|
+
: `localDriver({ root, maxGetBytes: ${bytes} })`),
|
|
196
|
+
meta: { disk, key, bytes, maxBytes },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
|
|
201
|
+
* another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
|
|
202
|
+
* sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 and the memory
|
|
203
|
+
* disk hold both — this is the local disk's own limit, refused by name rather than hidden.
|
|
204
|
+
*
|
|
205
|
+
* The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
|
|
206
|
+
* which one is only decidable by a reader who can see both.
|
|
207
|
+
*/
|
|
208
|
+
export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
|
|
209
|
+
new StorageError({
|
|
210
|
+
code: 'X_STORAGE_KEY_CONFLICT',
|
|
211
|
+
cause: `the "${disk}" disk is a local one and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
|
|
212
|
+
// The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
|
|
213
|
+
// `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
|
|
214
|
+
fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
|
|
215
|
+
meta: { disk, key, blocking },
|
|
216
|
+
});
|
|
217
|
+
|
|
164
218
|
/** The declared type is not on the allowlist at all. */
|
|
165
219
|
export const contentTypeNotAllowed = (
|
|
166
220
|
key: string,
|
|
@@ -258,6 +312,43 @@ export const deleteFailed = (
|
|
|
258
312
|
});
|
|
259
313
|
};
|
|
260
314
|
|
|
315
|
+
/**
|
|
316
|
+
* A write the disk REFUSED — `deleteFailed`'s twin for `put()` and `copy()`. The local disk's
|
|
317
|
+
* `EACCES` / `ENOSPC` / `EROFS` left as a bare filesystem `Error`, and a provider's refused PUT as
|
|
318
|
+
* a bare `S3Error`: no code, no fix, nothing for the http error map to render but an anonymous 500.
|
|
319
|
+
* The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
320
|
+
*/
|
|
321
|
+
export const putFailed = (disk: string, key: string, error: unknown, fix: string): StorageError => {
|
|
322
|
+
const reason = renderThrowable(error);
|
|
323
|
+
return new StorageError({
|
|
324
|
+
code: 'X_STORAGE_PUT_FAILED',
|
|
325
|
+
cause: `disk "${disk}" refused to write "${key}": ${reason}`,
|
|
326
|
+
fix,
|
|
327
|
+
meta: { disk, key, reason },
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A read the disk REFUSED — `putFailed`'s read-side twin, for `get`, `stat`, `exists`, `stream` and
|
|
333
|
+
* the read half of `copy`. A denied `s3:GetObject`, a throttle, `EACCES`, `EIO`. An object that is
|
|
334
|
+
* simply absent is `objectNotFound`, never this: "not there" and "could not look" need opposite
|
|
335
|
+
* answers from a caller. The `fix` is the driver's, for `deleteFailed`'s reason.
|
|
336
|
+
*/
|
|
337
|
+
export const readFailed = (
|
|
338
|
+
disk: string,
|
|
339
|
+
key: string,
|
|
340
|
+
error: unknown,
|
|
341
|
+
fix: string,
|
|
342
|
+
): StorageError => {
|
|
343
|
+
const reason = renderThrowable(error);
|
|
344
|
+
return new StorageError({
|
|
345
|
+
code: 'X_STORAGE_READ_FAILED',
|
|
346
|
+
cause: `disk "${disk}" refused to read "${key}": ${reason}`,
|
|
347
|
+
fix,
|
|
348
|
+
meta: { disk, key, reason },
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
|
|
261
352
|
/**
|
|
262
353
|
* A listing the disk REFUSED — a denied `s3:ListBucket`, a throttle, an expired credential, a root
|
|
263
354
|
* this process cannot read. Exactly `deleteFailed`'s shape and for exactly its reason: an empty
|
|
@@ -363,12 +454,3 @@ export const signingSecretMissing = (environment: string, disk = 'local'): Stora
|
|
|
363
454
|
fix: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
364
455
|
meta: { key: 'STORAGE_SIGNING_SECRET', environment },
|
|
365
456
|
});
|
|
366
|
-
|
|
367
|
-
/** An interface-complete driver whose remote half is not bound yet. Always carries a fix. */
|
|
368
|
-
export const storageNotImplemented = (feature: string, fix: string): StorageError =>
|
|
369
|
-
new StorageError({
|
|
370
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
371
|
-
cause: `${feature} is declared but not implemented in @ultimat3/storage`,
|
|
372
|
-
fix,
|
|
373
|
-
meta: { feature },
|
|
374
|
-
});
|
package/src/grant.ts
CHANGED
|
@@ -83,12 +83,12 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// Refused here as well as in the presigner one call down: `expiresAt: now + NaN` is `NaN` in the
|
|
86
|
-
// grant this function RETURNS, and the message a caller can act on names `
|
|
86
|
+
// grant this function RETURNS, and the message a caller can act on names `grantUpload`'s
|
|
87
87
|
// own option rather than `buildSignedUrl`'s.
|
|
88
88
|
// `=== undefined`, never `??`, for the reason `buildSignedUrl` states: `??` coalesces on `null`
|
|
89
89
|
// too, so an explicit one took the default instead of this refusal.
|
|
90
90
|
const expiresInMs = finiteCount(
|
|
91
|
-
'
|
|
91
|
+
'grantUpload',
|
|
92
92
|
'expiresInMs',
|
|
93
93
|
input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
|
|
94
94
|
1,
|
package/src/image.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
import {
|
|
7
7
|
assertFiniteImageQuality,
|
|
8
8
|
blurDataUrl,
|
|
9
|
+
finiteCount,
|
|
9
10
|
type ImageFormat,
|
|
10
11
|
imageUnsupported,
|
|
11
12
|
probeImage,
|
|
12
13
|
transformImageBytes,
|
|
13
14
|
} from '@ultimat3/core';
|
|
14
|
-
import { assertSafeKey
|
|
15
|
+
import { assertSafeKey } from './path';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
|
|
@@ -67,13 +68,28 @@ const FORMAT_EXTENSIONS: Readonly<Record<VariantFormat, string>> = {
|
|
|
67
68
|
png: 'png',
|
|
68
69
|
};
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One edge of a variant box: a whole number of pixels, at least one. Core's `finiteCount`, for the
|
|
73
|
+
* reason `quality` goes through core's screen below — `{ width: NaN }` minted `a@wNaN.webp`, a
|
|
74
|
+
* writable key for bytes no encoder can produce, and `w1.5` / `w-3` are an unbounded number of
|
|
75
|
+
* keys in a bucket. `!== undefined`, never a truthiness test: an explicit `null` is refused.
|
|
76
|
+
*/
|
|
77
|
+
function edge(subject: string, option: 'width' | 'height', value: number | undefined): void {
|
|
78
|
+
if (value !== undefined) finiteCount(subject, option, value, 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Derived, not stored: the same source + transform always yields the same key, so a variant
|
|
72
83
|
* is a cache lookup rather than a database row.
|
|
84
|
+
*
|
|
85
|
+
* The WHOLE source key is the stem, extension included. It used to be cut, so `p/hero.png` and
|
|
86
|
+
* `p/hero.jpg` minted one `p/hero@w100.webp` and whichever was requested first was served for
|
|
87
|
+
* both — two sources, one cache identity.
|
|
73
88
|
*/
|
|
74
89
|
export function variantKey(sourceKey: string, transform: ImageTransform): string {
|
|
75
90
|
const safe = assertSafeKey(sourceKey);
|
|
76
|
-
|
|
91
|
+
edge('variantKey', 'width', transform.width);
|
|
92
|
+
edge('variantKey', 'height', transform.height);
|
|
77
93
|
// `string`, not `VariantFormat`: a format off a query string or through an `as` reaches here as
|
|
78
94
|
// any string core can probe, and indexing FORMAT_EXTENSIONS with `gif` minted
|
|
79
95
|
// `photos/hero@full.undefined` — a well-formed, writable key naming a file nothing can serve.
|
|
@@ -101,7 +117,7 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
|
|
|
101
117
|
);
|
|
102
118
|
if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
|
|
103
119
|
if (parts.length === 0) parts.push('full');
|
|
104
|
-
return assertSafeKey(`${
|
|
120
|
+
return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
export interface SrcsetDescriptor {
|
|
@@ -139,23 +155,33 @@ export function srcsetDescriptors(
|
|
|
139
155
|
}));
|
|
140
156
|
}
|
|
141
157
|
|
|
142
|
-
/**
|
|
158
|
+
/** A derived edge is never zero: 1000x1 at width 100 rounded to a height no encoder can fill. */
|
|
159
|
+
const atLeastOne = (pixels: number): number => Math.max(1, Math.round(pixels));
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Aspect-ratio fitting. `cover` is the exact box asked for; everything else fits INSIDE the box
|
|
163
|
+
* and never above the source — upscaling is never useful, whichever edges were named.
|
|
164
|
+
*/
|
|
143
165
|
export function fitDimensions(source: ImageSize, transform: ImageTransform): ImageSize {
|
|
144
166
|
const ratio = source.height / source.width;
|
|
145
167
|
const { width, height } = transform;
|
|
168
|
+
edge('fitDimensions', 'width', width);
|
|
169
|
+
edge('fitDimensions', 'height', height);
|
|
146
170
|
if (width === undefined && height === undefined) return source;
|
|
147
171
|
if (height === undefined) {
|
|
148
172
|
const target = Math.min(width ?? source.width, source.width);
|
|
149
|
-
return { width: target, height:
|
|
173
|
+
return { width: target, height: atLeastOne(target * ratio) };
|
|
150
174
|
}
|
|
151
175
|
if (width === undefined) {
|
|
152
176
|
const target = Math.min(height, source.height);
|
|
153
|
-
return { width:
|
|
177
|
+
return { width: atLeastOne(target / ratio), height: target };
|
|
154
178
|
}
|
|
155
179
|
// `cover` crops to the exact box; `contain` scales down until both sides fit.
|
|
156
180
|
if (transform.fit === 'cover') return { width, height };
|
|
157
|
-
|
|
158
|
-
|
|
181
|
+
// Clamped at 1: a single named edge already stopped at the source, and two of them did not —
|
|
182
|
+
// a 40x20 source asked for 400x400 came back 400x200.
|
|
183
|
+
const scale = Math.min(1, width / source.width, height / source.height);
|
|
184
|
+
return { width: atLeastOne(source.width * scale), height: atLeastOne(source.height * scale) };
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/**
|
package/src/index.ts
CHANGED
|
@@ -43,22 +43,18 @@ export type {
|
|
|
43
43
|
StorageRead,
|
|
44
44
|
} from './driver';
|
|
45
45
|
export {
|
|
46
|
+
// Exported for the same reason `toBytes` is: a driver written outside this package has to
|
|
47
|
+
// refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
|
|
48
|
+
assertPutOptions,
|
|
46
49
|
DEFAULT_CONTENT_TYPE,
|
|
47
50
|
DEFAULT_LIST_LIMIT,
|
|
48
51
|
etagOf,
|
|
49
|
-
// Exported for the same reason `toBytes` is: a driver written outside this package has to
|
|
50
|
-
// refuse a `limit` the same way both shipped ones do, or it is a third answer to one question.
|
|
51
52
|
resolveListLimit,
|
|
52
53
|
sha256Base64,
|
|
53
54
|
toBytes,
|
|
54
55
|
} from './driver';
|
|
55
56
|
export type { LocalDriverOptions } from './driver-local';
|
|
56
|
-
export {
|
|
57
|
-
DEV_SIGNING_SECRET,
|
|
58
|
-
localDriver,
|
|
59
|
-
STORAGE_SIGNING_SECRET_KEY,
|
|
60
|
-
usesDevStorageSecret,
|
|
61
|
-
} from './driver-local';
|
|
57
|
+
export { localDriver } from './driver-local';
|
|
62
58
|
export type { MemoryDriverOptions, MemoryStorageDriver } from './driver-memory';
|
|
63
59
|
export { memoryDriver } from './driver-memory';
|
|
64
60
|
export type {
|
|
@@ -78,13 +74,17 @@ export {
|
|
|
78
74
|
contentTypeUnrecognised,
|
|
79
75
|
deleteFailed,
|
|
80
76
|
diskUnknown,
|
|
77
|
+
getTooLarge,
|
|
81
78
|
isStorageError,
|
|
79
|
+
keyConflict,
|
|
82
80
|
listFailed,
|
|
83
81
|
objectNotFound,
|
|
84
82
|
orgMismatch,
|
|
85
83
|
pathUnsafe,
|
|
84
|
+
putFailed,
|
|
86
85
|
putTooLarge,
|
|
87
86
|
quarantined,
|
|
87
|
+
readFailed,
|
|
88
88
|
STORAGE_ERROR_CODES,
|
|
89
89
|
STORAGE_ERROR_TITLES,
|
|
90
90
|
StorageError,
|
|
@@ -92,7 +92,6 @@ export {
|
|
|
92
92
|
signedUrlRejected,
|
|
93
93
|
signedUrlUnverifiable,
|
|
94
94
|
signingSecretMissing,
|
|
95
|
-
storageNotImplemented,
|
|
96
95
|
tooLarge,
|
|
97
96
|
uploadFailed,
|
|
98
97
|
} from './errors';
|
|
@@ -152,9 +151,15 @@ export {
|
|
|
152
151
|
SIGNED_URL_VERSION,
|
|
153
152
|
signConstraints,
|
|
154
153
|
signedUrlBaseFor,
|
|
154
|
+
signedUrlBasePath,
|
|
155
155
|
timingSafeEqual,
|
|
156
156
|
verifySignedUrl,
|
|
157
157
|
} from './signed-url';
|
|
158
|
+
export {
|
|
159
|
+
DEV_SIGNING_SECRET,
|
|
160
|
+
STORAGE_SIGNING_SECRET_KEY,
|
|
161
|
+
usesDevStorageSecret,
|
|
162
|
+
} from './signing-secret';
|
|
158
163
|
export type { Storage, StorageConfig } from './storage';
|
|
159
164
|
export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
|
|
160
165
|
export type { UploadCandidate, UploadPolicy, UploadPolicyInit, ValidatedUpload } from './upload';
|