@mdbase-dev/connect 0.1.0-beta.31 → 0.1.0-beta.32
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 +75 -19
- package/dist/advanced.d.ts +7 -0
- package/dist/advanced.js +4 -0
- package/dist/application-session.d.ts +10 -5
- package/dist/application-session.js +32 -16
- package/dist/async.js +11 -5
- package/dist/authority-proof.d.ts +3 -0
- package/dist/authority-proof.js +24 -0
- package/dist/browser/integrity.json +1 -1
- package/dist/browser/mdbase-connect.min.js +3 -3
- package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +5 -0
- package/dist/capabilities.js +2 -1
- package/dist/collection-client.d.ts +28 -28
- package/dist/collection-client.js +42 -42
- package/dist/connect-options.d.ts +12 -4
- package/dist/connect.d.ts +12 -4
- package/dist/connect.js +105 -104
- package/dist/connection-file-transport.d.ts +2 -2
- package/dist/connection-file-transport.js +4 -4
- package/dist/connection-notifications.d.ts +10 -4
- package/dist/connection-notifications.js +41 -23
- package/dist/connection-transport.d.ts +19 -15
- package/dist/connection-transport.js +202 -202
- package/dist/connection-types.d.ts +5 -4
- package/dist/connection.d.ts +43 -43
- package/dist/connection.js +221 -77
- package/dist/crypto-entry.d.ts +4 -0
- package/dist/crypto-entry.js +4 -0
- package/dist/crypto.js +4 -4
- package/dist/external-store.d.ts +10 -0
- package/dist/external-store.js +10 -0
- package/dist/file-transfer-internals.d.ts +22 -0
- package/dist/file-transfer-internals.js +186 -0
- package/dist/files.d.ts +16 -12
- package/dist/files.js +52 -206
- package/dist/hosted-file-request.js +3 -3
- package/dist/index.d.ts +2 -5
- package/dist/index.js +1 -5
- package/dist/internal-types.d.ts +8 -1
- package/dist/local-file-transport.d.ts +2 -2
- package/dist/local-file-transport.js +13 -13
- package/dist/mdbase-connect.d.ts +6 -4
- package/dist/mdbase-connect.js +9 -8
- package/dist/notifications.d.ts +3 -2
- package/dist/operation-helpers.d.ts +3 -3
- package/dist/operation-helpers.js +12 -29
- package/dist/operation-types.d.ts +33 -8
- package/dist/outcomes.d.ts +15 -15
- package/dist/outcomes.js +13 -1
- package/dist/pending-mutation-store.d.ts +13 -0
- package/dist/pending-mutation-store.js +64 -0
- package/dist/request-budget.d.ts +27 -0
- package/dist/request-budget.js +95 -0
- package/dist/runtime-utils.d.ts +2 -0
- package/dist/runtime-utils.js +19 -0
- package/dist/session.d.ts +4 -3
- package/dist/session.js +4 -4
- package/dist/stored-token.d.ts +10 -0
- package/dist/stored-token.js +60 -0
- package/dist/token-response.d.ts +11 -0
- package/dist/token-response.js +77 -0
- package/package.json +12 -2
package/dist/crypto.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { OPERATION_TRANSPORT_PROTOCOL_VERSION, GRANT_ENCRYPTION_PROTOCOL_VERSION, AUTHORITY_PROOF_DOMAIN, AUTHORITY_PROOF_HEADERS, AUTHORITY_PROOF_VERSION, RELAY_ENCRYPTION_SUITE, isConnectProblem } from "@mdbase-dev/connect-protocol";
|
|
2
2
|
const DATABASE_NAME = "mdbase-connect-keys";
|
|
3
3
|
const STORE_NAME = "grant-keys";
|
|
4
4
|
const MAX_U64 = 18446744073709551615n;
|
|
@@ -272,7 +272,7 @@ function context(binding) {
|
|
|
272
272
|
const value = binding.encryption;
|
|
273
273
|
return [
|
|
274
274
|
"mdbase-connect",
|
|
275
|
-
|
|
275
|
+
OPERATION_TRANSPORT_PROTOCOL_VERSION,
|
|
276
276
|
value.suite,
|
|
277
277
|
binding.grantId,
|
|
278
278
|
binding.applicationId,
|
|
@@ -288,7 +288,7 @@ function aad(metadata, direction) {
|
|
|
288
288
|
function envelopeMetadata(metadata) {
|
|
289
289
|
const encryption = metadata.binding.encryption;
|
|
290
290
|
return {
|
|
291
|
-
protocol_version:
|
|
291
|
+
protocol_version: OPERATION_TRANSPORT_PROTOCOL_VERSION,
|
|
292
292
|
suite: RELAY_ENCRYPTION_SUITE,
|
|
293
293
|
request_id: metadata.requestId,
|
|
294
294
|
grant_id: metadata.binding.grantId,
|
|
@@ -317,7 +317,7 @@ function sameEnvelopeMetadata(request, response) {
|
|
|
317
317
|
&& request.counter === response.counter;
|
|
318
318
|
}
|
|
319
319
|
export function validateGrantEncryption(encryption) {
|
|
320
|
-
if (encryption.protocol_version !==
|
|
320
|
+
if (encryption.protocol_version !== GRANT_ENCRYPTION_PROTOCOL_VERSION
|
|
321
321
|
|| encryption.suite !== RELAY_ENCRYPTION_SUITE
|
|
322
322
|
|| !Number.isSafeInteger(encryption.scope_epoch)
|
|
323
323
|
|| encryption.scope_epoch <= 0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Minimal framework-neutral shape consumed by React's useSyncExternalStore. */
|
|
2
|
+
export interface MdbaseExternalStore<Snapshot> {
|
|
3
|
+
subscribe(listener: () => void): () => void;
|
|
4
|
+
getSnapshot(): Snapshot;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Adapt an existing SDK session without introducing another cache or state
|
|
8
|
+
* owner. The stable object can be passed directly to useSyncExternalStore.
|
|
9
|
+
*/
|
|
10
|
+
export declare function externalStore<Snapshot>(source: MdbaseExternalStore<Snapshot>): MdbaseExternalStore<Snapshot>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt an existing SDK session without introducing another cache or state
|
|
3
|
+
* owner. The stable object can be passed directly to useSyncExternalStore.
|
|
4
|
+
*/
|
|
5
|
+
export function externalStore(source) {
|
|
6
|
+
return {
|
|
7
|
+
subscribe: (listener) => source.subscribe(listener),
|
|
8
|
+
getSnapshot: () => source.getSnapshot()
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FileTransferSession, PreparedFilePart } from "@mdbase-dev/connect-protocol";
|
|
2
|
+
import { MdbaseConnectError } from "./errors.js";
|
|
3
|
+
export declare const MAX_OBJECT_ATTEMPTS = 3;
|
|
4
|
+
export declare const MAX_BUFFERED_DOWNLOAD_BYTES: number;
|
|
5
|
+
export declare const UUID: RegExp;
|
|
6
|
+
export declare const SHA256_DIGEST: RegExp;
|
|
7
|
+
export type FileSource = Blob | ArrayBuffer | ArrayBufferView;
|
|
8
|
+
export type UploadPartBody = Blob | Uint8Array<ArrayBuffer>;
|
|
9
|
+
export declare function hashBlob(blob: Blob, signal: AbortSignal | undefined, progress: (transferredBytes: number) => void): Promise<string>;
|
|
10
|
+
export declare function sourceBlob(source: FileSource, mediaType?: string): Blob;
|
|
11
|
+
export declare function uploadBodyLength(body: UploadPartBody): number;
|
|
12
|
+
export declare function uploadBodyBytes(body: UploadPartBody): Promise<Uint8Array<ArrayBuffer>>;
|
|
13
|
+
export declare function validConcurrency(value?: number): number;
|
|
14
|
+
export declare function validPageSize(value: number): number;
|
|
15
|
+
export declare function requireTransferSession(session: FileTransferSession, transferId: string, direction: "upload" | "download"): void;
|
|
16
|
+
export declare function chunkLength(totalSize: number, partSize: number, index: number): number;
|
|
17
|
+
export declare function retryChunk<Result>(work: () => Promise<Result>, signal?: AbortSignal): Promise<Result>;
|
|
18
|
+
export declare function requirePreparedPart(part: PreparedFilePart, transferId: string, partIndex: number, offset: number, contentLength: number, method: "PUT"): void;
|
|
19
|
+
export declare function browserObjectHeaders(headers: Record<string, string>): Headers;
|
|
20
|
+
export declare function mapConcurrent<Result>(count: number, concurrency: number, work: (index: number) => Promise<Result>): Promise<Result[]>;
|
|
21
|
+
export declare function throwIfAborted(signal?: AbortSignal): void;
|
|
22
|
+
export declare function normalizeFileError(error: unknown): MdbaseConnectError;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
2
|
+
import { IncrementalSha256 } from "./file-sha256.js";
|
|
3
|
+
const HASH_CHUNK_BYTES = 1024 * 1024;
|
|
4
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
5
|
+
const MAX_CONCURRENCY = 8;
|
|
6
|
+
export const MAX_OBJECT_ATTEMPTS = 3;
|
|
7
|
+
export const MAX_BUFFERED_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
9
|
+
export const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
10
|
+
export async function hashBlob(blob, signal, progress) {
|
|
11
|
+
const hash = new IncrementalSha256();
|
|
12
|
+
for (let offset = 0; offset < blob.size; offset += HASH_CHUNK_BYTES) {
|
|
13
|
+
throwIfAborted(signal);
|
|
14
|
+
const chunk = new Uint8Array(await blob.slice(offset, offset + HASH_CHUNK_BYTES).arrayBuffer());
|
|
15
|
+
hash.update(chunk);
|
|
16
|
+
progress(Math.min(blob.size, offset + chunk.byteLength));
|
|
17
|
+
}
|
|
18
|
+
if (blob.size === 0)
|
|
19
|
+
progress(0);
|
|
20
|
+
return hash.digestHex();
|
|
21
|
+
}
|
|
22
|
+
export function sourceBlob(source, mediaType) {
|
|
23
|
+
if (source instanceof Blob) {
|
|
24
|
+
return mediaType && mediaType !== source.type
|
|
25
|
+
? source.slice(0, source.size, mediaType)
|
|
26
|
+
: source;
|
|
27
|
+
}
|
|
28
|
+
if (source instanceof ArrayBuffer)
|
|
29
|
+
return new Blob([source], { type: mediaType });
|
|
30
|
+
const copy = new Uint8Array(source.byteLength);
|
|
31
|
+
copy.set(new Uint8Array(source.buffer, source.byteOffset, source.byteLength));
|
|
32
|
+
return new Blob([copy.buffer], { type: mediaType });
|
|
33
|
+
}
|
|
34
|
+
export function uploadBodyLength(body) {
|
|
35
|
+
return body instanceof Blob ? body.size : body.byteLength;
|
|
36
|
+
}
|
|
37
|
+
export async function uploadBodyBytes(body) {
|
|
38
|
+
return body instanceof Blob
|
|
39
|
+
? new Uint8Array(await body.arrayBuffer())
|
|
40
|
+
: body;
|
|
41
|
+
}
|
|
42
|
+
export function validConcurrency(value = DEFAULT_CONCURRENCY) {
|
|
43
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_CONCURRENCY) {
|
|
44
|
+
throw connectError("invalid_request", `File concurrency must be between 1 and ${MAX_CONCURRENCY}.`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
export function validPageSize(value) {
|
|
49
|
+
if (!Number.isInteger(value) || value < 1 || value > 1_000) {
|
|
50
|
+
throw connectError("invalid_request", "File page size must be between 1 and 1000.");
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export function requireTransferSession(session, transferId, direction) {
|
|
55
|
+
if (session.protocol_version !== 1
|
|
56
|
+
|| session.type !== "file_transfer"
|
|
57
|
+
|| session.transfer_id !== transferId
|
|
58
|
+
|| session.direction !== direction
|
|
59
|
+
|| !Number.isSafeInteger(session.total_size)
|
|
60
|
+
|| session.total_size < 0
|
|
61
|
+
|| !Array.isArray(session.received)
|
|
62
|
+
|| !validTransferStrategy(session)) {
|
|
63
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file transfer session.");
|
|
64
|
+
}
|
|
65
|
+
const partSize = session.strategy.kind === "framed_chunks"
|
|
66
|
+
? session.strategy.chunk_size
|
|
67
|
+
: session.strategy.kind === "object_put"
|
|
68
|
+
? Math.max(1, session.total_size)
|
|
69
|
+
: session.strategy.part_size;
|
|
70
|
+
const partCount = session.strategy.kind === "object_put"
|
|
71
|
+
? 1
|
|
72
|
+
: Math.ceil(session.total_size / partSize);
|
|
73
|
+
if (new Set(session.received).size !== session.received.length
|
|
74
|
+
|| session.received.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= partCount)) {
|
|
75
|
+
throw connectError("invalid_operation_response", "The authority returned invalid transfer progress.");
|
|
76
|
+
}
|
|
77
|
+
const uploadedParts = session.uploaded_parts ?? [];
|
|
78
|
+
if (!Array.isArray(uploadedParts)
|
|
79
|
+
|| uploadedParts.some((part, index) => !Number.isSafeInteger(part?.part_number)
|
|
80
|
+
|| part.part_number < 1
|
|
81
|
+
|| part.part_number > partCount
|
|
82
|
+
|| typeof part.etag !== "string"
|
|
83
|
+
|| part.etag.length === 0
|
|
84
|
+
|| part.etag.length > 255
|
|
85
|
+
|| (index > 0 && uploadedParts[index - 1].part_number >= part.part_number))
|
|
86
|
+
|| (session.strategy.kind === "object_multipart"
|
|
87
|
+
? uploadedParts.length !== session.received.length
|
|
88
|
+
|| uploadedParts.some((part, index) => part.part_number - 1 !== session.received[index])
|
|
89
|
+
: uploadedParts.length !== 0)) {
|
|
90
|
+
throw connectError("invalid_operation_response", "The authority returned invalid uploaded part receipts.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function validTransferStrategy(session) {
|
|
94
|
+
if (session.strategy.kind === "framed_chunks") {
|
|
95
|
+
return session.protection === "grant_aead_v1"
|
|
96
|
+
&& Number.isSafeInteger(session.strategy.chunk_size)
|
|
97
|
+
&& session.strategy.chunk_size > 0;
|
|
98
|
+
}
|
|
99
|
+
if (session.protection !== "transport_tls")
|
|
100
|
+
return false;
|
|
101
|
+
if (session.strategy.kind === "object_put")
|
|
102
|
+
return true;
|
|
103
|
+
return Number.isSafeInteger(session.strategy.part_size) && session.strategy.part_size > 0;
|
|
104
|
+
}
|
|
105
|
+
export function chunkLength(totalSize, partSize, index) {
|
|
106
|
+
return Math.min(partSize, Math.max(0, totalSize - index * partSize));
|
|
107
|
+
}
|
|
108
|
+
export async function retryChunk(work, signal) {
|
|
109
|
+
for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
|
|
110
|
+
throwIfAborted(signal);
|
|
111
|
+
try {
|
|
112
|
+
return await work();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (signal?.aborted || attempt === MAX_OBJECT_ATTEMPTS)
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new Error("Unreachable file chunk retry state.");
|
|
120
|
+
}
|
|
121
|
+
export function requirePreparedPart(part, transferId, partIndex, offset, contentLength, method) {
|
|
122
|
+
let url;
|
|
123
|
+
try {
|
|
124
|
+
url = new URL(part.url);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid object URL.");
|
|
128
|
+
}
|
|
129
|
+
if (part.protocol_version !== 1
|
|
130
|
+
|| part.type !== "file_part"
|
|
131
|
+
|| part.transfer_id !== transferId
|
|
132
|
+
|| part.part_index !== partIndex
|
|
133
|
+
|| part.offset !== offset
|
|
134
|
+
|| part.content_length !== contentLength
|
|
135
|
+
|| part.method.toUpperCase() !== method
|
|
136
|
+
|| url.protocol !== "https:"
|
|
137
|
+
&& !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname))
|
|
138
|
+
|| url.username
|
|
139
|
+
|| url.password) {
|
|
140
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid prepared file part.");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function browserObjectHeaders(headers) {
|
|
144
|
+
const result = new Headers();
|
|
145
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
146
|
+
if (["host", "content-length"].includes(name.toLowerCase()))
|
|
147
|
+
continue;
|
|
148
|
+
result.set(name, value);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
export async function mapConcurrent(count, concurrency, work) {
|
|
153
|
+
const results = new Array(count);
|
|
154
|
+
let next = 0;
|
|
155
|
+
await Promise.all(Array.from({ length: Math.min(count, concurrency) }, async () => {
|
|
156
|
+
while (next < count) {
|
|
157
|
+
const index = next;
|
|
158
|
+
next += 1;
|
|
159
|
+
results[index] = await work(index);
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
return results;
|
|
163
|
+
}
|
|
164
|
+
export function throwIfAborted(signal) {
|
|
165
|
+
if (signal?.aborted) {
|
|
166
|
+
throw connectError("operation_cancelled", "The file transfer was cancelled.", {
|
|
167
|
+
operationOutcome: "not_sent",
|
|
168
|
+
cause: signal.reason
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export function normalizeFileError(error) {
|
|
173
|
+
if (error instanceof MdbaseConnectError)
|
|
174
|
+
return error;
|
|
175
|
+
if (typeof DOMException !== "undefined"
|
|
176
|
+
&& error instanceof DOMException
|
|
177
|
+
&& error.name === "AbortError") {
|
|
178
|
+
return connectError("operation_cancelled", "The file transfer was cancelled.", {
|
|
179
|
+
operationOutcome: "not_sent",
|
|
180
|
+
cause: error
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return connectError("temporarily_unavailable", "The file transfer could not be completed.", {
|
|
184
|
+
cause: error
|
|
185
|
+
});
|
|
186
|
+
}
|
package/dist/files.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { CollectionFileDescriptor, DeleteFileReceipt, FileCapability, FileTransferSession } from "@mdbase-dev/connect-protocol";
|
|
2
|
-
|
|
2
|
+
import { type FileSource } from "./file-transfer-internals.js";
|
|
3
|
+
import type { ConnectRequestOptions } from "./operation-types.js";
|
|
4
|
+
import { type ResolvedConnectTimeouts } from "./request-budget.js";
|
|
5
|
+
export type MdbaseFileSource = FileSource;
|
|
3
6
|
export interface MdbaseFileStreamSource {
|
|
4
7
|
/** Exact plaintext byte length. */
|
|
5
8
|
size: number;
|
|
@@ -17,37 +20,32 @@ export interface MdbaseFileProgress {
|
|
|
17
20
|
transferredBytes: number;
|
|
18
21
|
totalBytes: number;
|
|
19
22
|
}
|
|
20
|
-
export interface MdbaseFileListOptions {
|
|
23
|
+
export interface MdbaseFileListOptions extends ConnectRequestOptions {
|
|
21
24
|
folder?: string;
|
|
22
25
|
pageSize?: number;
|
|
23
|
-
signal?: AbortSignal;
|
|
24
26
|
}
|
|
25
|
-
export interface MdbaseFileUploadOptions {
|
|
27
|
+
export interface MdbaseFileUploadOptions extends ConnectRequestOptions {
|
|
26
28
|
mediaType?: string;
|
|
27
29
|
ifRevision?: string;
|
|
28
30
|
concurrency?: number;
|
|
29
31
|
/** Stable retry key. Reuse it after an ambiguous failure to resume or replay commit. */
|
|
30
32
|
transferId?: string;
|
|
31
|
-
signal?: AbortSignal;
|
|
32
33
|
onProgress?: (progress: MdbaseFileProgress) => void;
|
|
33
34
|
}
|
|
34
35
|
export type MdbaseFileStreamUploadOptions = Omit<MdbaseFileUploadOptions, "concurrency">;
|
|
35
|
-
export interface MdbaseFileDownloadOptions {
|
|
36
|
+
export interface MdbaseFileDownloadOptions extends ConnectRequestOptions {
|
|
36
37
|
concurrency?: number;
|
|
37
|
-
signal?: AbortSignal;
|
|
38
38
|
onProgress?: (progress: MdbaseFileProgress) => void;
|
|
39
39
|
}
|
|
40
|
-
export interface MdbaseFileMoveOptions {
|
|
40
|
+
export interface MdbaseFileMoveOptions extends ConnectRequestOptions {
|
|
41
41
|
ifRevision?: string;
|
|
42
42
|
/** Reuse after an ambiguous network failure to receive the original receipt. */
|
|
43
43
|
mutationId?: string;
|
|
44
|
-
signal?: AbortSignal;
|
|
45
44
|
}
|
|
46
|
-
export interface MdbaseFileDeleteOptions {
|
|
45
|
+
export interface MdbaseFileDeleteOptions extends ConnectRequestOptions {
|
|
47
46
|
ifRevision?: string;
|
|
48
47
|
/** Reuse after an ambiguous network failure to receive the original receipt. */
|
|
49
48
|
mutationId?: string;
|
|
50
|
-
signal?: AbortSignal;
|
|
51
49
|
}
|
|
52
50
|
/** Internal transport seam used by direct and relayed encrypted chunk delivery. */
|
|
53
51
|
export interface MdbaseFramedFileTransport {
|
|
@@ -65,9 +63,11 @@ export declare class MdbaseFileClient {
|
|
|
65
63
|
private readonly request;
|
|
66
64
|
private readonly framed?;
|
|
67
65
|
private readonly hosted?;
|
|
68
|
-
|
|
66
|
+
private readonly timeouts;
|
|
67
|
+
constructor(capability: () => FileCapability | null, request: ControlRequest, framed?: MdbaseFramedFileTransport | undefined, hosted?: MdbaseHostedFileTransport | undefined, timeouts?: ResolvedConnectTimeouts);
|
|
69
68
|
list(options?: MdbaseFileListOptions): AsyncGenerator<CollectionFileDescriptor>;
|
|
70
69
|
upload(path: string, source: MdbaseFileSource, options?: MdbaseFileUploadOptions): Promise<CollectionFileDescriptor>;
|
|
70
|
+
private uploadWithinBudget;
|
|
71
71
|
/**
|
|
72
72
|
* Upload a one-shot byte stream without accumulating multiple file parts.
|
|
73
73
|
* SDK memory is bounded by one assembled negotiated part plus at most one
|
|
@@ -75,13 +75,17 @@ export declare class MdbaseFileClient {
|
|
|
75
75
|
* Reuse `transferId` with a newly opened source after an ambiguous failure.
|
|
76
76
|
*/
|
|
77
77
|
uploadStream(path: string, source: MdbaseFileStreamSource, options?: MdbaseFileStreamUploadOptions): Promise<CollectionFileDescriptor>;
|
|
78
|
+
private uploadStreamWithinBudget;
|
|
78
79
|
private uploadKnownSource;
|
|
79
80
|
download(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<Blob>;
|
|
80
81
|
/** Download and verify a file with network backpressure and bounded buffering. */
|
|
81
82
|
downloadStream(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<ReadableStream<Uint8Array>>;
|
|
83
|
+
private openDownloadStream;
|
|
82
84
|
downloadBytes(file: CollectionFileDescriptor, options?: MdbaseFileDownloadOptions): Promise<Uint8Array>;
|
|
83
85
|
move(file: CollectionFileDescriptor, path: string, options?: MdbaseFileMoveOptions): Promise<CollectionFileDescriptor>;
|
|
86
|
+
private moveWithinBudget;
|
|
84
87
|
delete(file: CollectionFileDescriptor, options?: MdbaseFileDeleteOptions): Promise<DeleteFileReceipt>;
|
|
88
|
+
private deleteWithinBudget;
|
|
85
89
|
private uploadPart;
|
|
86
90
|
private tryReplayUploadCommit;
|
|
87
91
|
private commitUpload;
|
package/dist/files.js
CHANGED
|
@@ -2,48 +2,55 @@ import { FILE_PROTOCOL_VERSION } from "@mdbase-dev/connect-protocol";
|
|
|
2
2
|
import { MdbaseConnectError, connectError } from "./errors.js";
|
|
3
3
|
import { IncrementalSha256 } from "./file-sha256.js";
|
|
4
4
|
import { BinaryPartReader } from "./file-stream-source.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const MAX_CONCURRENCY = 8;
|
|
8
|
-
const MAX_OBJECT_ATTEMPTS = 3;
|
|
9
|
-
const MAX_BUFFERED_DOWNLOAD_BYTES = 64 * 1024 * 1024;
|
|
10
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
11
|
-
const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
5
|
+
import { MAX_BUFFERED_DOWNLOAD_BYTES, MAX_OBJECT_ATTEMPTS, SHA256_DIGEST, UUID, browserObjectHeaders, chunkLength, hashBlob, mapConcurrent, normalizeFileError, requirePreparedPart, requireTransferSession, retryChunk, sourceBlob, throwIfAborted, uploadBodyBytes, uploadBodyLength, validConcurrency, validPageSize } from "./file-transfer-internals.js";
|
|
6
|
+
import { createRequestBudget, resolveConnectTimeouts, withCooperativeRequestBudget } from "./request-budget.js";
|
|
12
7
|
/** Ergonomic facade over resumable, authority-specific file delivery. */
|
|
13
8
|
export class MdbaseFileClient {
|
|
14
9
|
capability;
|
|
15
10
|
request;
|
|
16
11
|
framed;
|
|
17
12
|
hosted;
|
|
18
|
-
|
|
13
|
+
timeouts;
|
|
14
|
+
constructor(capability, request, framed, hosted, timeouts = resolveConnectTimeouts()) {
|
|
19
15
|
this.capability = capability;
|
|
20
16
|
this.request = request;
|
|
21
17
|
this.framed = framed;
|
|
22
18
|
this.hosted = hosted;
|
|
19
|
+
this.timeouts = timeouts;
|
|
23
20
|
}
|
|
24
21
|
async *list(options = {}) {
|
|
25
|
-
this.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
22
|
+
const budget = createRequestBudget(options, this.timeouts.requestMs);
|
|
23
|
+
const signal = budget.signal;
|
|
24
|
+
try {
|
|
25
|
+
this.requireAction("list");
|
|
26
|
+
let after;
|
|
27
|
+
do {
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
const query = new URLSearchParams({
|
|
30
|
+
protocol_version: String(FILE_PROTOCOL_VERSION),
|
|
31
|
+
...(options.folder ? { folder: options.folder } : {}),
|
|
32
|
+
...(after ? { after } : {}),
|
|
33
|
+
...(options.pageSize ? { limit: String(validPageSize(options.pageSize)) } : {})
|
|
34
|
+
});
|
|
35
|
+
const page = await this.request("GET", `?${query.toString()}`, undefined, signal);
|
|
36
|
+
if (page.protocol_version !== 1 || page.type !== "files_page" || !Array.isArray(page.files)) {
|
|
37
|
+
throw connectError("invalid_operation_response", "The authority returned an invalid file page.");
|
|
38
|
+
}
|
|
39
|
+
for (const file of page.files) {
|
|
40
|
+
throwIfAborted(signal);
|
|
41
|
+
yield file;
|
|
42
|
+
}
|
|
43
|
+
after = page.next;
|
|
44
|
+
} while (after);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
budget.dispose();
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
async upload(path, source, options = {}) {
|
|
51
|
+
return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadWithinBudget(path, source, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
52
|
+
}
|
|
53
|
+
async uploadWithinBudget(path, source, options) {
|
|
47
54
|
this.requireAction(options.ifRevision ? "replace" : "add");
|
|
48
55
|
const blob = sourceBlob(source, options.mediaType);
|
|
49
56
|
const concurrency = validConcurrency(options.concurrency);
|
|
@@ -63,6 +70,13 @@ export class MdbaseFileClient {
|
|
|
63
70
|
* Reuse `transferId` with a newly opened source after an ambiguous failure.
|
|
64
71
|
*/
|
|
65
72
|
async uploadStream(path, source, options = {}) {
|
|
73
|
+
return withCooperativeRequestBudget(options, this.timeouts.uploadMs, (budget) => this.uploadStreamWithinBudget(path, source, {
|
|
74
|
+
...options,
|
|
75
|
+
signal: budget.signal,
|
|
76
|
+
timeoutMs: null
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
async uploadStreamWithinBudget(path, source, options) {
|
|
66
80
|
this.requireAction(options.ifRevision ? "replace" : "add");
|
|
67
81
|
if (!Number.isSafeInteger(source.size) || source.size < 0) {
|
|
68
82
|
throw connectError("invalid_request", "Streamed file size must be a non-negative safe integer.");
|
|
@@ -192,6 +206,9 @@ export class MdbaseFileClient {
|
|
|
192
206
|
}
|
|
193
207
|
/** Download and verify a file with network backpressure and bounded buffering. */
|
|
194
208
|
async downloadStream(file, options = {}) {
|
|
209
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.openDownloadStream(file, options, budget.signal));
|
|
210
|
+
}
|
|
211
|
+
async openDownloadStream(file, options, startupSignal) {
|
|
195
212
|
this.requireAction("read");
|
|
196
213
|
validConcurrency(options.concurrency);
|
|
197
214
|
const transferId = crypto.randomUUID();
|
|
@@ -203,7 +220,7 @@ export class MdbaseFileClient {
|
|
|
203
220
|
transfer_id: transferId,
|
|
204
221
|
file_id: file.file_id,
|
|
205
222
|
revision: file.revision
|
|
206
|
-
},
|
|
223
|
+
}, startupSignal);
|
|
207
224
|
requireTransferSession(session, transferId, "download");
|
|
208
225
|
if (session.total_size !== file.size
|
|
209
226
|
|| session.strategy.kind !== "object_ranges"
|
|
@@ -333,6 +350,9 @@ export class MdbaseFileClient {
|
|
|
333
350
|
return new Uint8Array(await (await this.download(file, options)).arrayBuffer());
|
|
334
351
|
}
|
|
335
352
|
async move(file, path, options = {}) {
|
|
353
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.moveWithinBudget(file, path, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
354
|
+
}
|
|
355
|
+
async moveWithinBudget(file, path, options) {
|
|
336
356
|
this.requireAction("move");
|
|
337
357
|
const mutationId = options.mutationId ?? crypto.randomUUID();
|
|
338
358
|
let receipt;
|
|
@@ -363,6 +383,9 @@ export class MdbaseFileClient {
|
|
|
363
383
|
return receipt.file;
|
|
364
384
|
}
|
|
365
385
|
async delete(file, options = {}) {
|
|
386
|
+
return withCooperativeRequestBudget(options, this.timeouts.requestMs, (budget) => this.deleteWithinBudget(file, { ...options, signal: budget.signal, timeoutMs: null }));
|
|
387
|
+
}
|
|
388
|
+
async deleteWithinBudget(file, options) {
|
|
366
389
|
this.requireAction("delete");
|
|
367
390
|
const mutationId = options.mutationId ?? crypto.randomUUID();
|
|
368
391
|
let receipt;
|
|
@@ -465,180 +488,3 @@ export class MdbaseFileClient {
|
|
|
465
488
|
await this.request("DELETE", `transfers/${encodeURIComponent(transferId)}`).catch(() => undefined);
|
|
466
489
|
}
|
|
467
490
|
}
|
|
468
|
-
async function hashBlob(blob, signal, progress) {
|
|
469
|
-
const hash = new IncrementalSha256();
|
|
470
|
-
for (let offset = 0; offset < blob.size; offset += HASH_CHUNK_BYTES) {
|
|
471
|
-
throwIfAborted(signal);
|
|
472
|
-
const chunk = new Uint8Array(await blob.slice(offset, offset + HASH_CHUNK_BYTES).arrayBuffer());
|
|
473
|
-
hash.update(chunk);
|
|
474
|
-
progress(Math.min(blob.size, offset + chunk.byteLength));
|
|
475
|
-
}
|
|
476
|
-
if (blob.size === 0)
|
|
477
|
-
progress(0);
|
|
478
|
-
return hash.digestHex();
|
|
479
|
-
}
|
|
480
|
-
function sourceBlob(source, mediaType) {
|
|
481
|
-
if (source instanceof Blob) {
|
|
482
|
-
return mediaType && mediaType !== source.type
|
|
483
|
-
? source.slice(0, source.size, mediaType)
|
|
484
|
-
: source;
|
|
485
|
-
}
|
|
486
|
-
if (source instanceof ArrayBuffer)
|
|
487
|
-
return new Blob([source], { type: mediaType });
|
|
488
|
-
const copy = new Uint8Array(source.byteLength);
|
|
489
|
-
copy.set(new Uint8Array(source.buffer, source.byteOffset, source.byteLength));
|
|
490
|
-
return new Blob([copy.buffer], { type: mediaType });
|
|
491
|
-
}
|
|
492
|
-
function uploadBodyLength(body) {
|
|
493
|
-
return body instanceof Blob ? body.size : body.byteLength;
|
|
494
|
-
}
|
|
495
|
-
async function uploadBodyBytes(body) {
|
|
496
|
-
return body instanceof Blob
|
|
497
|
-
? new Uint8Array(await body.arrayBuffer())
|
|
498
|
-
: body;
|
|
499
|
-
}
|
|
500
|
-
function validConcurrency(value = DEFAULT_CONCURRENCY) {
|
|
501
|
-
if (!Number.isInteger(value) || value < 1 || value > MAX_CONCURRENCY) {
|
|
502
|
-
throw connectError("invalid_request", `File concurrency must be between 1 and ${MAX_CONCURRENCY}.`);
|
|
503
|
-
}
|
|
504
|
-
return value;
|
|
505
|
-
}
|
|
506
|
-
function validPageSize(value) {
|
|
507
|
-
if (!Number.isInteger(value) || value < 1 || value > 1_000) {
|
|
508
|
-
throw connectError("invalid_request", "File page size must be between 1 and 1000.");
|
|
509
|
-
}
|
|
510
|
-
return value;
|
|
511
|
-
}
|
|
512
|
-
function requireTransferSession(session, transferId, direction) {
|
|
513
|
-
if (session.protocol_version !== 1
|
|
514
|
-
|| session.type !== "file_transfer"
|
|
515
|
-
|| session.transfer_id !== transferId
|
|
516
|
-
|| session.direction !== direction
|
|
517
|
-
|| !Number.isSafeInteger(session.total_size)
|
|
518
|
-
|| session.total_size < 0
|
|
519
|
-
|| !Array.isArray(session.received)
|
|
520
|
-
|| !validTransferStrategy(session)) {
|
|
521
|
-
throw connectError("invalid_operation_response", "The authority returned an invalid file transfer session.");
|
|
522
|
-
}
|
|
523
|
-
const partSize = session.strategy.kind === "framed_chunks"
|
|
524
|
-
? session.strategy.chunk_size
|
|
525
|
-
: session.strategy.kind === "object_put"
|
|
526
|
-
? Math.max(1, session.total_size)
|
|
527
|
-
: session.strategy.part_size;
|
|
528
|
-
const partCount = session.strategy.kind === "object_put"
|
|
529
|
-
? 1
|
|
530
|
-
: Math.ceil(session.total_size / partSize);
|
|
531
|
-
if (new Set(session.received).size !== session.received.length
|
|
532
|
-
|| session.received.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= partCount)) {
|
|
533
|
-
throw connectError("invalid_operation_response", "The authority returned invalid transfer progress.");
|
|
534
|
-
}
|
|
535
|
-
const uploadedParts = session.uploaded_parts ?? [];
|
|
536
|
-
if (!Array.isArray(uploadedParts)
|
|
537
|
-
|| uploadedParts.some((part, index) => !Number.isSafeInteger(part?.part_number)
|
|
538
|
-
|| part.part_number < 1
|
|
539
|
-
|| part.part_number > partCount
|
|
540
|
-
|| typeof part.etag !== "string"
|
|
541
|
-
|| part.etag.length === 0
|
|
542
|
-
|| part.etag.length > 255
|
|
543
|
-
|| (index > 0 && uploadedParts[index - 1].part_number >= part.part_number))
|
|
544
|
-
|| (session.strategy.kind === "object_multipart"
|
|
545
|
-
? uploadedParts.length !== session.received.length
|
|
546
|
-
|| uploadedParts.some((part, index) => part.part_number - 1 !== session.received[index])
|
|
547
|
-
: uploadedParts.length !== 0)) {
|
|
548
|
-
throw connectError("invalid_operation_response", "The authority returned invalid uploaded part receipts.");
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
function validTransferStrategy(session) {
|
|
552
|
-
if (session.strategy.kind === "framed_chunks") {
|
|
553
|
-
return session.protection === "grant_aead_v1"
|
|
554
|
-
&& Number.isSafeInteger(session.strategy.chunk_size)
|
|
555
|
-
&& session.strategy.chunk_size > 0;
|
|
556
|
-
}
|
|
557
|
-
if (session.protection !== "transport_tls")
|
|
558
|
-
return false;
|
|
559
|
-
if (session.strategy.kind === "object_put")
|
|
560
|
-
return true;
|
|
561
|
-
return Number.isSafeInteger(session.strategy.part_size) && session.strategy.part_size > 0;
|
|
562
|
-
}
|
|
563
|
-
function chunkLength(totalSize, partSize, index) {
|
|
564
|
-
return Math.min(partSize, Math.max(0, totalSize - index * partSize));
|
|
565
|
-
}
|
|
566
|
-
async function retryChunk(work, signal) {
|
|
567
|
-
for (let attempt = 1; attempt <= MAX_OBJECT_ATTEMPTS; attempt += 1) {
|
|
568
|
-
throwIfAborted(signal);
|
|
569
|
-
try {
|
|
570
|
-
return await work();
|
|
571
|
-
}
|
|
572
|
-
catch (error) {
|
|
573
|
-
if (signal?.aborted || attempt === MAX_OBJECT_ATTEMPTS)
|
|
574
|
-
throw error;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
throw new Error("Unreachable file chunk retry state.");
|
|
578
|
-
}
|
|
579
|
-
function requirePreparedPart(part, transferId, partIndex, offset, contentLength, method) {
|
|
580
|
-
let url;
|
|
581
|
-
try {
|
|
582
|
-
url = new URL(part.url);
|
|
583
|
-
}
|
|
584
|
-
catch {
|
|
585
|
-
throw connectError("invalid_operation_response", "The authority returned an invalid object URL.");
|
|
586
|
-
}
|
|
587
|
-
if (part.protocol_version !== 1
|
|
588
|
-
|| part.type !== "file_part"
|
|
589
|
-
|| part.transfer_id !== transferId
|
|
590
|
-
|| part.part_index !== partIndex
|
|
591
|
-
|| part.offset !== offset
|
|
592
|
-
|| part.content_length !== contentLength
|
|
593
|
-
|| part.method.toUpperCase() !== method
|
|
594
|
-
|| url.protocol !== "https:"
|
|
595
|
-
&& !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname))
|
|
596
|
-
|| url.username
|
|
597
|
-
|| url.password) {
|
|
598
|
-
throw connectError("invalid_operation_response", "The authority returned an invalid prepared file part.");
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
function browserObjectHeaders(headers) {
|
|
602
|
-
const result = new Headers();
|
|
603
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
604
|
-
if (["host", "content-length"].includes(name.toLowerCase()))
|
|
605
|
-
continue;
|
|
606
|
-
result.set(name, value);
|
|
607
|
-
}
|
|
608
|
-
return result;
|
|
609
|
-
}
|
|
610
|
-
async function mapConcurrent(count, concurrency, work) {
|
|
611
|
-
const results = new Array(count);
|
|
612
|
-
let next = 0;
|
|
613
|
-
await Promise.all(Array.from({ length: Math.min(count, concurrency) }, async () => {
|
|
614
|
-
while (next < count) {
|
|
615
|
-
const index = next;
|
|
616
|
-
next += 1;
|
|
617
|
-
results[index] = await work(index);
|
|
618
|
-
}
|
|
619
|
-
}));
|
|
620
|
-
return results;
|
|
621
|
-
}
|
|
622
|
-
function throwIfAborted(signal) {
|
|
623
|
-
if (signal?.aborted) {
|
|
624
|
-
throw connectError("operation_cancelled", "The file transfer was cancelled.", {
|
|
625
|
-
operationOutcome: "not_sent",
|
|
626
|
-
cause: signal.reason
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
function normalizeFileError(error) {
|
|
631
|
-
if (error instanceof MdbaseConnectError)
|
|
632
|
-
return error;
|
|
633
|
-
if (typeof DOMException !== "undefined"
|
|
634
|
-
&& error instanceof DOMException
|
|
635
|
-
&& error.name === "AbortError") {
|
|
636
|
-
return connectError("operation_cancelled", "The file transfer was cancelled.", {
|
|
637
|
-
operationOutcome: "not_sent",
|
|
638
|
-
cause: error
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
return connectError("temporarily_unavailable", "The file transfer could not be completed.", {
|
|
642
|
-
cause: error
|
|
643
|
-
});
|
|
644
|
-
}
|