@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 M2C
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# @m2c/checkout
|
|
2
|
+
|
|
3
|
+
Headless browser checkout SDK for [M2C](https://m2cmarkets.com). It runs an M2C
|
|
4
|
+
auction, redirects the customer to the winning vendor's hosted checkout, and
|
|
5
|
+
reflects the conversion status back to your UI when they return.
|
|
6
|
+
|
|
7
|
+
It is **headless**: no components, no styles. You render every pixel (including
|
|
8
|
+
the trigger button); the SDK gives you state, events, and a typed terminal
|
|
9
|
+
result.
|
|
10
|
+
|
|
11
|
+
It holds **no secrets and does no cryptography**. The signed, retried merchant
|
|
12
|
+
webhook to your backend remains the source of truth for fulfillment - a return
|
|
13
|
+
to `success_url` proves the customer came back, not that they paid. Treat this
|
|
14
|
+
SDK's status as advisory UX.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
npm install @m2c/checkout
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
ESM, zero runtime dependencies, ships `.d.ts` types. Targets evergreen browsers
|
|
21
|
+
(ES2020). Importing the module is SSR-safe; the lifecycle methods need a DOM.
|
|
22
|
+
|
|
23
|
+
## Two modes
|
|
24
|
+
|
|
25
|
+
### Backend-initiated (recommended)
|
|
26
|
+
|
|
27
|
+
Your backend creates the checkout with its secret key (the existing
|
|
28
|
+
`POST /api/v1/auction` call **is** the create step) and forwards
|
|
29
|
+
`checkout_url`, `request_id`, and `ttl` to the browser. No key ships in the
|
|
30
|
+
client.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createClient } from '@m2c/checkout';
|
|
34
|
+
|
|
35
|
+
const client = createClient({
|
|
36
|
+
baseUrl: 'https://api.m2cmarkets.com',
|
|
37
|
+
// Your backend already receives the M2C webhook, so it is the authoritative
|
|
38
|
+
// status source. Point the SDK at your own endpoint.
|
|
39
|
+
statusSource: { kind: 'url', template: 'https://shop.example/checkout-status?ref={request_id}' },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// session = { checkoutUrl, requestId, ttl } from your backend
|
|
43
|
+
await client.startFromSession(session);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Client-initiated (no-backend shortcut)
|
|
47
|
+
|
|
48
|
+
The browser calls the auction directly with a **publishable** key. For merchants
|
|
49
|
+
without a backend. Reliable only for low-stakes, instant, in-session digital
|
|
50
|
+
delivery: a customer who never returns is never confirmed client-side.
|
|
51
|
+
|
|
52
|
+
Live publishable keys require HTTPS origins. Test publishable keys
|
|
53
|
+
(`pub_test_...`) may also allow loopback HTTP origins such as
|
|
54
|
+
`http://127.0.0.1:5173` for local sandbox testing.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const client = createClient({
|
|
58
|
+
baseUrl: 'https://api.m2cmarkets.com',
|
|
59
|
+
publishableKey: 'pub_live_...',
|
|
60
|
+
// default statusSource is { kind: 'm2c' } - poll the M2C advisory read endpoint
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await client.start({
|
|
64
|
+
transactionValue: 49.99, // major units, e.g. dollars
|
|
65
|
+
currency: 'USD',
|
|
66
|
+
description: 'Pro plan',
|
|
67
|
+
successUrl: 'https://shop.example/checkout/return',
|
|
68
|
+
cancelUrl: 'https://shop.example/checkout/canceled',
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## The redirect / resume model
|
|
73
|
+
|
|
74
|
+
The default launch is a **same-tab full-page redirect** (`location.assign`), for
|
|
75
|
+
maximum compatibility with vendor 3-D Secure / bank redirect flows that break
|
|
76
|
+
inside popups and iframes.
|
|
77
|
+
|
|
78
|
+
Because the redirect tears down the page, `start` / `startFromSession` **navigate
|
|
79
|
+
away and do not resolve in place**. You get the outcome on your return page by
|
|
80
|
+
calling `resume()`:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// On your success_url and cancel_url pages:
|
|
84
|
+
const client = createClient({ baseUrl: 'https://api.m2cmarkets.com', publishableKey: 'pub_live_...' });
|
|
85
|
+
|
|
86
|
+
const result = await client.resume({ outcome: 'success' }); // or 'cancel'
|
|
87
|
+
// result is null if no checkout was in progress, otherwise one of:
|
|
88
|
+
// { status: 'completed', requestId }
|
|
89
|
+
// { status: 'failed', requestId }
|
|
90
|
+
// { status: 'canceled', requestId }
|
|
91
|
+
// { status: 'pending_timeout', requestId } <- poll window elapsed; ask your webhook
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The SDK cannot tell `success_url` from `cancel_url` on its own, so pass
|
|
95
|
+
`outcome`. A `cancel` return resolves `canceled` immediately; a `success` return
|
|
96
|
+
polls the status source to a terminal result.
|
|
97
|
+
|
|
98
|
+
`start*` writes a small resume record to `sessionStorage` (namespaced by
|
|
99
|
+
`storageKey`, default `m2c.checkout`) before navigating. It is read and cleared
|
|
100
|
+
on the return page, so a refresh does not re-poll a stale checkout.
|
|
101
|
+
|
|
102
|
+
### Popup / new-tab launch
|
|
103
|
+
|
|
104
|
+
For apps that must keep the original page alive, such as web games, opt into a
|
|
105
|
+
separate checkout window:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const client = createClient({
|
|
109
|
+
baseUrl: 'https://api.m2cmarkets.com',
|
|
110
|
+
publishableKey: 'pub_live_...',
|
|
111
|
+
launchMode: 'popup', // or 'new_tab'
|
|
112
|
+
returnTimeoutMs: 60000, // optional fallback for abandoned popup/new-tab flows
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Call `start()` directly from a user click/tap. The SDK pre-opens a blank window
|
|
117
|
+
before the auction request, clears `opener`, then navigates that window after
|
|
118
|
+
the checkout URL is known. If the browser blocks the window, `start()` rejects
|
|
119
|
+
with `InvalidRequest` before creating the auction.
|
|
120
|
+
|
|
121
|
+
The default storage for popup/new-tab launch writes the resume record to
|
|
122
|
+
`localStorage` as well as `sessionStorage`, so the return page in the checkout
|
|
123
|
+
window can call `resume()`. If you inject custom `storage`, make sure it is
|
|
124
|
+
visible to both the opener page and the return page. Use the same client
|
|
125
|
+
factory on the opener and return page, or at least pass the same `launchMode`
|
|
126
|
+
and storage configuration:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const client = createClient({
|
|
130
|
+
baseUrl: 'https://api.m2cmarkets.com',
|
|
131
|
+
publishableKey: 'pub_live_...',
|
|
132
|
+
launchMode: 'popup', // same value used before start()
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// On your success_url / cancel_url page in the checkout window:
|
|
136
|
+
await client.resume({ outcome: 'success' });
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
When the return page calls `resume()`, the SDK also broadcasts the terminal
|
|
140
|
+
result back to the original same-origin page. In popup/new-tab mode, the
|
|
141
|
+
`start()` / `startFromSession()` promise in the original page resolves with the
|
|
142
|
+
same result.
|
|
143
|
+
|
|
144
|
+
This handoff does not make `successUrl` authoritative. `completed` and `failed`
|
|
145
|
+
still come from the configured status source, and fulfillment should still be
|
|
146
|
+
driven server-side from webhook state.
|
|
147
|
+
|
|
148
|
+
If the checkout window observably closes before a return page publishes a
|
|
149
|
+
result, the original page resolves `start()` / `startFromSession()` with
|
|
150
|
+
`{ status: 'window_closed', requestId }`. This is not a payment status and does
|
|
151
|
+
not mean the purchase failed or was canceled. Some hosted checkout pages sever
|
|
152
|
+
opener observability while the window is still alive; in that case the SDK keeps
|
|
153
|
+
waiting for the return handoff, then treats opener focus as an abandonment
|
|
154
|
+
signal if no return result appears.
|
|
155
|
+
|
|
156
|
+
Set `returnTimeoutMs` if you want popup/new-tab mode to stop waiting after a
|
|
157
|
+
fixed window when no return handoff arrives. This is an advisory UX fallback;
|
|
158
|
+
choose a value long enough for a normal customer checkout, and keep server-side
|
|
159
|
+
webhook state as the fulfillment source of truth.
|
|
160
|
+
|
|
161
|
+
To reconcile an ambiguous outcome after the fact - a `window_closed` or
|
|
162
|
+
`pending_timeout` that may have completed server-side - re-read the status for
|
|
163
|
+
the `requestId` with `checkStatus`, a one-shot read against the configured
|
|
164
|
+
status source:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const status = await client.checkStatus(requestId);
|
|
168
|
+
// 'processing' | 'completed' | 'failed' | 'canceled'
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`checkStatus` does not change the client's lifecycle state, and (like the poll)
|
|
172
|
+
it is advisory - your webhook remains the source of truth.
|
|
173
|
+
|
|
174
|
+
For example:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const result = await client.start({
|
|
178
|
+
transactionValue: 49.99,
|
|
179
|
+
successUrl: 'https://shop.example/checkout/return',
|
|
180
|
+
cancelUrl: 'https://shop.example/checkout/canceled',
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The checkout window still lands on your return page because that is where the
|
|
185
|
+
vendor redirects the customer. The original page receives the result through a
|
|
186
|
+
same-origin `BroadcastChannel` / `localStorage` handoff; the SDK does not rely
|
|
187
|
+
on `window.opener`.
|
|
188
|
+
|
|
189
|
+
## Progress events
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const unsubscribe = client.onStateChange((state, ctx) => {
|
|
193
|
+
// state: idle | creating | ready | launching | awaiting_return | returned
|
|
194
|
+
// | polling | completed | failed | canceled | pending_timeout
|
|
195
|
+
// | window_closed | error
|
|
196
|
+
// ctx: { requestId?, error? } (error is set only on the 'error' state)
|
|
197
|
+
render(state);
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## Status sources
|
|
202
|
+
|
|
203
|
+
Configure where status is read from after the return:
|
|
204
|
+
|
|
205
|
+
- `{ kind: 'm2c' }` - poll the M2C advisory read endpoint with the publishable
|
|
206
|
+
key (client-initiated only). The default.
|
|
207
|
+
- `{ kind: 'url', template }` - poll your endpoint; `{request_id}` is
|
|
208
|
+
substituted. Answer from your webhook-recorded state. Recommended for
|
|
209
|
+
backend-initiated mode.
|
|
210
|
+
- `{ kind: 'callback', checkStatus }` - call your async function on the backoff
|
|
211
|
+
schedule. A function cannot survive a full-page redirect, so **re-supply it on
|
|
212
|
+
the return page** via `createClient({ statusSource })` or
|
|
213
|
+
`resume({ statusSource })`.
|
|
214
|
+
|
|
215
|
+
Status readers may return the client statuses (`processing`, `completed`,
|
|
216
|
+
`failed`, `canceled`) or webhook-native statuses from your own store (`pending`,
|
|
217
|
+
`abandoned`, `refunded`, `chargedback`). The SDK maps them to the client result
|
|
218
|
+
enum before resolving.
|
|
219
|
+
|
|
220
|
+
The poll uses bounded exponential backoff (immediate, then 1s, 2s, 4s, 8s,
|
|
221
|
+
capped at 8s, ~90s total window; override with `poll`). Correlation is always
|
|
222
|
+
`request_id`, which you get from the auction response and your webhook records -
|
|
223
|
+
no pre-coordination needed.
|
|
224
|
+
|
|
225
|
+
> A push (`subscribe`) adapter is reserved in the type surface but not
|
|
226
|
+
> implemented yet; use `m2c`, `url`, or `callback`.
|
|
227
|
+
|
|
228
|
+
## Error handling
|
|
229
|
+
|
|
230
|
+
Rejections are `M2CCheckoutError` with a typed `code`:
|
|
231
|
+
|
|
232
|
+
| code | meaning |
|
|
233
|
+
|---|---|
|
|
234
|
+
| `InvalidRequest` | bad input / unsupported currency / bad URL (400, or client-side) |
|
|
235
|
+
| `OriginNotAllowed` | the page origin is not on the key's allowlist (403) |
|
|
236
|
+
| `AccountSuspended` | the merchant account is suspended (403) |
|
|
237
|
+
| `NoVendorsAvailable` | no linked/eligible vendor produced a bid (404) |
|
|
238
|
+
| `RateLimited` | slow down; see `retryAfterSeconds` (429) |
|
|
239
|
+
| `ServiceUnavailable` | transient backend failure (5xx) |
|
|
240
|
+
| `CheckoutExpired` | the TTL lapsed before launch (backend mode cannot re-mint) |
|
|
241
|
+
| `Network` | no HTTP response (fetch failure) |
|
|
242
|
+
|
|
243
|
+
`pending_timeout` is **not** an error - it resolves as a terminal result. During
|
|
244
|
+
polling, transient read failures (rate limit, 5xx, network, a not-yet-visible
|
|
245
|
+
status row) are swallowed and retried; a developer-actionable failure (bad
|
|
246
|
+
origin, invalid request) rejects so you see it.
|
|
247
|
+
|
|
248
|
+
## Framework recipes
|
|
249
|
+
|
|
250
|
+
Vanilla:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
button.onclick = () => client.start({ /* ... */ }).catch(showError);
|
|
254
|
+
client.onStateChange((s) => (statusEl.textContent = s));
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
React (drive a state variable from the event; resume on the return route):
|
|
258
|
+
|
|
259
|
+
```tsx
|
|
260
|
+
useEffect(() => client.onStateChange(setState), []);
|
|
261
|
+
const onBuy = () => client.start({ /* ... */ }).catch(setError);
|
|
262
|
+
|
|
263
|
+
// On the return route:
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
client.resume({ outcome: 'success' }).then(setResult).catch(setError);
|
|
266
|
+
}, []);
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Test mode
|
|
270
|
+
|
|
271
|
+
Use test keys (`pub_test_...`) and forward a backend session created with a
|
|
272
|
+
`sec_test_...` key. The auction runs against the in-server sandbox vendors and
|
|
273
|
+
returns a synthetic M2C-hosted `checkout_url`; the status read is scoped to the
|
|
274
|
+
test flag derived from the key. No special code path - the same flow drives the
|
|
275
|
+
sandbox.
|
|
276
|
+
|
|
277
|
+
## Server-side / non-browser usage
|
|
278
|
+
|
|
279
|
+
All DOM access is guarded, so importing the module on a server never throws.
|
|
280
|
+
`start` / `startFromSession` / `resume` throw `InvalidRequest` if no
|
|
281
|
+
`sessionStorage` / navigation is available; inject `storage`, `navigate`, and
|
|
282
|
+
`fetch` to drive the SDK in a non-browser environment (this is how the test
|
|
283
|
+
suite runs).
|
|
284
|
+
|
|
285
|
+
## What this SDK does not do
|
|
286
|
+
|
|
287
|
+
- Capture card data or render the vendor's checkout page (M2C is a router; the
|
|
288
|
+
vendor owns PCI scope).
|
|
289
|
+
- Decide fulfillment. Grant goods server-side off the M2C webhook. See the
|
|
290
|
+
fulfillment webhook receiver examples that ship alongside the SDKs.
|
|
291
|
+
- Ship UI. Headless by design.
|
|
292
|
+
|
|
293
|
+
## Packaging note
|
|
294
|
+
|
|
295
|
+
This package currently publishes ESM only, matching the rest of the M2C SDK
|
|
296
|
+
workspace and its plain-`tsc`, zero-dependency build. Every modern bundler and
|
|
297
|
+
Node 18+ consume it directly; a CJS build can be added without an API change if
|
|
298
|
+
a consumer needs it.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NormalizedConfig, StartParams } from './types.js';
|
|
2
|
+
export interface AuctionSession {
|
|
3
|
+
checkoutUrl: string;
|
|
4
|
+
requestId: string;
|
|
5
|
+
ttl?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Build the auction request body from client-initiated params. The server
|
|
9
|
+
* decoder rejects unknown fields, so only the contract keys are emitted, and
|
|
10
|
+
* each is validated against the wire limits first. `country` / `customer_ip`
|
|
11
|
+
* are intentionally never sent: the server derives them from the observed
|
|
12
|
+
* connection for publishable callers.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildAuctionBody(params: StartParams, allowInsecureUrls: boolean): string;
|
|
15
|
+
/**
|
|
16
|
+
* Run a client-initiated auction with the publishable key. The browser sets the
|
|
17
|
+
* `Origin` header (it is forbidden to set from script), which the server gates
|
|
18
|
+
* against the key's allowlist. Resolves the minimal publishable session
|
|
19
|
+
* ({ checkout_url, ttl, request_id }); rejects with a typed error otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export declare function runAuction(config: NormalizedConfig, params: StartParams): Promise<AuctionSession>;
|
package/dist/auction.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { M2CCheckoutError, auctionErrorForResponse, parseRetryAfter } from './errors.js';
|
|
2
|
+
import { assertTransactionValue, validateCheckoutUrl, validateDescription, validateDeviceType, validateReference, validateReferrer, validateSegments, validateUrl, } from './validate.js';
|
|
3
|
+
const AUCTION_PATH = '/api/v1/auction';
|
|
4
|
+
/**
|
|
5
|
+
* Build the auction request body from client-initiated params. The server
|
|
6
|
+
* decoder rejects unknown fields, so only the contract keys are emitted, and
|
|
7
|
+
* each is validated against the wire limits first. `country` / `customer_ip`
|
|
8
|
+
* are intentionally never sent: the server derives them from the observed
|
|
9
|
+
* connection for publishable callers.
|
|
10
|
+
*/
|
|
11
|
+
export function buildAuctionBody(params, allowInsecureUrls) {
|
|
12
|
+
assertTransactionValue(params.transactionValue);
|
|
13
|
+
const wire = {
|
|
14
|
+
transaction_value: params.transactionValue,
|
|
15
|
+
success_url: validateUrl(params.successUrl, 'successUrl', allowInsecureUrls),
|
|
16
|
+
cancel_url: validateUrl(params.cancelUrl, 'cancelUrl', allowInsecureUrls),
|
|
17
|
+
};
|
|
18
|
+
if (params.currency !== undefined) {
|
|
19
|
+
if (typeof params.currency !== 'string' || params.currency === '') {
|
|
20
|
+
throw new M2CCheckoutError('InvalidRequest', 'currency must be a non-empty string');
|
|
21
|
+
}
|
|
22
|
+
// The server's allowlist (model.IsSupportedCurrency) is the authority; we
|
|
23
|
+
// only guard the shape and let an unsupported code return a clean 400.
|
|
24
|
+
wire.currency = params.currency;
|
|
25
|
+
}
|
|
26
|
+
const description = validateDescription(params.description);
|
|
27
|
+
if (description !== undefined)
|
|
28
|
+
wire.description = description;
|
|
29
|
+
const segments = validateSegments(params.segments);
|
|
30
|
+
if (segments !== undefined)
|
|
31
|
+
wire.segments = segments;
|
|
32
|
+
if (params.language !== undefined) {
|
|
33
|
+
if (typeof params.language !== 'string') {
|
|
34
|
+
throw new M2CCheckoutError('InvalidRequest', 'language must be a string');
|
|
35
|
+
}
|
|
36
|
+
wire.language = params.language;
|
|
37
|
+
}
|
|
38
|
+
const deviceType = validateDeviceType(params.deviceType);
|
|
39
|
+
if (deviceType !== undefined)
|
|
40
|
+
wire.device_type = deviceType;
|
|
41
|
+
const referrer = validateReferrer(params.referrer);
|
|
42
|
+
if (referrer !== undefined)
|
|
43
|
+
wire.referrer = referrer;
|
|
44
|
+
const reference = validateReference(params.reference);
|
|
45
|
+
if (reference !== undefined)
|
|
46
|
+
wire.reference = reference;
|
|
47
|
+
return JSON.stringify(wire);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run a client-initiated auction with the publishable key. The browser sets the
|
|
51
|
+
* `Origin` header (it is forbidden to set from script), which the server gates
|
|
52
|
+
* against the key's allowlist. Resolves the minimal publishable session
|
|
53
|
+
* ({ checkout_url, ttl, request_id }); rejects with a typed error otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export async function runAuction(config, params) {
|
|
56
|
+
if (!config.publishableKey) {
|
|
57
|
+
throw new M2CCheckoutError('InvalidRequest', 'publishableKey is required for client-initiated start()');
|
|
58
|
+
}
|
|
59
|
+
if (!config.fetch) {
|
|
60
|
+
throw new M2CCheckoutError('InvalidRequest', 'no fetch implementation available; pass config.fetch or run where global fetch exists');
|
|
61
|
+
}
|
|
62
|
+
const body = buildAuctionBody(params, config.allowInsecureUrls);
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = await config.fetch(`${config.baseUrl}${AUCTION_PATH}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'X-API-Key': config.publishableKey,
|
|
70
|
+
},
|
|
71
|
+
body,
|
|
72
|
+
// No ambient credentials: the publishable key in the header is the auth.
|
|
73
|
+
credentials: 'omit',
|
|
74
|
+
// M2C never redirects this route; a redirect would re-send the key to
|
|
75
|
+
// another host, so treat any redirect as a failure rather than follow it.
|
|
76
|
+
redirect: 'error',
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new M2CCheckoutError('Network', `auction request failed: ${err.message}`, {
|
|
81
|
+
cause: err,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const text = await res.text().catch(() => '');
|
|
85
|
+
if (res.status !== 200) {
|
|
86
|
+
throw auctionErrorForResponse(res.status, extractErrorMessage(text), parseRetryAfter(res.headers.get('retry-after')));
|
|
87
|
+
}
|
|
88
|
+
return parseAuctionResponse(text, config.allowInsecureUrls);
|
|
89
|
+
}
|
|
90
|
+
function parseAuctionResponse(text, allowInsecureUrls) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new M2CCheckoutError('Unknown', 'auction response was not valid JSON');
|
|
97
|
+
}
|
|
98
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
99
|
+
throw new M2CCheckoutError('Unknown', 'auction response had an unexpected shape');
|
|
100
|
+
}
|
|
101
|
+
const obj = parsed;
|
|
102
|
+
const winner = obj.winner;
|
|
103
|
+
const requestId = obj.request_id;
|
|
104
|
+
if (typeof requestId !== 'string' || requestId === '') {
|
|
105
|
+
throw new M2CCheckoutError('Unknown', 'auction response was missing request_id');
|
|
106
|
+
}
|
|
107
|
+
if (typeof winner !== 'object' || winner === null) {
|
|
108
|
+
throw new M2CCheckoutError('Unknown', 'auction response was missing winner');
|
|
109
|
+
}
|
|
110
|
+
const checkoutUrl = winner.checkout_url;
|
|
111
|
+
const ttl = winner.ttl;
|
|
112
|
+
if (typeof checkoutUrl !== 'string' || checkoutUrl === '') {
|
|
113
|
+
throw new M2CCheckoutError('Unknown', 'auction response was missing winner.checkout_url');
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
checkoutUrl: validateCheckoutUrl(checkoutUrl, 'winner.checkout_url', allowInsecureUrls),
|
|
117
|
+
requestId,
|
|
118
|
+
ttl: typeof ttl === 'number' ? ttl : undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function extractErrorMessage(text) {
|
|
122
|
+
if (!text)
|
|
123
|
+
return undefined;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(text);
|
|
126
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
127
|
+
const err = parsed.error;
|
|
128
|
+
if (typeof err === 'string')
|
|
129
|
+
return err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Non-JSON error body; fall through to the raw text.
|
|
134
|
+
}
|
|
135
|
+
return text;
|
|
136
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { PollDeps } from './poll.js';
|
|
2
|
+
import type { CheckoutClient, CheckoutResult, CheckoutState, ClientConfig, ClientStatus, NormalizedConfig, ResumeParams, StartFromSessionParams, StartParams, StateChangeListener } from './types.js';
|
|
3
|
+
/** Create a headless checkout client. Safe to import in SSR; lifecycle methods need a DOM. */
|
|
4
|
+
export declare function createClient(config: ClientConfig): CheckoutClient;
|
|
5
|
+
declare class CheckoutClientImpl implements CheckoutClient {
|
|
6
|
+
private readonly config;
|
|
7
|
+
private state;
|
|
8
|
+
private requestId;
|
|
9
|
+
private readonly listeners;
|
|
10
|
+
private readonly pollDeps;
|
|
11
|
+
constructor(config: NormalizedConfig, pollDeps?: PollDeps);
|
|
12
|
+
getState(): CheckoutState;
|
|
13
|
+
onStateChange(listener: StateChangeListener): () => void;
|
|
14
|
+
start(params: StartParams): Promise<CheckoutResult>;
|
|
15
|
+
startFromSession(params: StartFromSessionParams): Promise<CheckoutResult>;
|
|
16
|
+
resume(params?: ResumeParams): Promise<CheckoutResult | null>;
|
|
17
|
+
checkStatus(requestId: string): Promise<ClientStatus>;
|
|
18
|
+
private persistAndLaunch;
|
|
19
|
+
private prepareAsyncLaunchWindow;
|
|
20
|
+
private waitForReturnResult;
|
|
21
|
+
private publishReturnResult;
|
|
22
|
+
private launchCheckout;
|
|
23
|
+
private openBlankCheckoutWindow;
|
|
24
|
+
private closeLaunchWindow;
|
|
25
|
+
private setState;
|
|
26
|
+
private fail;
|
|
27
|
+
private requireIdle;
|
|
28
|
+
private requireStorage;
|
|
29
|
+
private requireLaunchEnvironment;
|
|
30
|
+
private requireLaunchableStatusSource;
|
|
31
|
+
}
|
|
32
|
+
declare function normalizeConfig(config: ClientConfig): NormalizedConfig;
|
|
33
|
+
export { CheckoutClientImpl, normalizeConfig };
|