@powerhousedao/reactor-attachments 6.1.0-dev.2 → 6.1.0-dev.20

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.
@@ -0,0 +1,686 @@
1
+ //#region src/errors.ts
2
+ /**
3
+ * Thrown when an attachment ref or hash is not known to the store.
4
+ */
5
+ var AttachmentNotFound = class extends Error {
6
+ constructor(identifier) {
7
+ super(`Attachment not found: ${identifier}`);
8
+ this.name = "AttachmentNotFound";
9
+ }
10
+ };
11
+ /**
12
+ * Thrown when a reservation ID is not found in the reservation store.
13
+ */
14
+ var ReservationNotFound = class extends Error {
15
+ constructor(reservationId) {
16
+ super(`Reservation not found: ${reservationId}`);
17
+ this.name = "ReservationNotFound";
18
+ }
19
+ };
20
+ /**
21
+ * Thrown when an attachment ref string does not match the expected format.
22
+ */
23
+ var InvalidAttachmentRef = class extends Error {
24
+ constructor(ref) {
25
+ super(`Invalid attachment ref: ${ref}`);
26
+ this.name = "InvalidAttachmentRef";
27
+ }
28
+ };
29
+ /**
30
+ * Thrown when an upload exceeds the configured maximum byte cap.
31
+ * Route handlers should map this to HTTP 413 Payload Too Large.
32
+ */
33
+ var UploadTooLarge = class extends Error {
34
+ maxBytes;
35
+ constructor(maxBytes) {
36
+ super(`Upload exceeds maximum size of ${maxBytes} bytes`);
37
+ this.name = "UploadTooLarge";
38
+ this.maxBytes = maxBytes;
39
+ }
40
+ };
41
+ /**
42
+ * Thrown by reserve() when the claimed hash is already available in the store.
43
+ * The caller should use err.ref directly and upload nothing -- this is the
44
+ * dedup fast path: duplicate content never leaves the client.
45
+ */
46
+ var AttachmentAlreadyExists = class extends Error {
47
+ hash;
48
+ ref;
49
+ constructor(hash, ref) {
50
+ super(`Attachment already exists for hash: ${hash}`);
51
+ this.name = "AttachmentAlreadyExists";
52
+ this.hash = hash;
53
+ this.ref = ref;
54
+ }
55
+ };
56
+ /**
57
+ * Thrown by send() when the server-computed hash of the uploaded bytes
58
+ * does not match the hash claimed at reservation time. Nothing is committed;
59
+ * the reservation is retained so the client can retry with correct bytes.
60
+ */
61
+ var HashMismatch = class extends Error {
62
+ claimed;
63
+ actual;
64
+ constructor(claimed, actual) {
65
+ super(`Hash mismatch: claimed ${claimed} but computed ${actual}`);
66
+ this.name = "HashMismatch";
67
+ this.claimed = claimed;
68
+ this.actual = actual;
69
+ }
70
+ };
71
+ /**
72
+ * Thrown by send() when the uploaded byte count does not equal the
73
+ * sizeBytes declared at reservation time. The handle may reject
74
+ * mid-stream as soon as the count exceeds the declaration.
75
+ * Nothing is committed; the reservation is retained for retry.
76
+ *
77
+ * "actual" is the byte count received from the stream before aborting --
78
+ * it includes the chunk that crossed the declaration and can exceed bytes
79
+ * persisted. On mid-stream aborts the true total is unknown; at least
80
+ * "actual" bytes were sent.
81
+ */
82
+ var SizeMismatch = class extends Error {
83
+ declared;
84
+ actual;
85
+ constructor(declared, actual) {
86
+ super(`Size mismatch: declared ${declared} bytes but received ${actual}`);
87
+ this.name = "SizeMismatch";
88
+ this.declared = declared;
89
+ this.actual = actual;
90
+ }
91
+ };
92
+ /**
93
+ * Thrown by get() when the hash is reserved by an in-flight upload and
94
+ * bytes are not yet available anywhere. Deliberately NOT a subclass of
95
+ * AttachmentNotFound -- callers must distinguish "retry later" from "unknown".
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).
102
+ */
103
+ var AttachmentPending = class extends Error {
104
+ hash;
105
+ expiresAtUtc;
106
+ metadata;
107
+ constructor(hash, expiresAtUtc, meta) {
108
+ super(`Attachment pending upload for hash: ${hash}, expires: ${expiresAtUtc}`);
109
+ this.name = "AttachmentPending";
110
+ this.hash = hash;
111
+ this.expiresAtUtc = expiresAtUtc;
112
+ this.metadata = meta;
113
+ }
114
+ };
115
+ //#endregion
116
+ //#region src/ref.ts
117
+ const REF_PATTERN = /^attachment:\/\/v(\d+):(.+)$/;
118
+ const DEFAULT_VERSION = 1;
119
+ function parseRef(ref) {
120
+ const match = REF_PATTERN.exec(ref);
121
+ if (!match) throw new InvalidAttachmentRef(ref);
122
+ return {
123
+ version: Number(match[1]),
124
+ hash: match[2]
125
+ };
126
+ }
127
+ function createRef(hash, version = DEFAULT_VERSION) {
128
+ return `attachment://v${version}:${hash}`;
129
+ }
130
+ //#endregion
131
+ //#region src/attachment-service.ts
132
+ const CLIENT_HASH_PATTERN = /^[a-f0-9]{64}$/;
133
+ var AttachmentService = class {
134
+ constructor(store, reservations, uploadFactory) {
135
+ this.store = store;
136
+ this.reservations = reservations;
137
+ this.uploadFactory = uploadFactory;
138
+ }
139
+ async reserve(options) {
140
+ if (options.clientHash !== void 0) return this.reserveHashFirst(options);
141
+ const reservation = await this.reservations.create(options);
142
+ return this.uploadFactory.createUpload(reservation);
143
+ }
144
+ async stat(ref) {
145
+ const { hash } = parseRef(ref);
146
+ return this.store.stat(hash);
147
+ }
148
+ async get(ref, signal) {
149
+ const { hash } = parseRef(ref);
150
+ return this.store.get(hash, signal);
151
+ }
152
+ async reserveHashFirst(options) {
153
+ const normalized = options.clientHash.toLowerCase();
154
+ if (!CLIENT_HASH_PATTERN.test(normalized)) throw new Error(`clientHash must be a 64-character lowercase hex string, got: ${options.clientHash}`);
155
+ if (options.sizeBytes === void 0 || !Number.isInteger(options.sizeBytes) || options.sizeBytes <= 0 || !Number.isSafeInteger(options.sizeBytes)) throw new Error("sizeBytes must be a positive safe integer when clientHash is provided");
156
+ const normalizedOptions = {
157
+ ...options,
158
+ clientHash: normalized
159
+ };
160
+ let existingHeader = null;
161
+ try {
162
+ existingHeader = await this.store.stat(normalized);
163
+ } catch (err) {
164
+ if (!(err instanceof AttachmentNotFound) && !(err instanceof AttachmentPending)) throw err;
165
+ }
166
+ if (existingHeader !== null && existingHeader.status === "available") throw new AttachmentAlreadyExists(normalized, createRef(normalized));
167
+ const reservation = await this.reservations.create(normalizedOptions);
168
+ return this.uploadFactory.createUpload(reservation);
169
+ }
170
+ };
171
+ //#endregion
172
+ //#region src/switchboard/build-auth-headers.ts
173
+ async function buildAuthHeaders(url, jwtHandler) {
174
+ const headers = {};
175
+ if (jwtHandler) {
176
+ const token = await jwtHandler(url);
177
+ if (token) headers["Authorization"] = `Bearer ${token}`;
178
+ }
179
+ return headers;
180
+ }
181
+ //#endregion
182
+ //#region src/switchboard/switchboard-attachment-transport.ts
183
+ var SwitchboardAttachmentTransport = class {
184
+ remoteUrl;
185
+ jwtHandler;
186
+ fetchFn;
187
+ constructor(config) {
188
+ this.remoteUrl = config.remoteUrl;
189
+ this.jwtHandler = config.jwtHandler;
190
+ this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
191
+ }
192
+ async fetch(hash, signal) {
193
+ const url = `${this.remoteUrl}/attachments/${hash}`;
194
+ const headers = await buildAuthHeaders(url, this.jwtHandler);
195
+ const response = await this.fetchFn(url, {
196
+ signal,
197
+ headers
198
+ });
199
+ if (response.status === 202) {
200
+ const expiresAtUtc = this.parsePendingExpiry(response);
201
+ if (!expiresAtUtc) throw new Error("Attachment fetch returned 202 with missing or malformed Attachment-Pending header");
202
+ return {
203
+ kind: "pending",
204
+ hash,
205
+ expiresAtUtc,
206
+ retryAfterMs: parseRetryAfterMs(response)
207
+ };
208
+ }
209
+ if (response.status === 404) return { kind: "not-found" };
210
+ if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
211
+ const metadata = this.parseMetadataHeaders(response);
212
+ const body = response.body;
213
+ if (!body) throw new Error("Response body is null");
214
+ return {
215
+ kind: "data",
216
+ response: {
217
+ hash,
218
+ metadata,
219
+ body
220
+ }
221
+ };
222
+ }
223
+ async announce(_hash) {}
224
+ async push(hash, remote, data) {
225
+ const url = `${remote}/attachments/${hash}`;
226
+ const headers = await buildAuthHeaders(url, this.jwtHandler);
227
+ const response = await this.fetchFn(url, {
228
+ method: "PUT",
229
+ body: data,
230
+ headers,
231
+ duplex: "half"
232
+ });
233
+ if (!response.ok) throw new Error(`Attachment push failed: ${response.status} ${response.statusText}`);
234
+ }
235
+ parsePendingExpiry(response) {
236
+ const header = response.headers.get("Attachment-Pending");
237
+ if (!header) return null;
238
+ try {
239
+ const parsed = JSON.parse(header);
240
+ if (!isRecord$3(parsed)) return null;
241
+ if (typeof parsed.expiresAtUtc !== "string") return null;
242
+ return parsed.expiresAtUtc;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+ parseMetadataHeaders(response) {
248
+ let fallbackCache;
249
+ const fallback = () => {
250
+ if (fallbackCache === void 0) fallbackCache = contentTypeFallback$1(response);
251
+ return fallbackCache;
252
+ };
253
+ const metaHeader = response.headers.get("Attachment-Metadata");
254
+ if (metaHeader) try {
255
+ const parsed = JSON.parse(metaHeader);
256
+ if (isRecord$3(parsed)) {
257
+ if (parsed.extension === void 0) parsed.extension = null;
258
+ if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
259
+ if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
260
+ }
261
+ if (isAttachmentMetadata$1(parsed)) return parsed;
262
+ } catch {}
263
+ return fallback();
264
+ }
265
+ };
266
+ const DEFAULT_RETRY_AFTER_MS = 5e3;
267
+ function parseRetryAfterMs(response) {
268
+ const retryAfter = response.headers.get("Retry-After");
269
+ if (!retryAfter) return DEFAULT_RETRY_AFTER_MS;
270
+ const seconds = Number(retryAfter);
271
+ if (!Number.isFinite(seconds) || seconds < 0) return DEFAULT_RETRY_AFTER_MS;
272
+ return Math.round(seconds * 1e3);
273
+ }
274
+ function isRecord$3(value) {
275
+ return typeof value === "object" && value !== null && !Array.isArray(value);
276
+ }
277
+ function isAttachmentMetadata$1(value) {
278
+ if (!isRecord$3(value)) return false;
279
+ if (typeof value.mimeType !== "string") return false;
280
+ if (typeof value.fileName !== "string") return false;
281
+ if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
282
+ if (value.extension !== null && typeof value.extension !== "string") return false;
283
+ if (typeof value.createdAtUtc !== "string") return false;
284
+ if (value.lastAccessedAtUtc !== void 0 && typeof value.lastAccessedAtUtc !== "string") return false;
285
+ return true;
286
+ }
287
+ function contentTypeFallback$1(response) {
288
+ const contentLength = response.headers.get("Content-Length");
289
+ if (contentLength === null) throw new Error("Switchboard response missing both Attachment-Metadata and Content-Length headers");
290
+ const sizeBytes = Number(contentLength);
291
+ if (!Number.isInteger(sizeBytes) || sizeBytes < 0) throw new Error(`Switchboard response has invalid Content-Length header: ${JSON.stringify(contentLength)}`);
292
+ const lastModified = response.headers.get("Last-Modified");
293
+ const dateHeader = response.headers.get("Date");
294
+ const createdAtUtc = lastModified ? new Date(lastModified).toISOString() : dateHeader ? new Date(dateHeader).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
295
+ return {
296
+ mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
297
+ fileName: "unknown",
298
+ sizeBytes,
299
+ extension: null,
300
+ createdAtUtc,
301
+ lastAccessedAtUtc: createdAtUtc
302
+ };
303
+ }
304
+ //#endregion
305
+ //#region src/switchboard/remote-reservation-store.ts
306
+ function isRecord$2(value) {
307
+ return typeof value === "object" && value !== null && !Array.isArray(value);
308
+ }
309
+ function deriveExtension(fileName) {
310
+ const idx = fileName.lastIndexOf(".");
311
+ if (idx <= 0 || idx === fileName.length - 1) return null;
312
+ return fileName.slice(idx + 1).toLowerCase();
313
+ }
314
+ function isReservationBase(value) {
315
+ if (!isRecord$2(value)) return false;
316
+ if (typeof value.reservationId !== "string") return false;
317
+ if (typeof value.mimeType !== "string") return false;
318
+ if (typeof value.fileName !== "string") return false;
319
+ if (value.extension !== null && typeof value.extension !== "string") return false;
320
+ if (typeof value.createdAtUtc !== "string") return false;
321
+ if (typeof value.expiresAtUtc !== "string") return false;
322
+ return true;
323
+ }
324
+ function isReservation(value) {
325
+ if (!isReservationBase(value)) return false;
326
+ if (value.clientHash !== void 0 && value.clientHash !== null && typeof value.clientHash !== "string") return false;
327
+ if (value.sizeBytes !== void 0 && value.sizeBytes !== null && typeof value.sizeBytes !== "number") return false;
328
+ return true;
329
+ }
330
+ var RemoteReservationStore = class {
331
+ remoteUrl;
332
+ jwtHandler;
333
+ fetchFn;
334
+ constructor(config) {
335
+ this.remoteUrl = config.remoteUrl;
336
+ this.jwtHandler = config.jwtHandler;
337
+ this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
338
+ }
339
+ async create(options) {
340
+ const url = `${this.remoteUrl}/attachments/reservations`;
341
+ const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
342
+ const extension = options.extension ?? deriveExtension(options.fileName);
343
+ const bodyObj = {
344
+ mimeType: options.mimeType,
345
+ fileName: options.fileName,
346
+ extension
347
+ };
348
+ if (options.clientHash !== void 0) bodyObj.clientHash = options.clientHash;
349
+ if (options.sizeBytes !== void 0) bodyObj.sizeBytes = options.sizeBytes;
350
+ const response = await this.fetchFn(url, {
351
+ method: "POST",
352
+ headers: {
353
+ ...authHeaders,
354
+ "Content-Type": "application/json"
355
+ },
356
+ body: JSON.stringify(bodyObj)
357
+ });
358
+ if (response.status === 409) {
359
+ let body;
360
+ try {
361
+ body = await response.json();
362
+ } catch {
363
+ throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
364
+ }
365
+ if (isRecord$2(body) && body.error === "already_exists" && options.clientHash !== void 0) {
366
+ let ref;
367
+ if (typeof body.ref === "string") try {
368
+ parseRef(body.ref);
369
+ ref = body.ref;
370
+ } catch {
371
+ ref = createRef(options.clientHash);
372
+ }
373
+ else ref = createRef(options.clientHash);
374
+ throw new AttachmentAlreadyExists(options.clientHash, ref);
375
+ }
376
+ throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
377
+ }
378
+ if (!response.ok) throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
379
+ let json;
380
+ try {
381
+ json = await response.json();
382
+ } catch {
383
+ throw new Error("Reservation create returned non-JSON response");
384
+ }
385
+ if (typeof json !== "object" || json === null || typeof json.reservationId !== "string" || json.reservationId.length === 0) throw new Error("Reservation create returned a payload missing a non-empty reservationId string");
386
+ const body = json;
387
+ const now = /* @__PURE__ */ new Date();
388
+ return {
389
+ reservationId: body.reservationId,
390
+ mimeType: options.mimeType,
391
+ fileName: options.fileName,
392
+ extension,
393
+ createdAtUtc: body.createdAtUtc ?? now.toISOString(),
394
+ expiresAtUtc: body.expiresAtUtc ?? new Date(now.getTime() + 1440 * 60 * 1e3).toISOString(),
395
+ clientHash: options.clientHash ?? null,
396
+ sizeBytes: options.sizeBytes ?? null
397
+ };
398
+ }
399
+ async get(reservationId) {
400
+ const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
401
+ const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
402
+ const response = await this.fetchFn(url, { headers: authHeaders });
403
+ if (response.status === 404) throw new ReservationNotFound(reservationId);
404
+ if (!response.ok) throw new Error(`Reservation get failed: ${response.status} ${response.statusText}`);
405
+ let parsed;
406
+ try {
407
+ parsed = await response.json();
408
+ } catch {
409
+ throw new Error("Reservation get returned non-JSON response");
410
+ }
411
+ if (!isReservation(parsed)) throw new Error("Reservation get returned a payload that does not match the Reservation shape");
412
+ return {
413
+ reservationId: parsed.reservationId,
414
+ mimeType: parsed.mimeType,
415
+ fileName: parsed.fileName,
416
+ extension: parsed.extension,
417
+ createdAtUtc: parsed.createdAtUtc,
418
+ expiresAtUtc: parsed.expiresAtUtc,
419
+ clientHash: parsed.clientHash ?? null,
420
+ sizeBytes: parsed.sizeBytes ?? null
421
+ };
422
+ }
423
+ async delete(reservationId) {
424
+ const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
425
+ const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
426
+ const response = await this.fetchFn(url, {
427
+ method: "DELETE",
428
+ headers: authHeaders
429
+ });
430
+ if (!response.ok && response.status !== 404 && response.status !== 410) throw new Error(`Reservation delete failed: ${response.status} ${response.statusText}`);
431
+ }
432
+ deleteExpired() {
433
+ return Promise.reject(/* @__PURE__ */ new Error("RemoteReservationStore.deleteExpired is not supported"));
434
+ }
435
+ };
436
+ //#endregion
437
+ //#region src/switchboard/remote-attachment-upload.ts
438
+ function isRecord$1(value) {
439
+ return typeof value === "object" && value !== null && !Array.isArray(value);
440
+ }
441
+ var RemoteAttachmentUpload = class {
442
+ reservationId;
443
+ ref;
444
+ expiresAtUtc;
445
+ remoteUrl;
446
+ jwtHandler;
447
+ fetchFn;
448
+ constructor(reservation, config) {
449
+ this.reservationId = reservation.reservationId;
450
+ this.ref = reservation.clientHash !== null ? createRef(reservation.clientHash) : null;
451
+ this.expiresAtUtc = reservation.expiresAtUtc;
452
+ this.remoteUrl = config.remoteUrl;
453
+ this.jwtHandler = config.jwtHandler;
454
+ this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
455
+ }
456
+ async send(data) {
457
+ const url = `${this.remoteUrl}/attachments/reservations/${this.reservationId}`;
458
+ const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
459
+ const body = await new Response(data).blob();
460
+ const response = await this.fetchFn(url, {
461
+ method: "PUT",
462
+ headers: {
463
+ ...authHeaders,
464
+ "Content-Type": "application/octet-stream"
465
+ },
466
+ body
467
+ });
468
+ if (response.status === 422) {
469
+ let errorBody;
470
+ try {
471
+ errorBody = await response.json();
472
+ } catch {
473
+ throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
474
+ }
475
+ if (isRecord$1(errorBody)) {
476
+ if (errorBody.error === "hash_mismatch" && typeof errorBody.claimed === "string" && typeof errorBody.actual === "string") throw new HashMismatch(errorBody.claimed, errorBody.actual);
477
+ if (errorBody.error === "size_mismatch" && typeof errorBody.declared === "number" && typeof errorBody.actual === "number") throw new SizeMismatch(errorBody.declared, errorBody.actual);
478
+ }
479
+ throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
480
+ }
481
+ if (!response.ok) throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
482
+ return await response.json();
483
+ }
484
+ };
485
+ //#endregion
486
+ //#region src/switchboard/remote-attachment-upload-factory.ts
487
+ var RemoteAttachmentUploadFactory = class {
488
+ constructor(config) {
489
+ this.config = config;
490
+ }
491
+ createUpload(reservation) {
492
+ return new RemoteAttachmentUpload(reservation, this.config);
493
+ }
494
+ };
495
+ //#endregion
496
+ //#region src/switchboard/remote-attachment-store.ts
497
+ function isRecord(value) {
498
+ return typeof value === "object" && value !== null && !Array.isArray(value);
499
+ }
500
+ function isAttachmentMetadata(value) {
501
+ if (!isRecord(value)) return false;
502
+ if (typeof value.mimeType !== "string") return false;
503
+ if (typeof value.fileName !== "string") return false;
504
+ if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
505
+ if (value.extension !== null && typeof value.extension !== "string") return false;
506
+ if (typeof value.createdAtUtc !== "string") return false;
507
+ if (value.lastAccessedAtUtc !== void 0 && typeof value.lastAccessedAtUtc !== "string") return false;
508
+ return true;
509
+ }
510
+ function contentTypeFallback(response) {
511
+ const contentLength = response.headers.get("Content-Length");
512
+ if (contentLength === null) throw new Error("Switchboard response missing both Attachment-Metadata and Content-Length headers");
513
+ const sizeBytes = Number(contentLength);
514
+ if (!Number.isInteger(sizeBytes) || sizeBytes < 0) throw new Error(`Switchboard response has invalid Content-Length header: ${JSON.stringify(contentLength)}`);
515
+ const lastModified = response.headers.get("Last-Modified");
516
+ const dateHeader = response.headers.get("Date");
517
+ const createdAtUtc = lastModified ? new Date(lastModified).toISOString() : dateHeader ? new Date(dateHeader).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
518
+ return {
519
+ mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
520
+ fileName: "unknown",
521
+ sizeBytes,
522
+ extension: null,
523
+ createdAtUtc,
524
+ lastAccessedAtUtc: createdAtUtc
525
+ };
526
+ }
527
+ function parseMetadata(response) {
528
+ let fallbackCache;
529
+ const fallback = () => {
530
+ if (fallbackCache === void 0) fallbackCache = contentTypeFallback(response);
531
+ return fallbackCache;
532
+ };
533
+ const metaHeader = response.headers.get("Attachment-Metadata");
534
+ if (metaHeader) try {
535
+ const parsed = JSON.parse(metaHeader);
536
+ if (isRecord(parsed)) {
537
+ if (parsed.extension === void 0) parsed.extension = null;
538
+ if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
539
+ if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
540
+ }
541
+ if (isAttachmentMetadata(parsed)) return parsed;
542
+ } catch {}
543
+ return fallback();
544
+ }
545
+ function parsePendingExpiry(response) {
546
+ const header = response.headers.get("Attachment-Pending");
547
+ if (!header) return null;
548
+ try {
549
+ const parsed = JSON.parse(header);
550
+ if (!isRecord(parsed)) return null;
551
+ if (typeof parsed.expiresAtUtc !== "string") return null;
552
+ const result = { expiresAtUtc: parsed.expiresAtUtc };
553
+ if (typeof parsed.mimeType === "string") result.mimeType = parsed.mimeType;
554
+ if (typeof parsed.fileName === "string") result.fileName = parsed.fileName;
555
+ if (typeof parsed.sizeBytes === "number" && Number.isFinite(parsed.sizeBytes) && parsed.sizeBytes >= 0) result.sizeBytes = parsed.sizeBytes;
556
+ return result;
557
+ } catch {
558
+ return null;
559
+ }
560
+ }
561
+ function parsePendingHeader(response) {
562
+ const partial = parsePendingExpiry(response);
563
+ if (!partial) return null;
564
+ if (typeof partial.mimeType !== "string" || typeof partial.fileName !== "string" || partial.sizeBytes === void 0) return null;
565
+ return {
566
+ expiresAtUtc: partial.expiresAtUtc,
567
+ mimeType: partial.mimeType,
568
+ fileName: partial.fileName,
569
+ sizeBytes: partial.sizeBytes
570
+ };
571
+ }
572
+ var RemoteAttachmentStore = class {
573
+ remoteUrl;
574
+ jwtHandler;
575
+ fetchFn;
576
+ constructor(config) {
577
+ this.remoteUrl = config.remoteUrl;
578
+ this.jwtHandler = config.jwtHandler;
579
+ this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
580
+ }
581
+ /**
582
+ * Get attachment metadata. Normally returns a pending AttachmentHeader
583
+ * (status: 'pending') when the server responds 202 with a full
584
+ * Attachment-Pending header. When only expiresAtUtc is present in the
585
+ * header (degraded wire), throws AttachmentPending instead -- the
586
+ * AttachmentPending throw is the degraded-wire case.
587
+ */
588
+ async stat(hash) {
589
+ const url = `${this.remoteUrl}/attachments/${hash}`;
590
+ const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
591
+ const response = await this.fetchFn(url, {
592
+ method: "HEAD",
593
+ headers: authHeaders
594
+ });
595
+ if (response.status === 202) {
596
+ const fullPending = parsePendingHeader(response);
597
+ if (fullPending) return buildPendingHeader(hash, fullPending);
598
+ const partial = parsePendingExpiry(response);
599
+ if (partial) throw new AttachmentPending(hash, partial.expiresAtUtc);
600
+ throw new Error("Attachment stat returned 202 with missing or malformed Attachment-Pending header");
601
+ }
602
+ if (response.status === 404) throw new AttachmentNotFound(hash);
603
+ if (!response.ok) throw new Error(`Attachment stat failed: ${response.status} ${response.statusText}`);
604
+ return buildHeader(hash, parseMetadata(response));
605
+ }
606
+ async get(hash, signal) {
607
+ return this.fetchAttachment(hash, signal);
608
+ }
609
+ async fetchAttachment(hash, signal) {
610
+ const url = `${this.remoteUrl}/attachments/${hash}`;
611
+ const headers = await buildAuthHeaders(url, this.jwtHandler);
612
+ const response = await this.fetchFn(url, {
613
+ signal,
614
+ headers
615
+ });
616
+ if (response.status === 202) {
617
+ const pending = parsePendingExpiry(response);
618
+ if (!pending) throw new Error("Attachment fetch returned 202 with missing or malformed Attachment-Pending header");
619
+ throw new AttachmentPending(hash, pending.expiresAtUtc);
620
+ }
621
+ if (response.status === 404) throw new AttachmentNotFound(hash);
622
+ if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
623
+ if (!response.body) throw new Error("Response body is null");
624
+ return {
625
+ header: buildHeader(hash, parseMetadata(response)),
626
+ body: response.body
627
+ };
628
+ }
629
+ };
630
+ function buildHeader(hash, metadata) {
631
+ return {
632
+ hash,
633
+ mimeType: metadata.mimeType,
634
+ fileName: metadata.fileName,
635
+ sizeBytes: metadata.sizeBytes,
636
+ extension: metadata.extension,
637
+ status: "available",
638
+ source: "sync",
639
+ createdAtUtc: metadata.createdAtUtc,
640
+ lastAccessedAtUtc: metadata.lastAccessedAtUtc ?? metadata.createdAtUtc,
641
+ expiresAtUtc: null
642
+ };
643
+ }
644
+ function buildPendingHeader(hash, pending) {
645
+ const now = (/* @__PURE__ */ new Date()).toISOString();
646
+ return {
647
+ hash,
648
+ mimeType: pending.mimeType,
649
+ fileName: pending.fileName,
650
+ sizeBytes: pending.sizeBytes,
651
+ extension: null,
652
+ status: "pending",
653
+ source: "sync",
654
+ createdAtUtc: now,
655
+ lastAccessedAtUtc: now,
656
+ expiresAtUtc: pending.expiresAtUtc
657
+ };
658
+ }
659
+ //#endregion
660
+ //#region src/switchboard/create-remote-attachment-service.ts
661
+ function createRemoteAttachmentService(config) {
662
+ const reservations = new RemoteReservationStore(config);
663
+ const uploadFactory = new RemoteAttachmentUploadFactory(config);
664
+ return new AttachmentService(new RemoteAttachmentStore(config), reservations, uploadFactory);
665
+ }
666
+ //#endregion
667
+ //#region src/null-attachment-transport.ts
668
+ /**
669
+ * No-op transport for deployments without remote sync.
670
+ * fetch() always returns not-found, announce() and push() are no-ops.
671
+ */
672
+ var NullAttachmentTransport = class {
673
+ fetch() {
674
+ return Promise.resolve({ kind: "not-found" });
675
+ }
676
+ announce() {
677
+ return Promise.resolve();
678
+ }
679
+ push() {
680
+ return Promise.resolve();
681
+ }
682
+ };
683
+ //#endregion
684
+ export { SizeMismatch as _, RemoteAttachmentUpload as a, AttachmentService as c, AttachmentAlreadyExists as d, AttachmentNotFound as f, ReservationNotFound as g, InvalidAttachmentRef as h, RemoteAttachmentUploadFactory as i, createRef as l, HashMismatch as m, createRemoteAttachmentService as n, RemoteReservationStore as o, AttachmentPending as p, RemoteAttachmentStore as r, SwitchboardAttachmentTransport as s, NullAttachmentTransport as t, parseRef as u, UploadTooLarge as v };
685
+
686
+ //# sourceMappingURL=null-attachment-transport-Drx03s02.js.map