shopstack 0.2.5 → 0.3.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/src/client.js CHANGED
@@ -1,7 +1,15 @@
1
1
  import { randomBytes, randomUUID } from "node:crypto";
2
- import WebSocket from "ws";
2
+ import { readReservationProgress } from "./reservation-progress.js";
3
+ import { resolveProfileBaseUrl } from "./config.js";
4
+ import { checkoutMonitor } from "./checkout-monitor.js";
3
5
 
4
6
  const TERMINAL_STATUSES = new Set(["complete", "failed", "cancelled"]);
7
+ const TERMINAL_RESERVATION_STATUSES = new Set([
8
+ "confirmed",
9
+ "cancelled",
10
+ "booking_unknown",
11
+ "cancellation_unknown",
12
+ ]);
5
13
 
6
14
  export class ShopstackApiError extends Error {
7
15
  constructor(message, { code = "api_error", requestId, status } = {}) {
@@ -31,122 +39,17 @@ function delay(milliseconds) {
31
39
  : new Promise((resolve) => setTimeout(resolve, milliseconds));
32
40
  }
33
41
 
34
- function presentationRevision(checkout) {
35
- return Number.isInteger(checkout?.presentation_revision)
36
- ? checkout.presentation_revision
37
- : checkout.revision;
38
- }
39
-
40
- class CheckoutUpdateWatcher {
41
- constructor(client, checkoutId) {
42
- this.client = client;
43
- this.checkoutId = checkoutId;
44
- this.latestRevision = 0;
45
- this.waiter = undefined;
46
- this.socket = undefined;
47
- this.unavailable = typeof client.WebSocket !== "function";
48
- }
49
-
50
- async connect() {
51
- if (this.socket || this.unavailable) return;
52
- try {
53
- const subscription = await this.client.createCheckoutUpdateSubscription(
54
- this.checkoutId,
55
- );
56
- const socket = new this.client.WebSocket(subscription.socket_url, [
57
- subscription.protocol,
58
- subscription.token,
59
- ]);
60
- this.socket = socket;
61
- socket.addEventListener("message", (event) => {
62
- let payload;
63
- try {
64
- payload = JSON.parse(String(event.data));
65
- } catch {
66
- return;
67
- }
68
- if (
69
- payload?.type !== "checkout_changed" ||
70
- !Number.isInteger(payload.presentation_revision) ||
71
- payload.presentation_revision < 1 ||
72
- typeof payload.terminal !== "boolean"
73
- ) {
74
- return;
75
- }
76
- this.latestRevision = Math.max(
77
- this.latestRevision,
78
- payload.presentation_revision,
79
- );
80
- this.waiter?.();
81
- });
82
- const unavailable = () => {
83
- this.unavailable = true;
84
- this.waiter?.();
85
- };
86
- socket.addEventListener("error", unavailable);
87
- socket.addEventListener("close", unavailable);
88
- } catch {
89
- this.unavailable = true;
90
- }
91
- }
92
-
93
- async wait(afterRevision, timeoutMs) {
94
- await this.connect();
95
- if (this.latestRevision > afterRevision) return { kind: "changed" };
96
- if (!this.unavailable) {
97
- const changed = await new Promise((resolve) => {
98
- let settled = false;
99
- const settle = (value) => {
100
- if (settled) return;
101
- settled = true;
102
- this.waiter = undefined;
103
- clearTimeout(timer);
104
- resolve(value);
105
- };
106
- this.waiter = () =>
107
- settle(
108
- this.latestRevision > afterRevision ? "changed" : "unavailable",
109
- );
110
- const timer = setTimeout(
111
- () => settle("timeout"),
112
- Math.max(1, Math.min(timeoutMs, 25_000)),
113
- );
114
- });
115
- if (changed === "changed") return { kind: "changed" };
116
- if (changed === "timeout") return { kind: "refresh" };
117
- }
118
- try {
119
- const checkout = await this.client.waitForCheckoutUpdate(
120
- this.checkoutId,
121
- afterRevision,
122
- Math.max(1, Math.min(25, Math.ceil(timeoutMs / 1_000))),
123
- );
124
- return checkout === undefined
125
- ? { kind: "refresh" }
126
- : { checkout, kind: "checkout" };
127
- } catch {
128
- return { kind: "unavailable" };
129
- }
130
- }
131
-
132
- close() {
133
- try {
134
- this.socket?.close(1000, "Checkout monitoring complete");
135
- } catch {
136
- // Monitoring teardown must not alter the checkout result.
137
- }
138
- }
139
- }
140
-
141
42
  export class ShopstackClient {
142
43
  constructor({
143
44
  apiKey,
144
- baseUrl = "https://api.shopstack.ai/v1",
45
+ baseUrl = process.env.SHOPSTACK_API_URL,
145
46
  fetch: fetchImplementation = globalThis.fetch,
146
- webSocket: webSocketImplementation = WebSocket,
47
+ webSocket = globalThis.WebSocket,
147
48
  } = {}) {
148
49
  if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
149
- throw new TypeError("baseUrl must be an HTTP(S) URL");
50
+ throw new TypeError(
51
+ "Set baseUrl or SHOPSTACK_API_URL to the intended Shopstack HTTP(S) API endpoint.",
52
+ );
150
53
  }
151
54
  if (typeof fetchImplementation !== "function") {
152
55
  throw new TypeError("a fetch implementation is required");
@@ -154,7 +57,7 @@ export class ShopstackClient {
154
57
  this.apiKey = apiKey;
155
58
  this.baseUrl = baseUrl.replace(/\/+$/u, "");
156
59
  this.fetch = fetchImplementation;
157
- this.WebSocket = webSocketImplementation;
60
+ this.webSocket = webSocket;
158
61
  }
159
62
 
160
63
  async request(
@@ -164,10 +67,17 @@ export class ShopstackClient {
164
67
  body,
165
68
  idempotencyKey: mutationKey,
166
69
  method = "GET",
70
+ onProgress,
167
71
  signupRecoveryKey: recoveryKey,
72
+ signal,
168
73
  } = {},
169
74
  ) {
170
75
  const headers = new Headers({ Accept: "application/json" });
76
+ const progress =
77
+ typeof onProgress === "function" &&
78
+ method === "POST" &&
79
+ /^\/reservations(?:\/|$)/u.test(path);
80
+ if (progress) headers.set("Accept", "application/x-ndjson");
171
81
  const authorization = bearerToken ?? this.apiKey;
172
82
  if (authorization) {
173
83
  headers.set("Authorization", `Bearer ${authorization}`);
@@ -179,29 +89,56 @@ export class ShopstackClient {
179
89
  body: body === undefined ? undefined : JSON.stringify(body),
180
90
  headers,
181
91
  method,
92
+ signal,
182
93
  });
183
- const raw = await response.text();
184
94
  let payload;
185
- try {
186
- payload = raw ? JSON.parse(raw) : undefined;
187
- } catch {
188
- throw new ShopstackApiError("Shopstack returned invalid JSON.", {
189
- status: response.status,
190
- });
95
+ let status = response.status;
96
+ if (
97
+ progress &&
98
+ response.headers.get("content-type")?.split(";")[0] ===
99
+ "application/x-ndjson"
100
+ ) {
101
+ try {
102
+ ({ payload, status } = await readReservationProgress(
103
+ response,
104
+ onProgress,
105
+ ));
106
+ } catch {
107
+ const requestId = response.headers.get("x-request-id");
108
+ throw new ShopstackApiError(
109
+ "The reservation response was interrupted or invalid. Check the reservation state before retrying; booking may already have occurred.",
110
+ {
111
+ code: "reservation_response_incomplete",
112
+ requestId: /^req_[a-f0-9]{32}$/u.test(requestId ?? "")
113
+ ? requestId
114
+ : undefined,
115
+ status: 502,
116
+ },
117
+ );
118
+ }
119
+ } else {
120
+ const raw = await response.text();
121
+ try {
122
+ payload = raw ? JSON.parse(raw) : undefined;
123
+ } catch {
124
+ throw new ShopstackApiError("Shopstack returned invalid JSON.", {
125
+ status: response.status,
126
+ });
127
+ }
191
128
  }
192
- if (!response.ok) {
129
+ if (status < 200 || status >= 300) {
193
130
  const error = payload?.error;
194
131
  throw new ShopstackApiError(
195
132
  typeof error?.message === "string"
196
133
  ? error.message
197
- : `Shopstack request failed with status ${response.status}.`,
134
+ : `Shopstack request failed with status ${status}.`,
198
135
  {
199
136
  code: typeof error?.code === "string" ? error.code : "api_error",
200
137
  requestId:
201
138
  typeof error?.request_id === "string"
202
139
  ? error.request_id
203
140
  : undefined,
204
- status: response.status,
141
+ status,
205
142
  },
206
143
  );
207
144
  }
@@ -286,6 +223,7 @@ export class ShopstackClient {
286
223
  accountType,
287
224
  email: normalizedEmail,
288
225
  });
226
+ resolveProfileBaseUrl(pending, this.baseUrl);
289
227
  if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
290
228
  await storage.deletePending(pending);
291
229
  await publish("expired", {
@@ -296,6 +234,7 @@ export class ShopstackClient {
296
234
  }
297
235
  pending ??= {
298
236
  accountType,
237
+ baseUrl: this.baseUrl,
299
238
  attemptId: `attempt-${randomUUID()}`,
300
239
  email: normalizedEmail,
301
240
  idempotencyKey: idempotencyKey("account"),
@@ -432,6 +371,96 @@ export class ShopstackClient {
432
371
  });
433
372
  }
434
373
 
374
+ createReservation(reservation, { idempotencyKey: key, onProgress } = {}) {
375
+ return this.request("/reservations", {
376
+ body: reservation,
377
+ idempotencyKey: key ?? idempotencyKey("reservation"),
378
+ onProgress,
379
+ method: "POST",
380
+ });
381
+ }
382
+
383
+ getReservation(reservationId) {
384
+ return this.request(`/reservations/${encodeURIComponent(reservationId)}`);
385
+ }
386
+
387
+ listReservationOptions(reservationId, { limit = 6, offset = 0 } = {}) {
388
+ const query = new URLSearchParams({
389
+ offset: String(offset),
390
+ limit: String(limit),
391
+ });
392
+ return this.request(
393
+ `/reservations/${encodeURIComponent(reservationId)}/options?${query.toString()}`,
394
+ );
395
+ }
396
+
397
+ getReservationOption(reservationId, optionId) {
398
+ return this.request(
399
+ `/reservations/${encodeURIComponent(reservationId)}/options/${encodeURIComponent(optionId)}`,
400
+ );
401
+ }
402
+
403
+ sendReservationMessage(
404
+ reservationId,
405
+ content,
406
+ expectedRevision,
407
+ { idempotencyKey: key, onProgress } = {},
408
+ ) {
409
+ return this.request(
410
+ `/reservations/${encodeURIComponent(reservationId)}/messages`,
411
+ {
412
+ body: { content, expected_revision: expectedRevision },
413
+ idempotencyKey: key ?? idempotencyKey("reservation-message"),
414
+ onProgress,
415
+ method: "POST",
416
+ },
417
+ );
418
+ }
419
+
420
+ cancelReservation(
421
+ reservationId,
422
+ expectedRevision,
423
+ { idempotencyKey: key, onProgress } = {},
424
+ ) {
425
+ return this.request(
426
+ `/reservations/${encodeURIComponent(reservationId)}/cancel`,
427
+ {
428
+ body: { expected_revision: expectedRevision },
429
+ idempotencyKey: key ?? idempotencyKey("reservation-cancel"),
430
+ onProgress,
431
+ method: "POST",
432
+ },
433
+ );
434
+ }
435
+
436
+ async runReservation(reservationRequest, options = {}) {
437
+ let reservation = await this.createReservation(reservationRequest, {
438
+ idempotencyKey: options.idempotencyKey,
439
+ });
440
+ while (!TERMINAL_RESERVATION_STATUSES.has(reservation.status)) {
441
+ if (typeof options.onTurn === "function") {
442
+ await options.onTurn(reservation);
443
+ }
444
+ if (typeof options.respond !== "function") return reservation;
445
+ const content = await options.respond(reservation);
446
+ if (typeof content !== "string" || content.trim().length === 0) {
447
+ return reservation;
448
+ }
449
+ reservation = await this.sendReservationMessage(
450
+ reservation.id,
451
+ content.trim(),
452
+ reservation.revision,
453
+ {
454
+ idempotencyKey: `reservation-message-${reservation.id}-${reservation.revision}`,
455
+ },
456
+ );
457
+ }
458
+ if (typeof options.onTurn === "function") {
459
+ await options.onTurn(reservation);
460
+ }
461
+ return reservation;
462
+ }
463
+
435
464
  createCheckout(checkout, { idempotencyKey: key } = {}) {
436
465
  return this.request("/checkout", {
437
466
  body: checkout,
@@ -444,29 +473,37 @@ export class ShopstackClient {
444
473
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
445
474
  }
446
475
 
447
- createLiveView(checkoutId, { idempotencyKey: key } = {}) {
476
+ waitForCheckoutUpdate(checkoutId, { after, wait = 25, signal } = {}) {
477
+ if (!Number.isSafeInteger(after) || after < 0) {
478
+ throw new TypeError("after must be a non-negative safe integer.");
479
+ }
480
+ if (!Number.isSafeInteger(wait) || wait < 1 || wait > 25) {
481
+ throw new TypeError("wait must be an integer from 1 through 25.");
482
+ }
483
+ const query = new URLSearchParams({
484
+ after: String(after),
485
+ wait: String(wait),
486
+ });
448
487
  return this.request(
449
- `/checkout/${encodeURIComponent(checkoutId)}/live-view`,
450
- {
451
- idempotencyKey: key ?? idempotencyKey("live-view"),
452
- method: "POST",
453
- },
488
+ `/checkout/${encodeURIComponent(checkoutId)}/updates?${query}`,
489
+ { signal },
454
490
  );
455
491
  }
456
492
 
457
- createCheckoutUpdateSubscription(checkoutId) {
493
+ subscribeToCheckoutUpdates(checkoutId, { signal } = {}) {
458
494
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
459
495
  method: "POST",
496
+ signal,
460
497
  });
461
498
  }
462
499
 
463
- waitForCheckoutUpdate(checkoutId, afterRevision, waitSeconds = 25) {
464
- const query = new URLSearchParams({
465
- after: String(afterRevision),
466
- wait: String(waitSeconds),
467
- });
500
+ createLiveView(checkoutId, { idempotencyKey: key } = {}) {
468
501
  return this.request(
469
- `/checkout/${encodeURIComponent(checkoutId)}/updates?${query.toString()}`,
502
+ `/checkout/${encodeURIComponent(checkoutId)}/live-view`,
503
+ {
504
+ idempotencyKey: key ?? idempotencyKey("live-view"),
505
+ method: "POST",
506
+ },
470
507
  );
471
508
  }
472
509
 
@@ -496,12 +533,6 @@ export class ShopstackClient {
496
533
  return this.request(`/checkout/${encodeURIComponent(checkoutId)}/events`);
497
534
  }
498
535
 
499
- listArtifacts(checkoutId) {
500
- return this.request(
501
- `/checkout/${encodeURIComponent(checkoutId)}/artifacts`,
502
- );
503
- }
504
-
505
536
  providePaymentDetails(checkoutId, card, { idempotencyKey: key } = {}) {
506
537
  return this.request(
507
538
  `/checkout/${encodeURIComponent(checkoutId)}/payment-details`,
@@ -535,16 +566,12 @@ export class ShopstackClient {
535
566
  let checkout = await this.createCheckout(checkoutRequest, {
536
567
  idempotencyKey: options.idempotencyKey,
537
568
  });
538
- if (typeof options.onCreated === "function") {
539
- await options.onCreated(checkout);
540
- }
541
569
  let progressFingerprint;
542
570
  const publishProgress = async (current) => {
543
571
  if (typeof options.onProgress !== "function") return;
544
572
  const fingerprint = JSON.stringify([
545
573
  current.status,
546
574
  current.revision,
547
- current.presentation_revision,
548
575
  current.activity,
549
576
  current.intent?.name,
550
577
  current.intent?.phase,
@@ -556,20 +583,20 @@ export class ShopstackClient {
556
583
  progressFingerprint = fingerprint;
557
584
  await options.onProgress(current);
558
585
  };
559
- const watcher = new CheckoutUpdateWatcher(this, checkout.id);
586
+ await publishProgress(checkout);
560
587
  let handledPaymentRevision;
561
588
  let handledApprovalId;
562
589
  let handledMessageRevision;
590
+ const monitor = checkoutMonitor(this, checkout.id, this.webSocket);
563
591
  try {
564
- while (true) {
565
- await publishProgress(checkout);
566
- if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
567
- const elapsed = Date.now() - startedAt;
568
- if (elapsed >= timeoutMs) {
592
+ while (!TERMINAL_STATUSES.has(checkout.status)) {
593
+ if (Date.now() - startedAt >= timeoutMs) {
569
594
  throw new ShopstackApiError("Checkout monitoring timed out.", {
570
595
  code: "checkout_timeout",
571
596
  });
572
597
  }
598
+ checkout = await this.getCheckout(checkout.id);
599
+ await publishProgress(checkout);
573
600
  if (
574
601
  checkout.required_input?.type === "payment_card" &&
575
602
  handledPaymentRevision !== checkout.revision
@@ -578,10 +605,9 @@ export class ShopstackClient {
578
605
  handledPaymentRevision = checkout.revision;
579
606
  const card = await options.paymentDetails(checkout);
580
607
  if (card === undefined) return checkout;
581
- checkout = await this.providePaymentDetails(checkout.id, card, {
608
+ await this.providePaymentDetails(checkout.id, card, {
582
609
  idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
583
610
  });
584
- handledMessageRevision = checkout.revision;
585
611
  continue;
586
612
  }
587
613
  if (
@@ -593,13 +619,13 @@ export class ShopstackClient {
593
619
  handledApprovalId = checkout.approval.id;
594
620
  const approved = await options.approve(checkout.approval, checkout);
595
621
  if (typeof approved !== "boolean") return checkout;
596
- checkout = await this.decidePaymentApproval(
622
+ await this.decidePaymentApproval(
597
623
  checkout.id,
598
624
  checkout.approval.id,
599
625
  approved,
600
626
  { idempotencyKey: `approval-${checkout.approval.id}` },
601
627
  );
602
- if (!approved) return checkout;
628
+ if (!approved) return this.getCheckout(checkout.id);
603
629
  continue;
604
630
  }
605
631
  if (
@@ -617,27 +643,16 @@ export class ShopstackClient {
617
643
  });
618
644
  continue;
619
645
  }
620
- const remainingMs = timeoutMs - (Date.now() - startedAt);
621
- const update = await watcher.wait(
622
- presentationRevision(checkout),
623
- remainingMs,
624
- );
625
- if (update.kind === "unavailable") {
626
- const pollIntervalMs =
627
- options.pollIntervalMs ??
628
- (checkout.status === "help_required" ||
629
- checkout.status === "approval_required"
630
- ? 5_000
631
- : 2_000);
632
- await delay(Math.min(pollIntervalMs, remainingMs));
646
+ if (!TERMINAL_STATUSES.has(checkout.status)) {
647
+ await monitor.wait(
648
+ checkout.presentation_revision ?? 0,
649
+ timeoutMs - (Date.now() - startedAt),
650
+ );
633
651
  }
634
- checkout =
635
- update.kind === "checkout"
636
- ? update.checkout
637
- : await this.getCheckout(checkout.id);
638
652
  }
653
+ return checkout;
639
654
  } finally {
640
- watcher.close();
655
+ monitor.close();
641
656
  }
642
657
  }
643
658
  }
package/src/config.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  export interface ShopstackProfile {
2
2
  accountId: string;
3
3
  apiKey: string;
4
+ baseUrl?: string;
4
5
  keyType: "developer" | "user";
5
6
  userId?: string;
6
7
  }
7
8
 
8
9
  export interface PendingSignup {
9
10
  accountType: "developer" | "personal";
11
+ baseUrl?: string;
10
12
  attemptId?: string;
11
13
  email: string;
12
14
  expiresAt?: string;
@@ -53,3 +55,8 @@ export class ConfigStore {
53
55
  }
54
56
 
55
57
  export function defaultConfigPath(): string;
58
+
59
+ export function resolveProfileBaseUrl(
60
+ profile?: { baseUrl?: string },
61
+ selectedBaseUrl?: string,
62
+ ): string | undefined;
package/src/config.js CHANGED
@@ -2,6 +2,17 @@ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
+ export function resolveProfileBaseUrl(profile, selectedBaseUrl) {
6
+ const saved = profile?.baseUrl?.replace(/\/+$/u, "");
7
+ const selected = selectedBaseUrl?.replace(/\/+$/u, "");
8
+ if (saved !== undefined && selected !== undefined && saved !== selected) {
9
+ throw new Error(
10
+ "This profile belongs to a different Shopstack API. Select its endpoint or use a separate profile store.",
11
+ );
12
+ }
13
+ return saved ?? selected;
14
+ }
15
+
5
16
  export function defaultConfigPath() {
6
17
  return (
7
18
  process.env.SHOPSTACK_CONFIG_FILE ??
@@ -131,6 +142,7 @@ export class ConfigStore {
131
142
  accountId: result.account.id,
132
143
  apiKey: result.api_key,
133
144
  keyType: result.key_type,
145
+ ...(entry[1].baseUrl === undefined ? {} : { baseUrl: entry[1].baseUrl }),
134
146
  ...(result.user?.id === undefined ? {} : { userId: result.user.id }),
135
147
  };
136
148
  config.active = profileName;
@@ -0,0 +1,132 @@
1
+ const operations = new Set([
2
+ "resolve_location",
3
+ "get_search_filters",
4
+ "search_restaurants",
5
+ "prepare_booking",
6
+ "confirm_booking",
7
+ "decline_booking",
8
+ "ask_user",
9
+ "reply",
10
+ "request",
11
+ "model_decision",
12
+ "model_request",
13
+ "model_retry_wait",
14
+ "restaurant_request",
15
+ "provider_session",
16
+ "location_lookup",
17
+ "availability_recheck",
18
+ "slot_lock",
19
+ "slot_unlock",
20
+ "guest_profile",
21
+ "booking_submit",
22
+ "booking_status",
23
+ "booking_cancel",
24
+ "guest_inbox",
25
+ ]);
26
+ const phases = new Set(["started", "completed", "failed", "selected"]);
27
+ const failures = new Set([
28
+ "ETIMEDOUT",
29
+ "ECONNRESET",
30
+ "ECONNREFUSED",
31
+ "ENOTFOUND",
32
+ "EAI_AGAIN",
33
+ "UND_ERR_CONNECT_TIMEOUT",
34
+ "UND_ERR_HEADERS_TIMEOUT",
35
+ "UND_ERR_BODY_TIMEOUT",
36
+ "UND_ERR_SOCKET",
37
+ "TimeoutError",
38
+ "AbortError",
39
+ "model_invalid_response",
40
+ "operation_failed",
41
+ ]);
42
+ const nonnegative = (value) => Number.isSafeInteger(value) && value >= 0;
43
+
44
+ // Reconstruct the closed display shape. Provider bodies and arbitrary fields never pass through.
45
+ function publicProgress(frame, previous, requestId) {
46
+ if (
47
+ frame.request_id !== requestId ||
48
+ !nonnegative(frame.sequence) ||
49
+ frame.sequence <= previous ||
50
+ frame.sequence > 512 ||
51
+ !nonnegative(frame.call_id) ||
52
+ !nonnegative(frame.elapsed_ms) ||
53
+ !operations.has(frame.operation) ||
54
+ !phases.has(frame.phase)
55
+ )
56
+ throw new Error("Invalid progress frame.");
57
+ const event = Object.fromEntries(
58
+ [
59
+ "type",
60
+ "request_id",
61
+ "sequence",
62
+ "call_id",
63
+ "operation",
64
+ "phase",
65
+ "elapsed_ms",
66
+ ].map((key) => [key, frame[key]]),
67
+ );
68
+ if (nonnegative(frame.duration_ms)) event.duration_ms = frame.duration_ms;
69
+ if (
70
+ Number.isInteger(frame.http_status) &&
71
+ frame.http_status >= 100 &&
72
+ frame.http_status <= 599
73
+ )
74
+ event.http_status = frame.http_status;
75
+ if (failures.has(frame.failure)) event.failure = frame.failure;
76
+ return event;
77
+ }
78
+
79
+ export async function readReservationProgress(response, onProgress) {
80
+ const requestId = response.headers.get("x-request-id");
81
+ if (!/^req_[a-f0-9]{32}$/u.test(requestId ?? "") || !response.body)
82
+ throw new Error("Invalid progress stream.");
83
+ const reader = response.body.getReader();
84
+ const decoder = new TextDecoder("utf-8", { fatal: true });
85
+ let text = "";
86
+ let bytes = 0;
87
+ let sequence = 0;
88
+ let result;
89
+ try {
90
+ for (;;) {
91
+ const chunk = await reader.read();
92
+ if (chunk.done) break;
93
+ bytes += chunk.value.byteLength;
94
+ if (bytes > 17 * 1024 * 1024)
95
+ throw new Error("Progress stream limit exceeded.");
96
+ text += decoder.decode(chunk.value, { stream: true });
97
+ for (;;) {
98
+ const end = text.indexOf("\n");
99
+ if (end < 0) break;
100
+ const frame = JSON.parse(text.slice(0, end));
101
+ text = text.slice(end + 1);
102
+ if (result || !frame || typeof frame !== "object")
103
+ throw new Error("Invalid stream order.");
104
+ if (frame.type === "progress") {
105
+ const event = publicProgress(frame, sequence, requestId);
106
+ sequence = event.sequence;
107
+ try {
108
+ Promise.resolve(onProgress?.(event)).catch(() => {});
109
+ } catch {
110
+ /* Display failure cannot retry a mutation. */
111
+ }
112
+ } else if (
113
+ frame.type === "result" &&
114
+ frame.request_id === requestId &&
115
+ Number.isInteger(frame.status) &&
116
+ frame.status >= 200 &&
117
+ frame.status <= 599 &&
118
+ Object.hasOwn(frame, "body")
119
+ ) {
120
+ result = { status: frame.status, payload: frame.body };
121
+ } else throw new Error("Invalid result frame.");
122
+ }
123
+ }
124
+ text += decoder.decode();
125
+ if (!result || text.length) throw new Error("Incomplete progress stream.");
126
+ return result;
127
+ } finally {
128
+ // Also release failed/truncated streams. No automatic request retry.
129
+ await reader.cancel().catch(() => {});
130
+ reader.releaseLock();
131
+ }
132
+ }