@sunbay/checkout-sdk-js 0.0.0-stage → 0.0.2
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/dist/index.cjs +1149 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +467 -0
- package/dist/index.d.ts +467 -0
- package/dist/index.global.js +1246 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.mjs +1133 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +32 -3
- package/README.md +0 -3
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var SunbayCheckout = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
CheckoutElement: () => CheckoutElement,
|
|
25
|
+
CheckoutInstance: () => CheckoutInstance,
|
|
26
|
+
ErrorCode: () => ErrorCode,
|
|
27
|
+
createCheckout: () => createCheckout,
|
|
28
|
+
version: () => version
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ../protocol/dist/index.mjs
|
|
32
|
+
var PROTOCOL_NAME = "sunbay-checkout";
|
|
33
|
+
var PROTOCOL_MAJOR = 1;
|
|
34
|
+
var ENVELOPE_KINDS = ["request", "response", "event"];
|
|
35
|
+
var EnvelopeDecodeError = class extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "EnvelopeDecodeError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function isRecord(value) {
|
|
42
|
+
return typeof value === "object" && value !== null;
|
|
43
|
+
}
|
|
44
|
+
function assertString(obj, key) {
|
|
45
|
+
if (typeof obj[key] !== "string") {
|
|
46
|
+
throw new EnvelopeDecodeError(`Envelope field "${key}" is missing or not a string`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function encodeEnvelope(envelope) {
|
|
50
|
+
return JSON.stringify(envelope);
|
|
51
|
+
}
|
|
52
|
+
function decodeEnvelope(wire) {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(wire);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new EnvelopeDecodeError("Envelope is not valid JSON");
|
|
58
|
+
}
|
|
59
|
+
if (!isRecord(parsed)) {
|
|
60
|
+
throw new EnvelopeDecodeError("Envelope must be an object");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol !== PROTOCOL_NAME) {
|
|
63
|
+
throw new EnvelopeDecodeError(
|
|
64
|
+
`protocol must be "${PROTOCOL_NAME}", received ${JSON.stringify(parsed.protocol)}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (parsed.major !== PROTOCOL_MAJOR) {
|
|
68
|
+
throw new EnvelopeDecodeError(
|
|
69
|
+
`major must be ${PROTOCOL_MAJOR}, received ${JSON.stringify(parsed.major)}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
assertString(parsed, "runtimeId");
|
|
73
|
+
assertString(parsed, "checkoutId");
|
|
74
|
+
if (parsed.elementId !== void 0 && typeof parsed.elementId !== "string") {
|
|
75
|
+
throw new EnvelopeDecodeError("elementId must be a string when present");
|
|
76
|
+
}
|
|
77
|
+
const kind = parsed.kind;
|
|
78
|
+
if (typeof kind !== "string" || !ENVELOPE_KINDS.includes(kind)) {
|
|
79
|
+
throw new EnvelopeDecodeError(`Invalid kind: ${JSON.stringify(kind)}`);
|
|
80
|
+
}
|
|
81
|
+
switch (kind) {
|
|
82
|
+
case "request": {
|
|
83
|
+
assertString(parsed, "requestId");
|
|
84
|
+
assertString(parsed, "name");
|
|
85
|
+
if (!("payload" in parsed)) {
|
|
86
|
+
throw new EnvelopeDecodeError("request envelope is missing payload");
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case "response": {
|
|
91
|
+
assertString(parsed, "requestId");
|
|
92
|
+
if (typeof parsed.ok !== "boolean") {
|
|
93
|
+
throw new EnvelopeDecodeError("response envelope is missing boolean ok");
|
|
94
|
+
}
|
|
95
|
+
if (parsed.ok === true) {
|
|
96
|
+
if (!("data" in parsed)) {
|
|
97
|
+
throw new EnvelopeDecodeError("successful response is missing data");
|
|
98
|
+
}
|
|
99
|
+
} else if (!isRecord(parsed.error)) {
|
|
100
|
+
throw new EnvelopeDecodeError("failed response is missing error object");
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "event": {
|
|
105
|
+
assertString(parsed, "eventId");
|
|
106
|
+
assertString(parsed, "name");
|
|
107
|
+
if (!("payload" in parsed)) {
|
|
108
|
+
throw new EnvelopeDecodeError("event envelope is missing payload");
|
|
109
|
+
}
|
|
110
|
+
if ("operationId" in parsed && parsed.operationId !== void 0 && typeof parsed.operationId !== "string") {
|
|
111
|
+
throw new EnvelopeDecodeError("operationId must be a string when present");
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return parsed;
|
|
117
|
+
}
|
|
118
|
+
var ErrorCode = {
|
|
119
|
+
INVALID_OPTIONS: "INVALID_OPTIONS",
|
|
120
|
+
ELEMENT_CONFLICT: "ELEMENT_CONFLICT",
|
|
121
|
+
INSTANCE_DESTROYED: "INSTANCE_DESTROYED",
|
|
122
|
+
CHECKOUT_BUSY: "CHECKOUT_BUSY",
|
|
123
|
+
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
124
|
+
SESSION_LOAD_FAILED: "SESSION_LOAD_FAILED",
|
|
125
|
+
RUNTIME_LOAD_FAILED: "RUNTIME_LOAD_FAILED",
|
|
126
|
+
PROTOCOL_UNSUPPORTED: "PROTOCOL_UNSUPPORTED",
|
|
127
|
+
REQUEST_TIMEOUT: "REQUEST_TIMEOUT",
|
|
128
|
+
PAYMENT_FLOW_ERROR: "PAYMENT_FLOW_ERROR",
|
|
129
|
+
UNSUPPORTED_CAPABILITY: "UNSUPPORTED_CAPABILITY"
|
|
130
|
+
};
|
|
131
|
+
var IFRAME_RESIZE_MESSAGE_TYPE = "sunbay:checkout:iframe-resize";
|
|
132
|
+
function isIframeResizeMessage(data) {
|
|
133
|
+
if (typeof data !== "object" || data === null) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const msg = data;
|
|
137
|
+
return msg.type === IFRAME_RESIZE_MESSAGE_TYPE && typeof msg.elementId === "string" && msg.elementId.length > 0 && typeof msg.height === "number" && Number.isFinite(msg.height) && msg.height >= 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/transport/MainFrameTransport.ts
|
|
141
|
+
var DEFAULT_REQUEST_TIMEOUTS = {
|
|
142
|
+
default: 1e4,
|
|
143
|
+
initialize: 3e4,
|
|
144
|
+
handshake: 15e3
|
|
145
|
+
};
|
|
146
|
+
function genId(prefix) {
|
|
147
|
+
const rand = Math.random().toString(36).slice(2);
|
|
148
|
+
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
|
149
|
+
}
|
|
150
|
+
function makeError(code, message) {
|
|
151
|
+
return {
|
|
152
|
+
code,
|
|
153
|
+
message,
|
|
154
|
+
category: "runtime",
|
|
155
|
+
errorId: genId("err")
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function isRuntimeHello(value) {
|
|
159
|
+
if (typeof value !== "object" || value === null) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const record = value;
|
|
163
|
+
return record.protocol === PROTOCOL_NAME && record.kind === "hello";
|
|
164
|
+
}
|
|
165
|
+
var MainFrameTransport = class {
|
|
166
|
+
constructor(options, timeouts = DEFAULT_REQUEST_TIMEOUTS) {
|
|
167
|
+
this.iframe = null;
|
|
168
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
169
|
+
this.eventHandlers = /* @__PURE__ */ new Map();
|
|
170
|
+
this.helloHandler = null;
|
|
171
|
+
/**
|
|
172
|
+
* 握手协商出的运行时 id;未注入前为空串。出站 request 信封据此填充,
|
|
173
|
+
* main 端点仅处理 runtimeId 与自身一致的请求,空串会被静默丢弃。
|
|
174
|
+
*/
|
|
175
|
+
this.runtimeId = "";
|
|
176
|
+
this.destroyed = false;
|
|
177
|
+
this.checkoutId = options.checkoutId;
|
|
178
|
+
this.hostOrigin = options.hostOrigin;
|
|
179
|
+
this.timeouts = timeouts;
|
|
180
|
+
this.messageListener = (event) => {
|
|
181
|
+
this.handleMessage(event);
|
|
182
|
+
};
|
|
183
|
+
window.addEventListener("message", this.messageListener);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 创建隐藏 0 尺寸 main iframe,append 到 document.body。
|
|
187
|
+
*
|
|
188
|
+
* @returns 已挂载的 iframe 元素。
|
|
189
|
+
* @example
|
|
190
|
+
* const iframe = transport.createHiddenIframe();
|
|
191
|
+
*/
|
|
192
|
+
createHiddenIframe() {
|
|
193
|
+
if (this.iframe) {
|
|
194
|
+
return this.iframe;
|
|
195
|
+
}
|
|
196
|
+
const iframe = document.createElement("iframe");
|
|
197
|
+
iframe.src = `${this.hostOrigin}/sdk/main?checkoutId=${this.checkoutId}`;
|
|
198
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
199
|
+
iframe.title = "sunbay-checkout-main";
|
|
200
|
+
iframe.style.position = "absolute";
|
|
201
|
+
iframe.style.width = "0";
|
|
202
|
+
iframe.style.height = "0";
|
|
203
|
+
iframe.style.border = "0";
|
|
204
|
+
iframe.style.visibility = "hidden";
|
|
205
|
+
document.body.appendChild(iframe);
|
|
206
|
+
this.iframe = iframe;
|
|
207
|
+
return iframe;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* 发送一条 request 信封,按 requestId 配对响应。
|
|
211
|
+
*
|
|
212
|
+
* @param name - 命令名。
|
|
213
|
+
* @param payload - 命令载荷(JSON 可序列化)。
|
|
214
|
+
* @returns 响应 data;失败(ok:false / 超时 / destroy)reject 一个 CheckoutError。
|
|
215
|
+
* @example
|
|
216
|
+
* const session = await transport.request('getSession', {});
|
|
217
|
+
*/
|
|
218
|
+
request(name, payload) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
if (this.destroyed) {
|
|
221
|
+
reject(makeError(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed"));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const contentWindow = this.iframe?.contentWindow;
|
|
225
|
+
if (!contentWindow) {
|
|
226
|
+
reject(
|
|
227
|
+
makeError(
|
|
228
|
+
ErrorCode.RUNTIME_LOAD_FAILED,
|
|
229
|
+
"main iframe not created or contentWindow unavailable"
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const requestId = genId("req");
|
|
235
|
+
const envelope = {
|
|
236
|
+
protocol: PROTOCOL_NAME,
|
|
237
|
+
major: PROTOCOL_MAJOR,
|
|
238
|
+
runtimeId: this.runtimeId,
|
|
239
|
+
checkoutId: this.checkoutId,
|
|
240
|
+
kind: "request",
|
|
241
|
+
requestId,
|
|
242
|
+
name,
|
|
243
|
+
payload
|
|
244
|
+
};
|
|
245
|
+
const timeoutMs = this.resolveTimeout(name);
|
|
246
|
+
const timer = setTimeout(() => {
|
|
247
|
+
this.pending.delete(requestId);
|
|
248
|
+
reject(makeError(ErrorCode.REQUEST_TIMEOUT, `Command ${name} timed out`));
|
|
249
|
+
}, timeoutMs);
|
|
250
|
+
this.pending.set(requestId, { resolve, reject, timer });
|
|
251
|
+
contentWindow.postMessage(encodeEnvelope(envelope), this.hostOrigin);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* 注册事件 handler(分发 kind:'event' 信封的 payload)。
|
|
256
|
+
*
|
|
257
|
+
* @param event - 事件名。
|
|
258
|
+
* @param handler - 回调。
|
|
259
|
+
*/
|
|
260
|
+
on(event, handler) {
|
|
261
|
+
let set = this.eventHandlers.get(event);
|
|
262
|
+
if (!set) {
|
|
263
|
+
set = /* @__PURE__ */ new Set();
|
|
264
|
+
this.eventHandlers.set(event, set);
|
|
265
|
+
}
|
|
266
|
+
set.add(handler);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* 注销事件 handler。
|
|
270
|
+
*
|
|
271
|
+
* @param event - 事件名。
|
|
272
|
+
* @param handler - 先前注册的回调。
|
|
273
|
+
*/
|
|
274
|
+
off(event, handler) {
|
|
275
|
+
const set = this.eventHandlers.get(event);
|
|
276
|
+
if (set) {
|
|
277
|
+
set.delete(handler);
|
|
278
|
+
if (set.size === 0) {
|
|
279
|
+
this.eventHandlers.delete(event);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 注册握手(RuntimeHello)回调。hello 阶段尚无 checkoutId,单独处理。
|
|
285
|
+
*
|
|
286
|
+
* @param handler - 收到 hello 时调用。
|
|
287
|
+
*/
|
|
288
|
+
onHello(handler) {
|
|
289
|
+
this.helloHandler = handler;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 注入握手协商出的运行时 id。必须在发送首个 request(initialize)之前调用,
|
|
293
|
+
* 否则出站信封 runtimeId 为空串、被 main 端点静默丢弃导致请求超时。
|
|
294
|
+
*
|
|
295
|
+
* @param runtimeId - RuntimeHello 中的 runtimeId。
|
|
296
|
+
*/
|
|
297
|
+
setRuntimeId(runtimeId) {
|
|
298
|
+
this.runtimeId = runtimeId;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* 销毁:移除 iframe、清监听,未决 request 以 INSTANCE_DESTROYED reject。
|
|
302
|
+
*/
|
|
303
|
+
destroy() {
|
|
304
|
+
if (this.destroyed) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.destroyed = true;
|
|
308
|
+
window.removeEventListener("message", this.messageListener);
|
|
309
|
+
for (const [, entry] of this.pending) {
|
|
310
|
+
clearTimeout(entry.timer);
|
|
311
|
+
entry.reject(makeError(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed"));
|
|
312
|
+
}
|
|
313
|
+
this.pending.clear();
|
|
314
|
+
this.eventHandlers.clear();
|
|
315
|
+
this.helloHandler = null;
|
|
316
|
+
if (this.iframe && this.iframe.parentNode) {
|
|
317
|
+
this.iframe.parentNode.removeChild(this.iframe);
|
|
318
|
+
}
|
|
319
|
+
this.iframe = null;
|
|
320
|
+
}
|
|
321
|
+
/** 按命令名解析超时时长。 */
|
|
322
|
+
resolveTimeout(name) {
|
|
323
|
+
if (name === "initialize") {
|
|
324
|
+
return this.timeouts.initialize;
|
|
325
|
+
}
|
|
326
|
+
return this.timeouts.default;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 点对点 port relay(父窗口中继,Stripe controller 模型)。
|
|
330
|
+
*
|
|
331
|
+
* 兄弟 iframe(main 与各 Element iframe)之间拿不到彼此 window 引用,无法直接
|
|
332
|
+
* transfer MessagePort。本层(商户页,唯一同时可达两端且知其 origin 的角色)在
|
|
333
|
+
* 收到某 Element iframe 的 `element-online` 时:
|
|
334
|
+
* 1. 建 `MessageChannel`;
|
|
335
|
+
* 2. `port1` → main iframe(`relay-port`,targetOrigin=hostOrigin);
|
|
336
|
+
* 3. `port2` → 发起的 Element iframe(即 `event.source`,targetOrigin=hostOrigin)。
|
|
337
|
+
*
|
|
338
|
+
* 两端 origin 均为 hostOrigin,targetOrigin 恒正确(跨域亦不被丢弃)。
|
|
339
|
+
* 本层只搬运**空 port**,绝不接触卡值(卡值在 port 建立后端到端传输)。
|
|
340
|
+
*
|
|
341
|
+
* @param event 入站 message(origin 已校验为 hostOrigin)
|
|
342
|
+
* @returns 是否作为 element-online 处理(是则调用方应 return)
|
|
343
|
+
*/
|
|
344
|
+
tryHandleElementOnline(event) {
|
|
345
|
+
const data = event.data;
|
|
346
|
+
if (typeof data !== "object" || data === null) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
const msg = data;
|
|
350
|
+
if (msg.kind !== "element-online") {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (msg.checkoutId !== this.checkoutId || typeof msg.fieldType !== "string") {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
const mainWin = this.iframe?.contentWindow;
|
|
357
|
+
const elementWin = event.source;
|
|
358
|
+
if (!mainWin || !elementWin) {
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
const channel = new MessageChannel();
|
|
362
|
+
const relayMsg = {
|
|
363
|
+
kind: "relay-port",
|
|
364
|
+
checkoutId: this.checkoutId,
|
|
365
|
+
fieldType: msg.fieldType
|
|
366
|
+
};
|
|
367
|
+
mainWin.postMessage(relayMsg, this.hostOrigin, [channel.port1]);
|
|
368
|
+
elementWin.postMessage(relayMsg, this.hostOrigin, [channel.port2]);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
/** 处理入站 message:过滤 origin,识别 relay / hello / Envelope 并分发。 */
|
|
372
|
+
handleMessage(event) {
|
|
373
|
+
if (this.destroyed) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (event.origin !== this.hostOrigin) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (this.tryHandleElementOnline(event)) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (typeof event.data !== "string") {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
let raw;
|
|
386
|
+
try {
|
|
387
|
+
raw = JSON.parse(event.data);
|
|
388
|
+
} catch {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (isRuntimeHello(raw)) {
|
|
392
|
+
if (this.helloHandler) {
|
|
393
|
+
this.helloHandler(raw);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
let envelope;
|
|
398
|
+
try {
|
|
399
|
+
envelope = decodeEnvelope(event.data);
|
|
400
|
+
} catch {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (envelope.checkoutId !== this.checkoutId) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (envelope.kind === "response") {
|
|
407
|
+
this.handleResponse(envelope);
|
|
408
|
+
} else if (envelope.kind === "event") {
|
|
409
|
+
this.handleEvent(envelope);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
/** 分发 response 到配对的未决 request。 */
|
|
413
|
+
handleResponse(envelope) {
|
|
414
|
+
const entry = this.pending.get(envelope.requestId);
|
|
415
|
+
if (!entry) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.pending.delete(envelope.requestId);
|
|
419
|
+
clearTimeout(entry.timer);
|
|
420
|
+
if (envelope.ok) {
|
|
421
|
+
entry.resolve(envelope.data);
|
|
422
|
+
} else {
|
|
423
|
+
entry.reject(envelope.error);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/** 分发 event 到注册的 handler。 */
|
|
427
|
+
handleEvent(envelope) {
|
|
428
|
+
const set = this.eventHandlers.get(envelope.name);
|
|
429
|
+
if (!set) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
for (const handler of set) {
|
|
433
|
+
handler(envelope.payload);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
// src/Element.ts
|
|
439
|
+
var FIELD_TYPES = ["cardNumber", "cardExpiry", "cardCvc"];
|
|
440
|
+
var INPUT_TYPES = [
|
|
441
|
+
"cardNumber",
|
|
442
|
+
"cardExpiry",
|
|
443
|
+
"cardCvc",
|
|
444
|
+
"card",
|
|
445
|
+
"address"
|
|
446
|
+
];
|
|
447
|
+
function makeError2(code, message, elementId) {
|
|
448
|
+
const rand = Math.random().toString(36).slice(2);
|
|
449
|
+
return {
|
|
450
|
+
code,
|
|
451
|
+
message,
|
|
452
|
+
category: "configuration",
|
|
453
|
+
errorId: `err_${Date.now().toString(36)}_${rand}`,
|
|
454
|
+
elementId
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function matchesElement(payload, elementId) {
|
|
458
|
+
if (typeof payload !== "object" || payload === null) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
return payload.elementId === elementId;
|
|
462
|
+
}
|
|
463
|
+
var CheckoutElement = class _CheckoutElement {
|
|
464
|
+
constructor(opts) {
|
|
465
|
+
this.status = "created";
|
|
466
|
+
this.container = null;
|
|
467
|
+
this.iframe = null;
|
|
468
|
+
/** mount 期间临时订阅的事件 handler(resolve/reject 后解绑)。 */
|
|
469
|
+
this.readyHandler = null;
|
|
470
|
+
this.loadErrorHandler = null;
|
|
471
|
+
/** 仅 expressCheckout:常驻订阅的 expressAvailabilityChange handler(destroy 时解绑)。 */
|
|
472
|
+
this.availabilityHandler = null;
|
|
473
|
+
/**
|
|
474
|
+
* iframe 自适应高度:挂载期间常驻的 window 'message' 监听器(iframe 消失/销毁时解绑)。
|
|
475
|
+
* 存引用以便 removeEventListener;null 表示未监听。
|
|
476
|
+
*/
|
|
477
|
+
this.resizeHandler = null;
|
|
478
|
+
/** 最近一次已应用的 iframe 高度(px),用于去重,避免冗余 style 写入;null 表示尚未设置。 */
|
|
479
|
+
this.lastHeight = null;
|
|
480
|
+
this.elementId = opts.elementId;
|
|
481
|
+
this.type = opts.type;
|
|
482
|
+
this.hostOrigin = opts.hostOrigin;
|
|
483
|
+
this.checkoutId = opts.checkoutId;
|
|
484
|
+
this.transport = opts.transport;
|
|
485
|
+
this.options = opts.initialOptions;
|
|
486
|
+
this.colorScheme = opts.colorScheme;
|
|
487
|
+
this.appearance = opts.appearance;
|
|
488
|
+
this.mode = opts.initialOptions?.mode;
|
|
489
|
+
this.onDestroy = opts.onDestroy;
|
|
490
|
+
this.installInputActions();
|
|
491
|
+
this.installExpressAvailability();
|
|
492
|
+
}
|
|
493
|
+
/** 当前状态(只读快照,供外部/测试观测)。 */
|
|
494
|
+
getStatus() {
|
|
495
|
+
return this.status;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* 仅 expressCheckout:返回当前各钱包可用性快照(其他类型恒为 undefined)。
|
|
499
|
+
*
|
|
500
|
+
* 初值来自 elementReady 的 availableWallets,之后由 expressAvailabilityChange 更新。
|
|
501
|
+
*
|
|
502
|
+
* @returns 可用性快照;尚未就绪或非 express 时为 undefined。
|
|
503
|
+
*/
|
|
504
|
+
getAvailableWallets() {
|
|
505
|
+
return this.availableWallets;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 挂载到商户 DOM 目标:插入可见 iframe、发 createElement、等 elementReady。
|
|
509
|
+
*
|
|
510
|
+
* @param selectorOrEl - CSS 选择器或 HTMLElement 容器;容器须唯一且为空。
|
|
511
|
+
* @returns elementReady 后 resolve;失败移除节点并 reject(loadError / INVALID_OPTIONS)。
|
|
512
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED。
|
|
513
|
+
* @example
|
|
514
|
+
* await element.mount('#card-field');
|
|
515
|
+
*/
|
|
516
|
+
mount(selectorOrEl) {
|
|
517
|
+
this.assertAlive();
|
|
518
|
+
if (this.status === "loading" || this.status === "ready") {
|
|
519
|
+
return Promise.reject(
|
|
520
|
+
makeError2(
|
|
521
|
+
ErrorCode.ELEMENT_CONFLICT,
|
|
522
|
+
"This Element is already mounted; mount cannot be called twice",
|
|
523
|
+
this.elementId
|
|
524
|
+
)
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const container = this.resolveContainer(selectorOrEl);
|
|
528
|
+
if (!container) {
|
|
529
|
+
return Promise.reject(
|
|
530
|
+
makeError2(
|
|
531
|
+
ErrorCode.INVALID_OPTIONS,
|
|
532
|
+
"Target container not found or not uniquely resolvable",
|
|
533
|
+
this.elementId
|
|
534
|
+
)
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (container.childElementCount > 0) {
|
|
538
|
+
return Promise.reject(
|
|
539
|
+
makeError2(ErrorCode.INVALID_OPTIONS, "Target container is not empty", this.elementId)
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
this.status = "loading";
|
|
543
|
+
this.container = container;
|
|
544
|
+
const iframe = document.createElement("iframe");
|
|
545
|
+
iframe.src = this.resolveSrc();
|
|
546
|
+
iframe.title = `sunbay-checkout-${this.type}`;
|
|
547
|
+
iframe.setAttribute("data-element-id", this.elementId);
|
|
548
|
+
iframe.style.border = "0";
|
|
549
|
+
iframe.style.width = "100%";
|
|
550
|
+
iframe.style.display = "block";
|
|
551
|
+
container.appendChild(iframe);
|
|
552
|
+
this.iframe = iframe;
|
|
553
|
+
this.installResizeListener();
|
|
554
|
+
return new Promise((resolve, reject) => {
|
|
555
|
+
const cleanup = () => {
|
|
556
|
+
if (this.readyHandler) {
|
|
557
|
+
this.transport.off("elementReady", this.readyHandler);
|
|
558
|
+
this.readyHandler = null;
|
|
559
|
+
}
|
|
560
|
+
if (this.loadErrorHandler) {
|
|
561
|
+
this.transport.off("loadError", this.loadErrorHandler);
|
|
562
|
+
this.loadErrorHandler = null;
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
const onReady = (payload) => {
|
|
566
|
+
if (!matchesElement(payload, this.elementId)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
cleanup();
|
|
570
|
+
const wallets = payload.availableWallets;
|
|
571
|
+
if (wallets) {
|
|
572
|
+
this.availableWallets = wallets;
|
|
573
|
+
}
|
|
574
|
+
this.status = "ready";
|
|
575
|
+
resolve();
|
|
576
|
+
};
|
|
577
|
+
const onLoadError = (payload) => {
|
|
578
|
+
if (!matchesElement(payload, this.elementId)) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
cleanup();
|
|
582
|
+
this.status = "loadError";
|
|
583
|
+
this.removeIframe();
|
|
584
|
+
const err = payload?.error;
|
|
585
|
+
reject(
|
|
586
|
+
err ?? makeError2(
|
|
587
|
+
ErrorCode.RUNTIME_LOAD_FAILED,
|
|
588
|
+
"Failed to load Element",
|
|
589
|
+
this.elementId
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
};
|
|
593
|
+
this.readyHandler = onReady;
|
|
594
|
+
this.loadErrorHandler = onLoadError;
|
|
595
|
+
this.transport.on("elementReady", onReady);
|
|
596
|
+
this.transport.on("loadError", onLoadError);
|
|
597
|
+
this.transport.request("createElement", {
|
|
598
|
+
elementId: this.elementId,
|
|
599
|
+
type: this.type,
|
|
600
|
+
options: this.options
|
|
601
|
+
}).catch((cmdErr) => {
|
|
602
|
+
cleanup();
|
|
603
|
+
this.status = "loadError";
|
|
604
|
+
this.removeIframe();
|
|
605
|
+
reject(cmdErr);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* 卸载:发 destroyElement、移除 iframe;幂等;保留句柄与配置。
|
|
611
|
+
*
|
|
612
|
+
* @returns 完成后 resolve(未挂载时直接 resolve)。
|
|
613
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED。
|
|
614
|
+
*/
|
|
615
|
+
async unmount() {
|
|
616
|
+
this.assertAlive();
|
|
617
|
+
if (this.status !== "ready" && this.status !== "loading") {
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
await this.transport.request("destroyElement", { elementId: this.elementId });
|
|
622
|
+
} finally {
|
|
623
|
+
this.removeIframe();
|
|
624
|
+
this.container = null;
|
|
625
|
+
this.status = "created";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* 销毁句柄:幂等;移除 iframe;此后非 destroy/off 调用抛 INSTANCE_DESTROYED。
|
|
630
|
+
*/
|
|
631
|
+
destroy() {
|
|
632
|
+
if (this.status === "destroyed") {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (this.readyHandler) {
|
|
636
|
+
this.transport.off("elementReady", this.readyHandler);
|
|
637
|
+
this.readyHandler = null;
|
|
638
|
+
}
|
|
639
|
+
if (this.loadErrorHandler) {
|
|
640
|
+
this.transport.off("loadError", this.loadErrorHandler);
|
|
641
|
+
this.loadErrorHandler = null;
|
|
642
|
+
}
|
|
643
|
+
if (this.availabilityHandler) {
|
|
644
|
+
this.transport.off("expressAvailabilityChange", this.availabilityHandler);
|
|
645
|
+
this.availabilityHandler = null;
|
|
646
|
+
}
|
|
647
|
+
this.removeIframe();
|
|
648
|
+
this.container = null;
|
|
649
|
+
this.status = "destroyed";
|
|
650
|
+
this.onDestroy?.(this.elementId);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* 更新 Element 配置:发 updateElement;非法字段由托管侧整次拒绝。
|
|
654
|
+
*
|
|
655
|
+
* @param patch - 配置增量。
|
|
656
|
+
* @returns 成功写回本地 options 后 resolve。
|
|
657
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED。
|
|
658
|
+
*/
|
|
659
|
+
async update(patch) {
|
|
660
|
+
this.assertAlive();
|
|
661
|
+
const resp = await this.transport.request("updateElement", {
|
|
662
|
+
elementId: this.elementId,
|
|
663
|
+
patch
|
|
664
|
+
});
|
|
665
|
+
const next = resp?.options;
|
|
666
|
+
if (next) {
|
|
667
|
+
this.options = next;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* 注册事件 handler(透传到 transport,按事件名分发)。
|
|
672
|
+
*
|
|
673
|
+
* @param event - 事件名。
|
|
674
|
+
* @param handler - 回调。
|
|
675
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED。
|
|
676
|
+
*/
|
|
677
|
+
on(event, handler) {
|
|
678
|
+
this.assertAlive();
|
|
679
|
+
this.transport.on(event, handler);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* 注销事件 handler(销毁后仍可安全调用)。
|
|
683
|
+
*
|
|
684
|
+
* @param event - 事件名。
|
|
685
|
+
* @param handler - 先前注册的回调。
|
|
686
|
+
*/
|
|
687
|
+
off(event, handler) {
|
|
688
|
+
this.transport.off(event, handler);
|
|
689
|
+
}
|
|
690
|
+
/** 已挂载后安装输入类动作方法(由构造后自动调用)。 */
|
|
691
|
+
installInputActions() {
|
|
692
|
+
if (!INPUT_TYPES.includes(this.type)) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
this.focus = () => this.sendAction("focus");
|
|
696
|
+
this.blur = () => this.sendAction("blur");
|
|
697
|
+
this.clear = () => this.sendAction("clear");
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* 仅 expressCheckout:常驻订阅 expressAvailabilityChange,更新本地可用性快照。
|
|
701
|
+
*
|
|
702
|
+
* 该事件载荷不含 elementId;express 为单例(由 CheckoutInstance 保证),
|
|
703
|
+
* 故无需按 elementId 过滤。非 express 类型不订阅。
|
|
704
|
+
*/
|
|
705
|
+
installExpressAvailability() {
|
|
706
|
+
if (this.type !== "expressCheckout") {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const handler = (payload) => {
|
|
710
|
+
if (typeof payload !== "object" || payload === null) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const wallets = payload.availableWallets;
|
|
714
|
+
if (wallets) {
|
|
715
|
+
this.availableWallets = wallets;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
this.availabilityHandler = handler;
|
|
719
|
+
this.transport.on("expressAvailabilityChange", handler);
|
|
720
|
+
}
|
|
721
|
+
/** 发送一条输入类动作命令(销毁后同步抛 INSTANCE_DESTROYED)。 */
|
|
722
|
+
sendAction(name) {
|
|
723
|
+
this.assertAlive();
|
|
724
|
+
return this.transport.request(name, { elementId: this.elementId }).then(() => void 0);
|
|
725
|
+
}
|
|
726
|
+
/** 若已销毁则抛 INSTANCE_DESTROYED。 */
|
|
727
|
+
assertAlive() {
|
|
728
|
+
if (this.status === "destroyed") {
|
|
729
|
+
throw makeError2(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed", this.elementId);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** 解析挂载容器(选择器须唯一命中一个元素)。 */
|
|
733
|
+
resolveContainer(selectorOrEl) {
|
|
734
|
+
if (typeof selectorOrEl !== "string") {
|
|
735
|
+
return selectorOrEl;
|
|
736
|
+
}
|
|
737
|
+
const nodes = document.querySelectorAll(selectorOrEl);
|
|
738
|
+
if (nodes.length !== 1) {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
const node = nodes[0];
|
|
742
|
+
return node instanceof HTMLElement ? node : null;
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* 解析 iframe src:拆分字段走 /sdk/field/,其余走 /sdk/element/,带 checkoutId 与 elementId;
|
|
746
|
+
* 若提供了初始 colorScheme,则追加 `&colorScheme=<值>`(首帧无闪);
|
|
747
|
+
* 若初始 appearance 含 variables 或 rules,则追加
|
|
748
|
+
* `&appearance=<encodeURIComponent(JSON.stringify(appearance))>`(首帧即生效,无闪)。
|
|
749
|
+
* 仅含 colorScheme(无 variables/rules)的 appearance 不拼接,避免空外观串。
|
|
750
|
+
* 对 expressCheckout,另追加钱包开关 / 每钱包 buttonType / 全局 buttonHeight
|
|
751
|
+
* (见 {@link resolveExpressQuery})。
|
|
752
|
+
*
|
|
753
|
+
* elementId 随 src 下发,供托管 child 在 iframe-resize 消息中原样回传,父窗口据此在
|
|
754
|
+
* 同页多个 iframe 间按句柄分发高度。
|
|
755
|
+
*/
|
|
756
|
+
resolveSrc() {
|
|
757
|
+
const segment = FIELD_TYPES.includes(this.type) ? "field" : "element";
|
|
758
|
+
let query = `?checkoutId=${encodeURIComponent(this.checkoutId)}`;
|
|
759
|
+
query += `&elementId=${encodeURIComponent(this.elementId)}`;
|
|
760
|
+
if (this.colorScheme) {
|
|
761
|
+
query += `&colorScheme=${encodeURIComponent(this.colorScheme)}`;
|
|
762
|
+
}
|
|
763
|
+
if (this.appearance && (this.appearance.variables || this.appearance.rules)) {
|
|
764
|
+
query += `&appearance=${encodeURIComponent(JSON.stringify(this.appearance))}`;
|
|
765
|
+
}
|
|
766
|
+
if (this.type === "expressCheckout") {
|
|
767
|
+
query += this.resolveExpressQuery();
|
|
768
|
+
}
|
|
769
|
+
return `${this.hostOrigin}/sdk/${segment}/${this.type}${query}`;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* 构造 expressCheckout 专属 query 片段(仅 expressCheckout 调用):
|
|
773
|
+
* - 钱包显式置为 `false` → `&applePay=false` / `&googlePay=false`(强制隐藏,保持
|
|
774
|
+
* 既有开关语义;`true` / 缺省 → 自动,不拼开关);
|
|
775
|
+
* - 钱包以对象形态配置且含 `buttonType` → `&applePayButtonType=<v>` /
|
|
776
|
+
* `&googlePayButtonType=<v>`(布尔开关 / 缺省 → 不拼);
|
|
777
|
+
* - 全局 `buttonHeight`(有限数)→ `&buttonHeight=<n>`。
|
|
778
|
+
*
|
|
779
|
+
* 仅在有值时拼接;不拼空串。本片段不触碰卡值,仅承载展示配置。
|
|
780
|
+
*
|
|
781
|
+
* @returns 以 `&` 起始的 query 片段(无可拼项时返回空串)。
|
|
782
|
+
*/
|
|
783
|
+
resolveExpressQuery() {
|
|
784
|
+
const options = this.options;
|
|
785
|
+
if (!options) {
|
|
786
|
+
return "";
|
|
787
|
+
}
|
|
788
|
+
let query = "";
|
|
789
|
+
const applePay = options.paymentMethods?.applePay;
|
|
790
|
+
const googlePay = options.paymentMethods?.googlePay;
|
|
791
|
+
if (applePay === false) {
|
|
792
|
+
query += `&applePay=false`;
|
|
793
|
+
}
|
|
794
|
+
if (googlePay === false) {
|
|
795
|
+
query += `&googlePay=false`;
|
|
796
|
+
}
|
|
797
|
+
if (typeof applePay === "object" && applePay.buttonType) {
|
|
798
|
+
query += `&applePayButtonType=${encodeURIComponent(applePay.buttonType)}`;
|
|
799
|
+
}
|
|
800
|
+
if (typeof googlePay === "object" && googlePay.buttonType) {
|
|
801
|
+
query += `&googlePayButtonType=${encodeURIComponent(googlePay.buttonType)}`;
|
|
802
|
+
}
|
|
803
|
+
if (typeof options.buttonHeight === "number" && Number.isFinite(options.buttonHeight)) {
|
|
804
|
+
query += `&buttonHeight=${encodeURIComponent(String(options.buttonHeight))}`;
|
|
805
|
+
}
|
|
806
|
+
return query;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* 安装 iframe 自适应高度监听:订阅 window 'message',仅采信来自本 iframe
|
|
810
|
+
* `contentWindow` 且 origin === hostOrigin 的合法 {@link IframeResizeMessage},
|
|
811
|
+
* 且 elementId 与本句柄匹配时,将外层 iframe 高度调整为内容真实高度(宽度保持 100%)。
|
|
812
|
+
*
|
|
813
|
+
* 安全:先校验 `event.source === iframe.contentWindow`(关键来源校验),
|
|
814
|
+
* 再校验 `event.origin === hostOrigin`(更严格的同源校验),防跨源伪造。
|
|
815
|
+
* 高度去重:与上次相同则跳过,避免冗余 style 写入。
|
|
816
|
+
*/
|
|
817
|
+
installResizeListener() {
|
|
818
|
+
const handler = (event) => {
|
|
819
|
+
if (!this.iframe) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (event.source !== this.iframe.contentWindow) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (event.origin !== this.hostOrigin) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (!isIframeResizeMessage(event.data)) {
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (event.data.elementId !== this.elementId) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const { height } = event.data;
|
|
835
|
+
if (height === this.lastHeight) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
this.lastHeight = height;
|
|
839
|
+
this.iframe.style.height = `${height}px`;
|
|
840
|
+
};
|
|
841
|
+
this.resizeHandler = handler;
|
|
842
|
+
window.addEventListener("message", handler);
|
|
843
|
+
}
|
|
844
|
+
/** 解绑 iframe 自适应高度监听并重置去重状态(幂等)。 */
|
|
845
|
+
teardownResizeListener() {
|
|
846
|
+
if (this.resizeHandler) {
|
|
847
|
+
window.removeEventListener("message", this.resizeHandler);
|
|
848
|
+
this.resizeHandler = null;
|
|
849
|
+
}
|
|
850
|
+
this.lastHeight = null;
|
|
851
|
+
}
|
|
852
|
+
/** 移除 iframe DOM 节点(若存在),并解绑自适应高度监听。 */
|
|
853
|
+
removeIframe() {
|
|
854
|
+
this.teardownResizeListener();
|
|
855
|
+
if (this.iframe && this.iframe.parentNode) {
|
|
856
|
+
this.iframe.parentNode.removeChild(this.iframe);
|
|
857
|
+
}
|
|
858
|
+
this.iframe = null;
|
|
859
|
+
}
|
|
860
|
+
/** 工厂:等价于 `new CheckoutElement(opts)`(供 CheckoutInstance 统一入口)。 */
|
|
861
|
+
static create(opts) {
|
|
862
|
+
return new _CheckoutElement(opts);
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// src/CheckoutInstance.ts
|
|
867
|
+
var CARD_FIELD_TYPES = ["cardNumber", "cardExpiry", "cardCvc"];
|
|
868
|
+
function genId2(prefix) {
|
|
869
|
+
const rand = Math.random().toString(36).slice(2);
|
|
870
|
+
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
|
871
|
+
}
|
|
872
|
+
function makeError3(code, message, category = "configuration") {
|
|
873
|
+
return {
|
|
874
|
+
code,
|
|
875
|
+
message,
|
|
876
|
+
category,
|
|
877
|
+
errorId: genId2("err")
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
var CheckoutInstance = class {
|
|
881
|
+
constructor(opts) {
|
|
882
|
+
/** 已创建的 Element(按 elementId 索引)。 */
|
|
883
|
+
/**
|
|
884
|
+
* 互斥注册表的**单一数据源**:elementId → 已创建的 CheckoutElement。
|
|
885
|
+
* 互斥判定由本 Map 实时推导(见 {@link assertNoConflict}),元素 `destroy()` 经
|
|
886
|
+
* {@link releaseElement} 从本 Map 移除以释放名额(允许销毁后重建同类互斥 Element)。
|
|
887
|
+
*/
|
|
888
|
+
this.elements = /* @__PURE__ */ new Map();
|
|
889
|
+
/** 当前未决 confirm(同一时刻至多一个)。 */
|
|
890
|
+
this.pendingConfirm = null;
|
|
891
|
+
this.destroyed = false;
|
|
892
|
+
this.checkoutId = opts.checkoutId;
|
|
893
|
+
this.runtimeId = opts.runtimeId;
|
|
894
|
+
this.capabilities = opts.capabilities;
|
|
895
|
+
this.hostOrigin = opts.hostOrigin;
|
|
896
|
+
this.transport = opts.transport;
|
|
897
|
+
this.session = opts.session;
|
|
898
|
+
this.appearance = opts.appearance;
|
|
899
|
+
this.paymentResultHandler = (payload) => {
|
|
900
|
+
this.handlePaymentResult(payload);
|
|
901
|
+
};
|
|
902
|
+
this.transport.on("paymentResult", this.paymentResultHandler);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* 创建一个 Element 句柄(不自动挂载;互斥冲突抛 ELEMENT_CONFLICT)。
|
|
906
|
+
*
|
|
907
|
+
* 互斥规则(spec 6):
|
|
908
|
+
* - 聚合 `card` 与任一拆分字段(cardNumber/cardExpiry/cardCvc)互斥。
|
|
909
|
+
* - 同 `mode`(billing/shipping)的 address 不可重复。
|
|
910
|
+
* - `expressCheckout` 单例。
|
|
911
|
+
*
|
|
912
|
+
* @param type - Element 类型。
|
|
913
|
+
* @param options - 初始 Element 配置。
|
|
914
|
+
* @returns 新建的 CheckoutElement。
|
|
915
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED;互斥冲突抛 ELEMENT_CONFLICT。
|
|
916
|
+
*/
|
|
917
|
+
create(type, options) {
|
|
918
|
+
this.assertAlive();
|
|
919
|
+
this.assertNoConflict(type, options);
|
|
920
|
+
const elementId = genId2("el");
|
|
921
|
+
const elementOpts = {
|
|
922
|
+
elementId,
|
|
923
|
+
type,
|
|
924
|
+
hostOrigin: this.hostOrigin,
|
|
925
|
+
checkoutId: this.checkoutId,
|
|
926
|
+
transport: this.transport,
|
|
927
|
+
initialOptions: options,
|
|
928
|
+
colorScheme: this.appearance?.colorScheme,
|
|
929
|
+
appearance: this.appearance,
|
|
930
|
+
onDestroy: (id) => this.releaseElement(id)
|
|
931
|
+
};
|
|
932
|
+
const element = CheckoutElement.create(elementOpts);
|
|
933
|
+
this.elements.set(elementId, element);
|
|
934
|
+
return element;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* 发起支付确认。生成 operationId,发 confirm 命令拿 accepted,返回的 Promise
|
|
938
|
+
* 由后续同 operationId 的 paymentResult 事件兑现。进行中再次调用 → CHECKOUT_BUSY。
|
|
939
|
+
*
|
|
940
|
+
* @param opts - 账单/配送地址、姓名、邮箱、returnUrl 等可选参数。
|
|
941
|
+
* @returns 支付终态结果 PaymentOutcome。
|
|
942
|
+
* @throws 已销毁时 reject INSTANCE_DESTROYED;进行中 reject CHECKOUT_BUSY。
|
|
943
|
+
*/
|
|
944
|
+
confirm(opts) {
|
|
945
|
+
if (this.destroyed) {
|
|
946
|
+
return Promise.reject(
|
|
947
|
+
makeError3(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed", "runtime")
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (this.pendingConfirm) {
|
|
951
|
+
return Promise.reject(
|
|
952
|
+
makeError3(ErrorCode.CHECKOUT_BUSY, "A payment confirmation is already in progress", "runtime")
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
const operationId = genId2("op");
|
|
956
|
+
const payload = { ...opts, operationId };
|
|
957
|
+
return new Promise((resolve, reject) => {
|
|
958
|
+
this.pendingConfirm = { operationId, resolve, reject };
|
|
959
|
+
this.transport.request("confirm", payload).catch((err) => {
|
|
960
|
+
if (this.pendingConfirm && this.pendingConfirm.operationId === operationId) {
|
|
961
|
+
this.pendingConfirm = null;
|
|
962
|
+
}
|
|
963
|
+
reject(err);
|
|
964
|
+
});
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* 获取最新会话视图(透传 getSession 命令并写回本地快照)。
|
|
969
|
+
*
|
|
970
|
+
* @returns 最新 PublicSessionView。
|
|
971
|
+
* @throws 已销毁时 reject INSTANCE_DESTROYED。
|
|
972
|
+
*/
|
|
973
|
+
async getSession() {
|
|
974
|
+
this.assertAlive();
|
|
975
|
+
const resp = await this.transport.request("getSession", {});
|
|
976
|
+
this.session = resp;
|
|
977
|
+
return resp;
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* 刷新会话(透传 refresh 命令并写回本地快照)。
|
|
981
|
+
*
|
|
982
|
+
* @returns 刷新后的 PublicSessionView。
|
|
983
|
+
* @throws 已销毁时 reject INSTANCE_DESTROYED。
|
|
984
|
+
*/
|
|
985
|
+
async refresh() {
|
|
986
|
+
this.assertAlive();
|
|
987
|
+
const resp = await this.transport.request("refresh", {});
|
|
988
|
+
this.session = resp;
|
|
989
|
+
return resp;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* 查询支付状态(原样返回 TxnStatus,不转义)。
|
|
993
|
+
*
|
|
994
|
+
* @returns txnStatus(可能含 txnId)。
|
|
995
|
+
* @throws 已销毁时 reject INSTANCE_DESTROYED。
|
|
996
|
+
*/
|
|
997
|
+
async getPaymentStatus() {
|
|
998
|
+
this.assertAlive();
|
|
999
|
+
return await this.transport.request("getPaymentStatus", {});
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* 更新结账外观/语言(不改会话或金额)。
|
|
1003
|
+
*
|
|
1004
|
+
* @param patch - locale / appearance 增量。
|
|
1005
|
+
* @throws 已销毁时 reject INSTANCE_DESTROYED。
|
|
1006
|
+
*/
|
|
1007
|
+
async updateCheckout(patch) {
|
|
1008
|
+
this.assertAlive();
|
|
1009
|
+
const payload = patch;
|
|
1010
|
+
await this.transport.request("updateCheckout", payload);
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* 注册 Checkout 级事件 handler(透传 transport)。
|
|
1014
|
+
*
|
|
1015
|
+
* @param event - 事件名。
|
|
1016
|
+
* @param handler - 回调。
|
|
1017
|
+
* @throws 已销毁时抛 INSTANCE_DESTROYED。
|
|
1018
|
+
*/
|
|
1019
|
+
on(event, handler) {
|
|
1020
|
+
this.assertAlive();
|
|
1021
|
+
this.transport.on(event, handler);
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* 注销事件 handler(销毁后仍可安全调用)。
|
|
1025
|
+
*
|
|
1026
|
+
* @param event - 事件名。
|
|
1027
|
+
* @param handler - 先前注册的回调。
|
|
1028
|
+
*/
|
|
1029
|
+
off(event, handler) {
|
|
1030
|
+
this.transport.off(event, handler);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* 销毁实例:幂等。释放全部 Element、解绑 paymentResult 监听、以
|
|
1034
|
+
* INSTANCE_DESTROYED reject 未决 confirm、发 destroy 命令并销毁 transport。
|
|
1035
|
+
* 此后迟到的 paymentResult 一律忽略,不改写已结束的 Promise。
|
|
1036
|
+
*/
|
|
1037
|
+
destroy() {
|
|
1038
|
+
if (this.destroyed) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
this.destroyed = true;
|
|
1042
|
+
for (const element of this.elements.values()) {
|
|
1043
|
+
element.destroy();
|
|
1044
|
+
}
|
|
1045
|
+
this.elements.clear();
|
|
1046
|
+
this.transport.off("paymentResult", this.paymentResultHandler);
|
|
1047
|
+
if (this.pendingConfirm) {
|
|
1048
|
+
this.pendingConfirm.reject(
|
|
1049
|
+
makeError3(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed", "runtime")
|
|
1050
|
+
);
|
|
1051
|
+
this.pendingConfirm = null;
|
|
1052
|
+
}
|
|
1053
|
+
void Promise.resolve(this.transport.request("destroy", {})).catch(() => void 0);
|
|
1054
|
+
this.transport.destroy();
|
|
1055
|
+
}
|
|
1056
|
+
/** 若已销毁则抛 INSTANCE_DESTROYED。 */
|
|
1057
|
+
assertAlive() {
|
|
1058
|
+
if (this.destroyed) {
|
|
1059
|
+
throw makeError3(ErrorCode.INSTANCE_DESTROYED, "Checkout instance has been destroyed", "runtime");
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
/** 互斥检查:冲突时抛 ELEMENT_CONFLICT(由 elements Map 实时推导,不改变注册表)。 */
|
|
1063
|
+
assertNoConflict(type, options) {
|
|
1064
|
+
const existing = [...this.elements.values()];
|
|
1065
|
+
const has = (t) => existing.some((e) => e.type === t);
|
|
1066
|
+
const hasCardField = () => existing.some((e) => CARD_FIELD_TYPES.includes(e.type));
|
|
1067
|
+
if (type === "card") {
|
|
1068
|
+
if (has("card")) {
|
|
1069
|
+
throw makeError3(ErrorCode.ELEMENT_CONFLICT, "A combined card Element already exists");
|
|
1070
|
+
}
|
|
1071
|
+
if (hasCardField()) {
|
|
1072
|
+
throw makeError3(
|
|
1073
|
+
ErrorCode.ELEMENT_CONFLICT,
|
|
1074
|
+
"Combined card and split card fields are mutually exclusive; split card fields already exist"
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (CARD_FIELD_TYPES.includes(type)) {
|
|
1080
|
+
if (has("card")) {
|
|
1081
|
+
throw makeError3(
|
|
1082
|
+
ErrorCode.ELEMENT_CONFLICT,
|
|
1083
|
+
"Split card fields and combined card are mutually exclusive; a combined card already exists"
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
if (has(type)) {
|
|
1087
|
+
throw makeError3(
|
|
1088
|
+
ErrorCode.ELEMENT_CONFLICT,
|
|
1089
|
+
`A split card field of type ${type} already exists`
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
if (type === "address") {
|
|
1095
|
+
const mode = options?.mode;
|
|
1096
|
+
if (mode && existing.some((e) => e.type === "address" && e.mode === mode)) {
|
|
1097
|
+
throw makeError3(
|
|
1098
|
+
ErrorCode.ELEMENT_CONFLICT,
|
|
1099
|
+
`An address Element with mode=${mode} already exists`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (type === "expressCheckout") {
|
|
1105
|
+
if (has("expressCheckout")) {
|
|
1106
|
+
throw makeError3(ErrorCode.ELEMENT_CONFLICT, "An expressCheckout Element already exists");
|
|
1107
|
+
}
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* 元素 `destroy()` 回调:从互斥注册表(elements Map)移除该 elementId,释放名额。
|
|
1113
|
+
* 幂等;实例整体 destroy 时(已置 destroyed)走统一 `elements.clear()` 路径,此处 no-op,
|
|
1114
|
+
* 避免遍历 elements 调 element.destroy() 时的并发修改。
|
|
1115
|
+
*/
|
|
1116
|
+
releaseElement(elementId) {
|
|
1117
|
+
if (this.destroyed) {
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
this.elements.delete(elementId);
|
|
1121
|
+
}
|
|
1122
|
+
/** 处理 paymentResult 事件:按 operationId 兑现未决 confirm;迟到/不匹配忽略。 */
|
|
1123
|
+
handlePaymentResult(payload) {
|
|
1124
|
+
if (this.destroyed) {
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (typeof payload !== "object" || payload === null) {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const evt = payload;
|
|
1131
|
+
const pending = this.pendingConfirm;
|
|
1132
|
+
if (!pending) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (evt.operationId !== pending.operationId) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (!evt.outcome) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
this.pendingConfirm = null;
|
|
1142
|
+
pending.resolve(evt.outcome);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
// src/config/env.ts
|
|
1147
|
+
var HOST_ORIGINS = {
|
|
1148
|
+
dev: "https://checkout.sunbay-dev.com",
|
|
1149
|
+
prod: "https://checkout.sunbay.us"
|
|
1150
|
+
};
|
|
1151
|
+
function resolveHostOrigin(env = "dev", override) {
|
|
1152
|
+
if (override) {
|
|
1153
|
+
return override;
|
|
1154
|
+
}
|
|
1155
|
+
return HOST_ORIGINS[env];
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/createCheckout.ts
|
|
1159
|
+
var HANDSHAKE_TIMEOUT_MS = 15e3;
|
|
1160
|
+
function genId3(prefix) {
|
|
1161
|
+
const rand = Math.random().toString(36).slice(2);
|
|
1162
|
+
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
|
1163
|
+
}
|
|
1164
|
+
function makeError4(code, message) {
|
|
1165
|
+
return {
|
|
1166
|
+
code,
|
|
1167
|
+
message,
|
|
1168
|
+
category: "runtime",
|
|
1169
|
+
errorId: genId3("err")
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
var defaultTransportFactory = ({ checkoutId, hostOrigin }) => new MainFrameTransport({ checkoutId, hostOrigin });
|
|
1173
|
+
async function createCheckout(opts) {
|
|
1174
|
+
const checkoutId = genId3("co");
|
|
1175
|
+
const hostOrigin = resolveHostOrigin(opts.env, opts.hostOrigin);
|
|
1176
|
+
const factory = opts.__transportFactory ?? defaultTransportFactory;
|
|
1177
|
+
const transport = factory({ checkoutId, hostOrigin });
|
|
1178
|
+
transport.createHiddenIframe();
|
|
1179
|
+
let hello;
|
|
1180
|
+
try {
|
|
1181
|
+
hello = await waitForHello(transport);
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
transport.destroy();
|
|
1184
|
+
throw err;
|
|
1185
|
+
}
|
|
1186
|
+
if (!hello.supportedMajorVersions.includes(PROTOCOL_MAJOR)) {
|
|
1187
|
+
transport.destroy();
|
|
1188
|
+
throw makeError4(
|
|
1189
|
+
ErrorCode.PROTOCOL_UNSUPPORTED,
|
|
1190
|
+
`No common major protocol version: SDK=${PROTOCOL_MAJOR}, runtime=${JSON.stringify(hello.supportedMajorVersions)}`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
transport.setRuntimeId(hello.runtimeId);
|
|
1194
|
+
const initReq = {
|
|
1195
|
+
sessionId: opts.sessionId,
|
|
1196
|
+
locale: opts.locale,
|
|
1197
|
+
appearance: opts.appearance,
|
|
1198
|
+
paymentMethods: opts.paymentMethods
|
|
1199
|
+
};
|
|
1200
|
+
let initResp;
|
|
1201
|
+
try {
|
|
1202
|
+
initResp = await transport.request("initialize", initReq);
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
transport.destroy();
|
|
1205
|
+
throw err;
|
|
1206
|
+
}
|
|
1207
|
+
return new CheckoutInstance({
|
|
1208
|
+
checkoutId,
|
|
1209
|
+
runtimeId: hello.runtimeId,
|
|
1210
|
+
hostOrigin,
|
|
1211
|
+
transport,
|
|
1212
|
+
capabilities: initResp.capabilities,
|
|
1213
|
+
session: initResp.session,
|
|
1214
|
+
appearance: opts.appearance
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
function waitForHello(transport) {
|
|
1218
|
+
return new Promise((resolve, reject) => {
|
|
1219
|
+
let settled = false;
|
|
1220
|
+
const timer = setTimeout(() => {
|
|
1221
|
+
if (settled) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
settled = true;
|
|
1225
|
+
reject(makeError4(ErrorCode.RUNTIME_LOAD_FAILED, "Timed out waiting for hosted runtime handshake"));
|
|
1226
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
1227
|
+
transport.onHello((raw) => {
|
|
1228
|
+
if (settled) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
settled = true;
|
|
1232
|
+
clearTimeout(timer);
|
|
1233
|
+
resolve(raw);
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/index.ts
|
|
1239
|
+
if (typeof window !== "undefined") {
|
|
1240
|
+
const w = window;
|
|
1241
|
+
w.SunbayCheckout = { ...w.SunbayCheckout ?? {}, createCheckout };
|
|
1242
|
+
}
|
|
1243
|
+
var version = true ? "0.0.2" : "0.0.0";
|
|
1244
|
+
return __toCommonJS(index_exports);
|
|
1245
|
+
})();
|
|
1246
|
+
//# sourceMappingURL=index.global.js.map
|