@stowage/core 0.1.0 → 0.2.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/README.md +46 -9
- package/dist/index.d.ts +174 -29
- package/dist/index.js +648 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @stowage/core
|
|
|
12
12
|
## Example
|
|
13
13
|
|
|
14
14
|
A function written against `Storage` runs against every adapter: `memoryStorage()` in a test,
|
|
15
|
-
`fsStorage()` on a laptop, `s3Storage()` in production.
|
|
15
|
+
`fsStorage()` on a laptop, `s3Storage()` or `azureBlobStorage()` in production.
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
18
|
import { isStorageError, type Storage } from "@stowage/core";
|
|
@@ -32,23 +32,23 @@ export async function readSettings(storage: Storage): Promise<unknown> {
|
|
|
32
32
|
|
|
33
33
|
## Runtimes
|
|
34
34
|
|
|
35
|
-
Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01
|
|
35
|
+
Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01` without Node APIs. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
|
|
36
36
|
|
|
37
|
-
The bundle measures
|
|
37
|
+
The bundle measures 3.3 kB minified and gzipped.
|
|
38
38
|
|
|
39
39
|
## Limits
|
|
40
40
|
|
|
41
41
|
This section is empty. `@stowage/core` declares no capability; each storage declares its own, out
|
|
42
|
-
of the
|
|
42
|
+
of the five names of [spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/docs/spec.md#49-capabilities).
|
|
43
43
|
|
|
44
44
|
## Notes
|
|
45
45
|
|
|
46
46
|
A failure reaches the caller in one of two shapes: a `StorageError`, which `isStorageError` tells
|
|
47
47
|
apart, or the runtime's `AbortError` once a signal fired
|
|
48
|
-
([spec 4.10](https://github.com/stowage-js/stowage/blob/@stowage/core@0.
|
|
48
|
+
([spec 4.10](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/docs/spec.md#410-errors)).
|
|
49
49
|
A name added to `StorageErrorCode` or `capabilityNames` is a minor release, so a `switch` over
|
|
50
50
|
either needs a default branch
|
|
51
|
-
([spec
|
|
51
|
+
([spec 10](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/docs/spec.md#10-versions)).
|
|
52
52
|
|
|
53
53
|
```ts
|
|
54
54
|
import { isStorageError } from "@stowage/core";
|
|
@@ -70,12 +70,49 @@ export function describeFailure(failure: unknown): string {
|
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
An adapter written outside this repository takes what two adapters here need on the wire from
|
|
74
|
+
this package rather than writing it again
|
|
75
|
+
([spec 4.13](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/docs/spec.md#413-exports-for-adapter-authors)):
|
|
76
|
+
|
|
77
|
+
- `invalidKeyReason` checks a key against its rule of spec 4.8, as the first act of every
|
|
78
|
+
operation.
|
|
79
|
+
- `errorCodeForStatus` and `isTransientStatus` are the status mapping of spec 4.10, and
|
|
80
|
+
`withRetry` the retry loop around one request.
|
|
81
|
+
- `parseXml` reads the XML an answer document is written in, and rejects anything outside that
|
|
82
|
+
subset with `XmlSyntaxError`.
|
|
83
|
+
- `readEnvironment` reads one environment variable, and answers `""` where it is unset or the
|
|
84
|
+
runtime cannot read it.
|
|
85
|
+
- `isUserMetadataKey`, `encodeUserMetadataValue`, `decodeUserMetadataValue` and
|
|
86
|
+
`userMetadataByteLength` carry user metadata in headers, and measure the 2 KB of spec 4.3.
|
|
87
|
+
- `PresignedPut` is what `presignPut` resolves with on every adapter that declares
|
|
88
|
+
`presignedUrls`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { invalidKeyReason, StorageError } from "@stowage/core";
|
|
92
|
+
|
|
93
|
+
export function requireWritableKey(bucket: string, key: string): void {
|
|
94
|
+
const reason = invalidKeyReason(key, "writable");
|
|
95
|
+
|
|
96
|
+
if (reason === undefined) return;
|
|
97
|
+
|
|
98
|
+
throw new StorageError({
|
|
99
|
+
code: "InvalidKey",
|
|
100
|
+
message: reason,
|
|
101
|
+
operation: "put",
|
|
102
|
+
bucket,
|
|
103
|
+
provider: "my-provider",
|
|
104
|
+
key,
|
|
105
|
+
attempts: 0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
73
110
|
## Specification
|
|
74
111
|
|
|
75
|
-
[`docs/spec.md` at `@stowage/core@0.
|
|
112
|
+
[`docs/spec.md` at `@stowage/core@0.2.0`](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/docs/spec.md#4-the-core-api-stowagecore)
|
|
76
113
|
is the contract: a caller may rely on what it states and on nothing else this package happens to
|
|
77
|
-
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/core@0.
|
|
78
|
-
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/core@0.
|
|
114
|
+
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/core@0.2.0/CONTEXT.md)
|
|
115
|
+
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/core@0.2.0/docs/adr)
|
|
79
116
|
are at the same tag.
|
|
80
117
|
|
|
81
118
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
* - `presignedUrls`: the concrete type carries `presignGet` and `presignPut`. Where not
|
|
9
9
|
* declared, neither method exists on the type.
|
|
10
10
|
* - `rangeReads`: `get` honors `range`. Where not declared, a `range` is `Unsupported`.
|
|
11
|
-
* - `userMetadata`: `put` stores `userMetadata
|
|
12
|
-
*
|
|
11
|
+
* - `userMetadata`: `put` stores `userMetadata` whose keys are ASCII identifiers,
|
|
12
|
+
* `[A-Za-z_][A-Za-z0-9_]*`; `stat` and `get` return it, `copy` keeps it. Where not declared,
|
|
13
|
+
* a non-empty `userMetadata` is `Unsupported` and reads return `{}`.
|
|
14
|
+
* - `userMetadataTokenKeys`: beside `userMetadata`, a key may be any ASCII HTTP token, such as
|
|
15
|
+
* `content-hash`. Where not declared, a key outside identifiers is `Unsupported` naming it;
|
|
16
|
+
* without `userMetadata` too, the call is `Unsupported` naming that.
|
|
13
17
|
*/
|
|
14
|
-
export declare const capabilityNames: readonly ["keyBytesPreserved", "presignedUrls", "rangeReads", "userMetadata"];
|
|
18
|
+
export declare const capabilityNames: readonly ["keyBytesPreserved", "presignedUrls", "rangeReads", "userMetadata", "userMetadataTokenKeys"];
|
|
15
19
|
/** One name out of {@link capabilityNames}. */
|
|
16
20
|
type CapabilityName = (typeof capabilityNames)[number];
|
|
17
21
|
//#endregion
|
|
@@ -23,6 +27,16 @@ type ResolverOptions = {
|
|
|
23
27
|
/** A value, or a function that yields one. No core signature mentions it (ADR 0007). */
|
|
24
28
|
type Resolvable<T> = T | ((options?: ResolverOptions) => T | Promise<T>);
|
|
25
29
|
//#endregion
|
|
30
|
+
//#region src/environment.d.ts
|
|
31
|
+
/**
|
|
32
|
+
* ADR 0007: `process.env` is the one route through Node, Bun, Deno's compatibility layer
|
|
33
|
+
* and a Worker under `nodejs_compat`. A Worker without it has no `process` at all, and
|
|
34
|
+
* Deno without `--allow-env` throws `NotCapable` rather than answering `undefined`, so
|
|
35
|
+
* both leave the value empty. A name is read on its own, because enumerating
|
|
36
|
+
* `process.env` needs the unscoped permission in Deno.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readEnvironment(name: string): string;
|
|
39
|
+
//#endregion
|
|
26
40
|
//#region src/errors.d.ts
|
|
27
41
|
/**
|
|
28
42
|
* What went wrong, as the one field a caller branches on (spec 4.10). A code added here is a
|
|
@@ -61,6 +75,19 @@ interface StorageErrorFields {
|
|
|
61
75
|
readonly capability?: CapabilityName;
|
|
62
76
|
readonly cause?: unknown;
|
|
63
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* A failure the rules of spec section 4 decide, stated without the adapter that raises it:
|
|
80
|
+
* the adapter adds what only it knows, its bucket, the operation and the attempts, and
|
|
81
|
+
* raises it through its own error factory (spec 4.13).
|
|
82
|
+
*/
|
|
83
|
+
type Refusal = {
|
|
84
|
+
readonly code: "Unsupported";
|
|
85
|
+
readonly message: string;
|
|
86
|
+
readonly capability: CapabilityName;
|
|
87
|
+
} | {
|
|
88
|
+
readonly code: "InvalidRequest" | "InvalidOption";
|
|
89
|
+
readonly message: string;
|
|
90
|
+
};
|
|
64
91
|
export declare class StorageError extends Error {
|
|
65
92
|
readonly code: StorageErrorCode;
|
|
66
93
|
readonly operation: string;
|
|
@@ -88,32 +115,16 @@ type KeyRule = "writable" | "addressable" | "prefix";
|
|
|
88
115
|
/** The reason a key violates the rule, or `undefined` where it holds. */
|
|
89
116
|
export declare function invalidKeyReason(key: string, rule: KeyRule): string | undefined;
|
|
90
117
|
//#endregion
|
|
91
|
-
//#region src/
|
|
92
|
-
interface RetryOptions {
|
|
93
|
-
readonly maxAttempts: number;
|
|
94
|
-
readonly signal?: AbortSignal;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Repeats `attempt` while it rejects with a `StorageError` whose `retryable` is `true`,
|
|
98
|
-
* up to `maxAttempts` times, waiting a random delay between zero and
|
|
99
|
-
* `min(5 s, 100 ms × 2^n)` before attempt `n + 1`. The error it finally rejects with
|
|
100
|
-
* carries the number of attempts made.
|
|
101
|
-
*
|
|
102
|
-
* Spec 4.13 has this be the one definition of the loop of spec 7.5, so that an adapter
|
|
103
|
-
* written outside this repository repeats on the same curve. `adapter-fs` and
|
|
104
|
-
* `adapter-memory` have no request to send again and do not call it.
|
|
105
|
-
*/
|
|
106
|
-
export declare function withRetry<T>(attempt: () => Promise<T>, options: RetryOptions): Promise<T>;
|
|
107
|
-
//#endregion
|
|
108
|
-
//#region src/status.d.ts
|
|
118
|
+
//#region src/presigned-put.d.ts
|
|
109
119
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* reaches here where it recognizes none.
|
|
120
|
+
* What `presignPut` resolves with on every adapter that declares `presignedUrls`, so the code
|
|
121
|
+
* that uploads through it never names the provider (ADR 0022).
|
|
113
122
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
interface PresignedPut {
|
|
124
|
+
readonly url: string;
|
|
125
|
+
/** The headers the client sends beside the body. `Content-Length` is never among them. */
|
|
126
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
127
|
+
}
|
|
117
128
|
//#endregion
|
|
118
129
|
//#region src/storage.d.ts
|
|
119
130
|
type PutBody = Uint8Array | string | ReadableStream<Uint8Array>;
|
|
@@ -126,7 +137,8 @@ interface PutOptions extends OperationOptions {
|
|
|
126
137
|
* Stored where the storage declares `userMetadata`, and `Unsupported` elsewhere unless it is
|
|
127
138
|
* empty. Keys are non-empty ASCII HTTP tokens compared case-insensitively; values may hold
|
|
128
139
|
* any Unicode. Keys and values together hold at most 2 KB of encoded header bytes, and more
|
|
129
|
-
* is `InvalidRequest`.
|
|
140
|
+
* is `InvalidRequest`. A key outside ASCII identifiers, such as `content-hash`, is
|
|
141
|
+
* `Unsupported` where the storage does not declare `userMetadataTokenKeys`.
|
|
130
142
|
*/
|
|
131
143
|
userMetadata?: Record<string, string>;
|
|
132
144
|
}
|
|
@@ -194,10 +206,143 @@ interface Storage {
|
|
|
194
206
|
stat(key: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
195
207
|
exists(key: string, options?: OperationOptions): Promise<boolean>;
|
|
196
208
|
list(options?: ListOptions): ObjectListing;
|
|
209
|
+
/**
|
|
210
|
+
* Sends the keys in batches, one request per batch, and each adapter states its batch
|
|
211
|
+
* size. A per-key failure fills the report; a failure of a request as a whole rejects.
|
|
212
|
+
*/
|
|
197
213
|
delete(...keys: readonly string[]): Promise<DeleteReport>;
|
|
198
214
|
deleteAll(prefix: string, options?: OperationOptions): Promise<DeleteReport>;
|
|
199
215
|
copy(from: string, to: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
200
216
|
move(from: string, to: string, options?: OperationOptions): Promise<ObjectStat>;
|
|
201
217
|
}
|
|
202
218
|
//#endregion
|
|
203
|
-
|
|
219
|
+
//#region src/range.d.ts
|
|
220
|
+
/** Refuses bounds spec 4.3 does not allow, which is decided before the object is looked up. */
|
|
221
|
+
export declare function rangeBoundsRefusal(range: ByteRange | undefined): Refusal | undefined;
|
|
222
|
+
/**
|
|
223
|
+
* Refuses a range that starts at or beyond the object's `size` bytes, which spec 4.3 names a
|
|
224
|
+
* refusal of the request rather than of the option, because a provider answers it with `416`.
|
|
225
|
+
*/
|
|
226
|
+
export declare function rangeStartRefusal(range: ByteRange, size: number, key: string): Refusal | undefined;
|
|
227
|
+
/** The last byte the range names, both ends inclusive, clipped to the object's `size` bytes. */
|
|
228
|
+
export declare function lastByteOf(range: ByteRange | undefined, size: number): number;
|
|
229
|
+
/**
|
|
230
|
+
* Whether the whole object is the body the range asks for, clipped as spec 4.3 clips it. A
|
|
231
|
+
* provider may answer a range with the whole object and `200`, which RFC 9110 allows, and
|
|
232
|
+
* that answer is the one asked for exactly where this holds. An empty object holds no byte a
|
|
233
|
+
* range could start at, so no range covers it.
|
|
234
|
+
*/
|
|
235
|
+
export declare function rangeCoversWhole(range: ByteRange, size: number): boolean;
|
|
236
|
+
/** The `Range` field of RFC 9110, both ends inclusive as `ByteRange` is. */
|
|
237
|
+
export declare function rangeHeader(range: ByteRange): string;
|
|
238
|
+
/**
|
|
239
|
+
* The size of the whole object out of a `206`'s `Content-Range`, which is what spec 4.4 has a
|
|
240
|
+
* ranged `get` report rather than the length of the range.
|
|
241
|
+
*/
|
|
242
|
+
export declare function wholeSizeOf(contentRange: string | null): number | undefined;
|
|
243
|
+
//#endregion
|
|
244
|
+
//#region src/retry.d.ts
|
|
245
|
+
interface RetryOptions {
|
|
246
|
+
readonly maxAttempts: number;
|
|
247
|
+
readonly signal?: AbortSignal;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Repeats `attempt` while it rejects with a `StorageError` whose `retryable` is `true`,
|
|
251
|
+
* up to `maxAttempts` times, waiting a random delay between zero and
|
|
252
|
+
* `min(5 s, 100 ms × 2^n)` before attempt `n + 1`. The error it finally rejects with
|
|
253
|
+
* carries the number of attempts made.
|
|
254
|
+
*
|
|
255
|
+
* Spec 4.13 has this be the one definition of the loop of spec 7.5, so that an adapter
|
|
256
|
+
* written outside this repository repeats on the same curve. `adapter-fs` and
|
|
257
|
+
* `adapter-memory` have no request to send again and do not call it.
|
|
258
|
+
*/
|
|
259
|
+
export declare function withRetry<T>(attempt: () => Promise<T>, options: RetryOptions): Promise<T>;
|
|
260
|
+
//#endregion
|
|
261
|
+
//#region src/status.d.ts
|
|
262
|
+
/**
|
|
263
|
+
* The code the status decides on its own, or `undefined` for a status that decides
|
|
264
|
+
* nothing. It is the fallback of spec 4.10: an adapter maps a provider code first and
|
|
265
|
+
* reaches here where it recognizes none.
|
|
266
|
+
*/
|
|
267
|
+
export declare function errorCodeForStatus(status: number): StorageErrorCode | undefined;
|
|
268
|
+
/** Whether the status names a condition that may be gone a moment later (spec 4.10). */
|
|
269
|
+
export declare function isTransientStatus(status: number): boolean;
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/upload-stream.d.ts
|
|
272
|
+
interface StreamUploadOptions {
|
|
273
|
+
readonly partSize: number;
|
|
274
|
+
readonly concurrency: number;
|
|
275
|
+
/** The provider's limit on the parts of one upload. */
|
|
276
|
+
readonly maxParts: number;
|
|
277
|
+
/** What the `InvalidRequest` for a stream above `maxParts` is told against. */
|
|
278
|
+
readonly bucket: string;
|
|
279
|
+
readonly provider: string;
|
|
280
|
+
readonly key: string;
|
|
281
|
+
readonly signal?: AbortSignal;
|
|
282
|
+
}
|
|
283
|
+
interface StreamUpload<T> {
|
|
284
|
+
/** The stream ended within the first part: the bytes go as one request. */
|
|
285
|
+
whole(bytes: Uint8Array<ArrayBuffer>): Promise<T>;
|
|
286
|
+
/** The first part filled: the adapter starts, sends through `sendParts`, and commits. */
|
|
287
|
+
multipart(sendParts: SendParts): Promise<T>;
|
|
288
|
+
}
|
|
289
|
+
type SendParts = <R>(send: (index: number, bytes: Uint8Array<ArrayBuffer>, signal: AbortSignal) => Promise<R>) => Promise<{
|
|
290
|
+
readonly results: readonly R[];
|
|
291
|
+
readonly size: number;
|
|
292
|
+
}>;
|
|
293
|
+
/**
|
|
294
|
+
* Reads the stream into parts of `partSize` and hands a stream that ends within the first
|
|
295
|
+
* to `whole`, any other to `multipart`. The reader, its buffers and the cancellation of
|
|
296
|
+
* the source stay here, so the spec promises one call rather than the order of five
|
|
297
|
+
* (ADR 0030).
|
|
298
|
+
*
|
|
299
|
+
* The source is canceled wherever the upload settles before the stream ended, as spec 4.2
|
|
300
|
+
* has it for a `put` that rejects before reading its body to the end.
|
|
301
|
+
*/
|
|
302
|
+
export declare function uploadStream<T>(stream: ReadableStream<Uint8Array>, options: StreamUploadOptions, upload: StreamUpload<T>): Promise<T>;
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/user-metadata.d.ts
|
|
305
|
+
type UserMetadataKeyRule = "token" | "identifier";
|
|
306
|
+
export declare function isUserMetadataKey(name: string, rule: UserMetadataKeyRule): boolean;
|
|
307
|
+
/** The value as a header carries it: as written where it travels so, else as encoded words. */
|
|
308
|
+
export declare function encodeUserMetadataValue(value: string, options?: {
|
|
309
|
+
always?: boolean;
|
|
310
|
+
}): string;
|
|
311
|
+
/**
|
|
312
|
+
* What spec 4.3 bounds at 2 KB: every key and its value as `encodeUserMetadataValue` writes
|
|
313
|
+
* it, without `always`, so the bound does not depend on what an adapter encodes beyond the rule.
|
|
314
|
+
*/
|
|
315
|
+
export declare function userMetadataByteLength(userMetadata: Readonly<Record<string, string>>): number;
|
|
316
|
+
type UserMetadataCheck = {
|
|
317
|
+
readonly held: Readonly<Record<string, string>>;
|
|
318
|
+
} | {
|
|
319
|
+
readonly refusal: Refusal;
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* The user metadata as a storage holds it, keys folded to lower case as a header field name
|
|
323
|
+
* is, or the first refusal of spec 4.3, read off what the storage declares.
|
|
324
|
+
*/
|
|
325
|
+
export declare function checkUserMetadata(userMetadata: Record<string, string> | undefined, capabilities: readonly CapabilityName[]): UserMetadataCheck;
|
|
326
|
+
/** A header value read back, with every form of encoded word RFC 2047 allows decoded. */
|
|
327
|
+
export declare function decodeUserMetadataValue(value: string): string;
|
|
328
|
+
//#endregion
|
|
329
|
+
//#region src/xml.d.ts
|
|
330
|
+
interface XmlElement {
|
|
331
|
+
readonly name: string;
|
|
332
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
333
|
+
readonly children: readonly XmlElement[];
|
|
334
|
+
/** The text directly inside the element, its children's left out. */
|
|
335
|
+
readonly text: string;
|
|
336
|
+
}
|
|
337
|
+
/** A document outside the subset `parseXml` reads, reported rather than read around (ADR 0003). */
|
|
338
|
+
export declare class XmlSyntaxError extends Error {
|
|
339
|
+
override readonly name = "XmlSyntaxError";
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The root element of an XML document of the subset the providers answer with: elements,
|
|
343
|
+
* attributes, text, comments, the five named entities and a numeric character reference to
|
|
344
|
+
* any Unicode scalar value but `U+0000`, under one optional declaration (spec 4.13).
|
|
345
|
+
*/
|
|
346
|
+
export declare function parseXml(document: string): XmlElement;
|
|
347
|
+
//#endregion
|
|
348
|
+
export type { ByteRange, CapabilityName, DeleteReport, GetOptions, KeyRule, ListOptions, ListPage, ObjectEntry, ObjectListing, ObjectStat, OperationOptions, PresignedPut, PutBody, PutOptions, Refusal, Resolvable, ResolverOptions, RetryOptions, SendParts, Storage, StorageErrorCode, StorageErrorFields, StoredObject, StreamUpload, StreamUploadOptions, UserMetadataCheck, UserMetadataKeyRule, XmlElement };
|
package/dist/index.js
CHANGED
|
@@ -8,16 +8,38 @@
|
|
|
8
8
|
* - `presignedUrls`: the concrete type carries `presignGet` and `presignPut`. Where not
|
|
9
9
|
* declared, neither method exists on the type.
|
|
10
10
|
* - `rangeReads`: `get` honors `range`. Where not declared, a `range` is `Unsupported`.
|
|
11
|
-
* - `userMetadata`: `put` stores `userMetadata
|
|
12
|
-
*
|
|
11
|
+
* - `userMetadata`: `put` stores `userMetadata` whose keys are ASCII identifiers,
|
|
12
|
+
* `[A-Za-z_][A-Za-z0-9_]*`; `stat` and `get` return it, `copy` keeps it. Where not declared,
|
|
13
|
+
* a non-empty `userMetadata` is `Unsupported` and reads return `{}`.
|
|
14
|
+
* - `userMetadataTokenKeys`: beside `userMetadata`, a key may be any ASCII HTTP token, such as
|
|
15
|
+
* `content-hash`. Where not declared, a key outside identifiers is `Unsupported` naming it;
|
|
16
|
+
* without `userMetadata` too, the call is `Unsupported` naming that.
|
|
13
17
|
*/
|
|
14
18
|
const capabilityNames = [
|
|
15
19
|
"keyBytesPreserved",
|
|
16
20
|
"presignedUrls",
|
|
17
21
|
"rangeReads",
|
|
18
|
-
"userMetadata"
|
|
22
|
+
"userMetadata",
|
|
23
|
+
"userMetadataTokenKeys"
|
|
19
24
|
];
|
|
20
25
|
//#endregion
|
|
26
|
+
//#region src/environment.ts
|
|
27
|
+
/**
|
|
28
|
+
* ADR 0007: `process.env` is the one route through Node, Bun, Deno's compatibility layer
|
|
29
|
+
* and a Worker under `nodejs_compat`. A Worker without it has no `process` at all, and
|
|
30
|
+
* Deno without `--allow-env` throws `NotCapable` rather than answering `undefined`, so
|
|
31
|
+
* both leave the value empty. A name is read on its own, because enumerating
|
|
32
|
+
* `process.env` needs the unscoped permission in Deno.
|
|
33
|
+
*/
|
|
34
|
+
function readEnvironment(name) {
|
|
35
|
+
try {
|
|
36
|
+
if (typeof process === "undefined") return "";
|
|
37
|
+
return process?.env?.[name] ?? "";
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
21
43
|
//#region src/errors.ts
|
|
22
44
|
const storageErrorBrand = Symbol.for("stowage.error");
|
|
23
45
|
var StorageError = class extends Error {
|
|
@@ -92,16 +114,18 @@ function withAttempts(failure, attempts) {
|
|
|
92
114
|
//#endregion
|
|
93
115
|
//#region src/keys.ts
|
|
94
116
|
const writableKeyLimit = 1024;
|
|
95
|
-
const utf8 = new TextEncoder();
|
|
117
|
+
const utf8$1 = new TextEncoder();
|
|
96
118
|
/** The reason a key violates the rule, or `undefined` where it holds. */
|
|
97
119
|
function invalidKeyReason(key, rule) {
|
|
98
120
|
if (key === "") return rule === "prefix" ? void 0 : "is empty";
|
|
99
121
|
const controlCharacter = firstControlCharacter(key);
|
|
100
122
|
if (controlCharacter !== void 0) return `holds the control character ${controlCharacter}`;
|
|
123
|
+
const loneSurrogate = firstLoneSurrogate(key);
|
|
124
|
+
if (loneSurrogate !== void 0) return `holds the lone surrogate ${loneSurrogate}`;
|
|
101
125
|
if (rule === "writable") {
|
|
102
126
|
if (key.includes("\\")) return "holds a backslash";
|
|
103
127
|
if (key.endsWith("/")) return "ends with a slash";
|
|
104
|
-
const bytes = utf8.encode(key).length;
|
|
128
|
+
const bytes = utf8$1.encode(key).length;
|
|
105
129
|
if (bytes > writableKeyLimit) return `is ${bytes} UTF-8 bytes, above the limit of ${writableKeyLimit}`;
|
|
106
130
|
}
|
|
107
131
|
return invalidSegmentReason(key);
|
|
@@ -109,9 +133,19 @@ function invalidKeyReason(key, rule) {
|
|
|
109
133
|
function firstControlCharacter(key) {
|
|
110
134
|
for (let index = 0; index < key.length; index += 1) {
|
|
111
135
|
const code = key.charCodeAt(index);
|
|
112
|
-
if (code <= 31 || code === 127) return
|
|
136
|
+
if (code <= 31 || code === 127) return codePointName(code);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function firstLoneSurrogate(key) {
|
|
140
|
+
if (key.isWellFormed()) return void 0;
|
|
141
|
+
for (const character of key) {
|
|
142
|
+
const code = character.codePointAt(0) ?? 0;
|
|
143
|
+
if (code >= 55296 && code <= 57343) return codePointName(code);
|
|
113
144
|
}
|
|
114
145
|
}
|
|
146
|
+
function codePointName(code) {
|
|
147
|
+
return `U+${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
148
|
+
}
|
|
115
149
|
function invalidSegmentReason(key) {
|
|
116
150
|
const segments = key.split("/");
|
|
117
151
|
for (const [index, segment] of segments.entries()) {
|
|
@@ -122,6 +156,59 @@ function invalidSegmentReason(key) {
|
|
|
122
156
|
}
|
|
123
157
|
}
|
|
124
158
|
//#endregion
|
|
159
|
+
//#region src/range.ts
|
|
160
|
+
const invalidBounds = {
|
|
161
|
+
code: "InvalidOption",
|
|
162
|
+
message: "The option `range` takes two whole numbers from zero up, `start` at most `end`"
|
|
163
|
+
};
|
|
164
|
+
/** Refuses bounds spec 4.3 does not allow, which is decided before the object is looked up. */
|
|
165
|
+
function rangeBoundsRefusal(range) {
|
|
166
|
+
if (range === void 0) return void 0;
|
|
167
|
+
const { start, end } = range;
|
|
168
|
+
if (!isOffset(start) || end !== void 0 && (!isOffset(end) || end < start)) return invalidBounds;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Refuses a range that starts at or beyond the object's `size` bytes, which spec 4.3 names a
|
|
172
|
+
* refusal of the request rather than of the option, because a provider answers it with `416`.
|
|
173
|
+
*/
|
|
174
|
+
function rangeStartRefusal(range, size, key) {
|
|
175
|
+
if (range.start < size) return void 0;
|
|
176
|
+
return {
|
|
177
|
+
code: "InvalidRequest",
|
|
178
|
+
message: `The range starts beyond the ${size} bytes under the key ${JSON.stringify(key)}`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The last byte the range names, both ends inclusive, clipped to the object's `size` bytes. */
|
|
182
|
+
function lastByteOf(range, size) {
|
|
183
|
+
return Math.min(range?.end ?? size - 1, size - 1);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Whether the whole object is the body the range asks for, clipped as spec 4.3 clips it. A
|
|
187
|
+
* provider may answer a range with the whole object and `200`, which RFC 9110 allows, and
|
|
188
|
+
* that answer is the one asked for exactly where this holds. An empty object holds no byte a
|
|
189
|
+
* range could start at, so no range covers it.
|
|
190
|
+
*/
|
|
191
|
+
function rangeCoversWhole(range, size) {
|
|
192
|
+
return range.start === 0 && size > 0 && lastByteOf(range, size) === size - 1;
|
|
193
|
+
}
|
|
194
|
+
/** The `Range` field of RFC 9110, both ends inclusive as `ByteRange` is. */
|
|
195
|
+
function rangeHeader(range) {
|
|
196
|
+
return `bytes=${range.start}-${range.end ?? ""}`;
|
|
197
|
+
}
|
|
198
|
+
const contentRangePattern = /^bytes (\d+)-(\d+)\/(\d+)$/u;
|
|
199
|
+
/**
|
|
200
|
+
* The size of the whole object out of a `206`'s `Content-Range`, which is what spec 4.4 has a
|
|
201
|
+
* ranged `get` report rather than the length of the range.
|
|
202
|
+
*/
|
|
203
|
+
function wholeSizeOf(contentRange) {
|
|
204
|
+
const found = contentRangePattern.exec(contentRange?.trim() ?? "");
|
|
205
|
+
const size = Number(found?.[3]);
|
|
206
|
+
return Number.isSafeInteger(size) ? size : void 0;
|
|
207
|
+
}
|
|
208
|
+
function isOffset(value) {
|
|
209
|
+
return Number.isInteger(value) && value >= 0;
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
125
212
|
//#region src/retry.ts
|
|
126
213
|
const baseDelay = 100;
|
|
127
214
|
const maximumDelay = 5e3;
|
|
@@ -188,4 +275,558 @@ function isTransientStatus(status) {
|
|
|
188
275
|
return status === 408 || status === 429 || status >= 500 && status <= 599;
|
|
189
276
|
}
|
|
190
277
|
//#endregion
|
|
191
|
-
|
|
278
|
+
//#region src/part-reader.ts
|
|
279
|
+
/** Where a buffer starts before the stream has shown whether it fills a part. */
|
|
280
|
+
const initialCapacity = 65536;
|
|
281
|
+
/**
|
|
282
|
+
* A stream read into parts of one size (spec 4.13). A part is known to be the last only
|
|
283
|
+
* once the stream has ended behind it, so a full part looks one chunk ahead; that chunk
|
|
284
|
+
* is the stream's own and becomes the start of the next part.
|
|
285
|
+
*
|
|
286
|
+
* A part handed back through `recycle` lends its buffer to a later one. ADR 0016 bounds
|
|
287
|
+
* an upload at `partSize × concurrency` of buffers, and a fresh buffer per part would
|
|
288
|
+
* keep the settled ones alive until the collector noticed them.
|
|
289
|
+
*
|
|
290
|
+
* Until a part has filled, the buffer grows by doubling, because ADR 0009 has a body
|
|
291
|
+
* shorter than a part allocate only what it needs. Once one has, the stream is known to
|
|
292
|
+
* go as a multipart upload, and every buffer after it starts at the full part size.
|
|
293
|
+
*/
|
|
294
|
+
var PartReader = class {
|
|
295
|
+
#reader;
|
|
296
|
+
#partSize;
|
|
297
|
+
#signal;
|
|
298
|
+
#cancelOnAbort = () => {
|
|
299
|
+
this.#reader.cancel(this.#signal?.reason).catch(() => {});
|
|
300
|
+
};
|
|
301
|
+
#free = [];
|
|
302
|
+
#ahead;
|
|
303
|
+
#filledOnce = false;
|
|
304
|
+
constructor(stream, partSize, signal) {
|
|
305
|
+
this.#reader = stream.getReader();
|
|
306
|
+
this.#partSize = partSize;
|
|
307
|
+
this.#signal = signal;
|
|
308
|
+
signal?.addEventListener("abort", this.#cancelOnAbort, { once: true });
|
|
309
|
+
}
|
|
310
|
+
async next() {
|
|
311
|
+
let bytes = this.#freshBuffer();
|
|
312
|
+
let filled = 0;
|
|
313
|
+
while (filled < this.#partSize) {
|
|
314
|
+
const chunk = this.#ahead ?? await this.#read();
|
|
315
|
+
this.#ahead = void 0;
|
|
316
|
+
if (chunk === void 0) return {
|
|
317
|
+
bytes: bytes.subarray(0, filled),
|
|
318
|
+
last: true
|
|
319
|
+
};
|
|
320
|
+
const taken = Math.min(chunk.byteLength, this.#partSize - filled);
|
|
321
|
+
if (filled + taken > bytes.byteLength) bytes = this.#grown(bytes, filled + taken);
|
|
322
|
+
bytes.set(chunk.subarray(0, taken), filled);
|
|
323
|
+
filled += taken;
|
|
324
|
+
if (taken < chunk.byteLength) this.#ahead = chunk.subarray(taken);
|
|
325
|
+
}
|
|
326
|
+
this.#filledOnce = true;
|
|
327
|
+
this.#ahead ??= await this.#read();
|
|
328
|
+
return {
|
|
329
|
+
bytes,
|
|
330
|
+
last: this.#ahead === void 0
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/** The part's request settled, so nothing reads its bytes any more. */
|
|
334
|
+
recycle(part) {
|
|
335
|
+
if (part.bytes.buffer.byteLength === this.#partSize) this.#free.push(part.bytes.buffer);
|
|
336
|
+
}
|
|
337
|
+
/** Spec 4.2 leaves the stream canceled where `put` rejects before reading it to its end. */
|
|
338
|
+
async cancel(reason) {
|
|
339
|
+
await this.#reader.cancel(reason).catch(() => {});
|
|
340
|
+
}
|
|
341
|
+
release() {
|
|
342
|
+
this.#signal?.removeEventListener("abort", this.#cancelOnAbort);
|
|
343
|
+
this.#reader.releaseLock();
|
|
344
|
+
}
|
|
345
|
+
#freshBuffer() {
|
|
346
|
+
const pooled = this.#free.pop();
|
|
347
|
+
if (pooled !== void 0) return new Uint8Array(pooled);
|
|
348
|
+
return new Uint8Array(this.#filledOnce ? this.#partSize : Math.min(this.#partSize, initialCapacity));
|
|
349
|
+
}
|
|
350
|
+
#grown(bytes, needed) {
|
|
351
|
+
let capacity = bytes.byteLength;
|
|
352
|
+
while (capacity < needed) capacity *= 2;
|
|
353
|
+
const larger = new Uint8Array(Math.min(capacity, this.#partSize));
|
|
354
|
+
larger.set(bytes);
|
|
355
|
+
return larger;
|
|
356
|
+
}
|
|
357
|
+
async #read() {
|
|
358
|
+
for (;;) {
|
|
359
|
+
const { done, value } = await this.#reader.read();
|
|
360
|
+
this.#signal?.throwIfAborted();
|
|
361
|
+
if (done) return void 0;
|
|
362
|
+
if (value.byteLength > 0) return value;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/upload-stream.ts
|
|
368
|
+
/**
|
|
369
|
+
* Reads the stream into parts of `partSize` and hands a stream that ends within the first
|
|
370
|
+
* to `whole`, any other to `multipart`. The reader, its buffers and the cancellation of
|
|
371
|
+
* the source stay here, so the spec promises one call rather than the order of five
|
|
372
|
+
* (ADR 0030).
|
|
373
|
+
*
|
|
374
|
+
* The source is canceled wherever the upload settles before the stream ended, as spec 4.2
|
|
375
|
+
* has it for a `put` that rejects before reading its body to the end.
|
|
376
|
+
*/
|
|
377
|
+
async function uploadStream(stream, options, upload) {
|
|
378
|
+
const parts = new PartReader(stream, options.partSize, options.signal);
|
|
379
|
+
const uploadSettled = new AbortController();
|
|
380
|
+
let readToEnd = false;
|
|
381
|
+
let cancelReason = /* @__PURE__ */ new Error("The upload settled before the stream ended");
|
|
382
|
+
try {
|
|
383
|
+
const first = await parts.next();
|
|
384
|
+
if (first.last) {
|
|
385
|
+
readToEnd = true;
|
|
386
|
+
return await upload.whole(first.bytes);
|
|
387
|
+
}
|
|
388
|
+
let called = false;
|
|
389
|
+
const sendParts = async (send) => {
|
|
390
|
+
uploadSettled.signal.throwIfAborted();
|
|
391
|
+
if (called) throw new Error("`sendParts` sends the parts of one upload once");
|
|
392
|
+
called = true;
|
|
393
|
+
const sent = await sendAll(parts, first, options, uploadSettled.signal, send);
|
|
394
|
+
readToEnd = true;
|
|
395
|
+
return sent;
|
|
396
|
+
};
|
|
397
|
+
return await upload.multipart(sendParts);
|
|
398
|
+
} catch (failure) {
|
|
399
|
+
cancelReason = failure;
|
|
400
|
+
throw failure;
|
|
401
|
+
} finally {
|
|
402
|
+
uploadSettled.abort(cancelReason);
|
|
403
|
+
if (!readToEnd) await parts.cancel(cancelReason);
|
|
404
|
+
parts.release();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* `concurrency` parts in flight, and the next part read only once one of them settled,
|
|
409
|
+
* so the part buffers never outnumber the parts in flight (ADR 0016). The first failure
|
|
410
|
+
* stops the parts still in flight, and is what `sendParts` rejects with once they settled.
|
|
411
|
+
*/
|
|
412
|
+
async function sendAll(parts, first, options, uploadSettled, send) {
|
|
413
|
+
const stop = new AbortController();
|
|
414
|
+
const partSignal = options.signal === void 0 ? stop.signal : AbortSignal.any([options.signal, stop.signal]);
|
|
415
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
416
|
+
const results = [];
|
|
417
|
+
let failure;
|
|
418
|
+
let size = 0;
|
|
419
|
+
const fail = (reason) => {
|
|
420
|
+
failure ??= { reason };
|
|
421
|
+
stop.abort();
|
|
422
|
+
parts.cancel(reason);
|
|
423
|
+
};
|
|
424
|
+
const sendPart = async (index, part) => {
|
|
425
|
+
try {
|
|
426
|
+
results[index] = await send(index, part.bytes, partSignal);
|
|
427
|
+
} catch (reason) {
|
|
428
|
+
fail(reason);
|
|
429
|
+
} finally {
|
|
430
|
+
parts.recycle(part);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const stopWithUpload = () => {
|
|
434
|
+
fail(uploadSettled.reason);
|
|
435
|
+
};
|
|
436
|
+
uploadSettled.addEventListener("abort", stopWithUpload, { once: true });
|
|
437
|
+
try {
|
|
438
|
+
for (let index = 0, part = first; !stop.signal.aborted; index += 1) {
|
|
439
|
+
if (index === options.maxParts - 1 && !part.last) throw tooManyParts(options);
|
|
440
|
+
const sending = sendPart(index, part);
|
|
441
|
+
inFlight.add(sending);
|
|
442
|
+
sending.finally(() => inFlight.delete(sending));
|
|
443
|
+
size += part.bytes.byteLength;
|
|
444
|
+
if (part.last) break;
|
|
445
|
+
while (inFlight.size >= options.concurrency) await Promise.race(inFlight);
|
|
446
|
+
part = await parts.next();
|
|
447
|
+
}
|
|
448
|
+
} catch (reason) {
|
|
449
|
+
fail(reason);
|
|
450
|
+
}
|
|
451
|
+
await Promise.all(inFlight);
|
|
452
|
+
uploadSettled.removeEventListener("abort", stopWithUpload);
|
|
453
|
+
if (failure !== void 0) {
|
|
454
|
+
await parts.cancel(failure.reason);
|
|
455
|
+
throw failure.reason;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
results,
|
|
459
|
+
size
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Known once the last part the provider takes is full and the stream goes on. ADR 0016
|
|
464
|
+
* fixes the part size before the first part, so the way past it is a larger one.
|
|
465
|
+
*/
|
|
466
|
+
function tooManyParts(options) {
|
|
467
|
+
return new StorageError({
|
|
468
|
+
code: "InvalidRequest",
|
|
469
|
+
message: `The stream needs more than ${options.maxParts} parts of the configured \`partSize\` of ${options.partSize} bytes; a larger \`multipart.partSize\` carries it`,
|
|
470
|
+
operation: "put",
|
|
471
|
+
bucket: options.bucket,
|
|
472
|
+
provider: options.provider,
|
|
473
|
+
key: options.key,
|
|
474
|
+
attempts: 0
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/user-metadata.ts
|
|
479
|
+
const keyPatterns = {
|
|
480
|
+
/** The characters RFC 9110 allows in a field name, which is what a header carries. */
|
|
481
|
+
token: /^[!#$%&'*+.^_`|~\dA-Za-z-]+$/u,
|
|
482
|
+
/** An ASCII identifier, which Azure requires of a metadata name (ADR 0020). */
|
|
483
|
+
identifier: /^[A-Za-z_][A-Za-z\d_]*$/u
|
|
484
|
+
};
|
|
485
|
+
function isUserMetadataKey(name, rule) {
|
|
486
|
+
return keyPatterns[rule].test(name);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* What survives a header field as it stands: printable ASCII with no space at either end,
|
|
490
|
+
* which HTTP trims, and no `=?`, which the reader would take for the start of an encoded
|
|
491
|
+
* word.
|
|
492
|
+
*/
|
|
493
|
+
const travelsAsWritten = /^(?:[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?)?$/u;
|
|
494
|
+
const encodedWordStart = "=?UTF-8?B?";
|
|
495
|
+
const encodedWordEnd = "?=";
|
|
496
|
+
/**
|
|
497
|
+
* RFC 2047 bounds an encoded word at 75 characters; 45 bytes are 60 characters of base64,
|
|
498
|
+
* which leaves room for the 12 the word's frame costs.
|
|
499
|
+
*/
|
|
500
|
+
const bytesPerEncodedWord = 45;
|
|
501
|
+
const utf8 = new TextEncoder();
|
|
502
|
+
/** The value as a header carries it: as written where it travels so, else as encoded words. */
|
|
503
|
+
function encodeUserMetadataValue(value, options) {
|
|
504
|
+
if (value === "") return value;
|
|
505
|
+
if (options?.always !== true && travelsAsWritten.test(value) && !value.includes("=?")) return value;
|
|
506
|
+
return utf8PiecesOf(value).map((piece) => `${encodedWordStart}${btoa(String.fromCharCode(...piece))}${encodedWordEnd}`).join(" ");
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* What spec 4.3 bounds at 2 KB: every key and its value as `encodeUserMetadataValue` writes
|
|
510
|
+
* it, without `always`, so the bound does not depend on what an adapter encodes beyond the rule.
|
|
511
|
+
*/
|
|
512
|
+
function userMetadataByteLength(userMetadata) {
|
|
513
|
+
let bytes = 0;
|
|
514
|
+
for (const [name, value] of Object.entries(userMetadata)) bytes += utf8.encode(name).length + encodeUserMetadataValue(value).length;
|
|
515
|
+
return bytes;
|
|
516
|
+
}
|
|
517
|
+
/** Spec 4.3 bounds the set at 2 KB of the header bytes it costs once it is encoded. */
|
|
518
|
+
const headerByteLimit = 2048;
|
|
519
|
+
const noUserMetadata = { held: Object.freeze(Object.create(null)) };
|
|
520
|
+
/**
|
|
521
|
+
* The user metadata as a storage holds it, keys folded to lower case as a header field name
|
|
522
|
+
* is, or the first refusal of spec 4.3, read off what the storage declares.
|
|
523
|
+
*/
|
|
524
|
+
function checkUserMetadata(userMetadata, capabilities) {
|
|
525
|
+
const entries = Object.entries(userMetadata ?? {});
|
|
526
|
+
if (entries.length === 0) return noUserMetadata;
|
|
527
|
+
if (!capabilities.includes("userMetadata")) return unsupported("userMetadata", "This storage holds no user metadata");
|
|
528
|
+
const held = Object.create(null);
|
|
529
|
+
for (const [name, value] of entries) {
|
|
530
|
+
if (!isUserMetadataKey(name, "token")) return refused(`The user metadata key ${JSON.stringify(name)} is no ASCII HTTP token`);
|
|
531
|
+
const folded = name.toLowerCase();
|
|
532
|
+
if (folded in held) return refused(`The user metadata key ${JSON.stringify(folded)} is given more than once`);
|
|
533
|
+
held[folded] = value;
|
|
534
|
+
}
|
|
535
|
+
const malformed = Object.entries(held).find(([, value]) => !value.isWellFormed());
|
|
536
|
+
if (malformed !== void 0) return refused(`The user metadata value of ${JSON.stringify(malformed[0])} holds a lone surrogate, which has no UTF-8 form`);
|
|
537
|
+
const headerBytes = userMetadataByteLength(held);
|
|
538
|
+
if (headerBytes > headerByteLimit) return refused(`The user metadata is ${headerBytes} encoded header bytes, above the limit of ${headerByteLimit}`);
|
|
539
|
+
const beyondIdentifiers = entries.find(([name]) => !isUserMetadataKey(name, "identifier"));
|
|
540
|
+
if (beyondIdentifiers !== void 0 && !capabilities.includes("userMetadataTokenKeys")) return unsupported("userMetadataTokenKeys", `This storage holds no user metadata key beyond identifiers, such as ${JSON.stringify(beyondIdentifiers[0])}`);
|
|
541
|
+
return { held: Object.freeze(held) };
|
|
542
|
+
}
|
|
543
|
+
function refused(message) {
|
|
544
|
+
return { refusal: {
|
|
545
|
+
code: "InvalidRequest",
|
|
546
|
+
message
|
|
547
|
+
} };
|
|
548
|
+
}
|
|
549
|
+
function unsupported(capability, message) {
|
|
550
|
+
return { refusal: {
|
|
551
|
+
code: "Unsupported",
|
|
552
|
+
message,
|
|
553
|
+
capability
|
|
554
|
+
} };
|
|
555
|
+
}
|
|
556
|
+
/** The UTF-8 bytes in pieces of at most one encoded word, each ending on a character. */
|
|
557
|
+
function utf8PiecesOf(value) {
|
|
558
|
+
const pieces = [];
|
|
559
|
+
let pending = [];
|
|
560
|
+
for (const character of value) {
|
|
561
|
+
const bytes = utf8.encode(character);
|
|
562
|
+
if (pending.length + bytes.length > bytesPerEncodedWord) {
|
|
563
|
+
pieces.push(Uint8Array.from(pending));
|
|
564
|
+
pending = [];
|
|
565
|
+
}
|
|
566
|
+
pending.push(...bytes);
|
|
567
|
+
}
|
|
568
|
+
pieces.push(Uint8Array.from(pending));
|
|
569
|
+
return pieces;
|
|
570
|
+
}
|
|
571
|
+
const encodedWord = /=\?([^?\s]+)\?([BbQq])\?([^?\s]*)\?=/gu;
|
|
572
|
+
const whitespaceOnly = /^[ \t]+$/u;
|
|
573
|
+
const hexPair = /^[\dA-Fa-f]{2}$/u;
|
|
574
|
+
/** A header value read back, with every form of encoded word RFC 2047 allows decoded. */
|
|
575
|
+
function decodeUserMetadataValue(value) {
|
|
576
|
+
let decoded = "";
|
|
577
|
+
let last = 0;
|
|
578
|
+
let previousWasEncoded = false;
|
|
579
|
+
for (const match of value.matchAll(encodedWord)) {
|
|
580
|
+
const between = value.slice(last, match.index);
|
|
581
|
+
const word = decodeWord(match[1] ?? "", match[2] ?? "", match[3] ?? "");
|
|
582
|
+
if (!(previousWasEncoded && word !== void 0 && whitespaceOnly.test(between))) decoded += between;
|
|
583
|
+
decoded += word ?? match[0];
|
|
584
|
+
previousWasEncoded = word !== void 0;
|
|
585
|
+
last = match.index + match[0].length;
|
|
586
|
+
}
|
|
587
|
+
return decoded + value.slice(last);
|
|
588
|
+
}
|
|
589
|
+
/** The text of one encoded word, or `undefined` where it names what cannot be read. */
|
|
590
|
+
function decodeWord(charset, encoding, text) {
|
|
591
|
+
const bytes = encoding.toUpperCase() === "B" ? base64Bytes(text) : quotedBytes(text);
|
|
592
|
+
if (bytes === void 0) return void 0;
|
|
593
|
+
try {
|
|
594
|
+
return new TextDecoder(charset, { fatal: true }).decode(bytes);
|
|
595
|
+
} catch {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function base64Bytes(text) {
|
|
600
|
+
try {
|
|
601
|
+
return Uint8Array.from(atob(text), (character) => character.charCodeAt(0));
|
|
602
|
+
} catch {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/** The `Q` encoding: `_` is a space and `=XX` one byte in hex. */
|
|
607
|
+
function quotedBytes(text) {
|
|
608
|
+
const bytes = [];
|
|
609
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
610
|
+
const character = text[index];
|
|
611
|
+
if (character === "_") bytes.push(32);
|
|
612
|
+
else if (character === "=") {
|
|
613
|
+
const pair = text.slice(index + 1, index + 3);
|
|
614
|
+
if (!hexPair.test(pair)) return void 0;
|
|
615
|
+
bytes.push(Number.parseInt(pair, 16));
|
|
616
|
+
index += 2;
|
|
617
|
+
} else bytes.push(text.charCodeAt(index));
|
|
618
|
+
}
|
|
619
|
+
return Uint8Array.from(bytes);
|
|
620
|
+
}
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/xml.ts
|
|
623
|
+
/** A document outside the subset `parseXml` reads, reported rather than read around (ADR 0003). */
|
|
624
|
+
var XmlSyntaxError = class extends Error {
|
|
625
|
+
name = "XmlSyntaxError";
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* The root element of an XML document of the subset the providers answer with: elements,
|
|
629
|
+
* attributes, text, comments, the five named entities and a numeric character reference to
|
|
630
|
+
* any Unicode scalar value but `U+0000`, under one optional declaration (spec 4.13).
|
|
631
|
+
*/
|
|
632
|
+
function parseXml(document) {
|
|
633
|
+
const scanner = new Scanner(document);
|
|
634
|
+
scanner.skipDeclaration();
|
|
635
|
+
scanner.skipMisc();
|
|
636
|
+
const root = scanner.readElement();
|
|
637
|
+
scanner.skipMisc();
|
|
638
|
+
scanner.requireEnd();
|
|
639
|
+
return root;
|
|
640
|
+
}
|
|
641
|
+
const namePattern = /[:A-Z_a-z\u00C0-\uFFFF][:A-Z_a-z\u00C0-\uFFFF.\d-]*/uy;
|
|
642
|
+
const whitespacePattern = /[ \t\r\n]*/y;
|
|
643
|
+
const entityPattern = /&(?:#x([\da-fA-F]+)|#(\d+)|([A-Za-z]\w*));/uy;
|
|
644
|
+
/**
|
|
645
|
+
* What a character reference may name: any Unicode scalar value but `U+0000`. That is
|
|
646
|
+
* wider than `Char` of XML 1.0 on purpose, because S3 writes a key character XML cannot
|
|
647
|
+
* carry, such as `U+FFFE`, as a reference, and the key has to read back byte for byte
|
|
648
|
+
* (ADR 0027).
|
|
649
|
+
*/
|
|
650
|
+
function isReferableCharacter(codePoint) {
|
|
651
|
+
return codePoint >= 1 && codePoint <= 55295 || codePoint >= 57344 && codePoint <= 1114111;
|
|
652
|
+
}
|
|
653
|
+
/** The five entities XML defines without a DTD, which is every one a document here has. */
|
|
654
|
+
const predefinedEntities = /* @__PURE__ */ new Map([
|
|
655
|
+
["amp", "&"],
|
|
656
|
+
["lt", "<"],
|
|
657
|
+
["gt", ">"],
|
|
658
|
+
["quot", "\""],
|
|
659
|
+
["apos", "'"]
|
|
660
|
+
]);
|
|
661
|
+
var Scanner = class {
|
|
662
|
+
#document;
|
|
663
|
+
#position = 0;
|
|
664
|
+
constructor(document) {
|
|
665
|
+
this.#document = document;
|
|
666
|
+
}
|
|
667
|
+
skipDeclaration() {
|
|
668
|
+
if (!this.#startsWith("<?xml")) return;
|
|
669
|
+
this.#position = this.#positionPast("?>", "The XML declaration is never closed");
|
|
670
|
+
}
|
|
671
|
+
/** Whitespace and comments, which are all that may stand beside the root element. */
|
|
672
|
+
skipMisc() {
|
|
673
|
+
for (;;) {
|
|
674
|
+
this.#skipWhitespace();
|
|
675
|
+
if (this.#startsWith("<!DOCTYPE")) throw this.#error("A DTD is refused");
|
|
676
|
+
if (!this.#startsWith("<!--")) return;
|
|
677
|
+
this.#skipComment();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
requireEnd() {
|
|
681
|
+
if (this.#position < this.#document.length) throw this.#error("Something follows the root element");
|
|
682
|
+
}
|
|
683
|
+
readElement() {
|
|
684
|
+
this.#expect("<");
|
|
685
|
+
const name = this.#readName();
|
|
686
|
+
const attributes = Object.create(null);
|
|
687
|
+
if (this.#readStartTagEnd(attributes) === "empty") return {
|
|
688
|
+
name,
|
|
689
|
+
attributes,
|
|
690
|
+
children: [],
|
|
691
|
+
text: ""
|
|
692
|
+
};
|
|
693
|
+
const children = [];
|
|
694
|
+
let text = "";
|
|
695
|
+
for (;;) {
|
|
696
|
+
if (this.#position >= this.#document.length) throw this.#error(`The element <${name}> is never closed`);
|
|
697
|
+
if (this.#startsWith("</")) {
|
|
698
|
+
this.#position += 2;
|
|
699
|
+
this.#closeElement(name);
|
|
700
|
+
return {
|
|
701
|
+
name,
|
|
702
|
+
attributes,
|
|
703
|
+
children,
|
|
704
|
+
text
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (this.#startsWith("<!--")) this.#skipComment();
|
|
708
|
+
else if (this.#startsWith("<![CDATA[")) throw this.#error("A CDATA section is refused");
|
|
709
|
+
else if (this.#startsWith("<")) children.push(this.readElement());
|
|
710
|
+
else text += this.#readText();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
#closeElement(name) {
|
|
714
|
+
const closing = this.#readName();
|
|
715
|
+
if (closing !== name) throw this.#error(`The element <${name}> is closed by </${closing}>`);
|
|
716
|
+
this.#skipWhitespace();
|
|
717
|
+
this.#expect(">");
|
|
718
|
+
}
|
|
719
|
+
#readStartTagEnd(attributes) {
|
|
720
|
+
for (;;) {
|
|
721
|
+
const before = this.#position;
|
|
722
|
+
this.#skipWhitespace();
|
|
723
|
+
if (this.#startsWith("/>")) {
|
|
724
|
+
this.#position += 2;
|
|
725
|
+
return "empty";
|
|
726
|
+
}
|
|
727
|
+
if (this.#startsWith(">")) {
|
|
728
|
+
this.#position += 1;
|
|
729
|
+
return "open";
|
|
730
|
+
}
|
|
731
|
+
if (this.#position === before) throw this.#error("An attribute follows no whitespace");
|
|
732
|
+
const attribute = this.#readName();
|
|
733
|
+
if (attribute in attributes) throw this.#error(`The attribute ${attribute} is given twice`);
|
|
734
|
+
this.#skipWhitespace();
|
|
735
|
+
this.#expect("=");
|
|
736
|
+
this.#skipWhitespace();
|
|
737
|
+
attributes[attribute] = this.#readAttributeValue();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* XML 1.0, section 3.3.3: without a DTD every attribute is CDATA, so each whitespace
|
|
742
|
+
* character written into the value reads as a space, and a line break as one space. A
|
|
743
|
+
* reference to one stays the character it names.
|
|
744
|
+
*/
|
|
745
|
+
#readAttributeValue() {
|
|
746
|
+
const quote = this.#document[this.#position];
|
|
747
|
+
if (quote !== "\"" && quote !== "'") throw this.#error("An attribute value is not quoted");
|
|
748
|
+
this.#position += 1;
|
|
749
|
+
let value = "";
|
|
750
|
+
for (;;) {
|
|
751
|
+
const character = this.#document[this.#position];
|
|
752
|
+
if (character === void 0) throw this.#error("An attribute value is never closed");
|
|
753
|
+
if (character === quote) {
|
|
754
|
+
this.#position += 1;
|
|
755
|
+
return value;
|
|
756
|
+
}
|
|
757
|
+
if (character === "<") throw this.#error("A `<` stands inside an attribute value");
|
|
758
|
+
if (character === "&") {
|
|
759
|
+
value += this.#readEntity();
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
this.#position += this.#startsWith("\r\n") ? 2 : 1;
|
|
763
|
+
value += character === " " || character === "\n" || character === "\r" ? " " : character;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** Text up to the next markup, every `&` in it the start of an entity it decodes. */
|
|
767
|
+
#readText() {
|
|
768
|
+
const markup = this.#document.indexOf("<", this.#position);
|
|
769
|
+
const end = markup === -1 ? this.#document.length : markup;
|
|
770
|
+
let text = "";
|
|
771
|
+
while (this.#position < end) {
|
|
772
|
+
const ampersand = this.#document.indexOf("&", this.#position);
|
|
773
|
+
if (ampersand === -1 || ampersand >= end) {
|
|
774
|
+
text += this.#document.slice(this.#position, end);
|
|
775
|
+
this.#position = end;
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
text += this.#document.slice(this.#position, ampersand);
|
|
779
|
+
this.#position = ampersand;
|
|
780
|
+
text += this.#readEntity();
|
|
781
|
+
}
|
|
782
|
+
return text;
|
|
783
|
+
}
|
|
784
|
+
#readEntity() {
|
|
785
|
+
entityPattern.lastIndex = this.#position;
|
|
786
|
+
const found = entityPattern.exec(this.#document);
|
|
787
|
+
if (found === null) throw this.#error("An `&` starts no entity");
|
|
788
|
+
const [entity, hex, decimal, name] = found;
|
|
789
|
+
if (name !== void 0) {
|
|
790
|
+
const character = predefinedEntities.get(name);
|
|
791
|
+
if (character === void 0) throw this.#error(`The entity ${entity} is not defined`);
|
|
792
|
+
this.#position = entityPattern.lastIndex;
|
|
793
|
+
return character;
|
|
794
|
+
}
|
|
795
|
+
const codePoint = hex === void 0 ? Number(decimal) : Number.parseInt(hex, 16);
|
|
796
|
+
if (!isReferableCharacter(codePoint)) throw this.#error(`The reference ${entity} names U+0000, a surrogate or no Unicode scalar value at all`);
|
|
797
|
+
this.#position = entityPattern.lastIndex;
|
|
798
|
+
return String.fromCodePoint(codePoint);
|
|
799
|
+
}
|
|
800
|
+
#skipComment() {
|
|
801
|
+
this.#position = this.#positionPast("-->", "A comment is never closed", 4);
|
|
802
|
+
}
|
|
803
|
+
#readName() {
|
|
804
|
+
namePattern.lastIndex = this.#position;
|
|
805
|
+
const found = namePattern.exec(this.#document);
|
|
806
|
+
if (found === null) throw this.#error("A name is missing where one belongs");
|
|
807
|
+
this.#position = namePattern.lastIndex;
|
|
808
|
+
return found[0];
|
|
809
|
+
}
|
|
810
|
+
#skipWhitespace() {
|
|
811
|
+
whitespacePattern.lastIndex = this.#position;
|
|
812
|
+
whitespacePattern.exec(this.#document);
|
|
813
|
+
this.#position = whitespacePattern.lastIndex;
|
|
814
|
+
}
|
|
815
|
+
#expect(literal) {
|
|
816
|
+
if (!this.#startsWith(literal)) throw this.#error(`\`${literal}\` is missing`);
|
|
817
|
+
this.#position += literal.length;
|
|
818
|
+
}
|
|
819
|
+
#positionPast(terminator, unterminated, offset = 0) {
|
|
820
|
+
const end = this.#document.indexOf(terminator, this.#position + offset);
|
|
821
|
+
if (end === -1) throw this.#error(unterminated);
|
|
822
|
+
return end + terminator.length;
|
|
823
|
+
}
|
|
824
|
+
#startsWith(literal) {
|
|
825
|
+
return this.#document.startsWith(literal, this.#position);
|
|
826
|
+
}
|
|
827
|
+
#error(message) {
|
|
828
|
+
return new XmlSyntaxError(`${message} (at character ${this.#position})`);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
//#endregion
|
|
832
|
+
export { StorageError, XmlSyntaxError, capabilityNames, checkUserMetadata, decodeUserMetadataValue, encodeUserMetadataValue, errorCodeForStatus, invalidKeyReason, isStorageError, isTransientStatus, isUserMetadataKey, lastByteOf, parseXml, rangeBoundsRefusal, rangeCoversWhole, rangeHeader, rangeStartRefusal, readEnvironment, uploadStream, userMetadataByteLength, wholeSizeOf, withRetry };
|
package/package.json
CHANGED