@salesforce-ui-embedding/internal-lightning-ui-embedding 2.2.7-rc.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/LICENSE.txt +21 -0
- package/README.md +3 -0
- package/dist/modules/lightning/uiEmbedding/uiEmbedding.css +23 -0
- package/dist/modules/lightning/uiEmbedding/uiEmbedding.html +8 -0
- package/dist/modules/lightning/uiEmbedding/uiEmbedding.js +965 -0
- package/dist/modules/lightning/uiEmbedding/uiEmbedding.js-meta.xml +9 -0
- package/dist/modules/lightning/uiEmbedding/utils/bootstrap.js +63 -0
- package/dist/modules/lightning/uiEmbedding/utils/constants.js +43 -0
- package/dist/modules/lightning/uiEmbedding/utils/host-bridge.js +150 -0
- package/dist/modules/lightning/uiEmbedding/utils/host-event-registry.js +35 -0
- package/dist/modules/lightning/uiEmbedding/utils/host-info.js +13 -0
- package/dist/modules/lightning/uiEmbedding/utils/instrumentation.js +90 -0
- package/dist/modules/lightning/uiEmbedding/utils/jsonrpc.js +122 -0
- package/dist/modules/lightning/uiEmbedding/utils/log.js +12 -0
- package/dist/modules/lightning/uiEmbedding/utils/request-router.js +375 -0
- package/dist/modules/lightning/uiEmbedding/utils/sandbox.js +27 -0
- package/dist/modules/lightning/uiEmbedding/utils/sf-embedding-contract.js +66 -0
- package/dist/modules/lightning/uiEmbedding/utils/ui-state.js +76 -0
- package/dist/modules/lightning/uiEmbedding/utils/url.js +21 -0
- package/dist/modules/types/external.js +0 -0
- package/package.json +32 -0
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Salesforce, Inc. All rights reserved.
|
|
3
|
+
* For full license text, see the LICENSE.txt file in this package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { api, LightningElement } from "lwc";
|
|
7
|
+
|
|
8
|
+
import { buildBootstrapEnvelope, validateHeartbeat, validateShutdown } from "./utils/bootstrap";
|
|
9
|
+
import { DEFAULT_HEARTBEAT_TIMEOUT_MS, ERROR_CODES, ERROR_PHASES, STATES } from "./utils/constants";
|
|
10
|
+
import { HostBridge } from "./utils/host-bridge";
|
|
11
|
+
import { HOST_INFO } from "./utils/host-info";
|
|
12
|
+
import { createInstrumentation } from "./utils/instrumentation";
|
|
13
|
+
import { createLogger } from "./utils/log";
|
|
14
|
+
import { computeSandboxTokens } from "./utils/sandbox";
|
|
15
|
+
import {
|
|
16
|
+
EVENTS_DISPATCH_METHOD,
|
|
17
|
+
EVENTS_SUBSCRIBE_METHOD,
|
|
18
|
+
EVENTS_UNSUBSCRIBE_METHOD,
|
|
19
|
+
HEARTBEAT_TYPE,
|
|
20
|
+
HostLwcEvent,
|
|
21
|
+
RESIZE_METHOD,
|
|
22
|
+
SHUTDOWN_TYPE,
|
|
23
|
+
UI_STATE_CHANGED_METHOD,
|
|
24
|
+
UI_STATE_SUBSCRIBE_METHOD,
|
|
25
|
+
UI_STATE_UNSUBSCRIBE_METHOD,
|
|
26
|
+
} from "./utils/sf-embedding-contract";
|
|
27
|
+
import { composeUiState, MIRRORED_ATTRIBUTES } from "./utils/ui-state";
|
|
28
|
+
import { appendHostMetaData, parseOrigin } from "./utils/url";
|
|
29
|
+
|
|
30
|
+
/** Stable reference for null/undefined `props` so the setter's `===` guard can short-circuit. */
|
|
31
|
+
const EMPTY_PROPS = Object.freeze({});
|
|
32
|
+
|
|
33
|
+
/** Reserved event names that must never be forwarded (component's own outbound events). */
|
|
34
|
+
const RESERVED_EVENT_NAMES = new Set([HostLwcEvent.READY, HostLwcEvent.ERROR]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize event.detail for MessageChannel: JSON round-trip an object to strip LWC/LWS membrane
|
|
38
|
+
* proxies that fail structured-clone. Strings and other primitives pass through untouched (no auto-parse).
|
|
39
|
+
*
|
|
40
|
+
* @remarks Intentional JSON-only contract — structuredClone is NOT usable here (it throws DataCloneError on the
|
|
41
|
+
* membrane proxy identically to postMessage; the round-trip reads through the proxy via property gets). The contract
|
|
42
|
+
* is lossy for out-of-band types: Date→ISO string, Map/Set→{}, undefined/functions/symbols dropped, NaN/Infinity→null,
|
|
43
|
+
* BigInt/circular refs throw (caught + logged as EVENT_FORWARD_FAILED, forward skipped, local dispatch still fires).
|
|
44
|
+
* Dispatch only primitives + plain objects in CustomEvent detail.
|
|
45
|
+
*/
|
|
46
|
+
function toCloneableDetail(detail) {
|
|
47
|
+
if (detail !== null && typeof detail === "object") {
|
|
48
|
+
return JSON.parse(JSON.stringify(detail));
|
|
49
|
+
}
|
|
50
|
+
return detail;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default class Embedding extends LightningElement {
|
|
54
|
+
_iframe = null;
|
|
55
|
+
_container = null;
|
|
56
|
+
_frameShadow = null;
|
|
57
|
+
_iframeCreated = false;
|
|
58
|
+
|
|
59
|
+
_currentState = STATES.PRE_HEARTBEAT;
|
|
60
|
+
_instanceId = crypto.randomUUID();
|
|
61
|
+
_debugEnabled = false;
|
|
62
|
+
_log = createLogger("[lightning-ui-embedding]", () => this._debugEnabled);
|
|
63
|
+
|
|
64
|
+
_channel = null;
|
|
65
|
+
_hostPort = null;
|
|
66
|
+
_portTransferred = false;
|
|
67
|
+
_heartbeatHandler = null;
|
|
68
|
+
|
|
69
|
+
// True once the iframe element has fired at least one `load` event on the current channel.
|
|
70
|
+
// Reset in `_resetChannel`. Paired with `_portTransferred` to discriminate a real full-doc-nav
|
|
71
|
+
// reload from the initial-bootstrap load that fires AFTER the embedded document's heartbeat.
|
|
72
|
+
//
|
|
73
|
+
// Why this matters: when the embedded app uses the Platform SDK, the SDK defers its heartbeat
|
|
74
|
+
// until DOMContentLoaded, so the parent's `load` task is queued before the SDK's `message`
|
|
75
|
+
// task and the natural order holds. But the protocol is also documented as plain
|
|
76
|
+
// `postMessage`-friendly so integrators (e.g. DVAG, who asked us to support this) can embed
|
|
77
|
+
// without the SDK — an inline `<script>` in the document head can call
|
|
78
|
+
// `parent.postMessage("sf-embedding/ready", …)` during HTML parse, long before the iframe's
|
|
79
|
+
// `load` event fires at the parent. The parent's event loop is then free to process the
|
|
80
|
+
// queued `message` task before the queued `load` task, so the very first `load` arrives with
|
|
81
|
+
// the port already transferred.
|
|
82
|
+
_initialLoadConsumed = false;
|
|
83
|
+
|
|
84
|
+
// Infinity disables the deadline; AbortController allows active cancellation.
|
|
85
|
+
_heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
86
|
+
_heartbeatController = null;
|
|
87
|
+
|
|
88
|
+
_src = null;
|
|
89
|
+
_iframeSrcOrigin = null;
|
|
90
|
+
_sandbox = null;
|
|
91
|
+
_removeSandboxTokens = null;
|
|
92
|
+
_title = "Embedded widget";
|
|
93
|
+
|
|
94
|
+
_bridge = null;
|
|
95
|
+
_negotiatedProtocolVersion = null;
|
|
96
|
+
|
|
97
|
+
// UiState provider state. The active wire subscription itself lives on the
|
|
98
|
+
// HostBridge (it owns the inbound subscribe/unsubscribe requests); the
|
|
99
|
+
// component owns the snapshot content and change detection below.
|
|
100
|
+
_props = EMPTY_PROPS;
|
|
101
|
+
_mutationObserver = null;
|
|
102
|
+
_uiStateFlushScheduled = false;
|
|
103
|
+
|
|
104
|
+
/** Re-entrancy guard: tracks the specific event objects we're re-dispatching from the embedding,
|
|
105
|
+
* so the override below skips only those (not unrelated events fired by listeners during the same task). */
|
|
106
|
+
_inboundDispatches = new WeakSet();
|
|
107
|
+
_forwardTarget = null;
|
|
108
|
+
_originalDispatchEvent = null;
|
|
109
|
+
|
|
110
|
+
// Instrumentation
|
|
111
|
+
_instrumentation = null;
|
|
112
|
+
_loadActivity = null;
|
|
113
|
+
_loadActivityStopped = false;
|
|
114
|
+
_activityStartedAt = 0;
|
|
115
|
+
|
|
116
|
+
@api
|
|
117
|
+
get src() {
|
|
118
|
+
return this._src;
|
|
119
|
+
}
|
|
120
|
+
set src(value) {
|
|
121
|
+
const val = value || null;
|
|
122
|
+
if (this._src === val) return;
|
|
123
|
+
if (!this._canMutateSessionBinding("src")) return;
|
|
124
|
+
this._src = val;
|
|
125
|
+
this._iframeSrcOrigin = val ? parseOrigin(val) : null;
|
|
126
|
+
this._updateIframeSrc();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@api
|
|
130
|
+
get sandbox() {
|
|
131
|
+
return this._sandbox;
|
|
132
|
+
}
|
|
133
|
+
set sandbox(value) {
|
|
134
|
+
const val = value || null;
|
|
135
|
+
if (this._sandbox === val) return;
|
|
136
|
+
if (!this._canMutateSessionBinding("sandbox")) return;
|
|
137
|
+
this._sandbox = val;
|
|
138
|
+
this._applySandbox();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@api
|
|
142
|
+
get removeSandboxTokens() {
|
|
143
|
+
return this._removeSandboxTokens;
|
|
144
|
+
}
|
|
145
|
+
set removeSandboxTokens(value) {
|
|
146
|
+
const val = value || null;
|
|
147
|
+
if (this._removeSandboxTokens === val) return;
|
|
148
|
+
if (!this._canMutateSessionBinding("removeSandboxTokens")) return;
|
|
149
|
+
this._removeSandboxTokens = val;
|
|
150
|
+
this._applySandbox();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@api
|
|
154
|
+
get props() {
|
|
155
|
+
return this._props;
|
|
156
|
+
}
|
|
157
|
+
set props(value) {
|
|
158
|
+
// Reference equality: authors must assign a new object to trigger ui-state-changed.
|
|
159
|
+
// null/undefined coerce to the shared EMPTY_PROPS sentinel so repeated unsets are a no-op.
|
|
160
|
+
const next = value ?? EMPTY_PROPS;
|
|
161
|
+
if (this._props === next) return;
|
|
162
|
+
this._props = next;
|
|
163
|
+
this._scheduleUiStateFlush();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@api
|
|
167
|
+
get title() {
|
|
168
|
+
return this._title;
|
|
169
|
+
}
|
|
170
|
+
set title(value) {
|
|
171
|
+
this._title = value || "embedding";
|
|
172
|
+
this._reflectHostTitle();
|
|
173
|
+
this._updateTitle();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@api
|
|
177
|
+
get debug() {
|
|
178
|
+
return this._debugEnabled;
|
|
179
|
+
}
|
|
180
|
+
set debug(value) {
|
|
181
|
+
this._debugEnabled = !!value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Positive number arms the deadline; Infinity disables it; anything else uses the default.
|
|
185
|
+
// Advisory only — fires sf-embedding.component.error / HEARTBEAT_TIMEOUT, no teardown.
|
|
186
|
+
@api
|
|
187
|
+
get heartbeatTimeoutMs() {
|
|
188
|
+
return this._heartbeatTimeoutMs;
|
|
189
|
+
}
|
|
190
|
+
set heartbeatTimeoutMs(value) {
|
|
191
|
+
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
|
|
192
|
+
this._heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
|
|
193
|
+
} else {
|
|
194
|
+
this._heartbeatTimeoutMs = value === Infinity ? Infinity : Math.floor(value);
|
|
195
|
+
}
|
|
196
|
+
if (this._heartbeatController) {
|
|
197
|
+
this._armHeartbeatDeadline();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@api
|
|
202
|
+
get bootstrapPhase() {
|
|
203
|
+
return this._currentState;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@api
|
|
207
|
+
get resolvedSandbox() {
|
|
208
|
+
return computeSandboxTokens(this._sandbox, this._removeSandboxTokens);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
get currentState() {
|
|
212
|
+
return this._currentState;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
connectedCallback() {
|
|
216
|
+
this._installHeartbeatListener();
|
|
217
|
+
this._installMutationObserver();
|
|
218
|
+
this._installHostDispatchOverride();
|
|
219
|
+
this._instrumentation = createInstrumentation("LightningUiEmbedding", this._src, window.location.origin);
|
|
220
|
+
this._startLoadActivity();
|
|
221
|
+
this._log("connectedCallback");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
renderedCallback() {
|
|
225
|
+
if (this._iframeCreated) return;
|
|
226
|
+
this._iframeCreated = true;
|
|
227
|
+
|
|
228
|
+
this._container = this.template.querySelector(".container");
|
|
229
|
+
if (!this._container) return;
|
|
230
|
+
|
|
231
|
+
const configError = this._validateConfiguration();
|
|
232
|
+
if (configError) {
|
|
233
|
+
this._instrumentation?.incrementCounter("sfembedding.config.validation_failed", 1, true, {
|
|
234
|
+
reason: configError.code,
|
|
235
|
+
});
|
|
236
|
+
this._error(ERROR_PHASES.CONFIGURATION, configError.code, configError.message, STATES.CLOSED);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this._frameShadow = this._container.attachShadow({ mode: "closed" });
|
|
241
|
+
const innerStyle = document.createElement("style");
|
|
242
|
+
innerStyle.textContent = `
|
|
243
|
+
.frame {
|
|
244
|
+
visibility: var(--frame-visibility, hidden);
|
|
245
|
+
display: block;
|
|
246
|
+
width: 100%;
|
|
247
|
+
height: 100%;
|
|
248
|
+
border: none;
|
|
249
|
+
}
|
|
250
|
+
`;
|
|
251
|
+
this._frameShadow.appendChild(innerStyle);
|
|
252
|
+
|
|
253
|
+
const frame = document.createElement("iframe");
|
|
254
|
+
frame.className = "frame";
|
|
255
|
+
frame.title = this._title;
|
|
256
|
+
frame.addEventListener("load", () => this._handleIframeLoad());
|
|
257
|
+
|
|
258
|
+
this._frameShadow.appendChild(frame);
|
|
259
|
+
this._iframe = frame;
|
|
260
|
+
|
|
261
|
+
this._applySandbox();
|
|
262
|
+
this._updateIframeSrc();
|
|
263
|
+
|
|
264
|
+
this._log("renderedCallback: iframe ready");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/sandbox/removeSandboxTokens are session-binding — read at mount, MUST NOT
|
|
268
|
+
// be mutated mid-instance. Post-mount changes are an unmount/remount boundary, so we reject
|
|
269
|
+
// them with a configuration error rather than silently re-rendering.
|
|
270
|
+
_canMutateSessionBinding(prop) {
|
|
271
|
+
if (!this._iframeCreated) return true;
|
|
272
|
+
this._instrumentation?.incrementCounter("sfembedding.session_binding.mutation_rejected", 1, true, {
|
|
273
|
+
property: prop,
|
|
274
|
+
});
|
|
275
|
+
this._error(
|
|
276
|
+
ERROR_PHASES.CONFIGURATION,
|
|
277
|
+
ERROR_CODES.SESSION_BINDING_MUTATED,
|
|
278
|
+
`'${prop}' is session-binding; remount the component to change it.`,
|
|
279
|
+
);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_validateConfiguration() {
|
|
284
|
+
const tokens = computeSandboxTokens(this._sandbox, this._removeSandboxTokens).split(" ");
|
|
285
|
+
if (!tokens.includes("allow-scripts")) {
|
|
286
|
+
return {
|
|
287
|
+
code: ERROR_CODES.ALLOW_SCRIPTS_STRIPPED,
|
|
288
|
+
message: "iframe sandbox MUST include 'allow-scripts'; embedding cannot run without it.",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
if (tokens.includes("allow-top-navigation")) {
|
|
292
|
+
return {
|
|
293
|
+
code: ERROR_CODES.BLOCKED_SANDBOX_TOKEN,
|
|
294
|
+
message: "iframe sandbox MUST NOT include 'allow-top-navigation'; poses phishing risk.",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (tokens.includes("allow-popups-to-escape-sandbox")) {
|
|
298
|
+
return {
|
|
299
|
+
code: ERROR_CODES.BLOCKED_SANDBOX_TOKEN,
|
|
300
|
+
message: "iframe sandbox MUST NOT include 'allow-popups-to-escape-sandbox'; poses security risk.",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (!this._src) {
|
|
304
|
+
return {
|
|
305
|
+
code: ERROR_CODES.MISSING_SRC,
|
|
306
|
+
message: "iframe src MUST be provided; the embedding cannot start without a src.",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (!this._iframeSrcOrigin) {
|
|
310
|
+
return {
|
|
311
|
+
code: ERROR_CODES.INVALID_SRC,
|
|
312
|
+
message: "iframe src MUST be a parseable URL.",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
if (this._iframeSrcOrigin === window.location.origin) {
|
|
316
|
+
return {
|
|
317
|
+
code: ERROR_CODES.SAME_ORIGIN_SRC,
|
|
318
|
+
message: "iframe src origin MUST differ from the host document origin.",
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Single error entry point. Reports exactly one uxerr — scoped to the open embedding_init span
|
|
326
|
+
* (which also counts embedding.init.error) when one is active, otherwise a standalone log —
|
|
327
|
+
* fires the public ERROR event, and logs. `code` doubles as the telemetry errorType. Pass
|
|
328
|
+
* `nextState` (e.g. STATES.CLOSED) to transition the instance. Pass `details.retryable = true`
|
|
329
|
+
* for transient errors where the embedding may recover (e.g. heartbeat timeout).
|
|
330
|
+
*/
|
|
331
|
+
_error(phase, code, message, nextState, details) {
|
|
332
|
+
if (nextState) this._currentState = nextState;
|
|
333
|
+
|
|
334
|
+
if (!this._loadActivityStopped && this._loadActivity) {
|
|
335
|
+
this._errorLoadActivity(new Error(message), code);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Error telemetry: phase/code/message only (PII-safe — no params.props).
|
|
339
|
+
this._instrumentation?.logError(new Error(message), { phase, code, instanceId: this._instanceId });
|
|
340
|
+
|
|
341
|
+
this.dispatchEvent(
|
|
342
|
+
// eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
|
|
343
|
+
new CustomEvent(HostLwcEvent.ERROR, {
|
|
344
|
+
detail: { instanceId: this._instanceId, phase, code, message, retryable: details?.retryable ?? false },
|
|
345
|
+
bubbles: true,
|
|
346
|
+
composed: true,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
this._log(`${code} —`, message);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
disconnectedCallback() {
|
|
354
|
+
// TODO: host-initiated teardown: send ui/notifications/shutdown + 500ms drain before close.
|
|
355
|
+
this._cancelHeartbeatDeadline();
|
|
356
|
+
if (this._heartbeatHandler) {
|
|
357
|
+
window.removeEventListener("message", this._heartbeatHandler);
|
|
358
|
+
this._heartbeatHandler = null;
|
|
359
|
+
}
|
|
360
|
+
if (this._mutationObserver) {
|
|
361
|
+
this._mutationObserver.disconnect();
|
|
362
|
+
this._mutationObserver = null;
|
|
363
|
+
}
|
|
364
|
+
this._restoreOriginalDispatchEvent();
|
|
365
|
+
// A pending microtask flush isn't cancellable, but `_closeHostBridge` nulls `_bridge` so the flush no-ops.
|
|
366
|
+
this._closeHostBridge();
|
|
367
|
+
this._channel = null;
|
|
368
|
+
this._container = null;
|
|
369
|
+
this._currentState = STATES.CLOSED;
|
|
370
|
+
this._frameShadow = null;
|
|
371
|
+
this._iframe = null;
|
|
372
|
+
this._iframeCreated = false;
|
|
373
|
+
this._initialLoadConsumed = false;
|
|
374
|
+
this._loadActivity = null;
|
|
375
|
+
this._loadActivityStopped = true;
|
|
376
|
+
this._portTransferred = false;
|
|
377
|
+
this._log("disconnectedCallback");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_closeHostBridge() {
|
|
381
|
+
if (this._bridge) {
|
|
382
|
+
// Disposing the bridge clears handlers, drops the subscription, and
|
|
383
|
+
// disposes the transport — which closes the port (idempotent).
|
|
384
|
+
this._bridge.dispose();
|
|
385
|
+
this._bridge = null;
|
|
386
|
+
this._hostPort = null;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// No bridge yet (port created but never opened): close the raw port.
|
|
390
|
+
if (!this._hostPort) return;
|
|
391
|
+
try {
|
|
392
|
+
this._hostPort.close();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
// ignore — already-closed ports throw on some runtimes.
|
|
395
|
+
this._instrumentation?.logError(err, { code: "PORT_CLOSE_FAILED", instanceId: this._instanceId });
|
|
396
|
+
}
|
|
397
|
+
this._hostPort = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
_installHeartbeatListener() {
|
|
401
|
+
const handler = (event) => this._handleWindowMessage(event);
|
|
402
|
+
this._heartbeatHandler = handler;
|
|
403
|
+
window.addEventListener("message", handler);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// New src / new logical session: re-mint instanceId and rebuild the channel.
|
|
407
|
+
_resetSession() {
|
|
408
|
+
this._instanceId = crypto.randomUUID();
|
|
409
|
+
this._instrumentation?.incrementCounter("sfembedding.session.reset", 1, false);
|
|
410
|
+
this._resetChannel();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Same logical session, new iframe document: rebuild channel state only; instanceId is
|
|
414
|
+
// stable so validateHeartbeat's expectedInstanceId check still passes.
|
|
415
|
+
_resetChannel() {
|
|
416
|
+
const prevState = this._currentState;
|
|
417
|
+
this._instrumentation?.incrementCounter("sfembedding.channel.reset", 1, false, { from: String(prevState) });
|
|
418
|
+
this._cancelHeartbeatDeadline();
|
|
419
|
+
this._closeHostBridge();
|
|
420
|
+
this._channel = new MessageChannel();
|
|
421
|
+
this._hostPort = this._channel.port2;
|
|
422
|
+
this._portTransferred = false;
|
|
423
|
+
this._initialLoadConsumed = false;
|
|
424
|
+
this._negotiatedProtocolVersion = null;
|
|
425
|
+
this._currentState = STATES.PRE_HEARTBEAT;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_handleIframeLoad() {
|
|
429
|
+
if (!this._iframeCreated) return;
|
|
430
|
+
// Both flags must be set to identify a real full-doc-nav reload. `_portTransferred` alone
|
|
431
|
+
// isn't enough: the embedded document can heartbeat before the iframe element's `load`
|
|
432
|
+
// task fires, so the very first `load` may arrive with the port already transferred.
|
|
433
|
+
if (this._initialLoadConsumed && this._portTransferred) {
|
|
434
|
+
this._log("iframe load: subsequent (full-doc nav) — rebuilding channel for instanceId", this._instanceId);
|
|
435
|
+
this._instrumentation?.incrementCounter("sfembedding.iframe.reload", 1, false);
|
|
436
|
+
this._resetChannel();
|
|
437
|
+
}
|
|
438
|
+
this._initialLoadConsumed = true;
|
|
439
|
+
// If the heartbeat won the race the deadline is already cancelled and the channel is READY;
|
|
440
|
+
// re-arming would re-open a closed deadline window for no reason.
|
|
441
|
+
if (!this._portTransferred) {
|
|
442
|
+
this._armHeartbeatDeadline();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Advisory deadline; expiry fires an error event but never tears down state.
|
|
447
|
+
_armHeartbeatDeadline() {
|
|
448
|
+
this._cancelHeartbeatDeadline();
|
|
449
|
+
const ms = this._heartbeatTimeoutMs;
|
|
450
|
+
if (!Number.isFinite(ms)) return;
|
|
451
|
+
const timeout = this._safeTimeoutSignal(ms);
|
|
452
|
+
if (!timeout) return;
|
|
453
|
+
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
this._heartbeatController = controller;
|
|
456
|
+
|
|
457
|
+
// Pin identity so a later src/session reset doesn't mis-attribute this timeout.
|
|
458
|
+
const snapshotSrc = this._src;
|
|
459
|
+
const snapshotInstanceId = this._instanceId;
|
|
460
|
+
|
|
461
|
+
const combined = this._anySignal([controller.signal, timeout]);
|
|
462
|
+
combined.addEventListener(
|
|
463
|
+
"abort",
|
|
464
|
+
() => {
|
|
465
|
+
if (controller.signal.aborted) return;
|
|
466
|
+
if (this._portTransferred) return;
|
|
467
|
+
if (this._src !== snapshotSrc) return;
|
|
468
|
+
if (this._instanceId !== snapshotInstanceId) return;
|
|
469
|
+
const message = `Embedded document did not send sf-embedding/ready heartbeat within ${ms}ms`;
|
|
470
|
+
this._instrumentation?.incrementCounter("sfembedding.heartbeat.timeout", 1, true);
|
|
471
|
+
this._error(ERROR_PHASES.BOOTSTRAP, ERROR_CODES.HEARTBEAT_TIMEOUT, message, undefined, {
|
|
472
|
+
retryable: true,
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
{ once: true },
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_cancelHeartbeatDeadline() {
|
|
480
|
+
if (this._heartbeatController) {
|
|
481
|
+
this._heartbeatController.abort();
|
|
482
|
+
this._heartbeatController = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// AbortSignal.timeout is used over setTimeout to satisfy @lwc/lwc/no-async-operation.
|
|
487
|
+
_anySignal(signals) {
|
|
488
|
+
const AS = AbortSignal;
|
|
489
|
+
if (typeof AS.any === "function") {
|
|
490
|
+
return AS.any(signals);
|
|
491
|
+
}
|
|
492
|
+
return signals[signals.length - 1];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
_safeTimeoutSignal(ms) {
|
|
496
|
+
const AS = AbortSignal;
|
|
497
|
+
if (typeof AS.timeout === "function") {
|
|
498
|
+
return AS.timeout(ms);
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
_handleWindowMessage(event) {
|
|
504
|
+
const iframe = this._iframe;
|
|
505
|
+
const channel = this._channel;
|
|
506
|
+
if (!iframe || !channel) return;
|
|
507
|
+
|
|
508
|
+
const type = event.data?.type;
|
|
509
|
+
if (type === HEARTBEAT_TYPE) {
|
|
510
|
+
this._handleHeartbeat(event, iframe, channel);
|
|
511
|
+
} else if (type === SHUTDOWN_TYPE) {
|
|
512
|
+
this._handleShutdown(event, iframe);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_handleHeartbeat(event, iframe, channel) {
|
|
517
|
+
const result = validateHeartbeat({
|
|
518
|
+
data: event.data,
|
|
519
|
+
source: event.source,
|
|
520
|
+
origin: event.origin,
|
|
521
|
+
expectedSource: iframe.contentWindow,
|
|
522
|
+
expectedOrigin: this._iframeSrcOrigin,
|
|
523
|
+
expectedInstanceId: this._instanceId,
|
|
524
|
+
alreadyTransferred: this._portTransferred,
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (!result.ok) {
|
|
528
|
+
if (result.reason === ERROR_CODES.WRONG_SOURCE) {
|
|
529
|
+
this._countWrongSource("heartbeat", event.origin);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const messages = {
|
|
533
|
+
WRONG_ORIGIN: `heartbeat origin '${event.origin}' does not match the expected iframe src origin '${this._iframeSrcOrigin ?? "<unset>"}'`,
|
|
534
|
+
PROTOCOL_VERSION_MISMATCH: `declared version '${result.protocolVersion ?? "<missing>"}' is not supported by host`,
|
|
535
|
+
INSTANCE_ID_MISMATCH: "heartbeat instanceId does not match the host-minted session instanceId",
|
|
536
|
+
DUPLICATE: "heartbeat received after port1 was already transferred",
|
|
537
|
+
};
|
|
538
|
+
const message = messages[result.reason] ?? `heartbeat rejected: ${result.reason}`;
|
|
539
|
+
this._instrumentation?.incrementCounter("sfembedding.heartbeat.rejected", 1, true, {
|
|
540
|
+
reason: result.reason,
|
|
541
|
+
});
|
|
542
|
+
this._error(ERROR_PHASES.BOOTSTRAP, result.reason, message);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
this._negotiatedProtocolVersion = result.protocolVersion;
|
|
547
|
+
this._log("heartbeat: negotiated protocol version", result.protocolVersion);
|
|
548
|
+
|
|
549
|
+
// Initialize the host bridge before transferring port1 — host end live before embedding can post back.
|
|
550
|
+
this._initializeHostBridge();
|
|
551
|
+
|
|
552
|
+
const envelope = buildBootstrapEnvelope({
|
|
553
|
+
instanceId: this._instanceId,
|
|
554
|
+
allowedOrigins: this._iframeSrcOrigin ? [this._iframeSrcOrigin] : [],
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
iframe.contentWindow.postMessage(envelope, this._iframeSrcOrigin, [channel.port1]);
|
|
559
|
+
} catch (err) {
|
|
560
|
+
// Transfer threw (e.g. detached contentWindow); close the started port that has no peer.
|
|
561
|
+
this._instrumentation?.incrementCounter("sfembedding.bootstrap.transfer_failed", 1, true);
|
|
562
|
+
this._cancelHeartbeatDeadline();
|
|
563
|
+
this._closeHostBridge();
|
|
564
|
+
this._error(
|
|
565
|
+
ERROR_PHASES.BOOTSTRAP,
|
|
566
|
+
ERROR_CODES.TRANSFER_FAILED,
|
|
567
|
+
`<iframe> port transfer threw. Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
568
|
+
STATES.CLOSED,
|
|
569
|
+
);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this._portTransferred = true;
|
|
574
|
+
this._cancelHeartbeatDeadline();
|
|
575
|
+
this._instrumentation?.incrementCounter("sfembedding.heartbeat.received", 1, false, {
|
|
576
|
+
protocolVersion: result.protocolVersion,
|
|
577
|
+
});
|
|
578
|
+
this._currentState = STATES.POST_HEARTBEAT;
|
|
579
|
+
this._log("heartbeat: validated, port1 transferred");
|
|
580
|
+
|
|
581
|
+
// Phase A approximation: transition to READY now, before sending host-initialized.
|
|
582
|
+
// Once ui/select-version and ui/discover-capabilities land, READY will fire
|
|
583
|
+
// synchronously with the discover-capabilities response.
|
|
584
|
+
this._currentState = STATES.READY;
|
|
585
|
+
|
|
586
|
+
this._completeLoadActivity();
|
|
587
|
+
|
|
588
|
+
this.dispatchEvent(
|
|
589
|
+
// eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
|
|
590
|
+
new CustomEvent(HostLwcEvent.READY, {
|
|
591
|
+
detail: { instanceId: this._instanceId },
|
|
592
|
+
bubbles: true,
|
|
593
|
+
composed: true,
|
|
594
|
+
}),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
this._announceHostInitialized();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
_initializeHostBridge() {
|
|
601
|
+
const port = this._hostPort;
|
|
602
|
+
if (!port) return;
|
|
603
|
+
|
|
604
|
+
// The bridge wraps the port in the shared transport and subscribes,
|
|
605
|
+
// which lazily starts the port (no manual addEventListener/start here).
|
|
606
|
+
// The bridge owns the ui/subscribe + ui/unsubscribe routing, the events
|
|
607
|
+
// subscribe/unsubscribe registry, and the resize-hint notification; it
|
|
608
|
+
// calls back into _composeUiState() for the snapshot returned on
|
|
609
|
+
// subscribe, _redispatchEventFromEmbedding() for inbound
|
|
610
|
+
// `ui/events/dispatch`, and _handleResizeHint() for `ui/notifications/resize`.
|
|
611
|
+
this._bridge = new HostBridge(
|
|
612
|
+
port,
|
|
613
|
+
() => this._composeUiState(),
|
|
614
|
+
(params) => this._redispatchEventFromEmbedding(params),
|
|
615
|
+
(params) => this._handleResizeHint(params),
|
|
616
|
+
(params) => this._handleSfEmbeddingError(params),
|
|
617
|
+
(...args) => this._log(...args),
|
|
618
|
+
this._instrumentation,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Pipe iframe errors to o11y (non-PII). */
|
|
623
|
+
_handleSfEmbeddingError(params) {
|
|
624
|
+
if (!params) return;
|
|
625
|
+
// Clamp untrusted iframe-supplied type/sdkMethod to bounded values before tagging (cardinality guard).
|
|
626
|
+
const KNOWN_ERROR_TYPES = [
|
|
627
|
+
"javascript-error",
|
|
628
|
+
"unhandled-rejection",
|
|
629
|
+
"rpc-failed",
|
|
630
|
+
"session-aborted",
|
|
631
|
+
"bootstrap-failed",
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
const KNOWN_SDK_METHODS = [
|
|
635
|
+
"uncaught",
|
|
636
|
+
"bootstrapSession",
|
|
637
|
+
UI_STATE_SUBSCRIBE_METHOD,
|
|
638
|
+
UI_STATE_UNSUBSCRIBE_METHOD,
|
|
639
|
+
EVENTS_SUBSCRIBE_METHOD,
|
|
640
|
+
EVENTS_UNSUBSCRIBE_METHOD,
|
|
641
|
+
EVENTS_DISPATCH_METHOD,
|
|
642
|
+
RESIZE_METHOD,
|
|
643
|
+
];
|
|
644
|
+
|
|
645
|
+
const boundedType = KNOWN_ERROR_TYPES.includes(params.type) ? params.type : "other";
|
|
646
|
+
const boundedSdkMethod = KNOWN_SDK_METHODS.includes(params.sdkMethod) ? params.sdkMethod : "other";
|
|
647
|
+
|
|
648
|
+
this._log("embedding-error", params.type, params.sdkMethod, params.filename ?? "");
|
|
649
|
+
const err = new Error(boundedType);
|
|
650
|
+
err.name = boundedType;
|
|
651
|
+
this._instrumentation?.logError(err, {
|
|
652
|
+
instanceId: this._instanceId,
|
|
653
|
+
sdkMethod: boundedSdkMethod,
|
|
654
|
+
type: boundedType,
|
|
655
|
+
isHostError: false,
|
|
656
|
+
});
|
|
657
|
+
this._instrumentation?.incrementCounter("embedding.sdk.error", 1, true, { type: boundedType });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Apply an inbound `ui/notifications/resize` hint directly to the iframe. */
|
|
661
|
+
_handleResizeHint(params) {
|
|
662
|
+
if (!params || !this._iframe) return;
|
|
663
|
+
// Both axes optional: an absent (or invalid) axis leaves that side of the iframe untouched.
|
|
664
|
+
const widthOk = Number.isFinite(params.width) && params.width >= 0;
|
|
665
|
+
const heightOk = Number.isFinite(params.height) && params.height >= 0;
|
|
666
|
+
if (!widthOk && !heightOk) {
|
|
667
|
+
this._instrumentation?.incrementCounter("sfembedding.resize.rejected", 1, true, {
|
|
668
|
+
reason: "invalid_dimensions",
|
|
669
|
+
});
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (widthOk) this._iframe.style.width = `${params.width}px`;
|
|
673
|
+
if (heightOk) this._iframe.style.height = `${params.height}px`;
|
|
674
|
+
this._instrumentation?.incrementCounter("sfembedding.resize.applied", 1, false, {
|
|
675
|
+
width: widthOk ? "yes" : "no",
|
|
676
|
+
height: heightOk ? "yes" : "no",
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Re-dispatch an inbound custom event from the embedding on the host element. */
|
|
681
|
+
_redispatchEventFromEmbedding(params) {
|
|
682
|
+
if (!params || typeof params.eventType !== "string") return;
|
|
683
|
+
// eslint-disable-next-line @sfdc-internal/lightning-base-components/no-custom-event-identifier-arguments
|
|
684
|
+
const event = new CustomEvent(params.eventType, {
|
|
685
|
+
detail: params.detail,
|
|
686
|
+
bubbles: params.bubbles ?? true,
|
|
687
|
+
composed: params.composed ?? true,
|
|
688
|
+
cancelable: params.cancelable ?? false,
|
|
689
|
+
});
|
|
690
|
+
// Mark *this specific event* as inbound so the override skips only its forward, not unrelated
|
|
691
|
+
// events a listener may dispatch synchronously during handling.
|
|
692
|
+
this._inboundDispatches.add(event);
|
|
693
|
+
this.dispatchEvent(event);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Resolve the real host DOM element (the shadow root's host); */
|
|
697
|
+
_getHostElement() {
|
|
698
|
+
const el = this.template?.host ?? this;
|
|
699
|
+
return el && typeof el.getAttribute === "function" ? el : undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Compose a fresh UiState snapshot. */
|
|
703
|
+
_composeUiState() {
|
|
704
|
+
return composeUiState({
|
|
705
|
+
apiProps: this._props,
|
|
706
|
+
hostElement: this._getHostElement() ?? this,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Microtask-batched ui-state-changed flush; coalesces sync writes into one notification. */
|
|
711
|
+
_scheduleUiStateFlush() {
|
|
712
|
+
if (this._uiStateFlushScheduled) return;
|
|
713
|
+
this._uiStateFlushScheduled = true;
|
|
714
|
+
queueMicrotask(() => {
|
|
715
|
+
this._uiStateFlushScheduled = false;
|
|
716
|
+
this._flushUiStateChanged();
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
_flushUiStateChanged() {
|
|
721
|
+
const subscriptionId = this._bridge?.uiStateSubscriptionId;
|
|
722
|
+
if (!subscriptionId || !this._bridge) return;
|
|
723
|
+
const params = { subscriptionId, current: this._composeUiState() };
|
|
724
|
+
// Bridge stamps `_meta.traceId` centrally; never throws on transient failure.
|
|
725
|
+
this._bridge.notify(UI_STATE_CHANGED_METHOD, params);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Observe style + mirrored attrs + data-* on the host LWC; mutations schedule a flush. */
|
|
729
|
+
_installMutationObserver() {
|
|
730
|
+
if (this._mutationObserver) return;
|
|
731
|
+
const hostElement = this._getHostElement();
|
|
732
|
+
if (!hostElement) return;
|
|
733
|
+
const watched = new Set(["style", ...MIRRORED_ATTRIBUTES]);
|
|
734
|
+
const observer = new MutationObserver((mutations) => {
|
|
735
|
+
for (const m of mutations) {
|
|
736
|
+
if (m.type === "attributes" && m.attributeName) {
|
|
737
|
+
if (watched.has(m.attributeName) || m.attributeName.startsWith("data-")) {
|
|
738
|
+
if (m.attributeName === "title") {
|
|
739
|
+
// Only the raw-setAttribute path diverges here; the property setter already synced + stamped before reflecting.
|
|
740
|
+
const next = hostElement.getAttribute("title") || "embedding";
|
|
741
|
+
if (next !== this._title) {
|
|
742
|
+
this._title = next;
|
|
743
|
+
this._updateTitle();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
this._scheduleUiStateFlush();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
try {
|
|
753
|
+
// No `attributeFilter`: it's an allowlist with no prefix support, and we need data-*.
|
|
754
|
+
observer.observe(hostElement, { attributes: true });
|
|
755
|
+
this._mutationObserver = observer;
|
|
756
|
+
} catch (err) {
|
|
757
|
+
this._log("MutationObserver attach failed —", err);
|
|
758
|
+
this._instrumentation?.logError(err, { code: "OBSERVER_ATTACH_FAILED", instanceId: this._instanceId });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Install dispatchEvent override on the real host element to forward all eligible CustomEvents. */
|
|
763
|
+
_installHostDispatchOverride() {
|
|
764
|
+
const hostElement = this._getHostElement();
|
|
765
|
+
if (!hostElement || this._originalDispatchEvent) return; // no host, or already patched
|
|
766
|
+
|
|
767
|
+
// Capture the original so teardown can restore it; host element is stable for the instance lifetime.
|
|
768
|
+
const original = hostElement.dispatchEvent.bind(hostElement);
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
hostElement.dispatchEvent = (event) => {
|
|
772
|
+
if (
|
|
773
|
+
event instanceof CustomEvent &&
|
|
774
|
+
!RESERVED_EVENT_NAMES.has(event.type) &&
|
|
775
|
+
!event.type.startsWith("sf-embedding.") &&
|
|
776
|
+
!this._inboundDispatches.has(event) &&
|
|
777
|
+
this._bridge &&
|
|
778
|
+
this._bridge.hostEventSubscriberCount > 0
|
|
779
|
+
) {
|
|
780
|
+
try {
|
|
781
|
+
this._bridge.notify(EVENTS_DISPATCH_METHOD, {
|
|
782
|
+
eventType: event.type,
|
|
783
|
+
detail: toCloneableDetail(event.detail),
|
|
784
|
+
});
|
|
785
|
+
} catch (err) {
|
|
786
|
+
this._log("forward to embedding failed —", err);
|
|
787
|
+
this._instrumentation?.logError(err, {
|
|
788
|
+
code: "EVENT_FORWARD_FAILED",
|
|
789
|
+
phase: ERROR_PHASES.EVENT_FORWARDING,
|
|
790
|
+
instanceId: this._instanceId,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return original(event);
|
|
795
|
+
};
|
|
796
|
+
this._forwardTarget = hostElement;
|
|
797
|
+
this._originalDispatchEvent = original;
|
|
798
|
+
} catch (err) {
|
|
799
|
+
this._log("dispatchEvent override install failed —", err);
|
|
800
|
+
this._instrumentation?.logError(err, {
|
|
801
|
+
code: "EVENT_FORWARD_FAILED",
|
|
802
|
+
phase: ERROR_PHASES.EVENT_FORWARDING,
|
|
803
|
+
instanceId: this._instanceId,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Restore the original dispatchEvent on the host element. */
|
|
809
|
+
_restoreOriginalDispatchEvent() {
|
|
810
|
+
if (!this._forwardTarget || !this._originalDispatchEvent) return;
|
|
811
|
+
this._forwardTarget.dispatchEvent = this._originalDispatchEvent;
|
|
812
|
+
this._originalDispatchEvent = null;
|
|
813
|
+
this._forwardTarget = null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
_announceHostInitialized() {
|
|
817
|
+
if (!this._bridge) return;
|
|
818
|
+
|
|
819
|
+
const params = {
|
|
820
|
+
instanceId: this._instanceId,
|
|
821
|
+
hostInfo: HOST_INFO,
|
|
822
|
+
};
|
|
823
|
+
// Transport.post never throws on transient failure; traceId is stamped
|
|
824
|
+
// by the bridge's client.
|
|
825
|
+
this._bridge.announceInitialized(params);
|
|
826
|
+
this._instrumentation?.incrementCounter("sfembedding.host_initialized.sent", 1, false);
|
|
827
|
+
this._log("host-initialized sent");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
_handleShutdown(event, iframe) {
|
|
831
|
+
const result = validateShutdown({
|
|
832
|
+
data: event.data,
|
|
833
|
+
source: event.source,
|
|
834
|
+
origin: event.origin,
|
|
835
|
+
expectedSource: iframe.contentWindow,
|
|
836
|
+
expectedOrigin: this._iframeSrcOrigin,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
if (!result.ok) {
|
|
840
|
+
if (result.reason === ERROR_CODES.WRONG_SOURCE) {
|
|
841
|
+
this._countWrongSource("shutdown", event.origin);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
this._log("shutdown: rejected —", result.reason);
|
|
845
|
+
this._instrumentation?.incrementCounter("sfembedding.shutdown.rejected", 1, true, {
|
|
846
|
+
reason: result.reason,
|
|
847
|
+
});
|
|
848
|
+
this._instrumentation?.logError(new Error("shutdown rejected"), {
|
|
849
|
+
code: "SHUTDOWN_REJECTED",
|
|
850
|
+
reason: result.reason,
|
|
851
|
+
instanceId: this._instanceId,
|
|
852
|
+
});
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
this._log("shutdown received from embedding", result.reason);
|
|
857
|
+
this._instrumentation?.incrementCounter("sfembedding.shutdown.received", 1, false);
|
|
858
|
+
this._closeHostBridge();
|
|
859
|
+
this._error(
|
|
860
|
+
ERROR_PHASES.CONFIGURATION,
|
|
861
|
+
result.reason ?? ERROR_CODES.EMBEDDING_SHUTDOWN,
|
|
862
|
+
"embedding signalled shutdown.",
|
|
863
|
+
STATES.CLOSED,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
_applySandbox() {
|
|
868
|
+
if (!this._iframe) return;
|
|
869
|
+
const tokens = computeSandboxTokens(this._sandbox, this._removeSandboxTokens);
|
|
870
|
+
this._iframe.setAttribute("sandbox", tokens);
|
|
871
|
+
this._log("sandbox", tokens);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
_updateIframeSrc() {
|
|
875
|
+
if (!this._iframe) return;
|
|
876
|
+
|
|
877
|
+
this._resetSession();
|
|
878
|
+
|
|
879
|
+
this._iframe.removeAttribute("src");
|
|
880
|
+
if (this._src) {
|
|
881
|
+
try {
|
|
882
|
+
this._iframe.setAttribute(
|
|
883
|
+
"src",
|
|
884
|
+
appendHostMetaData(this._src, this._instanceId, window.location.origin),
|
|
885
|
+
);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
this._error(
|
|
888
|
+
ERROR_PHASES.CONFIGURATION,
|
|
889
|
+
ERROR_CODES.INVALID_SRC,
|
|
890
|
+
`iframe src MUST be a parseable URL. Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
891
|
+
STATES.CLOSED,
|
|
892
|
+
);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
this._log("updateIframeSrc", this._src ? "src set" : "blank");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
_reflectHostTitle() {
|
|
900
|
+
const host = this._getHostElement();
|
|
901
|
+
if (host) {
|
|
902
|
+
host.setAttribute("title", this._title);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
_updateTitle() {
|
|
907
|
+
if (this._iframe) {
|
|
908
|
+
this._iframe.setAttribute("title", this._title);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
_startLoadActivity() {
|
|
913
|
+
if (this._loadActivity && !this._loadActivityStopped) {
|
|
914
|
+
this._loadActivityStopped = true;
|
|
915
|
+
this._instrumentation?.incrementCounter("sfembedding.load_activity.superseded", 1, false);
|
|
916
|
+
this._instrumentation?.stopLoadActivity(this._loadActivity, {
|
|
917
|
+
instanceId: this._instanceId,
|
|
918
|
+
widgetSrc: this._src,
|
|
919
|
+
hostAppOrigin: window.location.origin,
|
|
920
|
+
outcome: "superseded",
|
|
921
|
+
});
|
|
922
|
+
this._loadActivity = null;
|
|
923
|
+
}
|
|
924
|
+
this._activityStartedAt = Date.now();
|
|
925
|
+
this._loadActivityStopped = false;
|
|
926
|
+
this._loadActivity = this._instrumentation?.startLoadActivity() ?? null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
_completeLoadActivity() {
|
|
930
|
+
if (this._loadActivityStopped || !this._loadActivity) return;
|
|
931
|
+
this._loadActivityStopped = true;
|
|
932
|
+
const timeToReady = Date.now() - this._activityStartedAt;
|
|
933
|
+
this._instrumentation?.stopLoadActivity(this._loadActivity, {
|
|
934
|
+
instanceId: this._instanceId,
|
|
935
|
+
widgetSrc: this._src,
|
|
936
|
+
hostAppOrigin: window.location.origin,
|
|
937
|
+
outcome: "embedding_success",
|
|
938
|
+
timeToReady,
|
|
939
|
+
});
|
|
940
|
+
this._loadActivity = null;
|
|
941
|
+
this._instrumentation?.incrementCounter("embedding.init.count", 1, false);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
_errorLoadActivity(err, code) {
|
|
945
|
+
if (this._loadActivityStopped || !this._loadActivity) return;
|
|
946
|
+
this._loadActivityStopped = true;
|
|
947
|
+
this._instrumentation?.errorLoadActivity(this._loadActivity, err, {
|
|
948
|
+
widgetSrc: this._src,
|
|
949
|
+
hostAppOrigin: window.location.origin,
|
|
950
|
+
message: err instanceof Error ? err.message : String(err),
|
|
951
|
+
isHostError: true,
|
|
952
|
+
instanceId: this._instanceId,
|
|
953
|
+
});
|
|
954
|
+
this._loadActivity = null;
|
|
955
|
+
// Use the bounded error code (not the free-text message) as the Argus tag to avoid
|
|
956
|
+
// unbounded cardinality — each unique string would mint a new time series.
|
|
957
|
+
this._instrumentation?.incrementCounter("embedding.init.error", 1, true, { reason: code });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Records expected multi-MFE WRONG_SOURCE fan-out as a non-error counter.
|
|
961
|
+
_countWrongSource(kind, origin) {
|
|
962
|
+
this._instrumentation?.incrementCounter(`sfembedding.${kind}.wrong_source`, 1, false, { origin });
|
|
963
|
+
this._log(`${kind}: WRONG_SOURCE from`, origin);
|
|
964
|
+
}
|
|
965
|
+
}
|