@meshconnect/web-link-sdk 3.12.0 → 3.14.1
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/Link.js +183 -69
- package/README.md +67 -11
- package/backup-bundle/generated.d.ts +2 -0
- package/backup-bundle/generated.js +7 -0
- package/backup-bundle/index.d.ts +21 -0
- package/backup-bundle/index.js +13 -0
- package/backup-bundle/theme.d.ts +9 -0
- package/backup-bundle/theme.js +13 -0
- package/backupFlow.d.ts +72 -0
- package/backupFlow.js +536 -0
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +3 -32
- package/utils/backup.d.ts +93 -0
- package/utils/backup.js +96 -0
- package/utils/backupTier.d.ts +55 -0
- package/utils/backupTier.js +84 -0
- package/utils/event-types.d.ts +23 -2
- package/utils/event-types.js +3 -1
- package/utils/types.d.ts +137 -2
- package/utils/version.d.ts +1 -1
- package/utils/version.js +1 -1
package/backupFlow.js
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
11
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
12
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
13
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
14
|
+
function step(op) {
|
|
15
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
16
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
17
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
18
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
19
|
+
switch (op[0]) {
|
|
20
|
+
case 0: case 1: t = op; break;
|
|
21
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
22
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
23
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
24
|
+
default:
|
|
25
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
26
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
27
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
28
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
29
|
+
if (t[2]) _.ops.pop();
|
|
30
|
+
_.trys.pop(); continue;
|
|
31
|
+
}
|
|
32
|
+
op = body.call(thisArg, _);
|
|
33
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
34
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
import { addPopup, buildIframeAllowPolicy, iframeId, removePopup } from './utils/popup';
|
|
38
|
+
import { sdkSpecs } from './utils/sdk-specs';
|
|
39
|
+
import { BACKUP_CONFIG_MESSAGE_TYPE, DEFAULT_BACKUP_WIDGET_ORIGIN, JIT_RESPONSE_MESSAGE_TYPE, SESSION_NONCE_PARAM, buildBackupWidgetUrl, createSessionNonce } from './utils/backup';
|
|
40
|
+
import { createBackupTierController } from './utils/backupTier';
|
|
41
|
+
import { BridgeParent } from '@meshconnect/uwc-bridge-parent';
|
|
42
|
+
import { removePrewarmIframe } from './utils/prewarm';
|
|
43
|
+
/**
|
|
44
|
+
* Validate a host `onStatusPoll` result against the three-variant contract and
|
|
45
|
+
* return ONLY the contract fields. Throws on anything malformed — a missing,
|
|
46
|
+
* misspelled, or non-string `status`, or a `'ready'` without a non-empty string
|
|
47
|
+
* `address` — so the handler's catch fails the poll closed (`ok: false`). The
|
|
48
|
+
* previous builder silently coerced any unknown status to `'pending'` (which
|
|
49
|
+
* polls forever) and forwarded `'ready'` without an address (an invalid
|
|
50
|
+
* success); both are money-path failure modes. Extra fields (e.g. a raw backend
|
|
51
|
+
* response carrying identifiers/credentials) are dropped, never forwarded across
|
|
52
|
+
* the bridge to the widget's independent origin.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeStatusResult(result) {
|
|
55
|
+
var status = typeof result === 'object' && result !== null
|
|
56
|
+
? result.status
|
|
57
|
+
: undefined;
|
|
58
|
+
if (status === 'pending')
|
|
59
|
+
return { status: 'pending' };
|
|
60
|
+
if (status === 'failed')
|
|
61
|
+
return { status: 'failed' };
|
|
62
|
+
if (status === 'ready') {
|
|
63
|
+
var address = result.address;
|
|
64
|
+
if (typeof address !== 'string' || address.length === 0) {
|
|
65
|
+
throw new Error("onStatusPoll returned status 'ready' without a valid address");
|
|
66
|
+
}
|
|
67
|
+
var addressTag = result.addressTag;
|
|
68
|
+
// A memo/tag chain deposit without its tag can strand funds, so a tag that is
|
|
69
|
+
// present but not a string is malformed — fail closed rather than drop it and
|
|
70
|
+
// report a tag-less 'ready'. `null`/`undefined`/`''` mean "no tag".
|
|
71
|
+
if (addressTag !== undefined &&
|
|
72
|
+
addressTag !== null &&
|
|
73
|
+
typeof addressTag !== 'string') {
|
|
74
|
+
throw new Error("onStatusPoll returned status 'ready' with a non-string addressTag");
|
|
75
|
+
}
|
|
76
|
+
return typeof addressTag === 'string' && addressTag.length > 0
|
|
77
|
+
? { status: 'ready', address: address, addressTag: addressTag }
|
|
78
|
+
: { status: 'ready', address: address };
|
|
79
|
+
}
|
|
80
|
+
throw new Error("onStatusPoll returned an invalid status: ".concat(JSON.stringify(status), " (expected 'pending' | 'ready' | 'failed')"));
|
|
81
|
+
}
|
|
82
|
+
export function createBackupFlow(host) {
|
|
83
|
+
// Set by `open`, delivered to the widget on its `loaded` handshake.
|
|
84
|
+
var backupSession;
|
|
85
|
+
// See `ActiveFlow`: `!backupSession` is NOT a safe token-forwarding gate, because
|
|
86
|
+
// an aborted re-open (`openLink` with a bad token, or `open` with a bad origin)
|
|
87
|
+
// clears `backupSession` while the previous backup iframe + listener are still
|
|
88
|
+
// live; a late `loaded` from that stale iframe would then pass a `!backupSession`
|
|
89
|
+
// check and ship `frontAccessTokens` to the independent backup origin. In that
|
|
90
|
+
// aborted/dead window `activeFlow` is `null`, so tokens are withheld.
|
|
91
|
+
var activeFlow = null;
|
|
92
|
+
// Tier-1 → Tier-2 cascade state machine, live only during a backup session.
|
|
93
|
+
var backupTierController = null;
|
|
94
|
+
// Monotonic id for the current backup session. Every async operation (JIT reply,
|
|
95
|
+
// cascade import, iframe `error`) captures the id it started under and re-checks
|
|
96
|
+
// it before acting, so work from a session that has ended or been replaced can
|
|
97
|
+
// never post into / clobber a later one (a reused `callId` must never associate a
|
|
98
|
+
// deposit address with the wrong session — money path).
|
|
99
|
+
var backupSessionId = 0;
|
|
100
|
+
// The widget iframe's window for the active backup session. Backup messages
|
|
101
|
+
// (`loaded`, JIT) are only honoured when they come from this exact window
|
|
102
|
+
// (`event.source`), so another frame cannot drive the client's callbacks.
|
|
103
|
+
var backupIframeWindow = null;
|
|
104
|
+
// Removes the Tier-1 `error` listener bound to the current backup iframe.
|
|
105
|
+
var removeBackupIframeErrorListener = null;
|
|
106
|
+
// True once cascaded to Tier 2, which loads the bundled widget in a SANDBOXED,
|
|
107
|
+
// opaque-origin iframe (blob: URL). Its `event.origin` is `'null'`, so messages
|
|
108
|
+
// are authenticated by `event.source` instead, and we post to it with a `'*'`
|
|
109
|
+
// target. (Tier 1 stays origin-pinned.)
|
|
110
|
+
var backupTier2 = false;
|
|
111
|
+
// Object URL backing the Tier-2 iframe; revoked on teardown to avoid a leak.
|
|
112
|
+
var backupTier2BlobUrl = null;
|
|
113
|
+
// This open's session nonce, put on the widget URL (`?sid=` / Tier-2 `#sid=`) and
|
|
114
|
+
// echoed by the widget on every message. Navigating a reused embedded iframe
|
|
115
|
+
// keeps its WindowProxy and Tier-1 sessions share an origin, so this is the only
|
|
116
|
+
// thing that tells a stale message from the previous document apart from the
|
|
117
|
+
// current open's — e.g. a queued `close` (spurious onExit) or `loaded` (would
|
|
118
|
+
// cancel the Tier-1 → Tier-2 fallback).
|
|
119
|
+
var sessionNonce = null;
|
|
120
|
+
// This open's explicit theme (`dark`/`light`; undefined for `system`/unset). Tier 1
|
|
121
|
+
// sends it as `?theme=`; Tier 2's blob: URL can't, so it is stamped onto the
|
|
122
|
+
// bundled HTML instead (`withWidgetTheme`).
|
|
123
|
+
var backupTheme;
|
|
124
|
+
/**
|
|
125
|
+
* Tear down any in-flight backup session state. Called when a session ends or
|
|
126
|
+
* when the primary (token) flow starts, so a pending Tier-1 timeout can never
|
|
127
|
+
* fire against a later iframe and a stale config can never be re-delivered.
|
|
128
|
+
* Bumping `backupSessionId` invalidates any async work still bound to the old id.
|
|
129
|
+
*/
|
|
130
|
+
function reset() {
|
|
131
|
+
backupSession = undefined;
|
|
132
|
+
// Entering a fresh/dead state: no flow is live until one re-commits. This is
|
|
133
|
+
// what makes a late `loaded` from a stale backup iframe fail the token-
|
|
134
|
+
// forwarding gate (`activeFlow === 'primary'`) during an aborted re-open.
|
|
135
|
+
activeFlow = null;
|
|
136
|
+
backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.destroy();
|
|
137
|
+
backupTierController = null;
|
|
138
|
+
backupIframeWindow = null;
|
|
139
|
+
removeBackupIframeErrorListener === null || removeBackupIframeErrorListener === void 0 ? void 0 : removeBackupIframeErrorListener();
|
|
140
|
+
removeBackupIframeErrorListener = null;
|
|
141
|
+
backupTier2 = false;
|
|
142
|
+
sessionNonce = null;
|
|
143
|
+
backupTheme = undefined;
|
|
144
|
+
if (backupTier2BlobUrl) {
|
|
145
|
+
URL.revokeObjectURL(backupTier2BlobUrl);
|
|
146
|
+
backupTier2BlobUrl = null;
|
|
147
|
+
}
|
|
148
|
+
backupSessionId += 1;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Run the host's JIT callback for a widget request and post the result back over
|
|
152
|
+
* the bridge, correlated by `callId` (OR-452). `addressInit` runs `onAddressInit`
|
|
153
|
+
* (its return is ignored — a throw/reject is a failure); `statusPoll` runs
|
|
154
|
+
* `onStatusPoll` and returns its `{ status, address?, addressTag? }`. A missing
|
|
155
|
+
* callback fails closed so an address-less destination surfaces an error rather
|
|
156
|
+
* than hanging. The callbacks run in the host app with the client's own session —
|
|
157
|
+
* their credentials never enter the widget; only `(symbol, networkId)` and the
|
|
158
|
+
* resolved address cross the bridge.
|
|
159
|
+
*/
|
|
160
|
+
function runJitRequest(payload) {
|
|
161
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
162
|
+
var callId, method, symbol, networkId, sessionId, targetWindow, replyTarget, respond, isJitDestination, onAddressInit, onStatusPoll, result, e_1;
|
|
163
|
+
var _a, _b;
|
|
164
|
+
return __generator(this, function (_c) {
|
|
165
|
+
switch (_c.label) {
|
|
166
|
+
case 0:
|
|
167
|
+
if (!payload || typeof payload.callId !== 'string')
|
|
168
|
+
return [2 /*return*/];
|
|
169
|
+
callId = payload.callId, method = payload.method, symbol = payload.symbol, networkId = payload.networkId;
|
|
170
|
+
sessionId = backupSessionId;
|
|
171
|
+
targetWindow = backupIframeWindow;
|
|
172
|
+
replyTarget = backupTier2 ? '*' : host.getLinkTokenOrigin();
|
|
173
|
+
respond = function (response) {
|
|
174
|
+
if (backupSessionId !== sessionId || !targetWindow || !replyTarget)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
targetWindow.postMessage({ type: JIT_RESPONSE_MESSAGE_TYPE, payload: response }, replyTarget);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
console.error('Mesh SDK: Failed to deliver JIT response to the widget');
|
|
181
|
+
console.error(e);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
_c.label = 1;
|
|
185
|
+
case 1:
|
|
186
|
+
_c.trys.push([1, 7, , 8]);
|
|
187
|
+
// The request drives real address generation against the client's backend —
|
|
188
|
+
// reject a malformed one rather than passing garbage to the callbacks.
|
|
189
|
+
if (typeof symbol !== 'string' || typeof networkId !== 'string') {
|
|
190
|
+
throw new TypeError('backup JIT request is missing a string symbol/networkId');
|
|
191
|
+
}
|
|
192
|
+
isJitDestination = backupSession === null || backupSession === void 0 ? void 0 : backupSession.destinations.some(function (d) { return d.symbol === symbol && d.networkId === networkId && !d.address; });
|
|
193
|
+
if (!isJitDestination) {
|
|
194
|
+
throw new Error('backup JIT request is not for an address-less destination in the session');
|
|
195
|
+
}
|
|
196
|
+
if (!(method === 'addressInit')) return [3 /*break*/, 3];
|
|
197
|
+
onAddressInit = (_a = host.getOptions()) === null || _a === void 0 ? void 0 : _a.onAddressInit;
|
|
198
|
+
if (!onAddressInit) {
|
|
199
|
+
throw new Error('onAddressInit callback is required to resolve an address-less backup destination');
|
|
200
|
+
}
|
|
201
|
+
return [4 /*yield*/, onAddressInit(symbol, networkId)];
|
|
202
|
+
case 2:
|
|
203
|
+
_c.sent();
|
|
204
|
+
respond({ callId: callId, ok: true });
|
|
205
|
+
return [3 /*break*/, 6];
|
|
206
|
+
case 3:
|
|
207
|
+
if (!(method === 'statusPoll')) return [3 /*break*/, 5];
|
|
208
|
+
onStatusPoll = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onStatusPoll;
|
|
209
|
+
if (!onStatusPoll) {
|
|
210
|
+
throw new Error('onStatusPoll callback is required to resolve an address-less backup destination');
|
|
211
|
+
}
|
|
212
|
+
return [4 /*yield*/, onStatusPoll(symbol, networkId)
|
|
213
|
+
// Validate against the three-variant contract and post ONLY the contract
|
|
214
|
+
// fields to the widget's (independent) origin. A malformed result throws
|
|
215
|
+
// here, so the catch fails the poll closed (`ok: false`) rather than
|
|
216
|
+
// silently polling forever on a coerced 'pending' or forwarding a 'ready'
|
|
217
|
+
// with no address. Extra fields (e.g. a raw backend response with
|
|
218
|
+
// identifiers/credentials) are dropped, never forwarded.
|
|
219
|
+
];
|
|
220
|
+
case 4:
|
|
221
|
+
result = _c.sent();
|
|
222
|
+
// Validate against the three-variant contract and post ONLY the contract
|
|
223
|
+
// fields to the widget's (independent) origin. A malformed result throws
|
|
224
|
+
// here, so the catch fails the poll closed (`ok: false`) rather than
|
|
225
|
+
// silently polling forever on a coerced 'pending' or forwarding a 'ready'
|
|
226
|
+
// with no address. Extra fields (e.g. a raw backend response with
|
|
227
|
+
// identifiers/credentials) are dropped, never forwarded.
|
|
228
|
+
respond({ callId: callId, ok: true, result: sanitizeStatusResult(result) });
|
|
229
|
+
return [3 /*break*/, 6];
|
|
230
|
+
case 5: throw new Error("Unknown backup JIT method: ".concat(String(method)));
|
|
231
|
+
case 6: return [3 /*break*/, 8];
|
|
232
|
+
case 7:
|
|
233
|
+
e_1 = _c.sent();
|
|
234
|
+
// Log the real error host-side, but send the widget a fixed, non-revealing
|
|
235
|
+
// message — callback exceptions can contain URLs / identifiers / credentials,
|
|
236
|
+
// which must not cross to the independent widget origin.
|
|
237
|
+
console.error('Mesh SDK: backup JIT callback failed', e_1);
|
|
238
|
+
respond({ callId: callId, ok: false, error: 'JIT callback failed' });
|
|
239
|
+
return [3 /*break*/, 8];
|
|
240
|
+
case 8: return [2 /*return*/];
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Fail the backup flow closed: tear down the popup/bridge and listener and exit
|
|
247
|
+
* with an error (never leave a blank or hung screen — design §5A/§5H). Teardown
|
|
248
|
+
* runs before `onExit`, and `reset` clears the session, so the removed iframe
|
|
249
|
+
* cannot emit a later `close`/`done` that would call `onExit` twice.
|
|
250
|
+
*/
|
|
251
|
+
function failBackupClosed(errorMessage) {
|
|
252
|
+
var _a, _b;
|
|
253
|
+
(_a = host.getBridgeParent()) === null || _a === void 0 ? void 0 : _a.destroy();
|
|
254
|
+
host.setBridgeParent(null);
|
|
255
|
+
removePopup();
|
|
256
|
+
host.removeMessageListener();
|
|
257
|
+
var onExit = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onExit;
|
|
258
|
+
reset();
|
|
259
|
+
onExit === null || onExit === void 0 ? void 0 : onExit(errorMessage);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Cascade the backup flow from Tier 1 (widget from the backup origin) to Tier 2
|
|
263
|
+
* (the SDK-bundled offline widget) when the backup origin is unreachable (design
|
|
264
|
+
* §5H). Emits `backupTierChanged`, then swaps the current iframe to the bundled
|
|
265
|
+
* widget loaded as a SANDBOXED, opaque-origin `blob:` URL (not `srcdoc`) — see the
|
|
266
|
+
* detailed rationale at the swap below. The bundle is imported dynamically so
|
|
267
|
+
* Tier-1-only consumers can code-split the ~135 KB asset out of their main chunk.
|
|
268
|
+
*
|
|
269
|
+
* ⚠️ Host CSP: navigating the iframe to a `blob:` URL requires the embedding page's
|
|
270
|
+
* CSP to permit `blob:` in `frame-src`/`child-src` (or `default-src`). A host that
|
|
271
|
+
* only allows the Tier-1 origin there will block the Tier-2 frame and the fallback
|
|
272
|
+
* will time out — this is a documented integration requirement (see README).
|
|
273
|
+
*/
|
|
274
|
+
function cascadeBackupToTier2(reason, sessionId) {
|
|
275
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
276
|
+
var tier1Iframe, html, bundle, _a, iframe, blobUrl;
|
|
277
|
+
var _b, _c, _d;
|
|
278
|
+
return __generator(this, function (_e) {
|
|
279
|
+
switch (_e.label) {
|
|
280
|
+
case 0:
|
|
281
|
+
// Consumer code (often telemetry): a throw here must not abort the cascade —
|
|
282
|
+
// that would leave Tier 1 dead with no Tier 2, and the fail-closed timer would
|
|
283
|
+
// then report the whole backup flow unavailable.
|
|
284
|
+
try {
|
|
285
|
+
(_c = (_b = host.getOptions()) === null || _b === void 0 ? void 0 : _b.onEvent) === null || _c === void 0 ? void 0 : _c.call(_b, {
|
|
286
|
+
type: 'backupTierChanged',
|
|
287
|
+
payload: { from: 'tier1', to: 'tier2', reason: reason }
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
console.error('Mesh SDK: onEvent threw while handling backupTierChanged', e);
|
|
292
|
+
}
|
|
293
|
+
// `onEvent` ran consumer code synchronously; if it opened a new flow, `reset`
|
|
294
|
+
// advanced the session id — bail before touching any iframe.
|
|
295
|
+
if (backupSessionId !== sessionId)
|
|
296
|
+
return [2 /*return*/];
|
|
297
|
+
// Tear down the Tier-1 surface synchronously (before the async import) so
|
|
298
|
+
// nothing from it races the swap.
|
|
299
|
+
backupIframeWindow = null;
|
|
300
|
+
tier1Iframe = host.iframeElement();
|
|
301
|
+
if (tier1Iframe) {
|
|
302
|
+
(_d = host.getBridgeParent()) === null || _d === void 0 ? void 0 : _d.destroy();
|
|
303
|
+
host.setBridgeParent(null);
|
|
304
|
+
tier1Iframe.removeAttribute('src');
|
|
305
|
+
}
|
|
306
|
+
_e.label = 1;
|
|
307
|
+
case 1:
|
|
308
|
+
_e.trys.push([1, 3, , 4]);
|
|
309
|
+
return [4 /*yield*/, import('./backup-bundle')];
|
|
310
|
+
case 2:
|
|
311
|
+
bundle = _e.sent();
|
|
312
|
+
html = bundle.withWidgetTheme(bundle.getBundledOfflineWidget().html, backupTheme);
|
|
313
|
+
return [3 /*break*/, 4];
|
|
314
|
+
case 3:
|
|
315
|
+
_a = _e.sent();
|
|
316
|
+
// The bundled chunk could not be loaded (e.g. the host's own asset host is
|
|
317
|
+
// unreachable). Fail closed — never a blank QR. Only if still this session.
|
|
318
|
+
if (backupSessionId === sessionId) {
|
|
319
|
+
failBackupClosed('Backup deposit flow is unavailable');
|
|
320
|
+
}
|
|
321
|
+
return [2 /*return*/];
|
|
322
|
+
case 4:
|
|
323
|
+
// The session may have ended or been replaced while the bundle was importing —
|
|
324
|
+
// do not touch the current (possibly different) session's iframe.
|
|
325
|
+
if (backupSessionId !== sessionId)
|
|
326
|
+
return [2 /*return*/];
|
|
327
|
+
iframe = host.iframeElement();
|
|
328
|
+
if (!iframe) {
|
|
329
|
+
failBackupClosed('Backup deposit flow is unavailable');
|
|
330
|
+
return [2 /*return*/];
|
|
331
|
+
}
|
|
332
|
+
blobUrl = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
|
|
333
|
+
backupTier2BlobUrl = blobUrl;
|
|
334
|
+
backupTier2 = true;
|
|
335
|
+
iframe.removeAttribute('srcdoc');
|
|
336
|
+
// allow-scripts WITHOUT allow-same-origin ⇒ opaque origin. The iframe's `allow`
|
|
337
|
+
// (Permissions-Policy, incl. clipboard) set at mount is preserved for copy-address.
|
|
338
|
+
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
339
|
+
// A blob: URL can't take a query, so the session nonce rides in the fragment.
|
|
340
|
+
iframe.src = "".concat(blobUrl, "#").concat(SESSION_NONCE_PARAM, "=").concat(sessionNonce !== null && sessionNonce !== void 0 ? sessionNonce : '');
|
|
341
|
+
host.setBridgeParent(new BridgeParent(iframe));
|
|
342
|
+
backupIframeWindow = iframe.contentWindow;
|
|
343
|
+
return [2 /*return*/];
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function open(options, session, backupOptions) {
|
|
349
|
+
var _a, _b, _c, _d;
|
|
350
|
+
removePrewarmIframe();
|
|
351
|
+
// Every abort below tears down any prior backup flow first (reset) so a stale
|
|
352
|
+
// iframe from it can't keep reporting after this failed re-open calls onExit.
|
|
353
|
+
if (!session) {
|
|
354
|
+
reset();
|
|
355
|
+
(_a = options === null || options === void 0 ? void 0 : options.onExit) === null || _a === void 0 ? void 0 : _a.call(options, 'Invalid backup session!');
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
var customIframeId = backupOptions === null || backupOptions === void 0 ? void 0 : backupOptions.customIframeId;
|
|
359
|
+
if ((options === null || options === void 0 ? void 0 : options.renderType) === 'embedded' && !customIframeId) {
|
|
360
|
+
var msg = 'Mesh SDK: Failed to open backup link - renderType "embedded" requires a customIframeId';
|
|
361
|
+
console.error(msg);
|
|
362
|
+
reset();
|
|
363
|
+
(_b = options === null || options === void 0 ? void 0 : options.onExit) === null || _b === void 0 ? void 0 : _b.call(options, msg);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Start a fresh backup session (clears any prior controller/timer), then make
|
|
367
|
+
// THIS createLink's options the live ones so the async JIT/cascade/exit
|
|
368
|
+
// callbacks below (which read `host.getOptions()`) resolve against this session.
|
|
369
|
+
reset();
|
|
370
|
+
if (options)
|
|
371
|
+
host.setOptions(options);
|
|
372
|
+
backupSession = session;
|
|
373
|
+
sessionNonce = createSessionNonce();
|
|
374
|
+
var widgetOrigin = (backupOptions === null || backupOptions === void 0 ? void 0 : backupOptions.widgetOrigin) || DEFAULT_BACKUP_WIDGET_ORIGIN;
|
|
375
|
+
var widgetUrl;
|
|
376
|
+
var widgetOriginParsed;
|
|
377
|
+
try {
|
|
378
|
+
// Only http(s) origins may be loaded into the iframe. `new URL().origin`
|
|
379
|
+
// alone would accept `javascript:`/`data:` schemes, which could execute in
|
|
380
|
+
// the iframe's initial same-origin context — reject anything else, matching
|
|
381
|
+
// the primary flow's protocol check.
|
|
382
|
+
var parsedOrigin = new URL(widgetOrigin);
|
|
383
|
+
if (parsedOrigin.protocol !== 'http:' &&
|
|
384
|
+
parsedOrigin.protocol !== 'https:') {
|
|
385
|
+
throw new Error('widgetOrigin must be an http(s) URL');
|
|
386
|
+
}
|
|
387
|
+
// The widget reads its params (incl. the required session nonce) from the
|
|
388
|
+
// query; with a fragment in the base they'd be appended after `#`, the widget
|
|
389
|
+
// couldn't echo `sid`, and every open would fall to Tier 2.
|
|
390
|
+
if (parsedOrigin.hash || widgetOrigin.includes('#')) {
|
|
391
|
+
throw new Error('widgetOrigin must not contain a fragment');
|
|
392
|
+
}
|
|
393
|
+
// The widget reads only `theme=dark|light`; `system`/unset is left to its
|
|
394
|
+
// own `prefers-color-scheme` fallback.
|
|
395
|
+
var theme = (options === null || options === void 0 ? void 0 : options.theme) === 'dark' || (options === null || options === void 0 ? void 0 : options.theme) === 'light'
|
|
396
|
+
? options.theme
|
|
397
|
+
: undefined;
|
|
398
|
+
backupTheme = theme;
|
|
399
|
+
widgetUrl = buildBackupWidgetUrl(widgetOrigin, {
|
|
400
|
+
platform: sdkSpecs.platform,
|
|
401
|
+
sdkVersion: sdkSpecs.version,
|
|
402
|
+
theme: theme,
|
|
403
|
+
sessionNonce: sessionNonce
|
|
404
|
+
});
|
|
405
|
+
widgetOriginParsed = new URL(widgetUrl).origin;
|
|
406
|
+
host.setLinkTokenOrigin(widgetOriginParsed);
|
|
407
|
+
}
|
|
408
|
+
catch (_e) {
|
|
409
|
+
reset();
|
|
410
|
+
(_c = options === null || options === void 0 ? void 0 : options.onExit) === null || _c === void 0 ? void 0 : _c.call(options, 'Invalid backup widget origin!');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// Origin validated — this backup flow is now the live one. (A later no-iframe
|
|
414
|
+
// abort below calls `reset()`, which clears this again.) Access tokens are never
|
|
415
|
+
// forwarded while `activeFlow === 'backup'`.
|
|
416
|
+
activeFlow = 'backup';
|
|
417
|
+
host.removeMessageListener();
|
|
418
|
+
if (customIframeId) {
|
|
419
|
+
var iframe_1 = document.getElementById(customIframeId);
|
|
420
|
+
if (!iframe_1) {
|
|
421
|
+
// No surface to render into — fail closed rather than leave the session
|
|
422
|
+
// active with no iframe/controller (which would hang with no onExit).
|
|
423
|
+
var msg = "Mesh SDK: No iframe found with id ".concat(customIframeId);
|
|
424
|
+
console.warn(msg);
|
|
425
|
+
reset();
|
|
426
|
+
(_d = options === null || options === void 0 ? void 0 : options.onExit) === null || _d === void 0 ? void 0 : _d.call(options, msg);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
iframe_1.allow = buildIframeAllowPolicy(widgetOriginParsed);
|
|
430
|
+
// A reused embedded iframe may still carry Tier-2 state from a prior backup
|
|
431
|
+
// session — clear `srcdoc` (precedence over `src`) and `sandbox` (opaque).
|
|
432
|
+
iframe_1.removeAttribute('srcdoc');
|
|
433
|
+
iframe_1.removeAttribute('sandbox');
|
|
434
|
+
iframe_1.src = widgetUrl;
|
|
435
|
+
host.setCurrentIframeId(customIframeId);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
host.setCurrentIframeId(iframeId);
|
|
439
|
+
addPopup(widgetUrl);
|
|
440
|
+
}
|
|
441
|
+
host.addMessageListener();
|
|
442
|
+
host.setTargetOrigin(window.location.origin);
|
|
443
|
+
var iframe = host.iframeElement();
|
|
444
|
+
if (iframe) {
|
|
445
|
+
host.setBridgeParent(new BridgeParent(iframe));
|
|
446
|
+
// Track this session's widget window so JIT requests are only honoured when
|
|
447
|
+
// they come from it (event.source check in `handleJitRequest`).
|
|
448
|
+
backupIframeWindow = iframe.contentWindow;
|
|
449
|
+
// Capture the session id so the cascade/fail-closed/error callbacks below
|
|
450
|
+
// only act while this session is still the active one.
|
|
451
|
+
var sessionId_1 = backupSessionId;
|
|
452
|
+
// Arm the Tier-1 → Tier-2 cascade. If the backup origin never completes its
|
|
453
|
+
// ready handshake (unreachable / served-but-broken), fall back to the
|
|
454
|
+
// bundled offline widget; if even that never becomes ready, fail closed.
|
|
455
|
+
backupTierController = createBackupTierController({
|
|
456
|
+
onFallback: function (reason) {
|
|
457
|
+
if (backupSessionId === sessionId_1) {
|
|
458
|
+
void cascadeBackupToTier2(reason, sessionId_1);
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
onTier2Unavailable: function () {
|
|
462
|
+
if (backupSessionId === sessionId_1) {
|
|
463
|
+
failBackupClosed('Backup deposit flow is unavailable');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
// Best-effort hard-failure signal: a cross-origin iframe rarely fires `error`
|
|
468
|
+
// for a failed navigation (the ready-handshake timeout is the authoritative
|
|
469
|
+
// Tier-1 signal), but when it does fire we cascade at once instead of waiting
|
|
470
|
+
// the timeout out. Session-bound + removed on teardown so a late error from
|
|
471
|
+
// an old (embedded) iframe can't cascade a later session.
|
|
472
|
+
var onIframeError_1 = function () {
|
|
473
|
+
if (backupSessionId === sessionId_1) {
|
|
474
|
+
backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.reportLoadError();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
iframe.addEventListener('error', onIframeError_1);
|
|
478
|
+
removeBackupIframeErrorListener = function () {
|
|
479
|
+
return iframe.removeEventListener('error', onIframeError_1);
|
|
480
|
+
};
|
|
481
|
+
backupTierController.start();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function isFromActiveWidget(event) {
|
|
485
|
+
var _a;
|
|
486
|
+
return (!!backupSession &&
|
|
487
|
+
!!sessionNonce &&
|
|
488
|
+
!!event.source &&
|
|
489
|
+
event.source === backupIframeWindow &&
|
|
490
|
+
((_a = event.data) === null || _a === void 0 ? void 0 : _a[SESSION_NONCE_PARAM]) === sessionNonce);
|
|
491
|
+
}
|
|
492
|
+
function handleJitRequest(event) {
|
|
493
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
494
|
+
return __generator(this, function (_a) {
|
|
495
|
+
switch (_a.label) {
|
|
496
|
+
case 0:
|
|
497
|
+
if (!isFromActiveWidget(event)) return [3 /*break*/, 2];
|
|
498
|
+
return [4 /*yield*/, runJitRequest(event.data.payload)];
|
|
499
|
+
case 1:
|
|
500
|
+
_a.sent();
|
|
501
|
+
_a.label = 2;
|
|
502
|
+
case 2: return [2 /*return*/];
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function completeReadyHandshake(event) {
|
|
508
|
+
// Only the backup widget's OWN window may complete the ready handshake. The
|
|
509
|
+
// host's message handler also admits host-origin messages (Tier 1), so without
|
|
510
|
+
// this an unrelated same-origin frame posting `{ type: 'loaded' }` would call
|
|
511
|
+
// `markReady()` and permanently cancel the Tier-1 → Tier-2 fallback. The nonce
|
|
512
|
+
// check stops a stale `loaded` from a previous open in a reused embedded iframe
|
|
513
|
+
// (same window, same origin) doing the same.
|
|
514
|
+
if (isFromActiveWidget(event)) {
|
|
515
|
+
// This `loaded` IS the ready handshake — cancel the pending tier timeout
|
|
516
|
+
// (Tier-1 healthy, or Tier-2 mounted) before delivering the config.
|
|
517
|
+
backupTierController === null || backupTierController === void 0 ? void 0 : backupTierController.markReady();
|
|
518
|
+
host.sendMessageToIframe({
|
|
519
|
+
type: BACKUP_CONFIG_MESSAGE_TYPE,
|
|
520
|
+
payload: backupSession
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
reset: reset,
|
|
526
|
+
setPrimaryFlow: function () {
|
|
527
|
+
activeFlow = 'primary';
|
|
528
|
+
},
|
|
529
|
+
getActiveFlow: function () { return activeFlow; },
|
|
530
|
+
isTier2: function () { return backupTier2; },
|
|
531
|
+
isFromActiveWidget: isFromActiveWidget,
|
|
532
|
+
open: open,
|
|
533
|
+
handleJitRequest: handleJitRequest,
|
|
534
|
+
completeReadyHandshake: completeReadyHandshake
|
|
535
|
+
};
|
|
536
|
+
}
|
package/index.d.ts
CHANGED
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meshconnect/web-link-sdk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.14.1",
|
|
4
4
|
"description": "A client-side JS library for integrating with Mesh Connect",
|
|
5
5
|
"exports": "./index.js",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,37 +10,8 @@
|
|
|
10
10
|
"node": ">=22"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@meshconnect/node-api": "^2.0.
|
|
14
|
-
"@meshconnect/uwc-bridge-parent": "1.
|
|
15
|
-
},
|
|
16
|
-
"jest": {
|
|
17
|
-
"preset": "ts-jest",
|
|
18
|
-
"testEnvironment": "jsdom",
|
|
19
|
-
"setupFiles": [
|
|
20
|
-
"./jest.setup.ts"
|
|
21
|
-
],
|
|
22
|
-
"testPathIgnorePatterns": [
|
|
23
|
-
"/node_modules/",
|
|
24
|
-
"/dist/"
|
|
25
|
-
],
|
|
26
|
-
"coverageDirectory": "./coverage",
|
|
27
|
-
"transform": {
|
|
28
|
-
"^.+\\.[tj]sx?$": [
|
|
29
|
-
"babel-jest",
|
|
30
|
-
{
|
|
31
|
-
"presets": [
|
|
32
|
-
"@babel/preset-env",
|
|
33
|
-
"@babel/preset-typescript"
|
|
34
|
-
]
|
|
35
|
-
}
|
|
36
|
-
]
|
|
37
|
-
},
|
|
38
|
-
"transformIgnorePatterns": [
|
|
39
|
-
"/node_modules/(?!(@meshconnect/solana-web3.js|uuid|jayson|superstruct|@noble|@meshconnect|ethers)/)"
|
|
40
|
-
],
|
|
41
|
-
"testEnvironmentOptions": {
|
|
42
|
-
"url": "http://localhost/"
|
|
43
|
-
}
|
|
13
|
+
"@meshconnect/node-api": "^2.0.29",
|
|
14
|
+
"@meshconnect/uwc-bridge-parent": "1.7.0"
|
|
44
15
|
},
|
|
45
16
|
"module": "./index.js",
|
|
46
17
|
"main": "./index.js",
|