@m2c/checkout 0.1.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/LICENSE +21 -0
- package/README.md +298 -0
- package/dist/auction.d.ts +21 -0
- package/dist/auction.js +136 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +960 -0
- package/dist/errors.d.ts +42 -0
- package/dist/errors.js +79 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/poll.d.ts +25 -0
- package/dist/poll.js +51 -0
- package/dist/status.d.ts +17 -0
- package/dist/status.js +175 -0
- package/dist/storage.d.ts +37 -0
- package/dist/storage.js +62 -0
- package/dist/types.d.ts +178 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +34 -0
- package/dist/validate.js +138 -0
- package/package.json +55 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import { runAuction } from './auction.js';
|
|
2
|
+
import { M2CCheckoutError } from './errors.js';
|
|
3
|
+
import { DEFAULT_POLL_CONFIG, pollStatus, realPollDeps } from './poll.js';
|
|
4
|
+
import { checkStatusForSource, resolveStatusSource } from './status.js';
|
|
5
|
+
import { readAndClearResumeRecord, statusSourceRefFor, writeResumeRecord, } from './storage.js';
|
|
6
|
+
import { UUID_RE, validateCheckoutUrl, validateUrl } from './validate.js';
|
|
7
|
+
const DEFAULT_STORAGE_KEY = 'm2c.checkout';
|
|
8
|
+
const POPUP_TARGET = 'm2c_checkout';
|
|
9
|
+
const POPUP_FEATURES = 'popup=yes,width=520,height=720,resizable=yes,scrollbars=yes';
|
|
10
|
+
const RETURN_BRIDGE_KEY = '__m2c_checkout_return__';
|
|
11
|
+
const WINDOW_CLOSED_CHECK_INTERVAL_MS = 100;
|
|
12
|
+
const WINDOW_CLOSED_OBSERVABILITY_GRACE_MS = 300;
|
|
13
|
+
const WINDOW_CLOSED_AFTER_FOCUS_GRACE_MS = 500;
|
|
14
|
+
const RETURN_RESULT_CHECK_INTERVAL_MS = 100;
|
|
15
|
+
const WINDOW_CLOSED_BEFORE_LAUNCH_MESSAGE = 'checkout window was closed before launch';
|
|
16
|
+
// Same-tab redirect tears down the page, so `start*` never resolves in that
|
|
17
|
+
// execution context; the result is obtained from `resume()` on the return page.
|
|
18
|
+
// Returning one shared never-settling promise keeps the documented
|
|
19
|
+
// Promise<CheckoutResult> return type without leaking.
|
|
20
|
+
const PENDING_UNTIL_REDIRECT = new Promise(() => { });
|
|
21
|
+
/** Create a headless checkout client. Safe to import in SSR; lifecycle methods need a DOM. */
|
|
22
|
+
export function createClient(config) {
|
|
23
|
+
return new CheckoutClientImpl(normalizeConfig(config));
|
|
24
|
+
}
|
|
25
|
+
class CheckoutClientImpl {
|
|
26
|
+
constructor(config, pollDeps = realPollDeps) {
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.state = 'idle';
|
|
29
|
+
this.listeners = new Set();
|
|
30
|
+
this.pollDeps = pollDeps;
|
|
31
|
+
}
|
|
32
|
+
getState() {
|
|
33
|
+
return this.state;
|
|
34
|
+
}
|
|
35
|
+
onStateChange(listener) {
|
|
36
|
+
this.listeners.add(listener);
|
|
37
|
+
return () => {
|
|
38
|
+
this.listeners.delete(listener);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async start(params) {
|
|
42
|
+
assertParamsObject(params, 'start params');
|
|
43
|
+
this.requireLaunchEnvironment();
|
|
44
|
+
this.requireIdle();
|
|
45
|
+
this.requireLaunchableStatusSource('client');
|
|
46
|
+
let launchWindow;
|
|
47
|
+
try {
|
|
48
|
+
launchWindow = this.prepareAsyncLaunchWindow();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
throw this.fail(err);
|
|
52
|
+
}
|
|
53
|
+
this.setState('creating');
|
|
54
|
+
let session;
|
|
55
|
+
try {
|
|
56
|
+
session = await runAuction(this.config, params);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
this.closeLaunchWindow(launchWindow);
|
|
60
|
+
throw this.fail(err);
|
|
61
|
+
}
|
|
62
|
+
this.requestId = session.requestId;
|
|
63
|
+
this.setState('ready');
|
|
64
|
+
const returnResult = this.waitForReturnResult(session.requestId);
|
|
65
|
+
try {
|
|
66
|
+
this.persistAndLaunch('client', session.checkoutUrl, session.requestId, session.ttl, launchWindow);
|
|
67
|
+
returnResult.watchWindowClosed(launchWindow);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (isCheckoutWindowClosedBeforeLaunchError(err)) {
|
|
71
|
+
this.closeLaunchWindow(launchWindow);
|
|
72
|
+
returnResult.resolveWindowClosed();
|
|
73
|
+
return returnResult.promise;
|
|
74
|
+
}
|
|
75
|
+
returnResult.cancel();
|
|
76
|
+
this.closeLaunchWindow(launchWindow);
|
|
77
|
+
throw this.fail(err);
|
|
78
|
+
}
|
|
79
|
+
return returnResult.promise;
|
|
80
|
+
}
|
|
81
|
+
async startFromSession(params) {
|
|
82
|
+
assertParamsObject(params, 'startFromSession params');
|
|
83
|
+
this.requireLaunchEnvironment();
|
|
84
|
+
this.requireIdle();
|
|
85
|
+
if (typeof params.requestId !== 'string' || !UUID_RE.test(params.requestId)) {
|
|
86
|
+
throw new M2CCheckoutError('InvalidRequest', 'requestId must be a canonical UUID');
|
|
87
|
+
}
|
|
88
|
+
const checkoutUrl = validateCheckoutUrl(params.checkoutUrl, 'checkoutUrl', this.config.allowInsecureUrls);
|
|
89
|
+
if (params.ttl !== undefined && (typeof params.ttl !== 'number' || !Number.isFinite(params.ttl))) {
|
|
90
|
+
throw new M2CCheckoutError('InvalidRequest', 'ttl must be a finite number when supplied');
|
|
91
|
+
}
|
|
92
|
+
// Backend-initiated mode cannot re-mint a session, so an already-expired
|
|
93
|
+
// ttl is terminal here (unlike client-initiated start, which re-runs).
|
|
94
|
+
if (params.ttl !== undefined && params.ttl <= 0) {
|
|
95
|
+
throw new M2CCheckoutError('CheckoutExpired', 'the checkout session has expired; create a new one');
|
|
96
|
+
}
|
|
97
|
+
this.requireLaunchableStatusSource('session');
|
|
98
|
+
let launchWindow;
|
|
99
|
+
try {
|
|
100
|
+
launchWindow = this.prepareAsyncLaunchWindow();
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw this.fail(err);
|
|
104
|
+
}
|
|
105
|
+
this.requestId = params.requestId;
|
|
106
|
+
this.setState('ready');
|
|
107
|
+
const returnResult = this.waitForReturnResult(params.requestId);
|
|
108
|
+
try {
|
|
109
|
+
this.persistAndLaunch('session', checkoutUrl, params.requestId, params.ttl, launchWindow);
|
|
110
|
+
returnResult.watchWindowClosed(launchWindow);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (isCheckoutWindowClosedBeforeLaunchError(err)) {
|
|
114
|
+
this.closeLaunchWindow(launchWindow);
|
|
115
|
+
returnResult.resolveWindowClosed();
|
|
116
|
+
return returnResult.promise;
|
|
117
|
+
}
|
|
118
|
+
returnResult.cancel();
|
|
119
|
+
this.closeLaunchWindow(launchWindow);
|
|
120
|
+
throw this.fail(err);
|
|
121
|
+
}
|
|
122
|
+
return returnResult.promise;
|
|
123
|
+
}
|
|
124
|
+
async resume(params = {}) {
|
|
125
|
+
assertParamsObject(params, 'resume params');
|
|
126
|
+
const outcome = params.outcome ?? 'success';
|
|
127
|
+
if (outcome !== 'success' && outcome !== 'cancel') {
|
|
128
|
+
throw new M2CCheckoutError('InvalidRequest', 'resume outcome must be success or cancel');
|
|
129
|
+
}
|
|
130
|
+
this.requireStorage();
|
|
131
|
+
const storage = this.config.storage;
|
|
132
|
+
let record;
|
|
133
|
+
try {
|
|
134
|
+
record = readAndClearResumeRecord(storage, this.config.storageKey);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
throw this.fail(err);
|
|
138
|
+
}
|
|
139
|
+
if (!record) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
this.requestId = record.requestId;
|
|
143
|
+
this.setState('returned');
|
|
144
|
+
if (outcome === 'cancel') {
|
|
145
|
+
// A cancel return is authoritative on its own; no poll needed.
|
|
146
|
+
this.setState('canceled');
|
|
147
|
+
const result = { status: 'canceled', requestId: record.requestId };
|
|
148
|
+
this.publishReturnResult(result);
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
let checkStatus;
|
|
152
|
+
try {
|
|
153
|
+
checkStatus = resolveStatusSource(record.statusSourceRef, this.config, params.statusSource);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw this.fail(err);
|
|
157
|
+
}
|
|
158
|
+
this.setState('polling');
|
|
159
|
+
let status;
|
|
160
|
+
try {
|
|
161
|
+
status = await pollStatus(checkStatus, record.requestId, this.config.poll, this.pollDeps);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
throw this.fail(err);
|
|
165
|
+
}
|
|
166
|
+
const result = resultForStatus(status, record.requestId);
|
|
167
|
+
this.setState(result.status);
|
|
168
|
+
this.publishReturnResult(result);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
async checkStatus(requestId) {
|
|
172
|
+
if (typeof requestId !== 'string' || !UUID_RE.test(requestId)) {
|
|
173
|
+
throw new M2CCheckoutError('InvalidRequest', 'requestId must be a canonical UUID');
|
|
174
|
+
}
|
|
175
|
+
// One-shot read via the configured status source. Deliberately leaves
|
|
176
|
+
// lifecycle state untouched: this reconciles an arbitrary requestId (e.g.
|
|
177
|
+
// a window_closed / pending_timeout that later completed), not a checkout
|
|
178
|
+
// flow. The webhook remains the source of truth; this read is advisory.
|
|
179
|
+
const read = checkStatusForSource(this.config.statusSource, this.config);
|
|
180
|
+
return read(requestId);
|
|
181
|
+
}
|
|
182
|
+
persistAndLaunch(mode, checkoutUrl, requestId, ttl, launchWindow) {
|
|
183
|
+
const record = {
|
|
184
|
+
v: 1,
|
|
185
|
+
requestId,
|
|
186
|
+
mode,
|
|
187
|
+
launchMode: this.config.launchMode,
|
|
188
|
+
statusSourceRef: statusSourceRefFor(this.config.statusSource),
|
|
189
|
+
startedAt: this.pollDeps.now(),
|
|
190
|
+
...(ttl !== undefined ? { ttl } : {}),
|
|
191
|
+
};
|
|
192
|
+
if (this.config.launchMode !== 'redirect' && launchWindow && isWindowClosed(launchWindow)) {
|
|
193
|
+
throw checkoutWindowClosedBeforeLaunchError();
|
|
194
|
+
}
|
|
195
|
+
writeResumeRecord(this.config.storage, this.config.storageKey, record);
|
|
196
|
+
this.setState('launching');
|
|
197
|
+
this.setState('awaiting_return');
|
|
198
|
+
try {
|
|
199
|
+
this.launchCheckout(checkoutUrl, launchWindow);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
try {
|
|
203
|
+
this.config.storage.removeItem(this.config.storageKey);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// The resume record is only useful after checkout launch succeeds.
|
|
207
|
+
}
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
prepareAsyncLaunchWindow() {
|
|
212
|
+
if (this.config.launchMode === 'redirect') {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
return this.openBlankCheckoutWindow();
|
|
216
|
+
}
|
|
217
|
+
waitForReturnResult(requestId) {
|
|
218
|
+
if (this.config.launchMode === 'redirect') {
|
|
219
|
+
return {
|
|
220
|
+
promise: PENDING_UNTIL_REDIRECT,
|
|
221
|
+
cancel: () => { },
|
|
222
|
+
resolveWindowClosed: () => { },
|
|
223
|
+
watchWindowClosed: () => { },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return listenForReturnResult(this.config.storageKey, requestId, this.config.storage, this.config.returnTimeoutMs, (result) => {
|
|
227
|
+
this.requestId = result.requestId;
|
|
228
|
+
if (result.status !== 'window_closed') {
|
|
229
|
+
try {
|
|
230
|
+
this.config.storage?.removeItem(this.config.storageKey);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
// Stale-resume cleanup is best effort; the browser outcome still resolves.
|
|
234
|
+
}
|
|
235
|
+
this.setState('returned');
|
|
236
|
+
}
|
|
237
|
+
this.setState(result.status);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
publishReturnResult(result) {
|
|
241
|
+
publishReturnResult(this.config.storageKey, result, this.config.storage);
|
|
242
|
+
}
|
|
243
|
+
launchCheckout(checkoutUrl, launchWindow) {
|
|
244
|
+
if (this.config.launchMode === 'redirect') {
|
|
245
|
+
this.config.navigate(checkoutUrl);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const win = launchWindow ?? this.openBlankCheckoutWindow();
|
|
249
|
+
navigateWindow(win, checkoutUrl);
|
|
250
|
+
}
|
|
251
|
+
openBlankCheckoutWindow() {
|
|
252
|
+
const target = launchTargetFor(this.config.launchMode);
|
|
253
|
+
const features = launchFeaturesFor(this.config.launchMode);
|
|
254
|
+
const win = features === undefined
|
|
255
|
+
? this.config.openWindow('about:blank', target)
|
|
256
|
+
: this.config.openWindow('about:blank', target, features);
|
|
257
|
+
if (!win) {
|
|
258
|
+
throw new M2CCheckoutError('InvalidRequest', 'checkout window was blocked; call start from a user gesture or use launchMode: "redirect"');
|
|
259
|
+
}
|
|
260
|
+
clearOpener(win);
|
|
261
|
+
return win;
|
|
262
|
+
}
|
|
263
|
+
closeLaunchWindow(win) {
|
|
264
|
+
if (!win) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
win.close();
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Best effort cleanup for an empty placeholder window after launch fails.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
setState(state, extra = {}) {
|
|
275
|
+
this.state = state;
|
|
276
|
+
const ctx = { requestId: this.requestId, ...extra };
|
|
277
|
+
for (const listener of this.listeners) {
|
|
278
|
+
// One listener throwing must not abort the transition for the others or
|
|
279
|
+
// for the SDK's own state-machine progression.
|
|
280
|
+
try {
|
|
281
|
+
listener(state, ctx);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// Swallow: rendering errors are the merchant's to handle, not ours.
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
fail(err) {
|
|
289
|
+
const error = checkoutErrorFromUnknown(err);
|
|
290
|
+
this.setState('error', { error });
|
|
291
|
+
return error;
|
|
292
|
+
}
|
|
293
|
+
requireIdle() {
|
|
294
|
+
if (this.state !== 'idle') {
|
|
295
|
+
throw new M2CCheckoutError('InvalidRequest', `a checkout is already in progress (state: ${this.state}); create a new client`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
requireStorage() {
|
|
299
|
+
if (!this.config.storage) {
|
|
300
|
+
if (this.config.launchMode !== 'redirect') {
|
|
301
|
+
throw new M2CCheckoutError('InvalidRequest', '@m2c/checkout requires localStorage for popup/new_tab launch mode; use launchMode: "redirect" or inject config.storage');
|
|
302
|
+
}
|
|
303
|
+
throw new M2CCheckoutError('InvalidRequest', '@m2c/checkout requires sessionStorage; run in a browser or inject config.storage');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
requireLaunchEnvironment() {
|
|
307
|
+
this.requireStorage();
|
|
308
|
+
if (this.config.launchMode === 'redirect' && !this.config.navigate) {
|
|
309
|
+
throw new M2CCheckoutError('InvalidRequest', '@m2c/checkout requires navigation; run in a browser or inject config.navigate');
|
|
310
|
+
}
|
|
311
|
+
if (this.config.launchMode !== 'redirect' && !this.config.openWindow) {
|
|
312
|
+
throw new M2CCheckoutError('InvalidRequest', '@m2c/checkout requires window.open for popup launch; run in a browser or inject config.openWindow');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
requireLaunchableStatusSource(mode) {
|
|
316
|
+
if (this.config.statusSource.kind === 'subscribe') {
|
|
317
|
+
throw new M2CCheckoutError('InvalidRequest', 'the subscribe status source is not supported yet; use m2c, url, or callback');
|
|
318
|
+
}
|
|
319
|
+
if (mode === 'session' && this.config.statusSource.kind === 'm2c' && !this.config.publishableKey) {
|
|
320
|
+
throw new M2CCheckoutError('InvalidRequest', 'backend-initiated checkout requires a statusSource other than m2c, or a publishableKey for m2c status polling');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function resultForStatus(status, requestId) {
|
|
325
|
+
switch (status) {
|
|
326
|
+
case 'completed':
|
|
327
|
+
return { status: 'completed', requestId };
|
|
328
|
+
case 'failed':
|
|
329
|
+
return { status: 'failed', requestId };
|
|
330
|
+
case 'canceled':
|
|
331
|
+
return { status: 'canceled', requestId };
|
|
332
|
+
default:
|
|
333
|
+
// The poll window elapsed while still processing; the webhook is the truth.
|
|
334
|
+
return { status: 'pending_timeout', requestId };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function assertParamsObject(value, name) {
|
|
338
|
+
if (typeof value !== 'object' || value === null) {
|
|
339
|
+
throw new M2CCheckoutError('InvalidRequest', `${name} must be an object`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function checkoutErrorFromUnknown(err) {
|
|
343
|
+
if (err instanceof M2CCheckoutError) {
|
|
344
|
+
return err;
|
|
345
|
+
}
|
|
346
|
+
return new M2CCheckoutError('Unknown', err?.message ?? 'unknown error', { cause: err });
|
|
347
|
+
}
|
|
348
|
+
function launchTargetFor(mode) {
|
|
349
|
+
return mode === 'popup' ? POPUP_TARGET : '_blank';
|
|
350
|
+
}
|
|
351
|
+
function launchFeaturesFor(mode) {
|
|
352
|
+
return mode === 'popup' ? POPUP_FEATURES : undefined;
|
|
353
|
+
}
|
|
354
|
+
function clearOpener(win) {
|
|
355
|
+
try {
|
|
356
|
+
win.opener = null;
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Some browsers expose opener as read-only on WindowProxy.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function navigateWindow(win, url) {
|
|
363
|
+
if (win.closed) {
|
|
364
|
+
throw checkoutWindowClosedBeforeLaunchError();
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
win.location.assign(url);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
win.location.href = url;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function returnBridgeName(storageKey) {
|
|
374
|
+
return `${RETURN_BRIDGE_KEY}:${storageKey}`;
|
|
375
|
+
}
|
|
376
|
+
function listenForReturnResult(storageKey, requestId, storage, returnTimeoutMs, onResult) {
|
|
377
|
+
let settled = false;
|
|
378
|
+
let channel;
|
|
379
|
+
let removeStorageListener;
|
|
380
|
+
let removeWindowClosedListener;
|
|
381
|
+
let removeFocusListener;
|
|
382
|
+
let storedResultTimer;
|
|
383
|
+
let returnTimeout;
|
|
384
|
+
let focusWindowClosedTimer;
|
|
385
|
+
let resolveResult;
|
|
386
|
+
const cleanup = () => {
|
|
387
|
+
try {
|
|
388
|
+
channel?.close();
|
|
389
|
+
}
|
|
390
|
+
catch { }
|
|
391
|
+
removeStorageListener?.();
|
|
392
|
+
removeWindowClosedListener?.();
|
|
393
|
+
removeFocusListener?.();
|
|
394
|
+
if (storedResultTimer) {
|
|
395
|
+
globalThis.clearInterval(storedResultTimer);
|
|
396
|
+
}
|
|
397
|
+
if (returnTimeout) {
|
|
398
|
+
globalThis.clearTimeout(returnTimeout);
|
|
399
|
+
}
|
|
400
|
+
if (focusWindowClosedTimer) {
|
|
401
|
+
globalThis.clearTimeout(focusWindowClosedTimer);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const settle = (result) => {
|
|
405
|
+
if (settled) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
settled = true;
|
|
409
|
+
cleanup();
|
|
410
|
+
removeStoredReturnResult(storageKey, requestId, storage);
|
|
411
|
+
onResult(result);
|
|
412
|
+
resolveResult(result);
|
|
413
|
+
};
|
|
414
|
+
const settleWindowClosed = () => {
|
|
415
|
+
const stored = readStoredReturnMessage(storageKey, storage);
|
|
416
|
+
if (stored && stored.storageKey === storageKey && stored.requestId === requestId) {
|
|
417
|
+
settle(stored.result);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
settle({ status: 'window_closed', requestId });
|
|
421
|
+
};
|
|
422
|
+
const scheduleWindowClosedAfterFocus = () => {
|
|
423
|
+
if (settled || focusWindowClosedTimer) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
focusWindowClosedTimer = globalThis.setTimeout(() => {
|
|
427
|
+
focusWindowClosedTimer = undefined;
|
|
428
|
+
settleWindowClosed();
|
|
429
|
+
}, WINDOW_CLOSED_AFTER_FOCUS_GRACE_MS);
|
|
430
|
+
};
|
|
431
|
+
const watchFocusForWindowClosed = () => {
|
|
432
|
+
if (settled || removeFocusListener) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
removeFocusListener = listenForOpenerFocus(scheduleWindowClosedAfterFocus);
|
|
436
|
+
if (isOpenerFocused()) {
|
|
437
|
+
scheduleWindowClosedAfterFocus();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const handleMessage = (value) => {
|
|
441
|
+
const message = parseReturnBridgeMessage(value);
|
|
442
|
+
if (!message || message.storageKey !== storageKey || message.requestId !== requestId) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
settle(message.result);
|
|
446
|
+
};
|
|
447
|
+
const promise = new Promise((resolve) => {
|
|
448
|
+
resolveResult = resolve;
|
|
449
|
+
});
|
|
450
|
+
channel = openReturnBridgeChannel(storageKey);
|
|
451
|
+
if (channel) {
|
|
452
|
+
channel.onmessage = (event) => {
|
|
453
|
+
handleMessage(event.data);
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
removeStorageListener = listenForReturnStorageEvent(storageKey, handleMessage);
|
|
457
|
+
storedResultTimer = globalThis.setInterval(() => {
|
|
458
|
+
handleMessage(readStoredReturnMessage(storageKey, storage));
|
|
459
|
+
}, RETURN_RESULT_CHECK_INTERVAL_MS);
|
|
460
|
+
handleMessage(readStoredReturnMessage(storageKey, storage));
|
|
461
|
+
if (returnTimeoutMs !== undefined) {
|
|
462
|
+
returnTimeout = globalThis.setTimeout(() => {
|
|
463
|
+
settleWindowClosed();
|
|
464
|
+
}, returnTimeoutMs);
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
promise,
|
|
468
|
+
cancel: () => {
|
|
469
|
+
if (settled) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
settled = true;
|
|
473
|
+
cleanup();
|
|
474
|
+
},
|
|
475
|
+
resolveWindowClosed: () => {
|
|
476
|
+
settleWindowClosed();
|
|
477
|
+
},
|
|
478
|
+
watchWindowClosed: (win) => {
|
|
479
|
+
if (!win || settled) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
removeWindowClosedListener?.();
|
|
483
|
+
removeWindowClosedListener = listenForWindowClosed(win, settleWindowClosed, watchFocusForWindowClosed);
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function publishReturnResult(storageKey, result, storage) {
|
|
488
|
+
const message = { v: 1, storageKey, requestId: result.requestId, result };
|
|
489
|
+
const key = returnBridgeName(storageKey);
|
|
490
|
+
const serialized = JSON.stringify(message);
|
|
491
|
+
const channel = openReturnBridgeChannel(storageKey);
|
|
492
|
+
try {
|
|
493
|
+
storage?.setItem(key, serialized);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// BroadcastChannel or browser localStorage may still deliver the result.
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
globalThis.localStorage.setItem(key, serialized);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// BroadcastChannel may still deliver the result.
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
channel?.postMessage(message);
|
|
506
|
+
}
|
|
507
|
+
catch { }
|
|
508
|
+
globalThis.setTimeout(() => {
|
|
509
|
+
try {
|
|
510
|
+
channel?.close();
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
}, 0);
|
|
514
|
+
}
|
|
515
|
+
function openReturnBridgeChannel(storageKey) {
|
|
516
|
+
try {
|
|
517
|
+
if (typeof globalThis.BroadcastChannel === 'function') {
|
|
518
|
+
return new globalThis.BroadcastChannel(returnBridgeName(storageKey));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
catch { }
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
function listenForReturnStorageEvent(storageKey, onMessage) {
|
|
525
|
+
try {
|
|
526
|
+
if (typeof globalThis.addEventListener !== 'function') {
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
const key = returnBridgeName(storageKey);
|
|
530
|
+
const listener = (event) => {
|
|
531
|
+
if (event.key === key && event.newValue) {
|
|
532
|
+
onMessage(event.newValue);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
globalThis.addEventListener('storage', listener);
|
|
536
|
+
return () => {
|
|
537
|
+
try {
|
|
538
|
+
globalThis.removeEventListener('storage', listener);
|
|
539
|
+
}
|
|
540
|
+
catch { }
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function readStoredReturnMessage(storageKey, storage) {
|
|
548
|
+
const key = returnBridgeName(storageKey);
|
|
549
|
+
try {
|
|
550
|
+
const message = parseReturnBridgeMessage(storage?.getItem(key));
|
|
551
|
+
if (message) {
|
|
552
|
+
return message;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// Fall back to browser localStorage below.
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
return parseReturnBridgeMessage(globalThis.localStorage.getItem(key));
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function removeStoredReturnResult(storageKey, requestId, storage) {
|
|
566
|
+
const key = returnBridgeName(storageKey);
|
|
567
|
+
try {
|
|
568
|
+
const message = parseReturnBridgeMessage(storage?.getItem(key));
|
|
569
|
+
if (message?.requestId === requestId) {
|
|
570
|
+
storage?.removeItem(key);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
// Best effort cleanup for the injected durable return handoff.
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const message = parseReturnBridgeMessage(globalThis.localStorage.getItem(key));
|
|
578
|
+
if (message?.requestId === requestId) {
|
|
579
|
+
globalThis.localStorage.removeItem(key);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// Best effort cleanup for the durable return handoff.
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function parseReturnBridgeMessage(value) {
|
|
587
|
+
let parsed = value;
|
|
588
|
+
if (typeof value === 'string') {
|
|
589
|
+
try {
|
|
590
|
+
parsed = JSON.parse(value);
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
const message = parsed;
|
|
600
|
+
if (message.v !== 1 || typeof message.storageKey !== 'string' || typeof message.requestId !== 'string') {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (!isCheckoutResult(message.result) || message.result.requestId !== message.requestId) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
v: 1,
|
|
608
|
+
storageKey: message.storageKey,
|
|
609
|
+
requestId: message.requestId,
|
|
610
|
+
result: message.result,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function isCheckoutResult(value) {
|
|
614
|
+
if (typeof value !== 'object' || value === null) {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
const result = value;
|
|
618
|
+
return (typeof result.requestId === 'string' &&
|
|
619
|
+
(result.status === 'completed' ||
|
|
620
|
+
result.status === 'failed' ||
|
|
621
|
+
result.status === 'canceled' ||
|
|
622
|
+
result.status === 'pending_timeout' ||
|
|
623
|
+
result.status === 'window_closed'));
|
|
624
|
+
}
|
|
625
|
+
function listenForWindowClosed(win, onClosed, onUnobservable) {
|
|
626
|
+
let stopped = false;
|
|
627
|
+
let armed = false;
|
|
628
|
+
let timer;
|
|
629
|
+
let armTimer;
|
|
630
|
+
const stop = () => {
|
|
631
|
+
if (stopped) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
stopped = true;
|
|
635
|
+
globalThis.clearInterval(timer);
|
|
636
|
+
globalThis.clearTimeout(armTimer);
|
|
637
|
+
};
|
|
638
|
+
const check = () => {
|
|
639
|
+
if (stopped) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const closed = isWindowClosed(win);
|
|
643
|
+
if (!armed) {
|
|
644
|
+
if (closed) {
|
|
645
|
+
// Some hosted checkout pages sever opener observability during launch,
|
|
646
|
+
// making WindowProxy.closed look true while the tab is still alive.
|
|
647
|
+
stop();
|
|
648
|
+
onUnobservable();
|
|
649
|
+
}
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (!closed) {
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
stop();
|
|
656
|
+
onClosed();
|
|
657
|
+
};
|
|
658
|
+
timer = globalThis.setInterval(check, WINDOW_CLOSED_CHECK_INTERVAL_MS);
|
|
659
|
+
armTimer = globalThis.setTimeout(() => {
|
|
660
|
+
if (stopped) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (isWindowClosed(win)) {
|
|
664
|
+
stop();
|
|
665
|
+
onUnobservable();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
armed = true;
|
|
669
|
+
}, WINDOW_CLOSED_OBSERVABILITY_GRACE_MS);
|
|
670
|
+
check();
|
|
671
|
+
return stop;
|
|
672
|
+
}
|
|
673
|
+
function listenForOpenerFocus(onFocus) {
|
|
674
|
+
try {
|
|
675
|
+
if (typeof globalThis.addEventListener !== 'function') {
|
|
676
|
+
return () => { };
|
|
677
|
+
}
|
|
678
|
+
globalThis.addEventListener('focus', onFocus);
|
|
679
|
+
globalThis.addEventListener('pageshow', onFocus);
|
|
680
|
+
const doc = globalThis.document;
|
|
681
|
+
let visibilityListener;
|
|
682
|
+
if (doc && typeof doc.addEventListener === 'function') {
|
|
683
|
+
visibilityListener = () => {
|
|
684
|
+
if (doc.visibilityState === 'visible') {
|
|
685
|
+
onFocus();
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
doc.addEventListener('visibilitychange', visibilityListener);
|
|
689
|
+
}
|
|
690
|
+
return () => {
|
|
691
|
+
try {
|
|
692
|
+
globalThis.removeEventListener('focus', onFocus);
|
|
693
|
+
globalThis.removeEventListener('pageshow', onFocus);
|
|
694
|
+
if (visibilityListener && doc && typeof doc.removeEventListener === 'function') {
|
|
695
|
+
doc.removeEventListener('visibilitychange', visibilityListener);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
catch { }
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
return () => { };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
function isOpenerFocused() {
|
|
706
|
+
try {
|
|
707
|
+
const doc = globalThis.document;
|
|
708
|
+
if (doc?.visibilityState === 'hidden') {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
if (typeof doc?.hasFocus === 'function') {
|
|
712
|
+
return doc.hasFocus();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch { }
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
function isWindowClosed(win) {
|
|
719
|
+
try {
|
|
720
|
+
return win.closed;
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function checkoutWindowClosedBeforeLaunchError() {
|
|
727
|
+
return new M2CCheckoutError('InvalidRequest', WINDOW_CLOSED_BEFORE_LAUNCH_MESSAGE);
|
|
728
|
+
}
|
|
729
|
+
function isCheckoutWindowClosedBeforeLaunchError(err) {
|
|
730
|
+
return (err instanceof M2CCheckoutError &&
|
|
731
|
+
err.code === 'InvalidRequest' &&
|
|
732
|
+
err.message === WINDOW_CLOSED_BEFORE_LAUNCH_MESSAGE);
|
|
733
|
+
}
|
|
734
|
+
function normalizeConfig(config) {
|
|
735
|
+
if (!config || typeof config.baseUrl !== 'string' || config.baseUrl === '') {
|
|
736
|
+
throw new M2CCheckoutError('InvalidRequest', 'baseUrl is required');
|
|
737
|
+
}
|
|
738
|
+
const allowInsecureUrls = config.allowInsecureUrls ?? false;
|
|
739
|
+
// Validate the base URL up front so a misconfiguration surfaces at construction.
|
|
740
|
+
validateUrl(config.baseUrl, 'baseUrl', allowInsecureUrls);
|
|
741
|
+
const baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
742
|
+
const launchMode = normalizeLaunchMode(config.launchMode);
|
|
743
|
+
const statusSource = normalizeStatusSource(config.statusSource ?? { kind: 'm2c' });
|
|
744
|
+
const poll = normalizePollConfig(config.poll);
|
|
745
|
+
const returnTimeoutMs = normalizeReturnTimeoutMs(config.returnTimeoutMs);
|
|
746
|
+
const fetchImpl = config.fetch ??
|
|
747
|
+
(typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : undefined);
|
|
748
|
+
return {
|
|
749
|
+
baseUrl,
|
|
750
|
+
publishableKey: config.publishableKey,
|
|
751
|
+
statusSource,
|
|
752
|
+
poll,
|
|
753
|
+
storageKey: config.storageKey ?? DEFAULT_STORAGE_KEY,
|
|
754
|
+
fetch: fetchImpl,
|
|
755
|
+
storage: config.storage ?? defaultStorage(launchMode !== 'redirect'),
|
|
756
|
+
navigate: config.navigate ?? defaultNavigate(),
|
|
757
|
+
launchMode,
|
|
758
|
+
openWindow: config.openWindow ?? defaultOpenWindow(),
|
|
759
|
+
returnTimeoutMs,
|
|
760
|
+
allowInsecureUrls,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function normalizeLaunchMode(mode) {
|
|
764
|
+
if (mode === undefined) {
|
|
765
|
+
return 'redirect';
|
|
766
|
+
}
|
|
767
|
+
if (mode === 'redirect' || mode === 'new_tab' || mode === 'popup') {
|
|
768
|
+
return mode;
|
|
769
|
+
}
|
|
770
|
+
throw new M2CCheckoutError('InvalidRequest', 'launchMode must be redirect, new_tab, or popup');
|
|
771
|
+
}
|
|
772
|
+
function normalizeStatusSource(source) {
|
|
773
|
+
if (typeof source !== 'object' || source === null) {
|
|
774
|
+
throw new M2CCheckoutError('InvalidRequest', 'statusSource must be an object');
|
|
775
|
+
}
|
|
776
|
+
const s = source;
|
|
777
|
+
switch (s.kind) {
|
|
778
|
+
case 'm2c':
|
|
779
|
+
return { kind: 'm2c' };
|
|
780
|
+
case 'url':
|
|
781
|
+
if (typeof s.template !== 'string' || s.template === '') {
|
|
782
|
+
throw new M2CCheckoutError('InvalidRequest', 'statusSource.template must be a non-empty string');
|
|
783
|
+
}
|
|
784
|
+
return { kind: 'url', template: s.template };
|
|
785
|
+
case 'callback':
|
|
786
|
+
if (typeof s.checkStatus !== 'function') {
|
|
787
|
+
throw new M2CCheckoutError('InvalidRequest', 'statusSource.checkStatus must be a function');
|
|
788
|
+
}
|
|
789
|
+
return { kind: 'callback', checkStatus: s.checkStatus };
|
|
790
|
+
case 'subscribe':
|
|
791
|
+
if (typeof s.subscribe !== 'function') {
|
|
792
|
+
throw new M2CCheckoutError('InvalidRequest', 'statusSource.subscribe must be a function');
|
|
793
|
+
}
|
|
794
|
+
return { kind: 'subscribe', subscribe: s.subscribe };
|
|
795
|
+
default:
|
|
796
|
+
throw new M2CCheckoutError('InvalidRequest', 'statusSource.kind is not recognized');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function normalizePollConfig(overrides) {
|
|
800
|
+
const poll = { ...DEFAULT_POLL_CONFIG, ...(overrides ?? {}) };
|
|
801
|
+
assertPollNumber(poll.firstDelayMs, 'firstDelayMs', 1);
|
|
802
|
+
assertPollNumber(poll.maxDelayMs, 'maxDelayMs', 1);
|
|
803
|
+
assertPollNumber(poll.factor, 'factor', 1);
|
|
804
|
+
assertPollNumber(poll.windowMs, 'windowMs', 1);
|
|
805
|
+
if (poll.maxDelayMs < poll.firstDelayMs) {
|
|
806
|
+
throw new M2CCheckoutError('InvalidRequest', 'poll.maxDelayMs must be greater than or equal to poll.firstDelayMs');
|
|
807
|
+
}
|
|
808
|
+
return poll;
|
|
809
|
+
}
|
|
810
|
+
function assertPollNumber(value, name, min) {
|
|
811
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min) {
|
|
812
|
+
throw new M2CCheckoutError('InvalidRequest', `poll.${name} must be a finite number >= ${min}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
function normalizeReturnTimeoutMs(value) {
|
|
816
|
+
if (value === undefined || value === 0) {
|
|
817
|
+
return undefined;
|
|
818
|
+
}
|
|
819
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
|
|
820
|
+
throw new M2CCheckoutError('InvalidRequest', 'returnTimeoutMs must be a finite number >= 1, or 0 to disable');
|
|
821
|
+
}
|
|
822
|
+
return value;
|
|
823
|
+
}
|
|
824
|
+
/** Resolve browser storage when present. Guarded: some sandboxes throw on access. */
|
|
825
|
+
function defaultStorage(writeCrossTab) {
|
|
826
|
+
const session = writableBrowserStorage('sessionStorage');
|
|
827
|
+
const local = writableBrowserStorage('localStorage');
|
|
828
|
+
if (writeCrossTab && !local) {
|
|
829
|
+
return undefined;
|
|
830
|
+
}
|
|
831
|
+
if (!session && !local) {
|
|
832
|
+
return undefined;
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
getItem: (key) => {
|
|
836
|
+
return preferredStorageValue(session?.getItem(key) ?? null, local?.getItem(key) ?? null, writeCrossTab);
|
|
837
|
+
},
|
|
838
|
+
setItem: (key, value) => {
|
|
839
|
+
const primary = session ?? local;
|
|
840
|
+
if (!primary) {
|
|
841
|
+
throw new Error('storage unavailable');
|
|
842
|
+
}
|
|
843
|
+
if (writeCrossTab && local) {
|
|
844
|
+
local.setItem(key, value);
|
|
845
|
+
if (session && session !== local) {
|
|
846
|
+
session.setItem(key, value);
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
primary.setItem(key, value);
|
|
851
|
+
},
|
|
852
|
+
removeItem: (key) => {
|
|
853
|
+
session?.removeItem(key);
|
|
854
|
+
local?.removeItem(key);
|
|
855
|
+
},
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
function preferredStorageValue(sessionValue, localValue, preferLocal) {
|
|
859
|
+
if (preferLocal) {
|
|
860
|
+
return localValue ?? sessionValue;
|
|
861
|
+
}
|
|
862
|
+
if (!sessionValue || !localValue) {
|
|
863
|
+
return sessionValue ?? localValue;
|
|
864
|
+
}
|
|
865
|
+
const sessionRecord = resumeRecordMeta(sessionValue);
|
|
866
|
+
const localRecord = resumeRecordMeta(localValue);
|
|
867
|
+
if (!sessionRecord && localRecord) {
|
|
868
|
+
return localValue;
|
|
869
|
+
}
|
|
870
|
+
const sessionCanBeCloned = sessionRecord?.launchMode === 'popup' || sessionRecord?.launchMode === 'new_tab';
|
|
871
|
+
if (sessionCanBeCloned && localRecord && localRecord.startedAt > sessionRecord.startedAt) {
|
|
872
|
+
return localValue;
|
|
873
|
+
}
|
|
874
|
+
return sessionValue;
|
|
875
|
+
}
|
|
876
|
+
function resumeRecordMeta(value) {
|
|
877
|
+
try {
|
|
878
|
+
const parsed = JSON.parse(value);
|
|
879
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
const record = parsed;
|
|
883
|
+
if (record.v !== 1 || typeof record.requestId !== 'string' || typeof record.startedAt !== 'number') {
|
|
884
|
+
return undefined;
|
|
885
|
+
}
|
|
886
|
+
const launchMode = record.launchMode === 'redirect' || record.launchMode === 'new_tab' || record.launchMode === 'popup'
|
|
887
|
+
? record.launchMode
|
|
888
|
+
: undefined;
|
|
889
|
+
return { startedAt: record.startedAt, launchMode };
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return undefined;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function writableBrowserStorage(name) {
|
|
896
|
+
const storage = browserStorage(name);
|
|
897
|
+
if (!storage || !canWriteStorage(storage)) {
|
|
898
|
+
return undefined;
|
|
899
|
+
}
|
|
900
|
+
return storage;
|
|
901
|
+
}
|
|
902
|
+
function browserStorage(name) {
|
|
903
|
+
try {
|
|
904
|
+
const s = globalThis[name];
|
|
905
|
+
return s ?? undefined;
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function canWriteStorage(storage) {
|
|
912
|
+
if (!storage) {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
const key = `__m2c_checkout_storage_test__${Math.random().toString(36).slice(2)}`;
|
|
916
|
+
try {
|
|
917
|
+
storage.setItem(key, '1');
|
|
918
|
+
storage.removeItem(key);
|
|
919
|
+
return true;
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
try {
|
|
923
|
+
storage.removeItem(key);
|
|
924
|
+
}
|
|
925
|
+
catch {
|
|
926
|
+
// The probe already failed; cleanup is best effort.
|
|
927
|
+
}
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/** Resolve a full-page navigator from `location.assign` when present. */
|
|
932
|
+
function defaultNavigate() {
|
|
933
|
+
try {
|
|
934
|
+
const loc = globalThis.location;
|
|
935
|
+
if (loc && typeof loc.assign === 'function') {
|
|
936
|
+
return (url) => {
|
|
937
|
+
loc.assign(url);
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
// No DOM (SSR / Node): leave undefined; lifecycle methods will throw clearly.
|
|
943
|
+
}
|
|
944
|
+
return undefined;
|
|
945
|
+
}
|
|
946
|
+
function defaultOpenWindow() {
|
|
947
|
+
try {
|
|
948
|
+
const w = globalThis.window;
|
|
949
|
+
if (w && typeof w.open === 'function') {
|
|
950
|
+
return w.open.bind(w);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
// No DOM (SSR / Node): leave undefined; lifecycle methods will throw clearly.
|
|
955
|
+
}
|
|
956
|
+
return undefined;
|
|
957
|
+
}
|
|
958
|
+
// Exported for the test harness so the poll schedule can be driven on a fake
|
|
959
|
+
// clock; not part of the public API.
|
|
960
|
+
export { CheckoutClientImpl, normalizeConfig };
|