@powerhousedao/reactor-attachments 6.2.2-dev.9 → 6.2.2-staging.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/dist/client.d.ts +288 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +445 -10
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +211 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +851 -35
- package/dist/index.js.map +1 -1
- package/dist/{null-attachment-transport-BBhQIk5A.d.ts → null-attachment-transport-CMrO_ZKA.d.ts} +257 -10
- package/dist/null-attachment-transport-CMrO_ZKA.d.ts.map +1 -0
- package/dist/{null-attachment-transport-Drx03s02.js → null-attachment-transport-CrsUafCi.js} +361 -27
- package/dist/null-attachment-transport-CrsUafCi.js.map +1 -0
- package/package.json +12 -4
- package/dist/null-attachment-transport-BBhQIk5A.d.ts.map +0 -1
- package/dist/null-attachment-transport-Drx03s02.js.map +0 -1
package/dist/{null-attachment-transport-Drx03s02.js → null-attachment-transport-CrsUafCi.js}
RENAMED
|
@@ -90,16 +90,20 @@ var SizeMismatch = class extends Error {
|
|
|
90
90
|
}
|
|
91
91
|
};
|
|
92
92
|
/**
|
|
93
|
-
* Thrown
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* After expiresAtUtc has passed the hash reads as not found.
|
|
97
|
-
*
|
|
98
|
-
* metadata is populated when the reservation is local and its fields are
|
|
99
|
-
* known (mimeType, fileName, sizeBytes). It is undefined when the pending
|
|
100
|
-
* state is learned from a remote transport that did not supply the full
|
|
101
|
-
* Attachment-Pending header (transport-pending / degraded wire case).
|
|
93
|
+
* Thrown when a remote transfer step fails. Identifies the stage only; it
|
|
94
|
+
* intentionally carries no URL, signature query, bucket key, or bearer
|
|
95
|
+
* material so it is always safe to log or surface.
|
|
102
96
|
*/
|
|
97
|
+
var AttachmentTransferError = class extends Error {
|
|
98
|
+
stage;
|
|
99
|
+
status;
|
|
100
|
+
constructor(stage, status) {
|
|
101
|
+
super(status === void 0 ? `Attachment ${stage} request failed` : `Attachment ${stage} request failed with status ${status}`);
|
|
102
|
+
this.name = "AttachmentTransferError";
|
|
103
|
+
this.stage = stage;
|
|
104
|
+
this.status = status;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
103
107
|
var AttachmentPending = class extends Error {
|
|
104
108
|
hash;
|
|
105
109
|
expiresAtUtc;
|
|
@@ -113,6 +117,107 @@ var AttachmentPending = class extends Error {
|
|
|
113
117
|
}
|
|
114
118
|
};
|
|
115
119
|
//#endregion
|
|
120
|
+
//#region src/targets.ts
|
|
121
|
+
function isRecord$4(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
function parseUrl(value) {
|
|
125
|
+
if (typeof value !== "string" || value.length === 0) throw new Error("Attachment target URL must be a non-empty string");
|
|
126
|
+
if (value.trim() !== value || hasForbiddenUrlWhitespace(value)) throw new Error("Attachment target URL must not contain raw whitespace");
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = new URL(value);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new Error("Attachment target URL is invalid");
|
|
132
|
+
}
|
|
133
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("Attachment target URL must be HTTP(S) and must not contain credentials");
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
function parseHeaders(value) {
|
|
137
|
+
if (!isRecord$4(value)) throw new Error("Attachment target headers must be an object");
|
|
138
|
+
const headers = Object.create(null);
|
|
139
|
+
const headerName = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
140
|
+
for (const [name, headerValue] of Object.entries(value)) {
|
|
141
|
+
if (!headerName.test(name)) throw new Error(`Attachment target header name is invalid: ${name}`);
|
|
142
|
+
if (typeof headerValue !== "string" || hasForbiddenHeaderCharacter(headerValue)) throw new Error(`Attachment target header value is invalid: ${name}`);
|
|
143
|
+
headers[name] = headerValue;
|
|
144
|
+
}
|
|
145
|
+
return headers;
|
|
146
|
+
}
|
|
147
|
+
function hasForbiddenHeaderCharacter(value) {
|
|
148
|
+
for (let index = 0; index < value.length; index++) {
|
|
149
|
+
const code = value.charCodeAt(index);
|
|
150
|
+
if (code <= 8 || code >= 10 && code <= 31 || code === 127 || code > 255) return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
function hasForbiddenUrlWhitespace(value) {
|
|
155
|
+
for (let index = 0; index < value.length; index++) {
|
|
156
|
+
const code = value.charCodeAt(index);
|
|
157
|
+
if (code <= 32 || code === 127) return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function parseExpiry(value, required) {
|
|
162
|
+
if (value === void 0 && !required) return void 0;
|
|
163
|
+
if (typeof value !== "string") throw new Error("Attachment target expiry must be an ISO 8601 UTC string");
|
|
164
|
+
const parsed = new Date(value);
|
|
165
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) throw new Error("Attachment target expiry must be an ISO 8601 UTC string");
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
function parseAttachmentUploadTarget(value) {
|
|
169
|
+
if (!isRecord$4(value)) throw new Error("Attachment upload target must be an object");
|
|
170
|
+
const url = parseUrl(value.url);
|
|
171
|
+
const headers = parseHeaders(value.headers);
|
|
172
|
+
switch (value.kind) {
|
|
173
|
+
case "switchboard":
|
|
174
|
+
if (value.method !== "PUT") throw new Error("Switchboard upload target method must be PUT");
|
|
175
|
+
return {
|
|
176
|
+
kind: "switchboard",
|
|
177
|
+
method: "PUT",
|
|
178
|
+
url,
|
|
179
|
+
headers,
|
|
180
|
+
...value.expiresAtUtc === void 0 ? {} : { expiresAtUtc: parseExpiry(value.expiresAtUtc, false) }
|
|
181
|
+
};
|
|
182
|
+
case "presigned-put":
|
|
183
|
+
if (value.method !== "PUT") throw new Error("Presigned upload target method must be PUT");
|
|
184
|
+
return {
|
|
185
|
+
kind: "presigned-put",
|
|
186
|
+
method: "PUT",
|
|
187
|
+
url,
|
|
188
|
+
headers,
|
|
189
|
+
expiresAtUtc: parseExpiry(value.expiresAtUtc, true)
|
|
190
|
+
};
|
|
191
|
+
default: throw new Error("Attachment upload target kind is unknown");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function parseAttachmentDownloadTarget(value) {
|
|
195
|
+
if (!isRecord$4(value)) throw new Error("Attachment download target must be an object");
|
|
196
|
+
const url = parseUrl(value.url);
|
|
197
|
+
const headers = parseHeaders(value.headers);
|
|
198
|
+
switch (value.kind) {
|
|
199
|
+
case "switchboard":
|
|
200
|
+
if (value.method !== "GET") throw new Error("Switchboard download target method must be GET");
|
|
201
|
+
return {
|
|
202
|
+
kind: "switchboard",
|
|
203
|
+
method: "GET",
|
|
204
|
+
url,
|
|
205
|
+
headers,
|
|
206
|
+
...value.expiresAtUtc === void 0 ? {} : { expiresAtUtc: parseExpiry(value.expiresAtUtc, false) }
|
|
207
|
+
};
|
|
208
|
+
case "presigned-get":
|
|
209
|
+
if (value.method !== "GET") throw new Error("Presigned download target method must be GET");
|
|
210
|
+
return {
|
|
211
|
+
kind: "presigned-get",
|
|
212
|
+
method: "GET",
|
|
213
|
+
url,
|
|
214
|
+
headers,
|
|
215
|
+
expiresAtUtc: parseExpiry(value.expiresAtUtc, true)
|
|
216
|
+
};
|
|
217
|
+
default: throw new Error("Attachment download target kind is unknown");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
116
221
|
//#region src/ref.ts
|
|
117
222
|
const REF_PATTERN = /^attachment:\/\/v(\d+):(.+)$/;
|
|
118
223
|
const DEFAULT_VERSION = 1;
|
|
@@ -131,13 +236,15 @@ function createRef(hash, version = DEFAULT_VERSION) {
|
|
|
131
236
|
//#region src/attachment-service.ts
|
|
132
237
|
const CLIENT_HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
133
238
|
var AttachmentService = class {
|
|
134
|
-
constructor(store, reservations, uploadFactory) {
|
|
239
|
+
constructor(store, reservations, uploadFactory, backend) {
|
|
135
240
|
this.store = store;
|
|
136
241
|
this.reservations = reservations;
|
|
137
242
|
this.uploadFactory = uploadFactory;
|
|
243
|
+
this.backend = backend;
|
|
138
244
|
}
|
|
139
245
|
async reserve(options) {
|
|
140
246
|
if (options.clientHash !== void 0) return this.reserveHashFirst(options);
|
|
247
|
+
if (this.backend?.kind === "s3") throw new Error("S3 attachment reservations require a client hash");
|
|
141
248
|
const reservation = await this.reservations.create(options);
|
|
142
249
|
return this.uploadFactory.createUpload(reservation);
|
|
143
250
|
}
|
|
@@ -145,9 +252,16 @@ var AttachmentService = class {
|
|
|
145
252
|
const { hash } = parseRef(ref);
|
|
146
253
|
return this.store.stat(hash);
|
|
147
254
|
}
|
|
148
|
-
async get(ref,
|
|
255
|
+
async get(ref, options) {
|
|
149
256
|
const { hash } = parseRef(ref);
|
|
150
|
-
|
|
257
|
+
const normalized = options === void 0 || options instanceof AbortSignal ? { signal: options } : options;
|
|
258
|
+
return normalized.documentId === void 0 ? this.store.get(hash, normalized.signal) : this.store.get(hash, normalized.signal, normalized.documentId);
|
|
259
|
+
}
|
|
260
|
+
getDownloadTarget(ref, options) {
|
|
261
|
+
const { hash } = parseRef(ref);
|
|
262
|
+
const getTarget = this.store.getDownloadTarget?.bind(this.store);
|
|
263
|
+
if (getTarget === void 0) return Promise.reject(/* @__PURE__ */ new Error("Download targets are not supported by this attachment store: only remote stores negotiate direct URLs"));
|
|
264
|
+
return getTarget(hash, options);
|
|
151
265
|
}
|
|
152
266
|
async reserveHashFirst(options) {
|
|
153
267
|
const normalized = options.clientHash.toLowerCase();
|
|
@@ -163,9 +277,18 @@ var AttachmentService = class {
|
|
|
163
277
|
} catch (err) {
|
|
164
278
|
if (!(err instanceof AttachmentNotFound) && !(err instanceof AttachmentPending)) throw err;
|
|
165
279
|
}
|
|
166
|
-
if (existingHeader !== null
|
|
280
|
+
if (existingHeader !== null) {
|
|
281
|
+
if (this.backend?.kind === "s3") {
|
|
282
|
+
if (await this.backend.exists(normalized)) throw new AttachmentAlreadyExists(normalized, createRef(normalized));
|
|
283
|
+
} else if (existingHeader.status === "available") throw new AttachmentAlreadyExists(normalized, createRef(normalized));
|
|
284
|
+
}
|
|
167
285
|
const reservation = await this.reservations.create(normalizedOptions);
|
|
168
|
-
return this.uploadFactory.createUpload(reservation);
|
|
286
|
+
if (this.backend?.kind !== "s3") return this.uploadFactory.createUpload(reservation);
|
|
287
|
+
const uploadTarget = await this.backend.prepareUploadTarget(reservation);
|
|
288
|
+
return this.uploadFactory.createUpload({
|
|
289
|
+
...reservation,
|
|
290
|
+
uploadTarget
|
|
291
|
+
});
|
|
169
292
|
}
|
|
170
293
|
};
|
|
171
294
|
//#endregion
|
|
@@ -302,6 +425,115 @@ function contentTypeFallback$1(response) {
|
|
|
302
425
|
};
|
|
303
426
|
}
|
|
304
427
|
//#endregion
|
|
428
|
+
//#region src/switchboard/upload-transport.ts
|
|
429
|
+
/**
|
|
430
|
+
* The default transport. Ignores `onProgress` — fetch has no upload-side
|
|
431
|
+
* progress event, and silence is how a transport reports that bytes are
|
|
432
|
+
* unobservable.
|
|
433
|
+
*/
|
|
434
|
+
function createFetchUploadTransport(fetchFn) {
|
|
435
|
+
return (request) => fetchFn(request.url, {
|
|
436
|
+
method: request.method,
|
|
437
|
+
headers: request.headers,
|
|
438
|
+
body: request.body,
|
|
439
|
+
...request.signal ? { signal: request.signal } : {}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/switchboard/xhr-upload-transport.ts
|
|
444
|
+
/**
|
|
445
|
+
* Upload transport backed by `XMLHttpRequest`, the only browser API that
|
|
446
|
+
* reports upload-side byte progress (`xhr.upload.onprogress`). Everything else
|
|
447
|
+
* about the request is identical to the fetch path.
|
|
448
|
+
*
|
|
449
|
+
* Capability detection happens **inside** the returned function, never at
|
|
450
|
+
* module load: this module is reachable from the client entry, which is
|
|
451
|
+
* executed in Node by the entrypoint tests, and Node has no
|
|
452
|
+
* `XMLHttpRequest`. Detecting per call also means a test can install a fake on
|
|
453
|
+
* `globalThis` without any module-registry reset.
|
|
454
|
+
*/
|
|
455
|
+
function createXhrUploadTransport(options) {
|
|
456
|
+
return (request) => {
|
|
457
|
+
const XhrCtor = globalThis.XMLHttpRequest;
|
|
458
|
+
if (typeof XhrCtor !== "function") return createFetchUploadTransport((options?.fetchFn ?? globalThis.fetch).bind(globalThis))(request);
|
|
459
|
+
return sendWithXhr(new XhrCtor(), request);
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Mirrors fetch's observable behavior closely enough that
|
|
464
|
+
* `RemoteAttachmentUpload` cannot tell the two apart:
|
|
465
|
+
*
|
|
466
|
+
* - `responseType` is left at `""`, so a non-JSON 422 body still reaches the
|
|
467
|
+
* caller's `json()` and fails there rather than being swallowed.
|
|
468
|
+
* - status 0 never resolves. XHR reports 0 for network failure, CORS refusal
|
|
469
|
+
* and abort alike; resolving it would fabricate a transfer error claiming
|
|
470
|
+
* the provider answered 0.
|
|
471
|
+
* - failures reject with fetch's error shapes: `TypeError` for network,
|
|
472
|
+
* `AbortError` for abort.
|
|
473
|
+
*/
|
|
474
|
+
function sendWithXhr(xhr, request) {
|
|
475
|
+
return new Promise((resolve, reject) => {
|
|
476
|
+
const signal = request.signal;
|
|
477
|
+
let settled = false;
|
|
478
|
+
const abortListener = () => xhr.abort();
|
|
479
|
+
const detach = () => signal?.removeEventListener("abort", abortListener);
|
|
480
|
+
const succeed = (response) => {
|
|
481
|
+
if (settled) return;
|
|
482
|
+
settled = true;
|
|
483
|
+
detach();
|
|
484
|
+
resolve(response);
|
|
485
|
+
};
|
|
486
|
+
const fail = (error) => {
|
|
487
|
+
if (settled) return;
|
|
488
|
+
settled = true;
|
|
489
|
+
detach();
|
|
490
|
+
reject(error);
|
|
491
|
+
};
|
|
492
|
+
if (signal?.aborted) {
|
|
493
|
+
fail(abortError());
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
xhr.open(request.method, request.url, true);
|
|
497
|
+
for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value);
|
|
498
|
+
if (request.onProgress) xhr.upload.addEventListener("progress", (event) => {
|
|
499
|
+
if (!event.lengthComputable) return;
|
|
500
|
+
request.onProgress?.(event.loaded, event.total);
|
|
501
|
+
});
|
|
502
|
+
xhr.addEventListener("load", () => {
|
|
503
|
+
if (xhr.status === 0) {
|
|
504
|
+
fail(networkError());
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
succeed(toResponse(xhr));
|
|
508
|
+
});
|
|
509
|
+
xhr.addEventListener("error", () => fail(networkError()));
|
|
510
|
+
xhr.addEventListener("timeout", () => fail(networkError()));
|
|
511
|
+
xhr.addEventListener("abort", () => fail(abortError()));
|
|
512
|
+
signal?.addEventListener("abort", abortListener);
|
|
513
|
+
xhr.send(request.body);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function toResponse(xhr) {
|
|
517
|
+
return {
|
|
518
|
+
status: xhr.status,
|
|
519
|
+
statusText: xhr.statusText,
|
|
520
|
+
ok: xhr.status >= 200 && xhr.status < 300,
|
|
521
|
+
json: () => {
|
|
522
|
+
try {
|
|
523
|
+
return Promise.resolve(JSON.parse(xhr.responseText));
|
|
524
|
+
} catch (err) {
|
|
525
|
+
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function networkError() {
|
|
531
|
+
return /* @__PURE__ */ new TypeError("Failed to fetch");
|
|
532
|
+
}
|
|
533
|
+
function abortError() {
|
|
534
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
305
537
|
//#region src/switchboard/remote-reservation-store.ts
|
|
306
538
|
function isRecord$2(value) {
|
|
307
539
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -393,7 +625,8 @@ var RemoteReservationStore = class {
|
|
|
393
625
|
createdAtUtc: body.createdAtUtc ?? now.toISOString(),
|
|
394
626
|
expiresAtUtc: body.expiresAtUtc ?? new Date(now.getTime() + 1440 * 60 * 1e3).toISOString(),
|
|
395
627
|
clientHash: options.clientHash ?? null,
|
|
396
|
-
sizeBytes: options.sizeBytes ?? null
|
|
628
|
+
sizeBytes: options.sizeBytes ?? null,
|
|
629
|
+
...body.uploadTarget === void 0 ? {} : { uploadTarget: parseAttachmentUploadTarget(body.uploadTarget) }
|
|
397
630
|
};
|
|
398
631
|
}
|
|
399
632
|
async get(reservationId) {
|
|
@@ -417,7 +650,8 @@ var RemoteReservationStore = class {
|
|
|
417
650
|
createdAtUtc: parsed.createdAtUtc,
|
|
418
651
|
expiresAtUtc: parsed.expiresAtUtc,
|
|
419
652
|
clientHash: parsed.clientHash ?? null,
|
|
420
|
-
sizeBytes: parsed.sizeBytes ?? null
|
|
653
|
+
sizeBytes: parsed.sizeBytes ?? null,
|
|
654
|
+
...parsed.uploadTarget === void 0 ? {} : { uploadTarget: parseAttachmentUploadTarget(parsed.uploadTarget) }
|
|
421
655
|
};
|
|
422
656
|
}
|
|
423
657
|
async delete(reservationId) {
|
|
@@ -442,28 +676,36 @@ var RemoteAttachmentUpload = class {
|
|
|
442
676
|
reservationId;
|
|
443
677
|
ref;
|
|
444
678
|
expiresAtUtc;
|
|
679
|
+
uploadTarget;
|
|
680
|
+
reservation;
|
|
445
681
|
remoteUrl;
|
|
446
682
|
jwtHandler;
|
|
447
|
-
|
|
683
|
+
uploadTransport;
|
|
448
684
|
constructor(reservation, config) {
|
|
449
685
|
this.reservationId = reservation.reservationId;
|
|
450
686
|
this.ref = reservation.clientHash !== null ? createRef(reservation.clientHash) : null;
|
|
451
687
|
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
688
|
+
if (reservation.uploadTarget) this.uploadTarget = reservation.uploadTarget;
|
|
689
|
+
this.reservation = reservation;
|
|
452
690
|
this.remoteUrl = config.remoteUrl;
|
|
453
691
|
this.jwtHandler = config.jwtHandler;
|
|
454
|
-
this.
|
|
692
|
+
this.uploadTransport = config.fetchFn ? createFetchUploadTransport(config.fetchFn.bind(globalThis)) : config.uploadTransport ?? createFetchUploadTransport(globalThis.fetch.bind(globalThis));
|
|
455
693
|
}
|
|
456
|
-
async send(data) {
|
|
694
|
+
async send(data, options) {
|
|
695
|
+
if (this.uploadTarget?.kind === "presigned-put") return this.sendPresigned(this.uploadTarget, data, options);
|
|
457
696
|
const url = `${this.remoteUrl}/attachments/reservations/${this.reservationId}`;
|
|
458
697
|
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
459
698
|
const body = await new Response(data).blob();
|
|
460
|
-
const response = await this.
|
|
699
|
+
const response = await this.uploadTransport({
|
|
700
|
+
url,
|
|
461
701
|
method: "PUT",
|
|
462
702
|
headers: {
|
|
463
703
|
...authHeaders,
|
|
464
704
|
"Content-Type": "application/octet-stream"
|
|
465
705
|
},
|
|
466
|
-
body
|
|
706
|
+
body,
|
|
707
|
+
...options?.onProgress ? { onProgress: (loaded, total) => options.onProgress?.(loaded, total) } : {},
|
|
708
|
+
...options?.signal ? { signal: options.signal } : {}
|
|
467
709
|
});
|
|
468
710
|
if (response.status === 422) {
|
|
469
711
|
let errorBody;
|
|
@@ -481,6 +723,44 @@ var RemoteAttachmentUpload = class {
|
|
|
481
723
|
if (!response.ok) throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
|
|
482
724
|
return await response.json();
|
|
483
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Direct provider upload: PUT the bytes to the presigned URL with exactly
|
|
728
|
+
* the returned headers — never the Switchboard JWT — and treat any 2xx as
|
|
729
|
+
* final success with no follow-up control request. The result is
|
|
730
|
+
* synthesized from the hash-first reservation, which is the only path that
|
|
731
|
+
* can produce a presigned target.
|
|
732
|
+
*/
|
|
733
|
+
async sendPresigned(target, data, options) {
|
|
734
|
+
if (this.reservation.clientHash === null || this.ref === null) throw new Error("Presigned upload targets require a hash-first reservation");
|
|
735
|
+
const body = await new Response(data).blob();
|
|
736
|
+
const response = await this.uploadTransport({
|
|
737
|
+
url: target.url,
|
|
738
|
+
method: target.method,
|
|
739
|
+
headers: { ...target.headers },
|
|
740
|
+
body,
|
|
741
|
+
...options?.onProgress ? { onProgress: (loaded, total) => options.onProgress?.(loaded, total) } : {},
|
|
742
|
+
...options?.signal ? { signal: options.signal } : {}
|
|
743
|
+
});
|
|
744
|
+
if (!response.ok) throw new AttachmentTransferError("presigned-put", response.status);
|
|
745
|
+
const hash = this.reservation.clientHash;
|
|
746
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
747
|
+
return {
|
|
748
|
+
hash,
|
|
749
|
+
ref: this.ref,
|
|
750
|
+
header: {
|
|
751
|
+
hash,
|
|
752
|
+
mimeType: this.reservation.mimeType,
|
|
753
|
+
fileName: this.reservation.fileName,
|
|
754
|
+
sizeBytes: this.reservation.sizeBytes ?? body.size,
|
|
755
|
+
extension: this.reservation.extension,
|
|
756
|
+
status: "available",
|
|
757
|
+
source: "local",
|
|
758
|
+
createdAtUtc: this.reservation.createdAtUtc || now,
|
|
759
|
+
lastAccessedAtUtc: now,
|
|
760
|
+
expiresAtUtc: null
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
}
|
|
484
764
|
};
|
|
485
765
|
//#endregion
|
|
486
766
|
//#region src/switchboard/remote-attachment-upload-factory.ts
|
|
@@ -603,16 +883,70 @@ var RemoteAttachmentStore = class {
|
|
|
603
883
|
if (!response.ok) throw new Error(`Attachment stat failed: ${response.status} ${response.statusText}`);
|
|
604
884
|
return buildHeader(hash, parseMetadata(response));
|
|
605
885
|
}
|
|
606
|
-
async get(hash, signal) {
|
|
607
|
-
return this.fetchAttachment(hash, signal);
|
|
886
|
+
async get(hash, signal, documentId) {
|
|
887
|
+
if (documentId === void 0) return this.fetchAttachment(hash, signal);
|
|
888
|
+
const target = await this.getDownloadTarget(hash, {
|
|
889
|
+
documentId,
|
|
890
|
+
signal
|
|
891
|
+
});
|
|
892
|
+
if (target.kind === "presigned-get") return this.fetchPresigned(hash, target, signal);
|
|
893
|
+
return this.fetchAttachment(hash, signal, target);
|
|
608
894
|
}
|
|
609
|
-
|
|
610
|
-
|
|
895
|
+
/**
|
|
896
|
+
* Asks Switchboard for an authorized download target. The request carries
|
|
897
|
+
* the JWT; the response is runtime-validated before any byte transfer.
|
|
898
|
+
* Public so callers can mint direct URLs (previews, share links) without
|
|
899
|
+
* transferring bytes; `expiresIn` requests a caller-chosen lifetime.
|
|
900
|
+
*/
|
|
901
|
+
async getDownloadTarget(hash, options) {
|
|
902
|
+
const { documentId, expiresIn, signal } = options;
|
|
903
|
+
const expiry = expiresIn === void 0 ? "" : `&expiresIn=${encodeURIComponent(String(expiresIn))}`;
|
|
904
|
+
const url = `${this.remoteUrl}/attachments/${hash}/download-target?documentId=${encodeURIComponent(documentId)}${expiry}`;
|
|
611
905
|
const headers = await buildAuthHeaders(url, this.jwtHandler);
|
|
612
906
|
const response = await this.fetchFn(url, {
|
|
613
907
|
signal,
|
|
614
908
|
headers
|
|
615
909
|
});
|
|
910
|
+
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
911
|
+
if (!response.ok) throw new AttachmentTransferError("download-target", response.status);
|
|
912
|
+
let body;
|
|
913
|
+
try {
|
|
914
|
+
body = await response.json();
|
|
915
|
+
} catch {
|
|
916
|
+
throw new AttachmentTransferError("download-target");
|
|
917
|
+
}
|
|
918
|
+
return parseAttachmentDownloadTarget(body);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Executes a presigned GET with exactly the returned headers and no JWT.
|
|
922
|
+
* A provider 404 means the object is missing despite available metadata
|
|
923
|
+
* (the accepted abandoned-upload trade-off) and surfaces as the same typed
|
|
924
|
+
* not-found error callers already handle.
|
|
925
|
+
*/
|
|
926
|
+
async fetchPresigned(hash, target, signal) {
|
|
927
|
+
const response = await this.fetchFn(target.url, {
|
|
928
|
+
signal,
|
|
929
|
+
headers: { ...target.headers }
|
|
930
|
+
});
|
|
931
|
+
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
932
|
+
if (!response.ok) throw new AttachmentTransferError("presigned-get", response.status);
|
|
933
|
+
if (!response.body) throw new Error("Response body is null");
|
|
934
|
+
return {
|
|
935
|
+
header: buildHeader(hash, parseMetadata(response)),
|
|
936
|
+
body: response.body
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
async fetchAttachment(hash, signal, target) {
|
|
940
|
+
const url = target?.url ?? `${this.remoteUrl}/attachments/${hash}`;
|
|
941
|
+
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
942
|
+
const headers = {
|
|
943
|
+
...target?.headers,
|
|
944
|
+
...authHeaders
|
|
945
|
+
};
|
|
946
|
+
const response = await this.fetchFn(url, {
|
|
947
|
+
signal,
|
|
948
|
+
headers
|
|
949
|
+
});
|
|
616
950
|
if (response.status === 202) {
|
|
617
951
|
const pending = parsePendingExpiry(response);
|
|
618
952
|
if (!pending) throw new Error("Attachment fetch returned 202 with missing or malformed Attachment-Pending header");
|
|
@@ -681,6 +1015,6 @@ var NullAttachmentTransport = class {
|
|
|
681
1015
|
}
|
|
682
1016
|
};
|
|
683
1017
|
//#endregion
|
|
684
|
-
export { SizeMismatch as _, RemoteAttachmentUpload as a,
|
|
1018
|
+
export { UploadTooLarge as C, SizeMismatch as S, AttachmentPending as _, RemoteAttachmentUpload as a, InvalidAttachmentRef as b, createFetchUploadTransport as c, createRef as d, parseRef as f, AttachmentNotFound as g, AttachmentAlreadyExists as h, RemoteAttachmentUploadFactory as i, SwitchboardAttachmentTransport as l, parseAttachmentUploadTarget as m, createRemoteAttachmentService as n, RemoteReservationStore as o, parseAttachmentDownloadTarget as p, RemoteAttachmentStore as r, createXhrUploadTransport as s, NullAttachmentTransport as t, AttachmentService as u, AttachmentTransferError as v, ReservationNotFound as x, HashMismatch as y };
|
|
685
1019
|
|
|
686
|
-
//# sourceMappingURL=null-attachment-transport-
|
|
1020
|
+
//# sourceMappingURL=null-attachment-transport-CrsUafCi.js.map
|