@m2c/checkout 0.1.2 → 0.1.3

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 CHANGED
@@ -99,6 +99,14 @@ polls the status source to a terminal result.
99
99
  `storageKey`, default `m2c.checkout`) before navigating. It is read and cleared
100
100
  on the return page, so a refresh does not re-poll a stale checkout.
101
101
 
102
+ If the customer instead presses **Back** from the vendor checkout, the
103
+ originating page is restored (from the back-forward cache) and the `start` /
104
+ `startFromSession` promise resolves `{ status: 'window_closed', requestId }`.
105
+ This is an ambiguous browser outcome, not a payment status; keep using the
106
+ webhook/status source as the truth. The resume record remains in storage, so if
107
+ the customer goes Forward and completes the hosted checkout, the return page can
108
+ still call `resume()` and resolve the terminal payment result.
109
+
102
110
  ### Popup / new-tab launch
103
111
 
104
112
  For apps that must keep the original page alive, such as web games, opt into a
package/dist/client.d.ts CHANGED
@@ -18,6 +18,7 @@ declare class CheckoutClientImpl implements CheckoutClient {
18
18
  private persistAndLaunch;
19
19
  private prepareAsyncLaunchWindow;
20
20
  private waitForReturnResult;
21
+ private armRedirectWindowClosed;
21
22
  private publishReturnResult;
22
23
  private launchCheckout;
23
24
  private openBlankCheckoutWindow;
package/dist/client.js CHANGED
@@ -13,11 +13,6 @@ const WINDOW_CLOSED_OBSERVABILITY_GRACE_MS = 300;
13
13
  const WINDOW_CLOSED_AFTER_FOCUS_GRACE_MS = 500;
14
14
  const RETURN_RESULT_CHECK_INTERVAL_MS = 100;
15
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
16
  /** Create a headless checkout client. Safe to import in SSR; lifecycle methods need a DOM. */
22
17
  export function createClient(config) {
23
18
  return new CheckoutClientImpl(normalizeConfig(config));
@@ -216,12 +211,7 @@ class CheckoutClientImpl {
216
211
  }
217
212
  waitForReturnResult(requestId) {
218
213
  if (this.config.launchMode === 'redirect') {
219
- return {
220
- promise: PENDING_UNTIL_REDIRECT,
221
- cancel: () => { },
222
- resolveWindowClosed: () => { },
223
- watchWindowClosed: () => { },
224
- };
214
+ return this.armRedirectWindowClosed(requestId);
225
215
  }
226
216
  return listenForReturnResult(this.config.storageKey, requestId, this.config.storage, this.config.returnTimeoutMs, (result) => {
227
217
  this.requestId = result.requestId;
@@ -237,6 +227,54 @@ class CheckoutClientImpl {
237
227
  this.setState(result.status);
238
228
  });
239
229
  }
230
+ // Redirect mode tears the page down after navigate, so the happy-path result
231
+ // comes from resume() on success_url/cancel_url. But if the customer presses
232
+ // Back from the vendor checkout, the originating page is restored (back-forward
233
+ // cache) frozen in `awaiting_return` with nothing listening. Arm a page-restore
234
+ // listener so that case resolves instead of hanging forever.
235
+ armRedirectWindowClosed(requestId) {
236
+ let settled = false;
237
+ let triggered = false;
238
+ let removeListeners = () => { };
239
+ let resolveResult;
240
+ const promise = new Promise((resolve) => {
241
+ resolveResult = resolve;
242
+ });
243
+ const settle = (result) => {
244
+ if (settled)
245
+ return;
246
+ settled = true;
247
+ removeListeners();
248
+ resolveResult(result);
249
+ };
250
+ const settleWindowClosed = () => {
251
+ this.requestId = requestId;
252
+ this.setState('window_closed');
253
+ settle({ status: 'window_closed', requestId });
254
+ };
255
+ // `state === 'awaiting_return'` is the real gate: in same-tab redirect mode
256
+ // the originating page is usually shown again via Back navigation. That is
257
+ // an ambiguous browser outcome, not a payment status, so keep the resume
258
+ // record for a later success_url/cancel_url return.
259
+ const onRestore = () => {
260
+ if (triggered || settled || this.state !== 'awaiting_return')
261
+ return;
262
+ triggered = true;
263
+ settleWindowClosed();
264
+ };
265
+ removeListeners = listenForPageRestore(onRestore);
266
+ return {
267
+ promise,
268
+ cancel: () => {
269
+ if (settled)
270
+ return;
271
+ settled = true;
272
+ removeListeners();
273
+ },
274
+ resolveWindowClosed: () => { },
275
+ watchWindowClosed: () => { },
276
+ };
277
+ }
240
278
  publishReturnResult(result) {
241
279
  publishReturnResult(this.config.storageKey, result, this.config.storage);
242
280
  }
@@ -670,6 +708,40 @@ function listenForWindowClosed(win, onClosed, onUnobservable) {
670
708
  check();
671
709
  return stop;
672
710
  }
711
+ // Fire `onRestore` when the originating page is shown again - a `pageshow`
712
+ // (including back-forward cache restores) or the document becoming visible. The
713
+ // caller gates on lifecycle state, so an unconditional callback is safe here.
714
+ function listenForPageRestore(onRestore) {
715
+ try {
716
+ if (typeof globalThis.addEventListener !== 'function') {
717
+ return () => { };
718
+ }
719
+ const handler = () => onRestore();
720
+ globalThis.addEventListener('pageshow', handler);
721
+ const doc = globalThis.document;
722
+ let visibilityListener;
723
+ if (doc && typeof doc.addEventListener === 'function') {
724
+ visibilityListener = () => {
725
+ if (doc.visibilityState === 'visible') {
726
+ onRestore();
727
+ }
728
+ };
729
+ doc.addEventListener('visibilitychange', visibilityListener);
730
+ }
731
+ return () => {
732
+ try {
733
+ globalThis.removeEventListener('pageshow', handler);
734
+ if (visibilityListener && doc && typeof doc.removeEventListener === 'function') {
735
+ doc.removeEventListener('visibilitychange', visibilityListener);
736
+ }
737
+ }
738
+ catch { }
739
+ };
740
+ }
741
+ catch {
742
+ return () => { };
743
+ }
744
+ }
673
745
  function listenForOpenerFocus(onFocus) {
674
746
  try {
675
747
  if (typeof globalThis.addEventListener !== 'function') {
package/dist/types.d.ts CHANGED
@@ -7,7 +7,10 @@ import type { M2CCheckoutError } from './errors.js';
7
7
  export type ClientStatus = 'processing' | 'completed' | 'failed' | 'canceled';
8
8
  /** Lifecycle states the SDK passes through. Drives the merchant's progress UI. */
9
9
  export type CheckoutState = 'idle' | 'creating' | 'ready' | 'launching' | 'awaiting_return' | 'returned' | 'polling' | 'completed' | 'failed' | 'canceled' | 'pending_timeout' | 'window_closed' | 'error';
10
- /** Terminal outcome of a checkout. `pending_timeout` resolves; it does not reject. */
10
+ /**
11
+ * Terminal outcome of a checkout. `pending_timeout` and `window_closed` resolve;
12
+ * they do not reject.
13
+ */
11
14
  export type CheckoutResult = {
12
15
  status: 'completed';
13
16
  requestId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2c/checkout",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Headless browser checkout SDK for M2C: run an auction, redirect to the winning vendor's hosted checkout, and reflect conversion status.",
5
5
  "type": "module",
6
6
  "engines": {