shopstack 0.2.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -55
- package/SKILL.md +60 -43
- package/bin/shopstack +1 -3
- package/package.json +13 -23
- package/specs/openapi.json +3322 -0
- package/src/checkout-monitor.js +126 -0
- package/src/cli.js +364 -183
- package/src/client.d.ts +285 -51
- package/src/client.js +225 -205
- package/src/config.d.ts +19 -10
- package/src/config.js +46 -34
- package/src/reservation-progress.js +132 -0
- package/LICENSE +0 -21
package/src/client.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { readReservationProgress } from "./reservation-progress.js";
|
|
3
|
+
import { DEFAULT_API_URL, 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 } = {}) {
|
|
@@ -17,11 +25,11 @@ function idempotencyKey(prefix) {
|
|
|
17
25
|
return `${prefix}-${randomUUID()}`;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
function
|
|
21
|
-
return `
|
|
28
|
+
function signupRecoveryKey() {
|
|
29
|
+
return `signup_recovery_${randomBytes(32).toString("base64url")}`;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
function
|
|
32
|
+
function signupAttemptKey(accountType, email) {
|
|
25
33
|
return `${accountType}\u0000${email.trim().toLowerCase()}`;
|
|
26
34
|
}
|
|
27
35
|
|
|
@@ -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 =
|
|
45
|
+
baseUrl = process.env.SHOPSTACK_API_URL ?? DEFAULT_API_URL,
|
|
145
46
|
fetch: fetchImplementation = globalThis.fetch,
|
|
146
|
-
webSocket
|
|
47
|
+
webSocket = globalThis.WebSocket,
|
|
147
48
|
} = {}) {
|
|
148
49
|
if (typeof baseUrl !== "string" || !/^https?:\/\//u.test(baseUrl)) {
|
|
149
|
-
throw new TypeError(
|
|
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.
|
|
60
|
+
this.webSocket = webSocket;
|
|
158
61
|
}
|
|
159
62
|
|
|
160
63
|
async request(
|
|
@@ -164,76 +67,115 @@ export class ShopstackClient {
|
|
|
164
67
|
body,
|
|
165
68
|
idempotencyKey: mutationKey,
|
|
166
69
|
method = "GET",
|
|
167
|
-
|
|
70
|
+
onProgress,
|
|
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}`);
|
|
174
84
|
}
|
|
175
85
|
if (body !== undefined) headers.set("Content-Type", "application/json");
|
|
176
86
|
if (mutationKey) headers.set("Idempotency-Key", mutationKey);
|
|
177
|
-
if (recoveryKey) headers.set("
|
|
87
|
+
if (recoveryKey) headers.set("Signup-Recovery-Key", recoveryKey);
|
|
178
88
|
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 (
|
|
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 ${
|
|
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
|
|
141
|
+
status,
|
|
205
142
|
},
|
|
206
143
|
);
|
|
207
144
|
}
|
|
208
145
|
return payload;
|
|
209
146
|
}
|
|
210
147
|
|
|
211
|
-
|
|
212
|
-
|
|
148
|
+
_startAccountSignup({
|
|
149
|
+
accountType,
|
|
150
|
+
email,
|
|
151
|
+
idempotencyKey: key,
|
|
152
|
+
recoveryKey,
|
|
153
|
+
}) {
|
|
154
|
+
return this.request("/accounts", {
|
|
213
155
|
body: { account_type: accountType, email },
|
|
214
156
|
idempotencyKey: key,
|
|
215
157
|
method: "POST",
|
|
216
|
-
|
|
158
|
+
signupRecoveryKey: recoveryKey,
|
|
217
159
|
});
|
|
218
160
|
}
|
|
219
161
|
|
|
220
|
-
|
|
221
|
-
return this.
|
|
162
|
+
startAccountSignup({ accountType, email } = {}) {
|
|
163
|
+
return this._startAccountSignup({
|
|
222
164
|
accountType,
|
|
223
165
|
email,
|
|
224
166
|
idempotencyKey: idempotencyKey("account"),
|
|
225
|
-
recoveryKey:
|
|
167
|
+
recoveryKey: signupRecoveryKey(),
|
|
226
168
|
});
|
|
227
169
|
}
|
|
228
170
|
|
|
229
|
-
|
|
230
|
-
return this.request(`/
|
|
171
|
+
pollAccountSignup(signupId, pollToken) {
|
|
172
|
+
return this.request(`/accounts/${encodeURIComponent(signupId)}`, {
|
|
231
173
|
bearerToken: pollToken,
|
|
232
174
|
});
|
|
233
175
|
}
|
|
234
176
|
|
|
235
|
-
async
|
|
236
|
-
|
|
177
|
+
async waitForAccountSignup(
|
|
178
|
+
signupId,
|
|
237
179
|
pollToken,
|
|
238
180
|
{ pollIntervalMs = 2_000, timeoutMs = 15 * 60 * 1_000 } = {},
|
|
239
181
|
) {
|
|
@@ -241,16 +183,16 @@ export class ShopstackClient {
|
|
|
241
183
|
while (true) {
|
|
242
184
|
if (Date.now() - startedAt >= timeoutMs) {
|
|
243
185
|
throw new ShopstackApiError("Email verification timed out.", {
|
|
244
|
-
code: "
|
|
186
|
+
code: "signup_timeout",
|
|
245
187
|
});
|
|
246
188
|
}
|
|
247
|
-
const current = await this.
|
|
189
|
+
const current = await this.pollAccountSignup(signupId, pollToken);
|
|
248
190
|
if (current.status === "verified") return current;
|
|
249
191
|
await delay(pollIntervalMs);
|
|
250
192
|
}
|
|
251
193
|
}
|
|
252
194
|
|
|
253
|
-
async
|
|
195
|
+
async signup({
|
|
254
196
|
accountType,
|
|
255
197
|
email,
|
|
256
198
|
onProgress,
|
|
@@ -260,17 +202,17 @@ export class ShopstackClient {
|
|
|
260
202
|
timeoutMs = 15 * 60 * 1_000,
|
|
261
203
|
} = {}) {
|
|
262
204
|
const normalizedEmail = email.trim().toLowerCase();
|
|
263
|
-
const memoryKey =
|
|
264
|
-
this.
|
|
205
|
+
const memoryKey = signupAttemptKey(accountType, normalizedEmail);
|
|
206
|
+
this._pendingSignups ??= new Map();
|
|
265
207
|
const storage = persistence ?? {
|
|
266
208
|
completePending: async (state, result) => {
|
|
267
209
|
this.apiKey = result.api_key;
|
|
268
|
-
this.
|
|
210
|
+
this._pendingSignups.delete(memoryKey);
|
|
269
211
|
},
|
|
270
|
-
deletePending: async () => this.
|
|
271
|
-
loadPending: async () => this.
|
|
212
|
+
deletePending: async () => this._pendingSignups.delete(memoryKey),
|
|
213
|
+
loadPending: async () => this._pendingSignups.get(memoryKey),
|
|
272
214
|
savePending: async (state) => {
|
|
273
|
-
this.
|
|
215
|
+
this._pendingSignups.set(memoryKey, structuredClone(state));
|
|
274
216
|
},
|
|
275
217
|
};
|
|
276
218
|
const publish = async (state, details = {}) => {
|
|
@@ -281,6 +223,7 @@ export class ShopstackClient {
|
|
|
281
223
|
accountType,
|
|
282
224
|
email: normalizedEmail,
|
|
283
225
|
});
|
|
226
|
+
resolveProfileBaseUrl(pending, this.baseUrl);
|
|
284
227
|
if (pending?.expiresAt && Date.parse(pending.expiresAt) <= Date.now()) {
|
|
285
228
|
await storage.deletePending(pending);
|
|
286
229
|
await publish("expired", {
|
|
@@ -291,10 +234,11 @@ export class ShopstackClient {
|
|
|
291
234
|
}
|
|
292
235
|
pending ??= {
|
|
293
236
|
accountType,
|
|
237
|
+
baseUrl: this.baseUrl,
|
|
294
238
|
attemptId: `attempt-${randomUUID()}`,
|
|
295
239
|
email: normalizedEmail,
|
|
296
240
|
idempotencyKey: idempotencyKey("account"),
|
|
297
|
-
recoveryKey:
|
|
241
|
+
recoveryKey: signupRecoveryKey(),
|
|
298
242
|
};
|
|
299
243
|
await storage.savePending(pending);
|
|
300
244
|
await publish("pending", {
|
|
@@ -302,8 +246,8 @@ export class ShopstackClient {
|
|
|
302
246
|
email: normalizedEmail,
|
|
303
247
|
});
|
|
304
248
|
try {
|
|
305
|
-
if (!pending.
|
|
306
|
-
const started = await this.
|
|
249
|
+
if (!pending.signupId || !pending.pollToken) {
|
|
250
|
+
const started = await this._startAccountSignup({
|
|
307
251
|
accountType,
|
|
308
252
|
email: normalizedEmail,
|
|
309
253
|
idempotencyKey: pending.idempotencyKey,
|
|
@@ -313,7 +257,7 @@ export class ShopstackClient {
|
|
|
313
257
|
...pending,
|
|
314
258
|
expiresAt: started.expires_at,
|
|
315
259
|
pollToken: started.poll_token,
|
|
316
|
-
|
|
260
|
+
signupId: started.id,
|
|
317
261
|
};
|
|
318
262
|
await storage.savePending(pending);
|
|
319
263
|
if (typeof onVerificationRequired === "function") {
|
|
@@ -336,8 +280,8 @@ export class ShopstackClient {
|
|
|
336
280
|
email: normalizedEmail,
|
|
337
281
|
expires_at: pending.expiresAt,
|
|
338
282
|
});
|
|
339
|
-
const result = await this.
|
|
340
|
-
pending.
|
|
283
|
+
const result = await this.waitForAccountSignup(
|
|
284
|
+
pending.signupId,
|
|
341
285
|
pending.pollToken,
|
|
342
286
|
{ pollIntervalMs, timeoutMs },
|
|
343
287
|
);
|
|
@@ -354,7 +298,7 @@ export class ShopstackClient {
|
|
|
354
298
|
const { api_key: _credential, ...completed } = result;
|
|
355
299
|
return completed;
|
|
356
300
|
} catch (error) {
|
|
357
|
-
if (error?.code === "
|
|
301
|
+
if (error?.code === "signup_expired") {
|
|
358
302
|
await storage.deletePending(pending);
|
|
359
303
|
await publish("expired", {
|
|
360
304
|
account_type: accountType,
|
|
@@ -367,7 +311,7 @@ export class ShopstackClient {
|
|
|
367
311
|
});
|
|
368
312
|
} else if (
|
|
369
313
|
error?.code === "idempotency_conflict" ||
|
|
370
|
-
error?.code === "
|
|
314
|
+
error?.code === "invalid_signup_recovery"
|
|
371
315
|
) {
|
|
372
316
|
await storage.deletePending(pending);
|
|
373
317
|
await publish("conflict", {
|
|
@@ -427,6 +371,96 @@ export class ShopstackClient {
|
|
|
427
371
|
});
|
|
428
372
|
}
|
|
429
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
|
+
|
|
430
464
|
createCheckout(checkout, { idempotencyKey: key } = {}) {
|
|
431
465
|
return this.request("/checkout", {
|
|
432
466
|
body: checkout,
|
|
@@ -439,29 +473,37 @@ export class ShopstackClient {
|
|
|
439
473
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}`);
|
|
440
474
|
}
|
|
441
475
|
|
|
442
|
-
|
|
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
|
+
});
|
|
443
487
|
return this.request(
|
|
444
|
-
`/checkout/${encodeURIComponent(checkoutId)}/
|
|
445
|
-
{
|
|
446
|
-
idempotencyKey: key ?? idempotencyKey("live-view"),
|
|
447
|
-
method: "POST",
|
|
448
|
-
},
|
|
488
|
+
`/checkout/${encodeURIComponent(checkoutId)}/updates?${query}`,
|
|
489
|
+
{ signal },
|
|
449
490
|
);
|
|
450
491
|
}
|
|
451
492
|
|
|
452
|
-
|
|
493
|
+
subscribeToCheckoutUpdates(checkoutId, { signal } = {}) {
|
|
453
494
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/updates`, {
|
|
454
495
|
method: "POST",
|
|
496
|
+
signal,
|
|
455
497
|
});
|
|
456
498
|
}
|
|
457
499
|
|
|
458
|
-
|
|
459
|
-
const query = new URLSearchParams({
|
|
460
|
-
after: String(afterRevision),
|
|
461
|
-
wait: String(waitSeconds),
|
|
462
|
-
});
|
|
500
|
+
createLiveView(checkoutId, { idempotencyKey: key } = {}) {
|
|
463
501
|
return this.request(
|
|
464
|
-
`/checkout/${encodeURIComponent(checkoutId)}/
|
|
502
|
+
`/checkout/${encodeURIComponent(checkoutId)}/live-view`,
|
|
503
|
+
{
|
|
504
|
+
idempotencyKey: key ?? idempotencyKey("live-view"),
|
|
505
|
+
method: "POST",
|
|
506
|
+
},
|
|
465
507
|
);
|
|
466
508
|
}
|
|
467
509
|
|
|
@@ -491,12 +533,6 @@ export class ShopstackClient {
|
|
|
491
533
|
return this.request(`/checkout/${encodeURIComponent(checkoutId)}/events`);
|
|
492
534
|
}
|
|
493
535
|
|
|
494
|
-
listArtifacts(checkoutId) {
|
|
495
|
-
return this.request(
|
|
496
|
-
`/checkout/${encodeURIComponent(checkoutId)}/artifacts`,
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
536
|
providePaymentDetails(checkoutId, card, { idempotencyKey: key } = {}) {
|
|
501
537
|
return this.request(
|
|
502
538
|
`/checkout/${encodeURIComponent(checkoutId)}/payment-details`,
|
|
@@ -530,16 +566,12 @@ export class ShopstackClient {
|
|
|
530
566
|
let checkout = await this.createCheckout(checkoutRequest, {
|
|
531
567
|
idempotencyKey: options.idempotencyKey,
|
|
532
568
|
});
|
|
533
|
-
if (typeof options.onCreated === "function") {
|
|
534
|
-
await options.onCreated(checkout);
|
|
535
|
-
}
|
|
536
569
|
let progressFingerprint;
|
|
537
570
|
const publishProgress = async (current) => {
|
|
538
571
|
if (typeof options.onProgress !== "function") return;
|
|
539
572
|
const fingerprint = JSON.stringify([
|
|
540
573
|
current.status,
|
|
541
574
|
current.revision,
|
|
542
|
-
current.presentation_revision,
|
|
543
575
|
current.activity,
|
|
544
576
|
current.intent?.name,
|
|
545
577
|
current.intent?.phase,
|
|
@@ -551,20 +583,20 @@ export class ShopstackClient {
|
|
|
551
583
|
progressFingerprint = fingerprint;
|
|
552
584
|
await options.onProgress(current);
|
|
553
585
|
};
|
|
554
|
-
|
|
586
|
+
await publishProgress(checkout);
|
|
555
587
|
let handledPaymentRevision;
|
|
556
588
|
let handledApprovalId;
|
|
557
589
|
let handledMessageRevision;
|
|
590
|
+
const monitor = checkoutMonitor(this, checkout.id, this.webSocket);
|
|
558
591
|
try {
|
|
559
|
-
while (
|
|
560
|
-
|
|
561
|
-
if (TERMINAL_STATUSES.has(checkout.status)) return checkout;
|
|
562
|
-
const elapsed = Date.now() - startedAt;
|
|
563
|
-
if (elapsed >= timeoutMs) {
|
|
592
|
+
while (!TERMINAL_STATUSES.has(checkout.status)) {
|
|
593
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
564
594
|
throw new ShopstackApiError("Checkout monitoring timed out.", {
|
|
565
595
|
code: "checkout_timeout",
|
|
566
596
|
});
|
|
567
597
|
}
|
|
598
|
+
checkout = await this.getCheckout(checkout.id);
|
|
599
|
+
await publishProgress(checkout);
|
|
568
600
|
if (
|
|
569
601
|
checkout.required_input?.type === "payment_card" &&
|
|
570
602
|
handledPaymentRevision !== checkout.revision
|
|
@@ -573,10 +605,9 @@ export class ShopstackClient {
|
|
|
573
605
|
handledPaymentRevision = checkout.revision;
|
|
574
606
|
const card = await options.paymentDetails(checkout);
|
|
575
607
|
if (card === undefined) return checkout;
|
|
576
|
-
|
|
608
|
+
await this.providePaymentDetails(checkout.id, card, {
|
|
577
609
|
idempotencyKey: `payment-details-${checkout.id}-${checkout.revision}`,
|
|
578
610
|
});
|
|
579
|
-
handledMessageRevision = checkout.revision;
|
|
580
611
|
continue;
|
|
581
612
|
}
|
|
582
613
|
if (
|
|
@@ -588,13 +619,13 @@ export class ShopstackClient {
|
|
|
588
619
|
handledApprovalId = checkout.approval.id;
|
|
589
620
|
const approved = await options.approve(checkout.approval, checkout);
|
|
590
621
|
if (typeof approved !== "boolean") return checkout;
|
|
591
|
-
|
|
622
|
+
await this.decidePaymentApproval(
|
|
592
623
|
checkout.id,
|
|
593
624
|
checkout.approval.id,
|
|
594
625
|
approved,
|
|
595
626
|
{ idempotencyKey: `approval-${checkout.approval.id}` },
|
|
596
627
|
);
|
|
597
|
-
if (!approved) return checkout;
|
|
628
|
+
if (!approved) return this.getCheckout(checkout.id);
|
|
598
629
|
continue;
|
|
599
630
|
}
|
|
600
631
|
if (
|
|
@@ -612,27 +643,16 @@ export class ShopstackClient {
|
|
|
612
643
|
});
|
|
613
644
|
continue;
|
|
614
645
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (update.kind === "unavailable") {
|
|
621
|
-
const pollIntervalMs =
|
|
622
|
-
options.pollIntervalMs ??
|
|
623
|
-
(checkout.status === "help_required" ||
|
|
624
|
-
checkout.status === "approval_required"
|
|
625
|
-
? 5_000
|
|
626
|
-
: 2_000);
|
|
627
|
-
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
|
+
);
|
|
628
651
|
}
|
|
629
|
-
checkout =
|
|
630
|
-
update.kind === "checkout"
|
|
631
|
-
? update.checkout
|
|
632
|
-
: await this.getCheckout(checkout.id);
|
|
633
652
|
}
|
|
653
|
+
return checkout;
|
|
634
654
|
} finally {
|
|
635
|
-
|
|
655
|
+
monitor.close();
|
|
636
656
|
}
|
|
637
657
|
}
|
|
638
658
|
}
|