@asstio-dev/storefront 0.7.0 → 0.11.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/README.md +198 -6
- package/dist/checkout/browser.d.ts +76 -0
- package/dist/checkout/browser.js +444 -0
- package/dist/checkout/controllers.d.ts +55 -0
- package/dist/checkout/controllers.js +75 -0
- package/dist/checkout/server.d.ts +37 -0
- package/dist/checkout/server.js +68 -0
- package/dist/checkout/types.d.ts +42 -0
- package/dist/checkout/types.js +17 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/schema.d.ts +1029 -75
- package/dist/seo/breadcrumb.d.ts +9 -0
- package/dist/seo/breadcrumb.js +18 -0
- package/dist/seo/head.d.ts +10 -0
- package/dist/seo/head.js +101 -0
- package/dist/seo/locales.d.ts +26 -0
- package/dist/seo/locales.js +95 -0
- package/dist/seo/product.d.ts +6 -0
- package/dist/seo/product.js +128 -0
- package/dist/seo/serialize.d.ts +6 -0
- package/dist/seo/serialize.js +8 -0
- package/dist/seo/shared.d.ts +38 -0
- package/dist/seo/shared.js +106 -0
- package/dist/seo/types.d.ts +100 -0
- package/dist/seo/types.js +7 -0
- package/package.json +6 -2
- package/src/schema.d.ts +1029 -75
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { controllerFor } from "./controllers.js";
|
|
2
|
+
import { localProblem } from "./types.js";
|
|
3
|
+
const FINAL_ORDER = new Set(["completed", "pending"]);
|
|
4
|
+
export function mountCheckout(container, checkout, options) {
|
|
5
|
+
if (checkout.status !== "open" || !checkout.widget)
|
|
6
|
+
throw new Error("mountCheckout needs an `open` answer with a widget");
|
|
7
|
+
const doc = options.document ?? globalThis.document;
|
|
8
|
+
const win = options.window ?? globalThis;
|
|
9
|
+
if (!doc)
|
|
10
|
+
throw new Error("mountCheckout needs a document");
|
|
11
|
+
const setT = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
12
|
+
const clearT = options.clearTimeout ?? ((h) => clearTimeout(h));
|
|
13
|
+
const timers = new Set();
|
|
14
|
+
// A controller's own timer (qliro-q1's sync deadline) is the handle's: a remount or destroy cancels it.
|
|
15
|
+
const controllerTimer = (fn, ms) => { const h = setT(() => { timers.delete(h); fn(); }, ms); timers.add(h); };
|
|
16
|
+
const now = options.now ?? Date.now;
|
|
17
|
+
const lockedBudgetMs = options.lockedBudgetMs ?? 30_000;
|
|
18
|
+
const lockedMaxCalls = options.lockedMaxCalls ?? 10;
|
|
19
|
+
const pollFirstMs = options.pollFirstMs ?? 1000;
|
|
20
|
+
const pollMaxMs = options.pollMaxMs ?? 10_000;
|
|
21
|
+
const lockedPollsBeforeSync = options.lockedPollsBeforeSync ?? 3; // *(plan review 1 #12)*
|
|
22
|
+
const bindRetryMs = options.bindRetryMs ?? 500; // one interval while mounted *(final review S8)*
|
|
23
|
+
const preExpiryMs = options.preExpiryMs ?? 180_000;
|
|
24
|
+
let attemptId = checkout.attemptId;
|
|
25
|
+
let controller = controllerFor(checkout.widget.controller, win, controllerTimer);
|
|
26
|
+
let suspended = false;
|
|
27
|
+
// The controller that was last told to suspend: a remount replaces the controller, and a sync still queued behind
|
|
28
|
+
// it must suspend the new one when it starts *(plan review 2 #11)*.
|
|
29
|
+
let suspendedOn;
|
|
30
|
+
let destroyed = false;
|
|
31
|
+
let generation = 0; // Review Focus 2: a remount or destroy invalidates every running loop and timer
|
|
32
|
+
// Syncs accepted and not yet finished — the running one and every one queued behind it. Payment stays suspended
|
|
33
|
+
// while it is above one, an autonomous sync is skipped while it is above zero, and a running sync's waits end as soon
|
|
34
|
+
// as another is queued behind it *(plan review 2 #11, final review S7, #8)*.
|
|
35
|
+
let active = 0;
|
|
36
|
+
let queue = Promise.resolve(); // *(plan review 1 #43)*
|
|
37
|
+
let unsubscribeDeclined;
|
|
38
|
+
let expiryTimer;
|
|
39
|
+
let firedPreExpiryFor; // *(plan review 1 #42, plan review 2 #12)*
|
|
40
|
+
// Every pending sleep's resolver, so a remount or destroy can settle it instead of clearing its timer and leaving
|
|
41
|
+
// the promise forever pending: an unsettled dispatch never reaches its `finally`, the queue never drains, and every
|
|
42
|
+
// later autonomous sync waits for good — and an app awaiting `handle.sync` after `destroy()` waits forever
|
|
43
|
+
// *(plan review 1 #37)*.
|
|
44
|
+
// Each sleep's timer is kept with it, so a queued update can end the running sync's waits alone *(final review #8)*.
|
|
45
|
+
const sleepers = new Map();
|
|
46
|
+
/** A wait of the running sync — a burst retry or a poll. It answers `false` (stop) when the generation is gone, and
|
|
47
|
+
* also at once when another sync is queued behind the running one: the shopper's cart update must not wait out a
|
|
48
|
+
* poll that can last minutes *(final review #8)*. */
|
|
49
|
+
const sleep = (ms, gen) => new Promise((resolve) => {
|
|
50
|
+
if (active > 1) {
|
|
51
|
+
resolve(false);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const settle = (alive) => { const h = sleepers.get(settle); sleepers.delete(settle); if (h !== undefined) {
|
|
55
|
+
clearT(h);
|
|
56
|
+
timers.delete(h);
|
|
57
|
+
} resolve(alive); };
|
|
58
|
+
const h = setT(() => { timers.delete(h); sleepers.delete(settle); resolve(!destroyed && gen === generation); }, ms);
|
|
59
|
+
sleepers.set(settle, h);
|
|
60
|
+
timers.add(h);
|
|
61
|
+
});
|
|
62
|
+
/** End every pending wait of the running sync with `false`; the binding and expiry timers are left alone. */
|
|
63
|
+
function endWaits() {
|
|
64
|
+
for (const settle of Array.from(sleepers.keys()))
|
|
65
|
+
settle(false);
|
|
66
|
+
}
|
|
67
|
+
/** Clear every timer and settle every sleep with `false` (the caller's generation is gone). */
|
|
68
|
+
function cancelTimers() {
|
|
69
|
+
endWaits();
|
|
70
|
+
for (const h of timers)
|
|
71
|
+
clearT(h);
|
|
72
|
+
timers.clear();
|
|
73
|
+
}
|
|
74
|
+
const alive = (gen) => !destroyed && gen === generation;
|
|
75
|
+
const message = (e) => (e instanceof Error ? e.message : String(e));
|
|
76
|
+
/** A failure that never reached the API, reported only to the generation it belongs to *(plan review 2 #16, #17)*. */
|
|
77
|
+
function reportLocal(gen, code, e) {
|
|
78
|
+
if (alive(gen))
|
|
79
|
+
options.onProblem(localProblem(code, message(e)), { status: 0 });
|
|
80
|
+
}
|
|
81
|
+
function render(dto) {
|
|
82
|
+
const w = dto.widget;
|
|
83
|
+
container.innerHTML = "";
|
|
84
|
+
if (w.kind === "html") {
|
|
85
|
+
container.innerHTML = w.html ?? "";
|
|
86
|
+
// innerHTML never runs a script: each one is re-created with its attributes and text, in order (Review Focus 4).
|
|
87
|
+
for (const inert of Array.from(container.querySelectorAll("script"))) {
|
|
88
|
+
const live = doc.createElement("script");
|
|
89
|
+
for (const name of inert.getAttributeNames())
|
|
90
|
+
live.setAttribute(name, inert.getAttribute(name) ?? "");
|
|
91
|
+
live.async = inert.async; // a script created this way is async by default: keep the snippet's own order *(plan review 1 #44)*
|
|
92
|
+
live.textContent = inert.textContent;
|
|
93
|
+
inert.parentNode?.replaceChild(live, inert);
|
|
94
|
+
container.appendChild(live);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const live = doc.createElement("script");
|
|
99
|
+
live.async = false; // createElement scripts are async by default; a snippet's externals must run in document order *(plan review 1 #44)*
|
|
100
|
+
live.setAttribute("src", w.scriptUrl ?? "");
|
|
101
|
+
if (w.token)
|
|
102
|
+
live.setAttribute("data-token", w.token);
|
|
103
|
+
for (const [k, v] of Object.entries(w.attributes ?? {}))
|
|
104
|
+
live.setAttribute(k, v);
|
|
105
|
+
container.appendChild(live);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function suspend() {
|
|
109
|
+
suspended = true;
|
|
110
|
+
if (suspendedOn !== controller) {
|
|
111
|
+
suspendedOn = controller;
|
|
112
|
+
controller.suspend();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function resume(target) { if (suspended) {
|
|
116
|
+
suspended = false;
|
|
117
|
+
suspendedOn = undefined;
|
|
118
|
+
controller.resume(target);
|
|
119
|
+
} }
|
|
120
|
+
// ---- The locked burst (spec §10.3) ---------------------------------------------------------------------------
|
|
121
|
+
/** One SDK-owned `POST /checkout`; a transport rejection is reported and swallowed — the caller stops, the widget
|
|
122
|
+
* stays suspended and the queue is released *(plan review 2 #17)*. Posted directly: the rolling-minute ledger, its
|
|
123
|
+
* waits and the POST not-before are cut *(final review S1)*. */
|
|
124
|
+
async function post(gen) {
|
|
125
|
+
try {
|
|
126
|
+
return await options.sync();
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
reportLocal(gen, "SDK_SYNC_FAILED", e);
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const withinBurst = (b, waitMs) => b.calls < lockedMaxCalls && now() + waitMs - b.start < lockedBudgetMs;
|
|
134
|
+
/** One retry of the `locked`/429/503/`CHECKOUT_IN_PROGRESS` burst. The wait must land inside the burst's deadline —
|
|
135
|
+
* checked before the wait and again after it, so a `Retry-After` longer than what is left of the 30 s never turns
|
|
136
|
+
* into a call outside it *(plan review 2 #14)*. */
|
|
137
|
+
async function retry(budget, retryAfterMs, gen) {
|
|
138
|
+
if (!withinBurst(budget, retryAfterMs))
|
|
139
|
+
return "exhausted";
|
|
140
|
+
if (!(await sleep(retryAfterMs, gen)))
|
|
141
|
+
return "gone";
|
|
142
|
+
if (!withinBurst(budget, 0))
|
|
143
|
+
return "exhausted";
|
|
144
|
+
budget.calls++;
|
|
145
|
+
return (await post(gen)) ?? "gone";
|
|
146
|
+
}
|
|
147
|
+
// One rolling burst window per handle, not one per dispatch *(plan review 1 #40)*.
|
|
148
|
+
let budgetWindow = { start: now(), calls: 0 };
|
|
149
|
+
function currentBudget() {
|
|
150
|
+
if (now() - budgetWindow.start >= lockedBudgetMs)
|
|
151
|
+
budgetWindow = { start: now(), calls: 0 };
|
|
152
|
+
return budgetWindow;
|
|
153
|
+
}
|
|
154
|
+
// ---- The autonomous syncs --------------------------------------------------------------------------------------
|
|
155
|
+
function armExpiry(dto) {
|
|
156
|
+
if (expiryTimer !== undefined) {
|
|
157
|
+
clearT(expiryTimer);
|
|
158
|
+
timers.delete(expiryTimer);
|
|
159
|
+
expiryTimer = undefined;
|
|
160
|
+
}
|
|
161
|
+
// Review Focus 5: server-relative, three minutes before, never negative.
|
|
162
|
+
// The repository's OpenAPI emits every int32 as ["integer","string"], so the generated type is
|
|
163
|
+
// `number | string` and the multiplication is TS2362. The same rule for any int arithmetic in the SDK
|
|
164
|
+
// *(plan review 1 #38)*.
|
|
165
|
+
const delay = Math.max(0, Number(dto.expiresInSeconds) * 1000 - preExpiryMs);
|
|
166
|
+
// An attempt already inside the window answers `open` again, which would re-arm at delay 0 and sync in a
|
|
167
|
+
// zero-delay loop. Only that repeat is suppressed: an already-due deadline for an attempt whose pre-expiry sync
|
|
168
|
+
// has run. A future deadline from a new answer — the server extended the attempt — is armed as usual; round 1's
|
|
169
|
+
// guard blocked every later timer for the attempt *(plan review 1 #42, plan review 2 #12)*.
|
|
170
|
+
if (delay === 0 && firedPreExpiryFor === dto.attemptId)
|
|
171
|
+
return;
|
|
172
|
+
const gen = generation;
|
|
173
|
+
expiryTimer = setT(() => {
|
|
174
|
+
timers.delete(expiryTimer);
|
|
175
|
+
expiryTimer = undefined;
|
|
176
|
+
autonomousSync("expiry", gen);
|
|
177
|
+
}, delay);
|
|
178
|
+
timers.add(expiryTimer);
|
|
179
|
+
}
|
|
180
|
+
function bindProvider(dto, bound = controllerFor(dto.widget.controller, win, controllerTimer)) {
|
|
181
|
+
// Cleared, not only called: round 1 left the old unsubscriber in place, so a remount's `attach` returned at once
|
|
182
|
+
// and the new widget's decline signal was never bound *(plan review 2 #13)*.
|
|
183
|
+
unsubscribeDeclined?.();
|
|
184
|
+
unsubscribeDeclined = undefined;
|
|
185
|
+
controller = bound;
|
|
186
|
+
// A provider without the signal: nothing to bind and nothing to retry *(plan review 2 #13)*.
|
|
187
|
+
if (!bound.onDeclined)
|
|
188
|
+
return;
|
|
189
|
+
const gen = generation;
|
|
190
|
+
// `render` appends the provider's script; it has not executed when this runs, so the global the controller reads
|
|
191
|
+
// does not exist yet. Retry every `bindRetryMs` until the subscription is registered (`onDeclined` returns its
|
|
192
|
+
// unsubscriber) or the generation is gone — one interval for as long as the widget is mounted, so a script that
|
|
193
|
+
// loads late is still bound. The timers are the handle's, so a remount or destroy cancels them
|
|
194
|
+
// *(plan review 1 #43, plan review 2 #13, plan review 3 #4, final review S8)*.
|
|
195
|
+
const attach = () => {
|
|
196
|
+
if (!alive(gen))
|
|
197
|
+
return;
|
|
198
|
+
const unsubscribe = bound.onDeclined(() => autonomousSync("declined", gen));
|
|
199
|
+
if (unsubscribe) {
|
|
200
|
+
unsubscribeDeclined = unsubscribe;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const h = setT(() => { timers.delete(h); attach(); }, bindRetryMs);
|
|
204
|
+
timers.add(h);
|
|
205
|
+
};
|
|
206
|
+
attach();
|
|
207
|
+
}
|
|
208
|
+
function remount(dto) {
|
|
209
|
+
if (dto.status !== "open" || !dto.widget)
|
|
210
|
+
throw new Error("remount needs an `open` answer with a widget");
|
|
211
|
+
// Resolved before any state changes: an unknown controller throws while the generation is still alive, so the
|
|
212
|
+
// dispatch's catch reports it instead of dropping it for a generation that moved on *(final review B3 T2)*.
|
|
213
|
+
const next = controllerFor(dto.widget.controller, win, controllerTimer);
|
|
214
|
+
generation++;
|
|
215
|
+
cancelTimers(); // *(plan review 1 #37)*
|
|
216
|
+
expiryTimer = undefined;
|
|
217
|
+
firedPreExpiryFor = undefined;
|
|
218
|
+
attemptId = dto.attemptId;
|
|
219
|
+
// The ruling of round 1 #39: no controller call after a remount (the new widget's script has not loaded), and the
|
|
220
|
+
// flag is cleared. Unless a sync is queued behind the one running: then payment stays suspended, and that sync
|
|
221
|
+
// suspends the new controller when it starts (`suspendedOn` is cleared) *(plan review 2 #11)*.
|
|
222
|
+
suspended = active > 1;
|
|
223
|
+
suspendedOn = undefined;
|
|
224
|
+
render(dto);
|
|
225
|
+
bindProvider(dto, next);
|
|
226
|
+
armExpiry(dto);
|
|
227
|
+
}
|
|
228
|
+
const onVisible = () => { if (!destroyed && suspended && doc.visibilityState === "visible")
|
|
229
|
+
autonomousSync("visible", generation); };
|
|
230
|
+
doc.addEventListener("visibilitychange", onVisible);
|
|
231
|
+
/** A sync the SDK starts by itself. Skipped while another sync runs: that sync is a `POST /checkout` too, and its
|
|
232
|
+
* `open` answer re-arms the pre-expiry timer (an attempt already inside the window arms at once). The pre-expiry
|
|
233
|
+
* attempt is marked only when its sync really starts, so a skip never consumes it. The deferral queue rounds 2–3
|
|
234
|
+
* added is cut *(plan review 2 #12, final review S7)*. */
|
|
235
|
+
function autonomousSync(reason, gen) {
|
|
236
|
+
if (!alive(gen) || active > 0)
|
|
237
|
+
return;
|
|
238
|
+
if (reason === "visible" && !suspended)
|
|
239
|
+
return;
|
|
240
|
+
if (reason === "expiry")
|
|
241
|
+
firedPreExpiryFor = attemptId;
|
|
242
|
+
void enqueue(async (g) => {
|
|
243
|
+
const result = await post(g);
|
|
244
|
+
if (result)
|
|
245
|
+
await dispatch(result, g, currentBudget());
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// ---- The rule ------------------------------------------------------------------------------------------------
|
|
249
|
+
/** Serialized: two quick quantity clicks must not run two dispatches in one generation, or the first `open` resumes
|
|
250
|
+
* the widget while the second mutation is still in flight *(plan review 1 #43)*. The widget is suspended when the
|
|
251
|
+
* request is **accepted**, before it waits its turn: round 1 suspended after `await previous`, so `handle.sync`
|
|
252
|
+
* returned with payment still enabled even on an idle queue *(plan review 2 #11)*. A sync accepted while another
|
|
253
|
+
* runs ends the running one's waits (a burst retry, a poll that can last minutes): the running sync stops, and the
|
|
254
|
+
* newer update — which supersedes what it was waiting for — runs at once *(final review #8)*. Never rejects: any
|
|
255
|
+
* other throw — an app callback, an unknown controller on remount, a provider `lock()` — is reported as
|
|
256
|
+
* `SDK_DISPATCH_FAILED` to the generation it belongs to, and the first `suspend()` is inside the `try` (still before
|
|
257
|
+
* the first `await`, so still at acceptance), so a throw cannot leave `active` stuck *(final review B3 T2)*. */
|
|
258
|
+
function enqueue(work) {
|
|
259
|
+
if (destroyed)
|
|
260
|
+
return Promise.resolve();
|
|
261
|
+
active++;
|
|
262
|
+
const previous = queue;
|
|
263
|
+
let release;
|
|
264
|
+
queue = new Promise((r) => { release = r; });
|
|
265
|
+
let gen = generation;
|
|
266
|
+
return (async () => {
|
|
267
|
+
try {
|
|
268
|
+
suspend();
|
|
269
|
+
if (active > 1)
|
|
270
|
+
endWaits(); // *(final review #8)*
|
|
271
|
+
await previous;
|
|
272
|
+
if (destroyed)
|
|
273
|
+
return;
|
|
274
|
+
gen = generation; // before the suspend: a new controller's throwing lock() is reported under the live generation
|
|
275
|
+
suspend(); // a remount while this waited replaced the controller: suspend the new one
|
|
276
|
+
await work(gen);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
// The report itself may throw (`onProblem` is an app callback, and one of the causes): swallowed, so
|
|
280
|
+
// `handle.sync` and the autonomous path still never reject.
|
|
281
|
+
try {
|
|
282
|
+
reportLocal(gen, "SDK_DISPATCH_FAILED", e);
|
|
283
|
+
}
|
|
284
|
+
catch { /* never rejects */ }
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
active--;
|
|
288
|
+
release();
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
}
|
|
292
|
+
/** Status first; identity on `open` only. */
|
|
293
|
+
async function dispatch(result, gen, budget) {
|
|
294
|
+
if (!alive(gen))
|
|
295
|
+
return;
|
|
296
|
+
if (!result.ok) {
|
|
297
|
+
const retryable503 = result.status === 503 && (result.problem.code === "CHECKOUT_UNAVAILABLE" || result.problem.code === "CHECKOUT_PROVIDER_UNAVAILABLE");
|
|
298
|
+
// `sync` and `update` are single POSTs (`{ retries: 0 }`), so the handle retries CHECKOUT_IN_PROGRESS itself, in
|
|
299
|
+
// the same burst as `locked` *(plan review 3 #5, final review S1)*.
|
|
300
|
+
const inProgress = result.status === 409 && result.problem.code === "CHECKOUT_IN_PROGRESS";
|
|
301
|
+
if (retryable503 || inProgress || result.status === 429) {
|
|
302
|
+
const next = await retry(budget, (result.retryAfter ?? 1) * 1000, gen);
|
|
303
|
+
if (next === "gone")
|
|
304
|
+
return;
|
|
305
|
+
if (next !== "exhausted")
|
|
306
|
+
return dispatch(next, gen, budget);
|
|
307
|
+
}
|
|
308
|
+
// The last retry's problem, not the first: the app should see the latest state of the checkout. The 503 test
|
|
309
|
+
// asserts the last code for that reason *(plan review 1 #41)*.
|
|
310
|
+
options.onProblem(result.problem, { status: result.status });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const dto = result.data;
|
|
314
|
+
switch (dto.status) {
|
|
315
|
+
case "completed":
|
|
316
|
+
case "pending":
|
|
317
|
+
if (dto.order)
|
|
318
|
+
options.onOrder(dto.order, dto.attemptId);
|
|
319
|
+
return;
|
|
320
|
+
case "locked": {
|
|
321
|
+
const next = await retry(budget, (result.retryAfter ?? 1) * 1000, gen);
|
|
322
|
+
if (next === "gone")
|
|
323
|
+
return;
|
|
324
|
+
if (next !== "exhausted")
|
|
325
|
+
return dispatch(next, gen, budget);
|
|
326
|
+
return poll(dto.attemptId, gen);
|
|
327
|
+
}
|
|
328
|
+
case "processing":
|
|
329
|
+
return poll(dto.attemptId, gen);
|
|
330
|
+
case "declined":
|
|
331
|
+
case "expired": {
|
|
332
|
+
const next = await post(gen);
|
|
333
|
+
if (next)
|
|
334
|
+
return dispatchOnce(next, gen);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
case "open":
|
|
338
|
+
if (dto.attemptId === attemptId) {
|
|
339
|
+
armExpiry(dto);
|
|
340
|
+
// Another update is queued behind this one: its mutation is still to come, so payment stays suspended
|
|
341
|
+
// until its own answer *(plan review 2 #11)*. The provider unlocks once its view shows `dto.cart`; if it
|
|
342
|
+
// never does, the widget is re-rendered from this answer *(plan review 1 #3)*.
|
|
343
|
+
if (active <= 1)
|
|
344
|
+
resume({ cart: dto.cart, onUnsynced: () => { if (alive(gen))
|
|
345
|
+
remount(dto); } });
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
remount(dto);
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
default:
|
|
352
|
+
options.onProblem(localProblem("SDK_UNKNOWN_STATUS", String(dto.status)), { status: 200 });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** After a `declined`/`expired` or a `GET open`: one sync, dispatched without a second closing → sync loop. */
|
|
356
|
+
async function dispatchOnce(result, gen) {
|
|
357
|
+
if (!alive(gen))
|
|
358
|
+
return;
|
|
359
|
+
if (result.ok && (result.data.status === "declined" || result.data.status === "expired")) {
|
|
360
|
+
options.onProblem(localProblem("SDK_CHECKOUT_CLOSED", result.data.status), { status: 200 });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
return dispatch(result, gen, currentBudget()); // *(plan review 1 #40)*
|
|
364
|
+
}
|
|
365
|
+
/** `status(attemptId)` polling with backoff; a GET answer is never payable: `open` (like `declined`/`expired`) leads to one non-mutating `sync()`. */
|
|
366
|
+
async function poll(id, gen) {
|
|
367
|
+
let delay = pollFirstMs;
|
|
368
|
+
let lockedPolls = 0;
|
|
369
|
+
for (;;) {
|
|
370
|
+
if (!(await sleep(delay, gen)))
|
|
371
|
+
return;
|
|
372
|
+
let s;
|
|
373
|
+
try {
|
|
374
|
+
s = await options.status(id);
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
reportLocal(gen, "SDK_STATUS_FAILED", e); // *(plan review 2 #17)*
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!alive(gen))
|
|
381
|
+
return;
|
|
382
|
+
// §10.3 treats a 429 like `locked`: back off and keep polling *(plan review 1 #43)*. The client's backoff is
|
|
383
|
+
// capped at `pollMaxMs`; the server's `Retry-After` is a lower bound the cap never shortens *(plan review 2 #15)*.
|
|
384
|
+
if (!s.ok) {
|
|
385
|
+
if (s.status !== 429) {
|
|
386
|
+
options.onProblem(s.problem, { status: s.status });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
delay = Math.max(Math.min(delay * 2, pollMaxMs), (s.retryAfter ?? 1) * 1000);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (FINAL_ORDER.has(s.data.status)) {
|
|
393
|
+
if (s.data.order)
|
|
394
|
+
options.onOrder(s.data.order, s.data.attemptId);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// `locked` means an update or an owned read is in flight (§10.2), and only a POST resolves it. An attempt whose
|
|
398
|
+
// latest snapshot was never confirmed and whose claim is gone answers `locked` to every GET with nothing in
|
|
399
|
+
// flight, so a status poller would poll for ever: escalate to one sync() *(plan review 1 #12)*. Posted directly
|
|
400
|
+
// *(final review S1)*.
|
|
401
|
+
const escalate = s.data.status === "open" || s.data.status === "declined" || s.data.status === "expired"
|
|
402
|
+
|| (s.data.status === "locked" && ++lockedPolls >= lockedPollsBeforeSync);
|
|
403
|
+
if (escalate) {
|
|
404
|
+
const next = await post(gen);
|
|
405
|
+
if (next)
|
|
406
|
+
return dispatchOnce(next, gen);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
delay = Math.min(delay * 2, pollMaxMs);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const handle = {
|
|
413
|
+
get attemptId() { return attemptId; },
|
|
414
|
+
get suspended() { return suspended; },
|
|
415
|
+
suspend, resume, remount,
|
|
416
|
+
destroy() {
|
|
417
|
+
destroyed = true;
|
|
418
|
+
generation++;
|
|
419
|
+
cancelTimers(); // *(plan review 1 #37)*
|
|
420
|
+
unsubscribeDeclined?.();
|
|
421
|
+
unsubscribeDeclined = undefined;
|
|
422
|
+
doc.removeEventListener("visibilitychange", onVisible);
|
|
423
|
+
},
|
|
424
|
+
sync(update) {
|
|
425
|
+
return enqueue(async (gen) => {
|
|
426
|
+
let result;
|
|
427
|
+
try {
|
|
428
|
+
result = await update(); // once — never repeated (LensNow's add action has no idempotency key)
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
// Only for the generation it was started in: a rejection after destroy() or a remount is dropped, as a
|
|
432
|
+
// fulfilled answer is in `dispatch` *(plan review 2 #16)*. The widget stays suspended until a sync succeeds.
|
|
433
|
+
reportLocal(gen, "SDK_UPDATE_FAILED", e);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
await dispatch(result, gen, currentBudget()); // *(plan review 1 #40)*
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
render(checkout);
|
|
441
|
+
bindProvider(checkout);
|
|
442
|
+
armExpiry(checkout);
|
|
443
|
+
return handle;
|
|
444
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** What the provider's JavaScript offers (spec §4.4 `ClientSuspend`); each is optional on the page until the widget's script has loaded. */
|
|
2
|
+
export interface ProviderWindow {
|
|
3
|
+
q1?: {
|
|
4
|
+
lock(): void;
|
|
5
|
+
unlock(): void;
|
|
6
|
+
/** Qliro's sync after a `PUT Orders/{id}`: the iframe re-reads the order and calls back until `unlock()` is called
|
|
7
|
+
* (developers.qliro.com → Update order). */
|
|
8
|
+
onOrderUpdated?(cb: (order: unknown) => void): void;
|
|
9
|
+
/** Fires when our validation callback declined the purchase, with the `DeclineReason` it answered
|
|
10
|
+
* (developers.qliro.com → Listeners). */
|
|
11
|
+
onPaymentDeclined?(cb: (declineReason?: string, declineReasonMessage?: string) => void): void;
|
|
12
|
+
};
|
|
13
|
+
_klarnaCheckout?: (cb: (api: {
|
|
14
|
+
suspend(): void;
|
|
15
|
+
resume(): void;
|
|
16
|
+
}) => void) => void;
|
|
17
|
+
walley?: {
|
|
18
|
+
checkout?: {
|
|
19
|
+
api?: {
|
|
20
|
+
suspend(): void;
|
|
21
|
+
resume(): void;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** What a resume must see in the provider's own view of the order before it enables payment: the confirmed answer
|
|
27
|
+
* (`open` for the mounted attempt) *(plan review 1 #3)*. */
|
|
28
|
+
export interface ResumeTarget {
|
|
29
|
+
cart: {
|
|
30
|
+
grandTotalInc: string;
|
|
31
|
+
};
|
|
32
|
+
/** The provider never showed `cart` before its deadline: the handle re-renders the widget from the confirmed answer. */
|
|
33
|
+
onUnsynced(): void;
|
|
34
|
+
}
|
|
35
|
+
export interface CheckoutController {
|
|
36
|
+
suspend(): void;
|
|
37
|
+
/** `target` lets a controller that can see the provider's order wait for it (qliro-q1); the others ignore it. */
|
|
38
|
+
resume(target?: ResumeTarget): void;
|
|
39
|
+
/** Subscribe to the provider's payment-declined signal where its API offers one. Returns the unsubscribe once the
|
|
40
|
+
* subscription is registered, and `undefined` while the provider's script has not exposed the signal yet — the
|
|
41
|
+
* handle retries only then. A controller whose provider has no such signal leaves this member out, and the handle
|
|
42
|
+
* never retries for it *(plan review 2 #13)*. */
|
|
43
|
+
onDeclined?(handler: () => void): (() => void) | undefined;
|
|
44
|
+
}
|
|
45
|
+
/** A controller's own timer, scheduled on the handle so that a remount or `destroy()` cancels it. */
|
|
46
|
+
export type ControllerTimer = (fn: () => void, ms: number) => void;
|
|
47
|
+
export type ControllerFactory = (win: ProviderWindow, timer: ControllerTimer) => CheckoutController;
|
|
48
|
+
/** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
|
|
49
|
+
* re-rendered, never unlocked blindly *(plan review 1 #3)*. */
|
|
50
|
+
export declare const QLIRO_SYNC_DEADLINE_MS = 10000;
|
|
51
|
+
/** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
|
|
52
|
+
* shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
|
|
53
|
+
export declare function qliroOrderMatches(order: unknown, cart: ResumeTarget["cart"]): boolean;
|
|
54
|
+
export declare const controllers: Record<"qliro-q1" | "klarna-kco" | "walley", ControllerFactory>;
|
|
55
|
+
export declare function controllerFor(name: string, win: ProviderWindow, timer?: ControllerTimer): CheckoutController;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** How long `qliro-q1`'s resume waits for `q1.onOrderUpdated` to show the confirmed cart. Past it the widget is
|
|
2
|
+
* re-rendered, never unlocked blindly *(plan review 1 #3)*. */
|
|
3
|
+
export const QLIRO_SYNC_DEADLINE_MS = 10_000;
|
|
4
|
+
const ore = (v) => Math.round(Number(v) * 100);
|
|
5
|
+
/** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
|
|
6
|
+
* shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
|
|
7
|
+
export function qliroOrderMatches(order, cart) {
|
|
8
|
+
const total = order?.totalPrice;
|
|
9
|
+
return (typeof total === "number" || typeof total === "string") && ore(total) === ore(cart.grandTotalInc);
|
|
10
|
+
}
|
|
11
|
+
export const controllers = {
|
|
12
|
+
"qliro-q1": (win, timer) => {
|
|
13
|
+
// Every suspend, every unlock and the deadline move the generation, so neither a resume's `onOrderUpdated` callback
|
|
14
|
+
// (Qliro calls it until unlock) nor its deadline acts after a newer suspend (Review Focus 3).
|
|
15
|
+
let generation = 0;
|
|
16
|
+
// Whether the last suspend reached `q1.lock()`: before the snippet's script has run there is no `q1` to lock, and the
|
|
17
|
+
// iframe may load the order before the PUT *(final review #4)*.
|
|
18
|
+
let locked = false;
|
|
19
|
+
return {
|
|
20
|
+
suspend: () => { generation++; locked = false; const q1 = win.q1; if (q1) {
|
|
21
|
+
q1.lock();
|
|
22
|
+
locked = true;
|
|
23
|
+
} },
|
|
24
|
+
resume: (target) => {
|
|
25
|
+
const q1 = win.q1;
|
|
26
|
+
const missedLock = !locked;
|
|
27
|
+
locked = false;
|
|
28
|
+
if (!q1) {
|
|
29
|
+
if (missedLock && target)
|
|
30
|
+
target.onUnsynced();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const mine = ++generation;
|
|
34
|
+
const unlock = () => { if (generation !== mine)
|
|
35
|
+
return; generation++; q1.unlock(); };
|
|
36
|
+
// Nothing to compare — no sync API, or the app resumed without an answer: unlock at once, as B did.
|
|
37
|
+
if (!q1.onOrderUpdated || !target) {
|
|
38
|
+
unlock();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (missedLock)
|
|
42
|
+
q1.lock(); // onOrderUpdated "requires q1.lock() to have been called first"
|
|
43
|
+
q1.onOrderUpdated((order) => { if (qliroOrderMatches(order, target.cart))
|
|
44
|
+
unlock(); });
|
|
45
|
+
// Never unlocked blindly: past the deadline the handle re-renders the widget from the confirmed answer
|
|
46
|
+
// *(plan review 1 #3)*.
|
|
47
|
+
timer(() => { if (generation !== mine)
|
|
48
|
+
return; generation++; target.onUnsynced(); }, QLIRO_SYNC_DEADLINE_MS);
|
|
49
|
+
},
|
|
50
|
+
onDeclined: (handler) => {
|
|
51
|
+
// Confirmed (plan C): `q1.onPaymentDeclined` fires on our validation decline. `undefined` until `q1` and the signal
|
|
52
|
+
// exist *(plan review 2 #13)*.
|
|
53
|
+
const q1 = win.q1;
|
|
54
|
+
if (!q1?.onPaymentDeclined)
|
|
55
|
+
return undefined;
|
|
56
|
+
q1.onPaymentDeclined(() => handler());
|
|
57
|
+
return () => { };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
"klarna-kco": (win) => ({
|
|
62
|
+
suspend: () => win._klarnaCheckout?.((api) => api.suspend()),
|
|
63
|
+
resume: () => win._klarnaCheckout?.((api) => api.resume()),
|
|
64
|
+
}),
|
|
65
|
+
walley: (win) => ({
|
|
66
|
+
suspend: () => win.walley?.checkout?.api?.suspend(),
|
|
67
|
+
resume: () => win.walley?.checkout?.api?.resume(),
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
export function controllerFor(name, win, timer = (fn, ms) => { setTimeout(fn, ms); }) {
|
|
71
|
+
const factory = controllers[name];
|
|
72
|
+
if (!factory)
|
|
73
|
+
throw new Error(`unknown checkout controller '${name}'`);
|
|
74
|
+
return factory(win, timer);
|
|
75
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Client } from "openapi-fetch";
|
|
2
|
+
import type { StorefrontPaths } from "../index.js";
|
|
3
|
+
import { type CheckoutResult, type OrderResult, type StatusResult } from "./types.js";
|
|
4
|
+
export type CheckoutClient = Client<StorefrontPaths>;
|
|
5
|
+
export interface StartCheckoutOptions {
|
|
6
|
+
/** One of `GET /v1/site` → `markets[].checkout.options[].key`; omit for the market's default. */
|
|
7
|
+
option?: string;
|
|
8
|
+
/** Replaces the market's origin in the URLs handed to the provider; must be one of the site's allowed origins. */
|
|
9
|
+
returnOrigin?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncRetryOptions {
|
|
12
|
+
sleep?: (ms: number) => Promise<void>;
|
|
13
|
+
/** How many `CHECKOUT_IN_PROGRESS` retries this call makes itself (default 2). Pass `0` for the POST behind
|
|
14
|
+
* `mountCheckout`'s `sync` and `update`: one call is one HTTP POST, and the browser handle retries inside its own
|
|
15
|
+
* 30 s burst instead of multiplying it *(plan review 3 #5, final review S1)*. */
|
|
16
|
+
retries?: number;
|
|
17
|
+
}
|
|
18
|
+
/** `POST /v1/checkout`: creates the cart's attempt or converges on the live one (spec §5.3). Call it from a Server Action or route handler, never during render. */
|
|
19
|
+
export declare function startCheckout(client: CheckoutClient, cartToken: string, opts?: StartCheckoutOptions): Promise<CheckoutResult>;
|
|
20
|
+
/** The same `POST /v1/checkout`, retrying `CHECKOUT_IN_PROGRESS` per `Retry-After`, twice (spec §10.3) — or
|
|
21
|
+
* `retries` times; `{ retries: 0 }` is the single-attempt call to put behind `mountCheckout` *(plan review 3 #5)*.
|
|
22
|
+
* Never repeats a cart mutation: it only syncs. */
|
|
23
|
+
export declare function syncCheckout(client: CheckoutClient, cartToken: string, opts?: StartCheckoutOptions, retry?: SyncRetryOptions): Promise<CheckoutResult>;
|
|
24
|
+
/** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
|
|
25
|
+
export declare function completeCheckout(client: CheckoutClient, attemptId: string, cartToken?: string): Promise<StatusResult>;
|
|
26
|
+
/** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
|
|
27
|
+
export declare function getCheckoutStatus(client: CheckoutClient, attemptId: string, cartToken: string): Promise<StatusResult>;
|
|
28
|
+
/** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
|
|
29
|
+
export declare function getOrder(client: CheckoutClient, orderId: string, token: string): Promise<OrderResult>;
|
|
30
|
+
export interface AwaitCompletionOptions {
|
|
31
|
+
firstDelayMs?: number;
|
|
32
|
+
maxDelayMs?: number;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
sleep?: (ms: number) => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** Polls `complete` with doubling backoff until the attempt has an order or is closed, or the timeout passes (the last answer is returned either way). */
|
|
37
|
+
export declare function awaitCompletion(complete: () => Promise<StatusResult>, opts?: AwaitCompletionOptions): Promise<StatusResult>;
|