@meshconnect/web-link-sdk 3.13.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 +2 -2
- 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,7 +10,7 @@
|
|
|
10
10
|
"node": ">=22"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@meshconnect/node-api": "^2.0.
|
|
13
|
+
"@meshconnect/node-api": "^2.0.29",
|
|
14
14
|
"@meshconnect/uwc-bridge-parent": "1.7.0"
|
|
15
15
|
},
|
|
16
16
|
"module": "./index.js",
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default origin for the standalone backup deposit widget (OR-449): the production
|
|
3
|
+
* Cloudflare Worker. It is under the `meshconnect.com` zone but served by
|
|
4
|
+
* Cloudflare, not Azure, so it shares no failure domain with the primary Mesh API
|
|
5
|
+
* (design doc §5C) — if the core API is down the widget still loads. Matches the
|
|
6
|
+
* React Native SDK's default. Override per-call via `openLinkBackup`'s
|
|
7
|
+
* `widgetOrigin` option for staging, demo, or self-host. Money path: this origin
|
|
8
|
+
* serves the deposit-address UI during an outage.
|
|
9
|
+
*/
|
|
10
|
+
export declare const DEFAULT_BACKUP_WIDGET_ORIGIN = "https://backup.meshconnect.com";
|
|
11
|
+
/**
|
|
12
|
+
* The `type` the backup widget's message bridge requires on the config it
|
|
13
|
+
* receives. The widget matches `event.data.type === 'meshBackupConfig'` and
|
|
14
|
+
* drops any message without it, so the config must be delivered as
|
|
15
|
+
* `{ type: BACKUP_CONFIG_MESSAGE_TYPE, payload: MeshBackupConfig }`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const BACKUP_CONFIG_MESSAGE_TYPE = "meshBackupConfig";
|
|
18
|
+
/**
|
|
19
|
+
* JIT-over-bridge RPC message types (OR-452). An address-less destination is
|
|
20
|
+
* resolved by the widget invoking the host's `onAddressInit`/`onStatusPoll`
|
|
21
|
+
* callbacks over this same postMessage bridge, correlated by `callId` — the
|
|
22
|
+
* widget never calls a client HTTP endpoint and holds no token.
|
|
23
|
+
*
|
|
24
|
+
* - Widget → host: `{ type: JIT_REQUEST_MESSAGE_TYPE, payload: MeshBackupJitRequestPayload }`
|
|
25
|
+
* - Host → widget: `{ type: JIT_RESPONSE_MESSAGE_TYPE, payload: MeshBackupJitResponsePayload }`
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ KEEP IN SYNC with `mesh-backup-widget`'s `src/bridge/contract.ts` — the
|
|
28
|
+
* string values and payload shapes are the frozen cross-repo contract.
|
|
29
|
+
*/
|
|
30
|
+
export declare const JIT_REQUEST_MESSAGE_TYPE = "meshBackupJitRequest";
|
|
31
|
+
export declare const JIT_RESPONSE_MESSAGE_TYPE = "meshBackupJitResponse";
|
|
32
|
+
/**
|
|
33
|
+
* How long to wait for the Tier-1 widget's ready handshake (its `loaded`
|
|
34
|
+
* message) after the iframe starts loading the backup origin, before treating
|
|
35
|
+
* the origin as unreachable and cascading to the bundled Tier-2 assets.
|
|
36
|
+
*
|
|
37
|
+
* On the web this is the **authoritative** Tier-1 failure signal: a cross-origin
|
|
38
|
+
* iframe does not reliably fire a load `error` event for a network/DNS failure
|
|
39
|
+
* (and `onload` fires even for an error page), so a served-but-broken, hung, or
|
|
40
|
+
* captive-portal-intercepted origin is only caught by the absence of the ready
|
|
41
|
+
* handshake. 5000 ms absorbs a slow mobile network with margin (the widget shell
|
|
42
|
+
* is ~24 KB on a CDN) while keeping the degraded-path UX acceptable. Per-SDK
|
|
43
|
+
* tunable constant (design §5H).
|
|
44
|
+
*/
|
|
45
|
+
export declare const TIER1_READY_TIMEOUT_MS = 5000;
|
|
46
|
+
/**
|
|
47
|
+
* Fail-closed safety net for Tier 2: the bundled assets load from a local `blob:`
|
|
48
|
+
* URL, so they should complete the ready handshake near-instantly.
|
|
49
|
+
* If they somehow do not within this window, the flow exits with an error rather
|
|
50
|
+
* than sitting on a blank screen (design §5A/§5H — never a blank QR). Deliberately
|
|
51
|
+
* generous because a Tier-2 timeout should be effectively unreachable.
|
|
52
|
+
*/
|
|
53
|
+
export declare const TIER2_READY_TIMEOUT_MS = 8000;
|
|
54
|
+
export interface BackupWidgetUrlParams {
|
|
55
|
+
/** SDK platform identifier (`'web'`). */
|
|
56
|
+
platform: string;
|
|
57
|
+
/** SDK version, for widget-side diagnostics. */
|
|
58
|
+
sdkVersion: string;
|
|
59
|
+
/**
|
|
60
|
+
* Resolved colour theme. Appended as `?theme=dark|light` — the only theme
|
|
61
|
+
* value the backup widget reads. Omit for `system`/unset so the widget falls
|
|
62
|
+
* back to `prefers-color-scheme`.
|
|
63
|
+
*/
|
|
64
|
+
theme?: 'dark' | 'light';
|
|
65
|
+
/**
|
|
66
|
+
* Per-open session nonce, appended as `?sid=`. The widget echoes it on every
|
|
67
|
+
* message it posts to the host, so a reused embedded iframe's stale messages
|
|
68
|
+
* from a previous open can be told apart (see `createSessionNonce`).
|
|
69
|
+
*/
|
|
70
|
+
sessionNonce?: string;
|
|
71
|
+
}
|
|
72
|
+
/** URL parameter carrying the per-open session nonce (query for Tier 1, fragment
|
|
73
|
+
* for the Tier-2 `blob:` URL). KEEP IN SYNC with `mesh-backup-widget`'s
|
|
74
|
+
* `src/bridge/sessionNonce.ts`. */
|
|
75
|
+
export declare const SESSION_NONCE_PARAM = "sid";
|
|
76
|
+
/**
|
|
77
|
+
* A fresh, unguessable nonce for one backup open (128 bits, hex). Navigating an
|
|
78
|
+
* embedded iframe keeps its `WindowProxy` and Tier-1 sessions share an origin, so
|
|
79
|
+
* `event.source` + origin alone can't tell the current open's messages from ones
|
|
80
|
+
* the previous document queued before a reopen; the widget echoes this value as
|
|
81
|
+
* `sid` on every message and the SDK drops any that don't match.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createSessionNonce(): string;
|
|
84
|
+
/**
|
|
85
|
+
* Builds the URL the backup-widget iframe loads. Unlike the primary path there
|
|
86
|
+
* is no link token to decode — the widget is a static SPA at `origin` and is
|
|
87
|
+
* hydrated with the `MeshBackupConfig` over the message bridge after load. Only
|
|
88
|
+
* non-sensitive display hints are carried on the URL; the deposit config never
|
|
89
|
+
* appears in it.
|
|
90
|
+
*
|
|
91
|
+
* A trailing slash on `origin` is normalised away so params attach cleanly.
|
|
92
|
+
*/
|
|
93
|
+
export declare function buildBackupWidgetUrl(origin: string, params: BackupWidgetUrlParams): string;
|