@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.
Files changed (64) hide show
  1. package/README.md +75 -19
  2. package/dist/advanced.d.ts +7 -0
  3. package/dist/advanced.js +4 -0
  4. package/dist/application-session.d.ts +10 -5
  5. package/dist/application-session.js +32 -16
  6. package/dist/async.js +11 -5
  7. package/dist/authority-proof.d.ts +3 -0
  8. package/dist/authority-proof.js +24 -0
  9. package/dist/browser/integrity.json +1 -1
  10. package/dist/browser/mdbase-connect.min.js +3 -3
  11. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  12. package/dist/browser.d.ts +3 -0
  13. package/dist/browser.js +5 -0
  14. package/dist/capabilities.js +2 -1
  15. package/dist/collection-client.d.ts +28 -28
  16. package/dist/collection-client.js +42 -42
  17. package/dist/connect-options.d.ts +12 -4
  18. package/dist/connect.d.ts +12 -4
  19. package/dist/connect.js +105 -104
  20. package/dist/connection-file-transport.d.ts +2 -2
  21. package/dist/connection-file-transport.js +4 -4
  22. package/dist/connection-notifications.d.ts +10 -4
  23. package/dist/connection-notifications.js +41 -23
  24. package/dist/connection-transport.d.ts +19 -15
  25. package/dist/connection-transport.js +202 -202
  26. package/dist/connection-types.d.ts +5 -4
  27. package/dist/connection.d.ts +43 -43
  28. package/dist/connection.js +221 -77
  29. package/dist/crypto-entry.d.ts +4 -0
  30. package/dist/crypto-entry.js +4 -0
  31. package/dist/crypto.js +4 -4
  32. package/dist/external-store.d.ts +10 -0
  33. package/dist/external-store.js +10 -0
  34. package/dist/file-transfer-internals.d.ts +22 -0
  35. package/dist/file-transfer-internals.js +186 -0
  36. package/dist/files.d.ts +16 -12
  37. package/dist/files.js +52 -206
  38. package/dist/hosted-file-request.js +3 -3
  39. package/dist/index.d.ts +2 -5
  40. package/dist/index.js +1 -5
  41. package/dist/internal-types.d.ts +8 -1
  42. package/dist/local-file-transport.d.ts +2 -2
  43. package/dist/local-file-transport.js +13 -13
  44. package/dist/mdbase-connect.d.ts +6 -4
  45. package/dist/mdbase-connect.js +9 -8
  46. package/dist/notifications.d.ts +3 -2
  47. package/dist/operation-helpers.d.ts +3 -3
  48. package/dist/operation-helpers.js +12 -29
  49. package/dist/operation-types.d.ts +33 -8
  50. package/dist/outcomes.d.ts +15 -15
  51. package/dist/outcomes.js +13 -1
  52. package/dist/pending-mutation-store.d.ts +13 -0
  53. package/dist/pending-mutation-store.js +64 -0
  54. package/dist/request-budget.d.ts +27 -0
  55. package/dist/request-budget.js +95 -0
  56. package/dist/runtime-utils.d.ts +2 -0
  57. package/dist/runtime-utils.js +19 -0
  58. package/dist/session.d.ts +4 -3
  59. package/dist/session.js +4 -4
  60. package/dist/stored-token.d.ts +10 -0
  61. package/dist/stored-token.js +60 -0
  62. package/dist/token-response.d.ts +11 -0
  63. package/dist/token-response.js +77 -0
  64. package/package.json +12 -2
package/dist/crypto.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ENCRYPTED_RELAY_PROTOCOL_VERSION, AUTHORITY_PROOF_DOMAIN, AUTHORITY_PROOF_HEADERS, AUTHORITY_PROOF_VERSION, RELAY_ENCRYPTION_SUITE, isConnectProblem } from "@mdbase-dev/connect-protocol";
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
- ENCRYPTED_RELAY_PROTOCOL_VERSION,
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: ENCRYPTED_RELAY_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 !== ENCRYPTED_RELAY_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
- export type MdbaseFileSource = Blob | ArrayBuffer | ArrayBufferView;
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
- constructor(capability: () => FileCapability | null, request: ControlRequest, framed?: MdbaseFramedFileTransport | undefined, hosted?: MdbaseHostedFileTransport | undefined);
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
- const HASH_CHUNK_BYTES = 1024 * 1024;
6
- const DEFAULT_CONCURRENCY = 4;
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
- constructor(capability, request, framed, hosted) {
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.requireAction("list");
26
- let after;
27
- do {
28
- throwIfAborted(options.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, options.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(options.signal);
41
- yield file;
42
- }
43
- after = page.next;
44
- } while (after);
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
- }, options.signal);
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
- }