@ultimat3/storage 23.0.0 → 25.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 +69 -42
- package/README.md +165 -15
- package/package.json +3 -3
- package/src/accept.ts +3 -6
- package/src/attachment.ts +46 -16
- package/src/driver-local-read.ts +114 -0
- package/src/driver-local-sidecar.ts +96 -0
- package/src/driver-local-write.ts +185 -0
- package/src/driver-local.ts +166 -188
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +109 -25
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +180 -167
- package/src/driver.ts +98 -19
- package/src/errors.ts +112 -13
- package/src/grant.ts +2 -2
- package/src/image.ts +34 -8
- package/src/index.ts +29 -16
- package/src/iso-bmff.ts +50 -0
- package/src/object-lock.ts +173 -0
- package/src/path.ts +17 -2
- package/src/signed-url.ts +41 -12
- package/src/signing-secret.ts +76 -0
- package/src/upload.ts +82 -5
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Single responsibility: the s3 disk's SIGNED requests — the ones `Bun.S3Client` has no option for
|
|
2
|
+
// (`x-amz-meta-*`, `Cache-Control`, `x-amz-object-lock-*`, `?retention`, `?legal-hold`) — built,
|
|
3
|
+
// signed through core's one SigV4 signer and sent with `fetch`. The endpoint, the addressing style,
|
|
4
|
+
// the region and the credentials are read from the SAME `S3DriverOptions` the Bun client is built
|
|
5
|
+
// from, so the two transports of one disk cannot point at two buckets.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type AwsCredentials,
|
|
9
|
+
type Clock,
|
|
10
|
+
ConfigInvalidError,
|
|
11
|
+
EnvMissingError,
|
|
12
|
+
signAwsRequest,
|
|
13
|
+
systemClock,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
|
|
16
|
+
/** The slice of `fetch` this file uses; injected in tests so no socket is ever opened. */
|
|
17
|
+
export type S3FetchLike = (
|
|
18
|
+
url: string,
|
|
19
|
+
init: {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
22
|
+
readonly body?: Uint8Array<ArrayBuffer> | undefined;
|
|
23
|
+
},
|
|
24
|
+
) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
/** Structurally `S3DriverOptions`, so the driver passes its own options through unchanged. */
|
|
27
|
+
export interface S3WireOptions {
|
|
28
|
+
readonly bucket: string;
|
|
29
|
+
readonly region?: string | undefined;
|
|
30
|
+
readonly endpoint?: string | undefined;
|
|
31
|
+
readonly forcePathStyle?: boolean | undefined;
|
|
32
|
+
readonly accessKeyIdEnv?: string | undefined;
|
|
33
|
+
readonly secretAccessKeyEnv?: string | undefined;
|
|
34
|
+
readonly sessionTokenEnv?: string | undefined;
|
|
35
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
36
|
+
readonly fetch?: S3FetchLike | undefined;
|
|
37
|
+
readonly clock?: Clock | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a provider's refusal is read as — the `code`/`message`/`statusCode` triple `Bun.S3Client`'s
|
|
42
|
+
* `S3Error` carries, so `regionMismatch` and `isAbsentObject` classify a signed request's failure
|
|
43
|
+
* exactly as they classify the Bun client's. A plain object: it is never thrown bare, only handed
|
|
44
|
+
* to a coded factory.
|
|
45
|
+
*/
|
|
46
|
+
export interface S3Refusal {
|
|
47
|
+
readonly name: 'S3Error';
|
|
48
|
+
readonly code: string;
|
|
49
|
+
readonly message: string;
|
|
50
|
+
readonly statusCode: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface S3SignedRequest {
|
|
54
|
+
readonly method: 'GET' | 'PUT';
|
|
55
|
+
readonly key: string;
|
|
56
|
+
/** A sub-resource such as `retention` — sent and signed as `?retention=`. */
|
|
57
|
+
readonly subresource?: string | undefined;
|
|
58
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
59
|
+
readonly body?: Uint8Array | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type S3SignedResult =
|
|
63
|
+
| { readonly ok: true; readonly response: Response }
|
|
64
|
+
| { readonly ok: false; readonly refusal: S3Refusal };
|
|
65
|
+
|
|
66
|
+
/** One credential reader for both transports: an unset or empty variable is `X_ENV_MISSING`. */
|
|
67
|
+
export function requireEnv(
|
|
68
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
69
|
+
name: string,
|
|
70
|
+
partner: string,
|
|
71
|
+
): string {
|
|
72
|
+
const value = env[name];
|
|
73
|
+
if (value === undefined || value === '') {
|
|
74
|
+
throw new EnvMissingError({
|
|
75
|
+
cause: `${name} is not set, so the s3 disk cannot authenticate`,
|
|
76
|
+
fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
|
|
77
|
+
meta: { missing: name },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a disk's requests go, and as whom beyond the key pair — resolved ONCE for both transports. */
|
|
84
|
+
export interface S3Target {
|
|
85
|
+
readonly endpoint?: string | undefined;
|
|
86
|
+
readonly region?: string | undefined;
|
|
87
|
+
readonly sessionToken?: string | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The first of `names` set to something other than `''` — Bun's reading of an unset variable. */
|
|
91
|
+
const firstSet = (
|
|
92
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
93
|
+
names: readonly string[],
|
|
94
|
+
): string | undefined => {
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const value = env[name];
|
|
97
|
+
if (value !== undefined && value !== '') return value;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bun's own defaults, restated so the signed path cannot target another place: an option wins,
|
|
104
|
+
* then `S3_*`, then `AWS_*` — `S3_ENDPOINT`/`AWS_ENDPOINT`, `S3_REGION`/`AWS_REGION`,
|
|
105
|
+
* `S3_SESSION_TOKEN`/`AWS_SESSION_TOKEN` (measured on Bun 1.4.2, and its `S3Options` docs). Read
|
|
106
|
+
* from `options.env ?? process.env`, and `buildClient` hands the result to Bun explicitly, so an
|
|
107
|
+
* injected table governs both transports. A `sessionTokenEnv` names the one variable to read.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveS3Target(options: S3WireOptions): S3Target {
|
|
110
|
+
const env = options.env ?? process.env;
|
|
111
|
+
const tokenVar = options.sessionTokenEnv;
|
|
112
|
+
const endpoint = options.endpoint ?? firstSet(env, ['S3_ENDPOINT', 'AWS_ENDPOINT']);
|
|
113
|
+
const region = options.region ?? firstSet(env, ['S3_REGION', 'AWS_REGION']);
|
|
114
|
+
const sessionToken =
|
|
115
|
+
tokenVar === undefined
|
|
116
|
+
? firstSet(env, ['S3_SESSION_TOKEN', 'AWS_SESSION_TOKEN'])
|
|
117
|
+
: firstSet(env, [tokenVar]);
|
|
118
|
+
return {
|
|
119
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
120
|
+
...(region === undefined ? {} : { region }),
|
|
121
|
+
...(sessionToken === undefined ? {} : { sessionToken }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read lazily, at the first signed request — importing or constructing the disk reads nothing. */
|
|
126
|
+
export function s3Credentials(options: S3WireOptions): AwsCredentials {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
|
|
129
|
+
const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
|
|
130
|
+
const { sessionToken } = resolveS3Target(options);
|
|
131
|
+
return {
|
|
132
|
+
accessKeyId: requireEnv(env, idVar, secretVar),
|
|
133
|
+
secretAccessKey: requireEnv(env, secretVar, idVar),
|
|
134
|
+
...(sessionToken === undefined || sessionToken === '' ? {} : { sessionToken }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bun's addressing, restated: path style unless `forcePathStyle` is EXPLICITLY false (Bun's own
|
|
140
|
+
* `virtualHostedStyle` defaults to false), and AWS's regional endpoint when none is configured.
|
|
141
|
+
*/
|
|
142
|
+
export function s3ObjectUrl(options: S3WireOptions, key: string): URL {
|
|
143
|
+
const target = resolveS3Target(options);
|
|
144
|
+
const endpoint = target.endpoint ?? `https://s3.${target.region ?? 'us-east-1'}.amazonaws.com`;
|
|
145
|
+
const base = new URL(endpoint);
|
|
146
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
147
|
+
if (options.forcePathStyle === false) {
|
|
148
|
+
return new URL(`${base.protocol}//${options.bucket}.${base.host}/${path}`);
|
|
149
|
+
}
|
|
150
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
151
|
+
return new URL(
|
|
152
|
+
`${base.protocol}//${base.host}${prefix}/${encodeURIComponent(options.bucket)}/${path}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const XML_FIELD = (name: string): RegExp => new RegExp(`<${name}>([^<]{0,512})</${name}>`);
|
|
157
|
+
|
|
158
|
+
/** The first `<name>…</name>` of an S3 XML answer — the shapes read here are flat and fixed. */
|
|
159
|
+
export function xmlField(xml: string, name: string): string | undefined {
|
|
160
|
+
return XML_FIELD(name).exec(xml)?.[1]?.trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A non-2xx answer as the refusal it is. A HEAD-style empty body still has its status. */
|
|
164
|
+
async function refusalOf(response: Response): Promise<S3Refusal> {
|
|
165
|
+
const body = await response.text().catch(() => '');
|
|
166
|
+
return {
|
|
167
|
+
name: 'S3Error',
|
|
168
|
+
code: xmlField(body, 'Code') ?? `HTTP${response.status}`,
|
|
169
|
+
message: xmlField(body, 'Message') ?? response.statusText,
|
|
170
|
+
statusCode: response.status,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sign and send one request. Resolves with the response or the provider's refusal — never throws
|
|
176
|
+
* for an answer the provider gave, so each caller decides what a refusal means for ITS call. A
|
|
177
|
+
* request with no answer at all (DNS, reset) rejects with whatever `fetch` raised.
|
|
178
|
+
*/
|
|
179
|
+
export async function sendSigned(
|
|
180
|
+
options: S3WireOptions,
|
|
181
|
+
request: S3SignedRequest,
|
|
182
|
+
): Promise<S3SignedResult> {
|
|
183
|
+
// The Bun client's own refusal, for the transport beside it: one disk, one rule.
|
|
184
|
+
if (options.bucket === '') {
|
|
185
|
+
throw new ConfigInvalidError({
|
|
186
|
+
cause: 's3 disk was defined without a bucket',
|
|
187
|
+
fix: "s3Driver({ bucket: 'my-bucket' }) # storage.disks.<name> in app.config.ts",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const url = s3ObjectUrl(options, request.key);
|
|
191
|
+
if (request.subresource !== undefined) url.search = `${request.subresource}=`;
|
|
192
|
+
const signed = await signAwsRequest({
|
|
193
|
+
method: request.method,
|
|
194
|
+
url,
|
|
195
|
+
headers: request.headers ?? {},
|
|
196
|
+
// The body is in memory and bounded by `maxPutBytes`, so it is hashed and the provider can
|
|
197
|
+
// refuse a body that changed in flight; a read has none.
|
|
198
|
+
payload: { body: request.body ?? new Uint8Array() },
|
|
199
|
+
credentials: s3Credentials(options),
|
|
200
|
+
region: resolveS3Target(options).region ?? 'auto',
|
|
201
|
+
service: 's3',
|
|
202
|
+
clock: options.clock ?? systemClock,
|
|
203
|
+
});
|
|
204
|
+
const send = options.fetch ?? fetch;
|
|
205
|
+
const body = request.body;
|
|
206
|
+
const response = await send(signed.url, {
|
|
207
|
+
method: signed.method,
|
|
208
|
+
headers: signed.headers,
|
|
209
|
+
// `fetch` takes an ArrayBuffer-backed view; only a view over shared memory is copied.
|
|
210
|
+
...(body === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: {
|
|
213
|
+
body:
|
|
214
|
+
body.buffer instanceof ArrayBuffer
|
|
215
|
+
? new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
|
216
|
+
: new Uint8Array(body),
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
return response.ok ? { ok: true, response } : { ok: false, refusal: await refusalOf(response) };
|
|
220
|
+
}
|