@ultimat3/storage 29.3.2 → 30.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 +27 -47
- package/README.md +130 -12
- package/package.json +12 -2
- package/src/accept-part.ts +30 -0
- package/src/accept.ts +7 -0
- package/src/attachment.ts +99 -6
- package/src/byte-pump.ts +46 -0
- package/src/driver-local-list.ts +75 -0
- package/src/driver-local-multipart.ts +133 -0
- package/src/driver-local-read.ts +13 -0
- package/src/driver-local-remove.ts +39 -0
- package/src/driver-local-stream.ts +104 -0
- package/src/driver-local-write.ts +28 -5
- package/src/driver-local.ts +257 -283
- package/src/driver-memory-multipart.ts +41 -0
- package/src/driver-memory.ts +205 -135
- package/src/driver-s3-client.ts +9 -0
- package/src/driver-s3-copy.ts +35 -0
- package/src/driver-s3-lock.ts +9 -2
- package/src/driver-s3-multipart-session.ts +87 -0
- package/src/driver-s3-multipart.ts +177 -0
- package/src/driver-s3-presign.ts +52 -0
- package/src/driver-s3-put-stream.ts +97 -0
- package/src/driver-s3-range.ts +99 -0
- package/src/driver-s3-settle.ts +150 -0
- package/src/driver-s3-signed.ts +69 -4
- package/src/driver-s3-sse.ts +29 -0
- package/src/driver-s3.ts +113 -65
- package/src/driver.ts +98 -38
- package/src/errors-multipart.ts +35 -0
- package/src/errors-promote.ts +14 -0
- package/src/errors-range.ts +47 -0
- package/src/errors-sniff.ts +35 -0
- package/src/errors-stream.ts +62 -0
- package/src/errors.ts +16 -1
- package/src/expire.ts +169 -0
- package/src/grant-multipart.ts +196 -0
- package/src/grant.ts +42 -25
- package/src/image-warm.ts +84 -0
- package/src/image.ts +57 -0
- package/src/index.ts +81 -2
- package/src/multipart-staged.ts +127 -0
- package/src/multipart.ts +119 -0
- package/src/put-stream.ts +52 -0
- package/src/range.ts +102 -0
- package/src/signed-url.ts +59 -2
- package/src/subject-target.ts +82 -0
- package/src/upload-client-multipart.ts +156 -0
- package/src/upload-client.ts +21 -0
- package/src/upload.ts +107 -21
package/src/grant.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AttachmentTarget } from './attachment';
|
|
|
10
10
|
import { attachmentKey, pendingKey, quarantineKey, uploadName } from './attachment';
|
|
11
11
|
import type { StorageDriver } from './driver';
|
|
12
12
|
import { contentTypeNotAllowed, tooLarge } from './errors';
|
|
13
|
+
import { uploadSizeUndeclared } from './errors-stream';
|
|
13
14
|
import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
|
|
14
15
|
import type { UploadPolicy } from './upload';
|
|
15
16
|
import { normalizeContentType, uploadPolicy } from './upload';
|
|
@@ -19,10 +20,11 @@ export interface UploadRequest {
|
|
|
19
20
|
readonly filename: string;
|
|
20
21
|
readonly contentType: string;
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* The file's size in bytes. Required, because it is what the URL binds: an s3 disk signs it as the
|
|
24
|
+
* exact `Content-Length`, and the local disks sign it as the ceiling `acceptSignedUpload` counts
|
|
25
|
+
* against. A lie here shrinks the client's own upload; it cannot pass the policy's `maxBytes`.
|
|
24
26
|
*/
|
|
25
|
-
readonly size
|
|
27
|
+
readonly size: number;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface UploadGrant {
|
|
@@ -32,6 +34,7 @@ export interface UploadGrant {
|
|
|
32
34
|
readonly method: 'PUT';
|
|
33
35
|
/** Normalised. The client MUST send exactly this as `Content-Type` or the accept refuses. */
|
|
34
36
|
readonly contentType: string;
|
|
37
|
+
/** What the URL accepts: the declared `size`, already held to the policy. */
|
|
35
38
|
readonly maxBytes: number;
|
|
36
39
|
/** Epoch ms. The grant's own view of the window; the signature is what actually enforces it. */
|
|
37
40
|
readonly expiresAt: number;
|
|
@@ -57,12 +60,21 @@ export interface GrantUploadInput {
|
|
|
57
60
|
readonly uploadId?: (() => string) | undefined;
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/** What every grant decides before it signs: the key, the type and size the policy accepted. */
|
|
64
|
+
export interface GrantedUpload {
|
|
65
|
+
readonly key: string;
|
|
66
|
+
readonly contentType: string;
|
|
67
|
+
readonly size: number;
|
|
68
|
+
readonly expiresInMs: number;
|
|
69
|
+
readonly now: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
73
|
+
* The refusals both grants share — `grantUpload`'s one PUT and `grantMultipart`'s parts — so a
|
|
74
|
+
* request one refuses the other refuses too: the key derived, never the client's; the type held to
|
|
75
|
+
* the allowlist; the size to the policy; the window screened.
|
|
64
76
|
*/
|
|
65
|
-
export
|
|
77
|
+
export function decideGrant(caller: string, input: GrantUploadInput): GrantedUpload {
|
|
66
78
|
const policy = input.policy ?? uploadPolicy();
|
|
67
79
|
const clock = input.clock ?? systemClock;
|
|
68
80
|
const declared = normalizeContentType(input.request.contentType);
|
|
@@ -78,33 +90,38 @@ export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant>
|
|
|
78
90
|
throw contentTypeNotAllowed(key, declared, policy.allowedContentTypes);
|
|
79
91
|
}
|
|
80
92
|
const size = input.request.size;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
93
|
+
// Off the wire, so the type promises nothing here. An empty file has nothing to upload.
|
|
94
|
+
if (!Number.isSafeInteger(size) || size < 1) throw uploadSizeUndeclared(key, size);
|
|
95
|
+
if (size > policy.maxBytes) throw tooLarge(key, size, policy.maxBytes);
|
|
84
96
|
|
|
85
97
|
// 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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// too, so an explicit one took the default instead of this refusal.
|
|
98
|
+
// grant this function RETURNS, and the message a caller can act on names the grant's own option
|
|
99
|
+
// rather than `buildSignedUrl`'s. `=== undefined`, never `??`, for the reason `buildSignedUrl`
|
|
100
|
+
// states: `??` coalesces on `null` too, so an explicit one took the default instead.
|
|
90
101
|
const expiresInMs = finiteCount(
|
|
91
|
-
|
|
102
|
+
caller,
|
|
92
103
|
'expiresInMs',
|
|
93
104
|
input.expiresInMs === undefined ? DEFAULT_SIGNED_URL_TTL_MS : input.expiresInMs,
|
|
94
105
|
1,
|
|
95
106
|
);
|
|
107
|
+
return { key, contentType: declared, size, expiresInMs, now: clock.now().getTime() };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Refuses before it signs. A URL only exists for a request the policy already accepted, so an
|
|
112
|
+
* over-limit or disallowed upload costs one round trip instead of a full transfer. The signature
|
|
113
|
+
* then carries the size on every disk, so the client cannot widen it: at the provider's PUT on s3
|
|
114
|
+
* (403 on any other length), and at `acceptSignedUpload` on the local disks. The content type is
|
|
115
|
+
* signed only on a disk that signs its own URLs; on s3, `promoteAttachment` sniffs it.
|
|
116
|
+
*/
|
|
117
|
+
export async function grantUpload(input: GrantUploadInput): Promise<UploadGrant> {
|
|
118
|
+
const { key, contentType, size, expiresInMs, now } = decideGrant('grantUpload', input);
|
|
96
119
|
const url = await input.disk.signedUrl(key, {
|
|
97
120
|
method: 'PUT',
|
|
98
|
-
maxBytes:
|
|
99
|
-
|
|
121
|
+
maxBytes: size,
|
|
122
|
+
contentLength: size,
|
|
123
|
+
contentType,
|
|
100
124
|
expiresInMs,
|
|
101
125
|
});
|
|
102
|
-
return {
|
|
103
|
-
key,
|
|
104
|
-
url,
|
|
105
|
-
method: 'PUT',
|
|
106
|
-
contentType: declared,
|
|
107
|
-
maxBytes: policy.maxBytes,
|
|
108
|
-
expiresAt: clock.now().getTime() + expiresInMs,
|
|
109
|
-
};
|
|
126
|
+
return { key, url, method: 'PUT', contentType, maxBytes: size, expiresAt: now + expiresInMs };
|
|
110
127
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Single responsibility: write a stored image's variants AHEAD of the first request, under the exact
|
|
2
|
+
// keys the media route looks up, so no visitor pays the decode. The job an upload enqueues calls
|
|
3
|
+
// this; the request path then answers `exists` + `get` and never reaches the pipeline.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
assert,
|
|
7
|
+
finiteCount,
|
|
8
|
+
IMAGE_MIME_TYPES,
|
|
9
|
+
isUltimateError,
|
|
10
|
+
probeImage,
|
|
11
|
+
transformImageBytes,
|
|
12
|
+
} from '@ultimat3/core';
|
|
13
|
+
import type { StorageDriver } from './driver';
|
|
14
|
+
import { type VariantFormat, variantKey, variantRevision } from './image';
|
|
15
|
+
|
|
16
|
+
export interface WarmVariantsInput {
|
|
17
|
+
readonly disk: StorageDriver;
|
|
18
|
+
/** The SOURCE object's key, as the media route is asked for it. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
/** Whole pixels. Widths above the source's are skipped: the route never stores an upscale. */
|
|
21
|
+
readonly widths: readonly number[];
|
|
22
|
+
/** `avif` is accepted by the type and fails per variant with core's `X_IMAGE_UNSUPPORTED`. */
|
|
23
|
+
readonly formats: readonly VariantFormat[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A variant that was not written: its key, and the failure's code and cause. */
|
|
27
|
+
export interface WarmFailure {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
/** `X_IMAGE_UNSUPPORTED` for an `avif`, a storage code for a refused `put`. */
|
|
30
|
+
readonly code: string;
|
|
31
|
+
readonly cause: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WarmVariantsResult {
|
|
35
|
+
/** The variant keys written, in width-then-format order. */
|
|
36
|
+
readonly written: readonly string[];
|
|
37
|
+
/** One entry per variant that could not be made or stored; the others were still written. */
|
|
38
|
+
readonly failed: readonly WarmFailure[];
|
|
39
|
+
/** Widths above the source's intrinsic width: never minted, never stored. */
|
|
40
|
+
readonly skipped: readonly number[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every `widths` x `formats` variant of `key`, keyed as `variantKey(key, { width, format,
|
|
45
|
+
* revision })` with the revision read off THIS read's etag — the one the bytes were made from, so
|
|
46
|
+
* an overwrite racing the warm-up can only leave keys its new etag never names. The transform spec
|
|
47
|
+
* is the builtin image driver's (`width` + `format`, default quality), so a warmed variant is the
|
|
48
|
+
* object the route would have stored itself.
|
|
49
|
+
*
|
|
50
|
+
* The source is READ and PROBED once, and the probe holds it to core's `MAX_IMAGE_PIXELS` before
|
|
51
|
+
* any pixel is decoded; each variant is then one pipeline pass over the bytes already in hand.
|
|
52
|
+
*/
|
|
53
|
+
export async function warmVariants(input: WarmVariantsInput): Promise<WarmVariantsResult> {
|
|
54
|
+
for (const width of input.widths) finiteCount('warmVariants', 'widths', width, 1);
|
|
55
|
+
const source = await input.disk.get(input.key);
|
|
56
|
+
const revision = variantRevision(source.object.etag);
|
|
57
|
+
assert(
|
|
58
|
+
revision !== undefined,
|
|
59
|
+
`warmVariants('${input.key}'): the source etag "${source.object.etag}" carries no 8-hex revision, so no variant of it has a key the media route would read`,
|
|
60
|
+
`x doctor --json # the disk driver answers no content etag; variants of ${input.key} are served, never stored`,
|
|
61
|
+
);
|
|
62
|
+
// A header read: past `MAX_IMAGE_PIXELS` it is core's `X_IMAGE_TOO_LARGE` before any decode.
|
|
63
|
+
const info = probeImage(source.bytes);
|
|
64
|
+
const written: string[] = [];
|
|
65
|
+
const failed: WarmFailure[] = [];
|
|
66
|
+
const skipped = input.widths.filter((width) => width > info.width);
|
|
67
|
+
for (const width of input.widths.filter((one) => one <= info.width)) {
|
|
68
|
+
for (const format of input.formats) {
|
|
69
|
+
const key = variantKey(input.key, { width, format, revision });
|
|
70
|
+
try {
|
|
71
|
+
const bytes = await transformImageBytes(source.bytes, { width, format });
|
|
72
|
+
await input.disk.put(key, bytes, {
|
|
73
|
+
contentType: IMAGE_MIME_TYPES[probeImage(bytes).format],
|
|
74
|
+
});
|
|
75
|
+
written.push(key);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// Coded failures are per-variant answers; anything uncoded is a defect and is not swallowed.
|
|
78
|
+
if (!isUltimateError(error)) throw error;
|
|
79
|
+
failed.push({ key, code: error.code, cause: error.cause });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { written, failed, skipped };
|
|
84
|
+
}
|
package/src/image.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// bytes, `transformImage()` returns exactly the size `fitDimensions()` already promised.
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
assert,
|
|
7
8
|
assertFiniteImageQuality,
|
|
8
9
|
blurDataUrl,
|
|
9
10
|
finiteCount,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
transformImageBytes,
|
|
14
15
|
} from '@ultimat3/core';
|
|
15
16
|
import { assertSafeKey } from './path';
|
|
17
|
+
import { normalizeContentType } from './upload';
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* The formats a stored VARIANT can be minted in — a strict subset of the ones `@ultimat3/core`
|
|
@@ -41,6 +43,50 @@ export type VariantFormat = (typeof VARIANT_FORMATS)[number];
|
|
|
41
43
|
export const isVariantFormat = (format: string): format is VariantFormat =>
|
|
42
44
|
(VARIANT_FORMATS as readonly string[]).includes(format);
|
|
43
45
|
|
|
46
|
+
/** The source encodings core can write back, so a variant with no `?f=` keeps its source's. */
|
|
47
|
+
const SOURCE_VARIANT_FORMATS: ReadonlyMap<string, VariantFormat> = new Map([
|
|
48
|
+
['image/png', 'png'],
|
|
49
|
+
['image/jpeg', 'jpeg'],
|
|
50
|
+
['image/webp', 'webp'],
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The format a variant of this stored type is written in when nobody named one. `undefined` (a
|
|
55
|
+
* `gif`) means no key can state what the transform will answer: such a variant is served, never
|
|
56
|
+
* stored. Without this `variantKey` read the absent format as `webp` while the driver kept the
|
|
57
|
+
* source's, so `?w=320` and `?w=320&f=webp` shared one key and the first request chose the bytes.
|
|
58
|
+
*/
|
|
59
|
+
export const variantFormatOf = (contentType: string): VariantFormat | undefined =>
|
|
60
|
+
SOURCE_VARIANT_FORMATS.get(normalizeContentType(contentType));
|
|
61
|
+
|
|
62
|
+
const REVISION = /^[0-9a-f]{8}$/;
|
|
63
|
+
/** The `@…r<8 hex>.<ext>` only `variantKey({ revision })` writes. The last `@` is the variant's. */
|
|
64
|
+
const REVISIONED_VARIANT = /^(.+)@(?:[^@/]*-)?r([0-9a-f]{8})\.(?:avif|webp|jpg|png)$/;
|
|
65
|
+
|
|
66
|
+
/** The source and revision a stored variant's key names, or `undefined` for any other key. */
|
|
67
|
+
export function variantSourceOf(
|
|
68
|
+
key: string,
|
|
69
|
+
): { readonly source: string; readonly revision: string } | undefined {
|
|
70
|
+
const match = REVISIONED_VARIANT.exec(key);
|
|
71
|
+
return match?.[1] === undefined || match[2] === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: { source: match[1], revision: match[2] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The part of a source's etag a variant key carries, so an overwritten source misses every variant
|
|
78
|
+
* of its old bytes. Hex only, because a provider's etag arrives quoted and a multipart one carries
|
|
79
|
+
* `-<parts>`. `undefined` when the etag is unknowable (`''`) or too short to tell two revisions
|
|
80
|
+
* apart: that variant has no honest key, so it is served, never stored.
|
|
81
|
+
*/
|
|
82
|
+
export function variantRevision(etag: string): string | undefined {
|
|
83
|
+
const revision = etag
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^0-9a-f]/g, '')
|
|
86
|
+
.slice(0, 8);
|
|
87
|
+
return REVISION.test(revision) ? revision : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
44
90
|
/** `cover` fills the box and crops the overflow; `contain` fits inside it, no crop. */
|
|
45
91
|
export type ImageFit = 'cover' | 'contain';
|
|
46
92
|
|
|
@@ -51,6 +97,8 @@ export interface ImageTransform {
|
|
|
51
97
|
/** 1-100. Omitted means the format default (`DEFAULT_QUALITY`). */
|
|
52
98
|
readonly quality?: number | undefined;
|
|
53
99
|
readonly fit?: ImageFit | undefined;
|
|
100
|
+
/** `variantRevision(source.etag)`: which bytes this variant was made from. Absent, unkeyed. */
|
|
101
|
+
readonly revision?: string | undefined;
|
|
54
102
|
}
|
|
55
103
|
|
|
56
104
|
export interface ImageSize {
|
|
@@ -116,6 +164,15 @@ export function variantKey(sourceKey: string, transform: ImageTransform): string
|
|
|
116
164
|
transform.quality === undefined ? DEFAULT_QUALITY : transform.quality,
|
|
117
165
|
);
|
|
118
166
|
if (quality !== DEFAULT_QUALITY) parts.push(`q${quality}`);
|
|
167
|
+
const { revision } = transform;
|
|
168
|
+
if (revision !== undefined) {
|
|
169
|
+
assert(
|
|
170
|
+
REVISION.test(revision),
|
|
171
|
+
'variantKey was given a revision that is not 8 hex digits, so it would mint a key nothing can derive again',
|
|
172
|
+
'variantKey(key, { width, revision: variantRevision(object.etag) })',
|
|
173
|
+
);
|
|
174
|
+
parts.push(`r${revision}`);
|
|
175
|
+
}
|
|
119
176
|
if (parts.length === 0) parts.push('full');
|
|
120
177
|
return assertSafeKey(`${safe}@${parts.join('-')}.${FORMAT_EXTENSIONS[format]}`);
|
|
121
178
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
export type { AcceptSignedUploadInput, SignedRequestInput } from './accept';
|
|
5
5
|
export { acceptSignedUpload, readSignedObject, signedUploadConstraints } from './accept';
|
|
6
|
+
export type { AcceptSignedPartInput } from './accept-part';
|
|
7
|
+
export { acceptSignedPart } from './accept-part';
|
|
6
8
|
export type {
|
|
7
9
|
AttachmentTarget,
|
|
10
|
+
DeleteVariantsInput,
|
|
8
11
|
PromoteAttachmentInput,
|
|
9
12
|
ReleaseQuarantineInput,
|
|
10
13
|
SweepFailure,
|
|
@@ -14,6 +17,7 @@ export type {
|
|
|
14
17
|
export {
|
|
15
18
|
attachmentKey,
|
|
16
19
|
attachmentPrefix,
|
|
20
|
+
deleteVariants,
|
|
17
21
|
isPendingKey,
|
|
18
22
|
isQuarantinedKey,
|
|
19
23
|
PENDING_SEGMENT,
|
|
@@ -28,8 +32,11 @@ export {
|
|
|
28
32
|
uploadExtension,
|
|
29
33
|
uploadName,
|
|
30
34
|
} from './attachment';
|
|
35
|
+
export type { PumpLimit } from './byte-pump';
|
|
36
|
+
export { pumpBounded } from './byte-pump';
|
|
31
37
|
export type {
|
|
32
38
|
ByteLimit,
|
|
39
|
+
CopyOptions,
|
|
33
40
|
ListOptions,
|
|
34
41
|
PutOptions,
|
|
35
42
|
ServerSideEncryption,
|
|
@@ -50,6 +57,7 @@ export {
|
|
|
50
57
|
etagOf,
|
|
51
58
|
resolveListLimit,
|
|
52
59
|
sha256Base64,
|
|
60
|
+
signedByteBound,
|
|
53
61
|
toBytes,
|
|
54
62
|
} from './driver';
|
|
55
63
|
export type { LocalDriverOptions } from './driver-local';
|
|
@@ -96,9 +104,33 @@ export {
|
|
|
96
104
|
uploadFailed,
|
|
97
105
|
xmlBodyUnreadable,
|
|
98
106
|
} from './errors';
|
|
107
|
+
export { multipartUnsupported, partsInvalid, uploadUnknown } from './errors-multipart';
|
|
99
108
|
export { alreadyPromoted, quarantined } from './errors-promote';
|
|
109
|
+
export { rangeTooLarge, rangeUnsatisfiable } from './errors-range';
|
|
110
|
+
export type {
|
|
111
|
+
AbortResult,
|
|
112
|
+
AbortStaleUploadsInput,
|
|
113
|
+
ExpireObjectsInput,
|
|
114
|
+
ExpireResult,
|
|
115
|
+
ObjectPurgeTargetInput,
|
|
116
|
+
} from './expire';
|
|
117
|
+
export { abortStaleUploads, expireObjects, objectPurgeTarget } from './expire';
|
|
100
118
|
export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
|
|
101
119
|
export { grantUpload } from './grant';
|
|
120
|
+
export type {
|
|
121
|
+
CompleteUploadInput,
|
|
122
|
+
GrantMultipartInput,
|
|
123
|
+
MultipartGrant,
|
|
124
|
+
SignPartInput,
|
|
125
|
+
UploadSessionInput,
|
|
126
|
+
} from './grant-multipart';
|
|
127
|
+
export {
|
|
128
|
+
abortUpload,
|
|
129
|
+
completeUpload,
|
|
130
|
+
grantMultipart,
|
|
131
|
+
listParts,
|
|
132
|
+
signPart,
|
|
133
|
+
} from './grant-multipart';
|
|
102
134
|
export type {
|
|
103
135
|
ImageFit,
|
|
104
136
|
ImageSize,
|
|
@@ -119,8 +151,21 @@ export {
|
|
|
119
151
|
srcsetDescriptors,
|
|
120
152
|
transformImage,
|
|
121
153
|
VARIANT_FORMATS,
|
|
154
|
+
variantFormatOf,
|
|
122
155
|
variantKey,
|
|
156
|
+
variantRevision,
|
|
157
|
+
variantSourceOf,
|
|
123
158
|
} from './image';
|
|
159
|
+
export type { WarmFailure, WarmVariantsInput, WarmVariantsResult } from './image-warm';
|
|
160
|
+
export { warmVariants } from './image-warm';
|
|
161
|
+
export type {
|
|
162
|
+
CompletedPart,
|
|
163
|
+
MultipartCapability,
|
|
164
|
+
MultipartSession,
|
|
165
|
+
SignPartOptions,
|
|
166
|
+
UploadedPart,
|
|
167
|
+
} from './multipart';
|
|
168
|
+
export { MAX_PART_BYTES, MAX_PARTS, MIN_PART_BYTES, matchParts, partBytesFor } from './multipart';
|
|
124
169
|
export type {
|
|
125
170
|
LockedAction,
|
|
126
171
|
ObjectLock,
|
|
@@ -129,7 +174,6 @@ export type {
|
|
|
129
174
|
RetentionMode,
|
|
130
175
|
} from './object-lock';
|
|
131
176
|
export { isLocked, ObjectLockedError, RETENTION_MODES } from './object-lock';
|
|
132
|
-
|
|
133
177
|
export {
|
|
134
178
|
assertSafeKey,
|
|
135
179
|
isSafeKey,
|
|
@@ -144,7 +188,16 @@ export {
|
|
|
144
188
|
orgPrefix,
|
|
145
189
|
scopedKey,
|
|
146
190
|
} from './path';
|
|
191
|
+
export type { PutStreamOptions } from './put-stream';
|
|
192
|
+
export { assertPutStream, streamLimit } from './put-stream';
|
|
193
|
+
/**
|
|
194
|
+
* `get(key, { range })`. A third-party driver screens and clamps through these two, or it is a
|
|
195
|
+
* fourth answer to what a range means.
|
|
196
|
+
*/
|
|
197
|
+
export type { ByteRange, GetOptions } from './range';
|
|
198
|
+
export { assertGetOptions, assertStreamRange, servedRange, streamedRange } from './range';
|
|
147
199
|
export type {
|
|
200
|
+
SignedPart,
|
|
148
201
|
SignedUrlConstraints,
|
|
149
202
|
SignedUrlFailure,
|
|
150
203
|
SignedUrlInput,
|
|
@@ -171,15 +224,31 @@ export {
|
|
|
171
224
|
} from './signing-secret';
|
|
172
225
|
export type { Storage, StorageConfig } from './storage';
|
|
173
226
|
export { definedStorage, defineStorage, disk, resetStorage, storage } from './storage';
|
|
174
|
-
export type {
|
|
227
|
+
export type {
|
|
228
|
+
StorageSubjectRef,
|
|
229
|
+
StorageSubjectTarget,
|
|
230
|
+
StorageSubjectTargetOptions,
|
|
231
|
+
} from './subject-target';
|
|
232
|
+
export { STORAGE_SUBJECT_COLUMNS, storageSubjectTarget } from './subject-target';
|
|
233
|
+
export type {
|
|
234
|
+
StoredHeadCandidate,
|
|
235
|
+
UploadCandidate,
|
|
236
|
+
UploadPolicy,
|
|
237
|
+
UploadPolicyInit,
|
|
238
|
+
ValidatedHead,
|
|
239
|
+
ValidatedUpload,
|
|
240
|
+
} from './upload';
|
|
175
241
|
export {
|
|
176
242
|
contentTypeMatches,
|
|
177
243
|
DEFAULT_MAX_UPLOAD_BYTES,
|
|
178
244
|
DOCUMENT_CONTENT_TYPES,
|
|
179
245
|
IMAGE_CONTENT_TYPES,
|
|
246
|
+
MAX_LEADING_WHITESPACE,
|
|
180
247
|
normalizeContentType,
|
|
248
|
+
SNIFF_WINDOW_BYTES,
|
|
181
249
|
sniffContentType,
|
|
182
250
|
uploadPolicy,
|
|
251
|
+
validateStoredHead,
|
|
183
252
|
validateUpload,
|
|
184
253
|
} from './upload';
|
|
185
254
|
export type {
|
|
@@ -196,3 +265,13 @@ export {
|
|
|
196
265
|
uploadFile,
|
|
197
266
|
xhrSignedPut,
|
|
198
267
|
} from './upload-client';
|
|
268
|
+
export type {
|
|
269
|
+
MultipartUploadClient,
|
|
270
|
+
MultipartUploadInput,
|
|
271
|
+
UploadSessionRef,
|
|
272
|
+
} from './upload-client-multipart';
|
|
273
|
+
export {
|
|
274
|
+
DEFAULT_PART_THRESHOLD_BYTES,
|
|
275
|
+
PART_CONCURRENCY,
|
|
276
|
+
uploadInParts,
|
|
277
|
+
} from './upload-client-multipart';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Single responsibility: an upload in parts on a disk that signs its own URLs (`local`, `memory`):
|
|
2
|
+
// the session rules over a `PartStore` the disk supplies. Same contract as s3's, so dev and tests
|
|
3
|
+
// behave as production: a part URL is signed, a part lands only in an open session, a Complete
|
|
4
|
+
// assembles exactly the parts named and drops the session, and an abort leaves nothing.
|
|
5
|
+
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { etagOf, type StorageDriver, type StorageObject } from './driver';
|
|
8
|
+
import { uploadUnknown } from './errors-multipart';
|
|
9
|
+
import type { MultipartCapability, MultipartSession, UploadedPart } from './multipart';
|
|
10
|
+
import { assertSafeKey } from './path';
|
|
11
|
+
|
|
12
|
+
/** What a staged session records at `create`. `initiated` is ISO, so a file store can hold it. */
|
|
13
|
+
export interface StagedSession {
|
|
14
|
+
readonly key: string;
|
|
15
|
+
readonly contentType: string;
|
|
16
|
+
readonly initiated: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Where a disk keeps open sessions and their parts: a directory, or a `Map`. */
|
|
20
|
+
export interface PartStore {
|
|
21
|
+
open(uploadId: string, session: StagedSession): Promise<void>;
|
|
22
|
+
session(uploadId: string): Promise<StagedSession | undefined>;
|
|
23
|
+
writePart(uploadId: string, part: UploadedPart, bytes: Uint8Array): Promise<void>;
|
|
24
|
+
/** Ascending by part number. */
|
|
25
|
+
parts(uploadId: string): Promise<readonly UploadedPart[]>;
|
|
26
|
+
/** The named parts' bytes, end to end, read as the stream is pulled. */
|
|
27
|
+
read(uploadId: string, parts: readonly UploadedPart[]): ReadableStream<Uint8Array>;
|
|
28
|
+
drop(uploadId: string): Promise<void>;
|
|
29
|
+
sessions(): Promise<readonly (readonly [string, StagedSession])[]>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface StagedMultipartOptions {
|
|
33
|
+
readonly store: PartStore;
|
|
34
|
+
readonly clock: Clock;
|
|
35
|
+
/** The disk the parts belong to: it signs their URLs and takes the Complete's stream. */
|
|
36
|
+
readonly driver: StorageDriver;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The disk with `multipart` attached IN PLACE, never spread: a spread reads `signedUrlBase` once,
|
|
41
|
+
* and `registerAs` re-points it after construction.
|
|
42
|
+
*/
|
|
43
|
+
export function withStagedMultipart<D extends StorageDriver>(
|
|
44
|
+
driver: D,
|
|
45
|
+
store: PartStore,
|
|
46
|
+
clock: Clock,
|
|
47
|
+
): D {
|
|
48
|
+
return Object.assign(driver, { multipart: stagedMultipart({ store, clock, driver }) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stagedMultipart(options: StagedMultipartOptions): MultipartCapability {
|
|
52
|
+
const { store } = options;
|
|
53
|
+
/** The session, held to the key it was opened for: an id is never a capability on its own. */
|
|
54
|
+
const openSession = async (key: string, uploadId: string): Promise<StagedSession> => {
|
|
55
|
+
const session = await store.session(uploadId);
|
|
56
|
+
if (session === undefined || session.key !== key) {
|
|
57
|
+
throw uploadUnknown(options.driver.name, key, uploadId);
|
|
58
|
+
}
|
|
59
|
+
return session;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
async create(key, { contentType }): Promise<string> {
|
|
64
|
+
const safe = assertSafeKey(key);
|
|
65
|
+
const uploadId = crypto.randomUUID();
|
|
66
|
+
await store.open(uploadId, {
|
|
67
|
+
key: safe,
|
|
68
|
+
contentType,
|
|
69
|
+
initiated: options.clock.now().toISOString(),
|
|
70
|
+
});
|
|
71
|
+
return uploadId;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
signPart: (key, uploadId, partNumber, { contentLength, expiresInMs }) =>
|
|
75
|
+
options.driver.signedUrl(key, {
|
|
76
|
+
method: 'PUT',
|
|
77
|
+
contentLength,
|
|
78
|
+
maxBytes: contentLength,
|
|
79
|
+
expiresInMs,
|
|
80
|
+
part: { uploadId, partNumber },
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
async listParts(key, uploadId): Promise<readonly UploadedPart[]> {
|
|
84
|
+
await openSession(assertSafeKey(key), uploadId);
|
|
85
|
+
return store.parts(uploadId);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async receivePart(key, uploadId, partNumber, bytes): Promise<UploadedPart> {
|
|
89
|
+
await openSession(assertSafeKey(key), uploadId);
|
|
90
|
+
const part = { partNumber, etag: etagOf(bytes), size: bytes.byteLength };
|
|
91
|
+
await store.writePart(uploadId, part, bytes);
|
|
92
|
+
return part;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async complete(key, uploadId, parts): Promise<StorageObject> {
|
|
96
|
+
const safe = assertSafeKey(key);
|
|
97
|
+
const session = await openSession(safe, uploadId);
|
|
98
|
+
const total = parts.reduce((sum, part) => sum + part.size, 0);
|
|
99
|
+
const object = await options.driver.putStream(safe, store.read(uploadId, parts), {
|
|
100
|
+
maxBytes: Math.max(total, 1),
|
|
101
|
+
contentType: session.contentType,
|
|
102
|
+
});
|
|
103
|
+
await store.drop(uploadId);
|
|
104
|
+
return object;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async abort(key, uploadId): Promise<void> {
|
|
108
|
+
const safe = assertSafeKey(key);
|
|
109
|
+
const session = await store.session(uploadId);
|
|
110
|
+
if (session?.key === safe) await store.drop(uploadId);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async listUploads(prefix): Promise<readonly MultipartSession[]> {
|
|
114
|
+
const open = await store.sessions();
|
|
115
|
+
return open
|
|
116
|
+
.filter(([, session]) => prefix === undefined || session.key.startsWith(prefix))
|
|
117
|
+
.map(([uploadId, session]) => {
|
|
118
|
+
const initiated = new Date(session.initiated);
|
|
119
|
+
return {
|
|
120
|
+
key: session.key,
|
|
121
|
+
uploadId,
|
|
122
|
+
...(Number.isNaN(initiated.getTime()) ? {} : { initiated }),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|