@xeonr/renderer-sdk 1.5.0 → 1.5.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/src/client.ts DELETED
@@ -1,765 +0,0 @@
1
- import type {
2
- HostInitMessage,
3
- HostMessage,
4
- HostThemeUpdateMessage,
5
- HostTokenRefreshMessage,
6
- HostGenerateTokenResultMessage,
7
- IframeHistoryPushMessage,
8
- IframeHistoryReplaceMessage,
9
- IframeCrashMessage,
10
- RendererCrashKind,
11
- } from './protocol.js';
12
- import { buildCrashMessage, isHostMessage, postCrashToHost } from './protocol.js';
13
- import {
14
- decodeUploadResource,
15
- decodeFolderResource,
16
- decodeBucketResource,
17
- fetchUpload as apiFetchUpload,
18
- fetchFolder as apiFetchFolder,
19
- fetchBucket as apiFetchBucket,
20
- } from './resources.js';
21
- // Lazy-loaded proto types — kept as type-only imports so they don't
22
- // pull the runtime modules into the SDK bundle for renderers that
23
- // never call the typed resource accessors. The actual runtime fetch
24
- // code lives in `resources.ts`.
25
- import type { Upload, Folder } from '@xeonr/uploads-protocol/uplim/api/v1/uploads_pb';
26
- import type { Bucket } from '@xeonr/uploads-protocol/uplim/api/v1/buckets_pb';
27
- import type { InitPayload, RendererApiAdapter, RendererConfig, RendererScope, RenderingType } from './types.js';
28
-
29
- export interface RendererClientOptions {
30
- /**
31
- * Expected origin of the host application.
32
- * If set, messages from other origins are silently ignored.
33
- * If omitted, all origins are accepted (suitable for local dev).
34
- */
35
- targetOrigin?: string;
36
-
37
- /**
38
- * How long after init to wait for `signalReady()` before auto-acking
39
- * anyway, in milliseconds. Only honored when the renderer's
40
- * `config.json` has `deferReady: true`. Default 30000 (30s).
41
- * Matches the host bridge's 10s init-timeout × ~3 — enough headroom
42
- * for slow data fetches without leaving the user staring at a
43
- * loading overlay indefinitely.
44
- */
45
- readyTimeoutMs?: number;
46
- }
47
-
48
- type InitCallback = (payload: InitPayload) => void;
49
- type ThemeCallback = (theme: 'light' | 'dark') => void;
50
- type TokenCallback = (token: string, expiresAt: number) => void;
51
- type NavigateCallback = (path: string) => void;
52
-
53
- /**
54
- * Client SDK for custom renderers running inside a sandboxed iframe.
55
- *
56
- * Usage:
57
- * ```ts
58
- * import { RendererClient } from '@xeonr/renderer-sdk';
59
- *
60
- * const client = new RendererClient();
61
- *
62
- * client.onInit((payload) => {
63
- * console.log('Received scope:', payload.scope);
64
- * console.log('Token:', payload.token);
65
- * // Bootstrap your app here
66
- * });
67
- *
68
- * client.onThemeChange((theme) => {
69
- * document.documentElement.setAttribute('data-theme', theme);
70
- * });
71
- * ```
72
- */
73
- export class RendererClient {
74
- private targetOrigin: string;
75
- private initCallbacks: InitCallback[] = [];
76
- private themeCallbacks: ThemeCallback[] = [];
77
- private tokenCallbacks: TokenCallback[] = [];
78
- private navigateCallbacks: NavigateCallback[] = [];
79
-
80
- private currentToken: string | null = null;
81
- private currentTokenExpiresAt: number | null = null;
82
- private currentScope: RendererScope | null = null;
83
- private currentRenderingType: RenderingType | null = null;
84
- private currentConfig: RendererConfig | null = null;
85
- private currentTheme: 'light' | 'dark' = 'light';
86
- private currentApiBaseUrl: string | null = null;
87
- private currentEntrypoint: 'dashboard' | 'portal' | null = null;
88
- private currentResource: unknown = null;
89
- // Typed resource cache. Populated lazily — `currentResource` is the
90
- // raw JSON the host sent; these are the decoded Message instances
91
- // (with proper prototypes for any methods callers might use). We
92
- // keep all three slots so a renderer can call `getBucket()` on an
93
- // upload scope without invalidating the upload cache.
94
- private cachedUpload: Upload | null = null;
95
- private cachedFolder: Folder | null = null;
96
- private cachedBucket: Bucket | null = null;
97
- private currentPath: string = '/';
98
- private currentConnected = false;
99
-
100
- private listener: ((event: MessageEvent) => void) | null = null;
101
- private errorListener: ((event: ErrorEvent) => void) | null = null;
102
- private rejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
103
- private hashChangeListener: (() => void) | null = null;
104
- // `signalReady` machinery — see `deferReady` in RendererClientOptions.
105
- // `ackDeferred` is set at construction; `ackSent` flips when we
106
- // actually post the ack so signalReady stays idempotent.
107
- private ackDeferred = false;
108
- private ackSent = false;
109
- private readyTimeoutMs = 30_000;
110
- private readyTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
111
- private readyRetryInterval: ReturnType<typeof setInterval> | null = null;
112
- private readyRetryTimeout: ReturnType<typeof setTimeout> | null = null;
113
- private historyPatched = false;
114
- private originalPushState: typeof history.pushState | null = null;
115
- private originalReplaceState: typeof history.replaceState | null = null;
116
- private destroyed = false;
117
- private suppressHashChange = false;
118
- private pendingTokenResolvers: Array<(result: { token: string; expiresAt: number }) => void> = [];
119
- private pendingGenerateTokenResolvers: Map<string, { resolve: (result: { accepted: boolean }) => void; reject: (error: Error) => void }> = new Map();
120
- private apiAdapter: RendererApiAdapter | null = null;
121
-
122
- constructor(options?: RendererClientOptions) {
123
- this.targetOrigin = options?.targetOrigin ?? '*';
124
- // `ackDeferred` is decided by config.json (read in handleInit)
125
- // rather than the constructor — the SDK can't know which mode
126
- // the renderer wants until init lands, but that's fine because
127
- // the ack itself only fires after init anyway.
128
- if (typeof options?.readyTimeoutMs === 'number' && options.readyTimeoutMs > 0) {
129
- this.readyTimeoutMs = options.readyTimeoutMs;
130
- }
131
- this.setup();
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Lifecycle
136
- // ---------------------------------------------------------------------------
137
-
138
- /** Register a callback for when the host sends the init message. */
139
- onInit(cb: InitCallback): () => void {
140
- this.initCallbacks.push(cb);
141
- return () => {
142
- this.initCallbacks = this.initCallbacks.filter(c => c !== cb);
143
- };
144
- }
145
-
146
- // ---------------------------------------------------------------------------
147
- // Receive from host
148
- // ---------------------------------------------------------------------------
149
-
150
- /** Register a callback for host theme changes. */
151
- onThemeChange(cb: ThemeCallback): () => void {
152
- this.themeCallbacks.push(cb);
153
- return () => {
154
- this.themeCallbacks = this.themeCallbacks.filter(c => c !== cb);
155
- };
156
- }
157
-
158
- /** Register a callback for token refreshes from the host. */
159
- onTokenRefresh(cb: TokenCallback): () => void {
160
- this.tokenCallbacks.push(cb);
161
- return () => {
162
- this.tokenCallbacks = this.tokenCallbacks.filter(c => c !== cb);
163
- };
164
- }
165
-
166
- /** Register a callback for hash-based navigation changes. */
167
- onNavigate(cb: NavigateCallback): () => void {
168
- this.navigateCallbacks.push(cb);
169
- return () => {
170
- this.navigateCallbacks = this.navigateCallbacks.filter(c => c !== cb);
171
- };
172
- }
173
-
174
- // ---------------------------------------------------------------------------
175
- // Send to host
176
- // ---------------------------------------------------------------------------
177
-
178
- /** Request the host to open a specific upload. */
179
- openUpload(uploadId: string): void {
180
- this.postToHost({ type: 'uplim:openUpload', uploadId });
181
- }
182
-
183
- /** Request a fresh integration access token from the host. */
184
- requestToken(): void {
185
- this.postToHost({ type: 'uplim:tokenRequest' });
186
- }
187
-
188
- /** Request a fresh token and wait for the response. */
189
- requestTokenAsync(): Promise<{ token: string; expiresAt: number }> {
190
- return new Promise((resolve) => {
191
- this.pendingTokenResolvers.push(resolve);
192
- this.postToHost({ type: 'uplim:tokenRequest' });
193
- });
194
- }
195
-
196
- /** Request the host to close this renderer (modal mode). */
197
- close(): void {
198
- this.postToHost({ type: 'uplim:close' });
199
- }
200
-
201
- /**
202
- * Request the host to prompt the user to generate a long-lived token.
203
- * The token is displayed to the user but never returned to the renderer.
204
- * Resolves with `{ accepted: true }` if the user accepts, or `{ accepted: false }` if rejected.
205
- */
206
- generateToken(opts: { reason: string; duration: 'forever' | string }): Promise<{ accepted: boolean }> {
207
- const requestId = crypto.randomUUID();
208
- return new Promise((resolve, reject) => {
209
- this.pendingGenerateTokenResolvers.set(requestId, { resolve, reject });
210
- this.postToHost({
211
- type: 'uplim:generateToken',
212
- requestId,
213
- reason: opts.reason,
214
- duration: opts.duration,
215
- });
216
- });
217
- }
218
-
219
- // ---------------------------------------------------------------------------
220
- // Getters
221
- // ---------------------------------------------------------------------------
222
-
223
- /** Get the current integration access token. */
224
- getToken(): string | null {
225
- return this.currentToken;
226
- }
227
-
228
- /** Get the token expiry as a Unix timestamp (ms). */
229
- getTokenExpiresAt(): number | null {
230
- return this.currentTokenExpiresAt;
231
- }
232
-
233
- /** Check if the current token is expired or about to expire (within 30s). */
234
- isTokenExpired(): boolean {
235
- if (!this.currentTokenExpiresAt) return true;
236
- return Date.now() >= this.currentTokenExpiresAt - 30_000;
237
- }
238
-
239
- /** Get the current scope. */
240
- getScope(): RendererScope | null {
241
- return this.currentScope;
242
- }
243
-
244
- /** Get the current rendering type. */
245
- getRenderingType(): RenderingType | null {
246
- return this.currentRenderingType;
247
- }
248
-
249
- /** Get the renderer config from config.json. */
250
- getConfig(): RendererConfig | null {
251
- return this.currentConfig;
252
- }
253
-
254
- /** Get the current theme. */
255
- getTheme(): 'light' | 'dark' {
256
- return this.currentTheme;
257
- }
258
-
259
- /** Get the current hash path (e.g. '/settings/advanced'). */
260
- getPath(): string {
261
- return this.currentPath;
262
- }
263
-
264
- /** Has the init message been processed? Late subscribers (e.g. a
265
- * second `useRendererClient` mount after init landed) read this to
266
- * seed their initial state without waiting for the next onInit. */
267
- isConnected(): boolean {
268
- return this.currentConnected;
269
- }
270
-
271
- /** Which HTML entrypoint was loaded (dashboard or portal). */
272
- getEntrypoint(): 'dashboard' | 'portal' | null {
273
- return this.currentEntrypoint;
274
- }
275
-
276
- /** Base URL for upl.im API calls, as supplied by the host. */
277
- getApiBaseUrl(): string | null {
278
- return this.currentApiBaseUrl;
279
- }
280
-
281
- /**
282
- * Pre-loaded resource snapshot the host sent in init.
283
- *
284
- * @deprecated Prefer the typed accessors `getUpload()` / `getFolder()`
285
- * / `getBucket()` — they return real Message instances rather than
286
- * untyped JSON and fall back transparently to a fresh fetch when
287
- * the cache is absent. This getter is kept for forward compat with
288
- * renderers building against older SDK versions.
289
- */
290
- getResource(): unknown {
291
- return this.currentResource;
292
- }
293
-
294
- /**
295
- * Return the Upload for the current scope. Returns the host-seeded
296
- * snapshot from init when present (no network call); otherwise
297
- * fetches via BucketUploadsService and caches the result for
298
- * subsequent calls in the same session.
299
- *
300
- * Throws when the scope isn't upload-typed — bucket / folder /
301
- * virtual-file scopes don't have a single Upload to return.
302
- *
303
- * Presigned URLs inside the Upload have ~1h TTL from issuance. For
304
- * long-running renderer sessions, call `refreshUpload()` before
305
- * any operation that needs a definitely-fresh URL.
306
- */
307
- async getUpload(): Promise<Upload> {
308
- if (this.currentScope?.type !== 'upload') {
309
- throw new Error('getUpload() requires an upload-typed scope');
310
- }
311
- if (this.cachedUpload && this.cachedUpload.uploadId === this.currentScope.uploadId) {
312
- return this.cachedUpload;
313
- }
314
- const upload = await apiFetchUpload(this.currentScope, this.getApiAdapter());
315
- this.cachedUpload = upload;
316
- return upload;
317
- }
318
-
319
- /** Force a fresh fetch of the Upload, bypassing the host-seeded cache. */
320
- async refreshUpload(): Promise<Upload> {
321
- if (this.currentScope?.type !== 'upload') {
322
- throw new Error('refreshUpload() requires an upload-typed scope');
323
- }
324
- const upload = await apiFetchUpload(this.currentScope, this.getApiAdapter());
325
- this.cachedUpload = upload;
326
- return upload;
327
- }
328
-
329
- /**
330
- * Return the Folder for the current scope. Works for `folder` and
331
- * `virtual-file` scopes (when the virtual file is folder-bound).
332
- */
333
- async getFolder(): Promise<Folder> {
334
- if (!this.currentScope) {
335
- throw new Error('getFolder() called before init');
336
- }
337
- if (this.cachedFolder) {
338
- return this.cachedFolder;
339
- }
340
- const folder = await apiFetchFolder(this.currentScope, this.getApiAdapter());
341
- this.cachedFolder = folder;
342
- return folder;
343
- }
344
-
345
- /** Force a fresh fetch of the Folder, bypassing the host-seeded cache. */
346
- async refreshFolder(): Promise<Folder> {
347
- if (!this.currentScope) {
348
- throw new Error('refreshFolder() called before init');
349
- }
350
- const folder = await apiFetchFolder(this.currentScope, this.getApiAdapter());
351
- this.cachedFolder = folder;
352
- return folder;
353
- }
354
-
355
- /**
356
- * Return the Bucket the current scope sits inside. Every scope
357
- * carries a `bucketId`, so this is always callable (subject to init
358
- * having landed).
359
- */
360
- async getBucket(): Promise<Bucket> {
361
- if (!this.currentScope) {
362
- throw new Error('getBucket() called before init');
363
- }
364
- if (this.cachedBucket && this.cachedBucket.bucketId === this.currentScope.bucketId) {
365
- return this.cachedBucket;
366
- }
367
- const bucket = await apiFetchBucket(this.currentScope, this.getApiAdapter());
368
- this.cachedBucket = bucket;
369
- return bucket;
370
- }
371
-
372
- /** Force a fresh fetch of the Bucket, bypassing the host-seeded cache. */
373
- async refreshBucket(): Promise<Bucket> {
374
- if (!this.currentScope) {
375
- throw new Error('refreshBucket() called before init');
376
- }
377
- const bucket = await apiFetchBucket(this.currentScope, this.getApiAdapter());
378
- this.cachedBucket = bucket;
379
- return bucket;
380
- }
381
-
382
- /**
383
- * Returns an API adapter compatible with `getUploadClientWithEnv()` from `@xeonr/uploads-sdk`.
384
- * Handles authentication and automatic token refresh via the host bridge.
385
- *
386
- * ```ts
387
- * import { getUploadClientWithEnv } from '@xeonr/uploads-sdk/api/base';
388
- * import { BucketUploadsService } from '@xeonr/uploads-protocol/uplim/api/v1/uploads_pb';
389
- *
390
- * const adapter = client.getApiAdapter();
391
- * const uploadsClient = getUploadClientWithEnv(BucketUploadsService, adapter);
392
- * ```
393
- */
394
- getApiAdapter(): RendererApiAdapter {
395
- if (this.apiAdapter) return this.apiAdapter;
396
-
397
- // Use a getter so hostname reflects the latest apiBaseUrl (set on init)
398
- const self = this;
399
- this.apiAdapter = {
400
- get hostname() { return self.currentApiBaseUrl ?? undefined; },
401
- tokenHelper: async () => this.currentToken,
402
- onAuthenticationExpired: async (retryCount: number) => {
403
- if (retryCount > 0) return false;
404
- const result = await this.requestTokenAsync();
405
- return !!result.token;
406
- },
407
- };
408
-
409
- return this.apiAdapter;
410
- }
411
-
412
- // ---------------------------------------------------------------------------
413
- // Cleanup
414
- // ---------------------------------------------------------------------------
415
-
416
- /** Remove all listeners and stop the client. */
417
- destroy(): void {
418
- this.destroyed = true;
419
- this.stopReadyRetry();
420
- if (this.readyTimeoutHandle) {
421
- clearTimeout(this.readyTimeoutHandle);
422
- this.readyTimeoutHandle = null;
423
- }
424
- if (this.listener) {
425
- window.removeEventListener('message', this.listener);
426
- this.listener = null;
427
- }
428
- if (this.errorListener) {
429
- window.removeEventListener('error', this.errorListener);
430
- this.errorListener = null;
431
- }
432
- if (this.rejectionListener) {
433
- window.removeEventListener('unhandledrejection', this.rejectionListener);
434
- this.rejectionListener = null;
435
- }
436
- if (this.hashChangeListener) {
437
- window.removeEventListener('hashchange', this.hashChangeListener);
438
- this.hashChangeListener = null;
439
- }
440
- if (this.historyPatched) {
441
- if (this.originalPushState) window.history.pushState = this.originalPushState;
442
- if (this.originalReplaceState) window.history.replaceState = this.originalReplaceState;
443
- this.historyPatched = false;
444
- }
445
- this.initCallbacks = [];
446
- this.themeCallbacks = [];
447
- this.tokenCallbacks = [];
448
- this.navigateCallbacks = [];
449
- for (const [, pending] of this.pendingGenerateTokenResolvers) {
450
- pending.reject(new Error('Client destroyed'));
451
- }
452
- this.pendingGenerateTokenResolvers.clear();
453
- }
454
-
455
- // ---------------------------------------------------------------------------
456
- // Internal
457
- // ---------------------------------------------------------------------------
458
-
459
- private setup(): void {
460
- this.listener = (event: MessageEvent) => {
461
- if (this.destroyed) return;
462
- if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) return;
463
- if (!isHostMessage(event.data)) return;
464
-
465
- // Any valid host message implies the host's listener is attached and the
466
- // handshake is underway, so stop pinging.
467
- this.stopReadyRetry();
468
- this.handleMessage(event.data);
469
- };
470
-
471
- window.addEventListener('message', this.listener);
472
-
473
- this.installCrashHooks();
474
-
475
- // Tell the host we're ready. We retry on an interval because a fast iframe can
476
- // post 'ready' before the host has attached its message listener — the first
477
- // ping is lost, the host times out, and the user has to click retry. Pinging
478
- // until the host responds closes that race without changing the protocol.
479
- this.postToHost({ type: 'uplim:ready' });
480
- this.readyRetryInterval = setInterval(() => {
481
- if (this.destroyed) return;
482
- this.postToHost({ type: 'uplim:ready' });
483
- }, 200);
484
- // Bound the retries: if the host genuinely isn't there after 8s, stop pinging
485
- // so we don't spam a misbehaving parent.
486
- this.readyRetryTimeout = setTimeout(() => {
487
- this.stopReadyRetry();
488
- }, 8000);
489
- }
490
-
491
- private stopReadyRetry(): void {
492
- if (this.readyRetryInterval) {
493
- clearInterval(this.readyRetryInterval);
494
- this.readyRetryInterval = null;
495
- }
496
- if (this.readyRetryTimeout) {
497
- clearTimeout(this.readyRetryTimeout);
498
- this.readyRetryTimeout = null;
499
- }
500
- }
501
-
502
- private handleMessage(message: HostMessage): void {
503
- switch (message.type) {
504
- case 'uplim:init':
505
- this.handleInit(message);
506
- break;
507
- case 'uplim:theme':
508
- this.handleThemeUpdate(message);
509
- break;
510
- case 'uplim:token':
511
- this.handleTokenRefresh(message);
512
- break;
513
- case 'uplim:generateTokenResult':
514
- this.handleGenerateTokenResult(message);
515
- break;
516
- case 'uplim:historyBack':
517
- window.history.back();
518
- break;
519
- case 'uplim:historyForward':
520
- window.history.forward();
521
- break;
522
- }
523
- }
524
-
525
- private handleInit(message: HostInitMessage): void {
526
- const { payload } = message;
527
- this.currentToken = payload.token;
528
- this.currentTokenExpiresAt = payload.tokenExpiresAt;
529
- this.currentScope = payload.scope;
530
- this.currentRenderingType = payload.renderingType;
531
- this.currentConfig = payload.config;
532
- this.currentTheme = payload.theme;
533
- this.currentApiBaseUrl = payload.apiBaseUrl;
534
- this.currentEntrypoint = payload.entrypoint;
535
- this.currentResource = payload.resource ?? null;
536
- // Decode the host-seeded resource into the matching typed slot
537
- // based on scope. Decode failures (schema drift, partial host
538
- // payload) fall back to `null` and the typed accessors then
539
- // fetch from the API on demand.
540
- this.cachedUpload = null;
541
- this.cachedFolder = null;
542
- this.cachedBucket = null;
543
- if (payload.resource) {
544
- switch (payload.scope.type) {
545
- case 'upload':
546
- this.cachedUpload = decodeUploadResource(payload.resource);
547
- break;
548
- case 'folder':
549
- case 'virtual-file':
550
- this.cachedFolder = decodeFolderResource(payload.resource);
551
- break;
552
- case 'bucket':
553
- this.cachedBucket = decodeBucketResource(payload.resource);
554
- break;
555
- }
556
- }
557
- this.currentConnected = true;
558
-
559
- // Apply initial path from host URL fragment
560
- if (payload.initialPath) {
561
- this.suppressHashChange = true;
562
- window.location.hash = payload.initialPath;
563
- this.currentPath = payload.initialPath;
564
- this.suppressHashChange = false;
565
- } else {
566
- this.currentPath = this.getHashPath();
567
- }
568
-
569
- // Start observing hash changes
570
- this.setupHashTracking();
571
-
572
- for (const cb of this.initCallbacks) {
573
- cb(payload);
574
- }
575
-
576
- // Ack semantics — driven by the renderer's config.json
577
- // (`deferReady` field). Declaring it in config rather than as a
578
- // constructor option keeps all renderer metadata in one place
579
- // (alongside permissions / sandbox / connectHosts / buildHash)
580
- // and avoids a code-vs-config-disagreement footgun.
581
- this.ackDeferred = payload.config?.deferReady === true;
582
- if (!this.ackDeferred) {
583
- this.sendAck();
584
- } else {
585
- this.readyTimeoutHandle = setTimeout(() => {
586
- if (this.destroyed || this.ackSent) return;
587
- // Best-effort log so an operator can spot the late ack
588
- // in dev tools; production console noise is acceptable
589
- // here because this only fires when something is wrong.
590
- console.warn(
591
- '[uplim] signalReady() not called within ' +
592
- this.readyTimeoutMs + 'ms — auto-acking so the host overlay can dismiss.',
593
- );
594
- this.sendAck();
595
- }, this.readyTimeoutMs);
596
- }
597
- }
598
-
599
- /**
600
- * Post the deferred ack so the host overlay dismisses. No-op once
601
- * the ack has been sent (the host-side bridge tolerates duplicates
602
- * but we suppress them here for cleanliness). When `deferReady` was
603
- * not set on construction this is also a no-op — the SDK already
604
- * acked synchronously on init.
605
- */
606
- signalReady(): void {
607
- if (this.destroyed || this.ackSent || !this.ackDeferred) return;
608
- this.sendAck();
609
- }
610
-
611
- private sendAck(): void {
612
- if (this.ackSent) return;
613
- this.ackSent = true;
614
- if (this.readyTimeoutHandle) {
615
- clearTimeout(this.readyTimeoutHandle);
616
- this.readyTimeoutHandle = null;
617
- }
618
- this.postToHost({ type: 'uplim:ack' });
619
- }
620
-
621
- private handleThemeUpdate(message: HostThemeUpdateMessage): void {
622
- this.currentTheme = message.theme;
623
- for (const cb of this.themeCallbacks) {
624
- cb(message.theme);
625
- }
626
- }
627
-
628
- private handleTokenRefresh(message: HostTokenRefreshMessage): void {
629
- this.currentToken = message.token;
630
- this.currentTokenExpiresAt = message.tokenExpiresAt;
631
-
632
- // Resolve any pending requestTokenAsync() calls
633
- const resolvers = this.pendingTokenResolvers;
634
- this.pendingTokenResolvers = [];
635
- for (const resolve of resolvers) {
636
- resolve({ token: message.token, expiresAt: message.tokenExpiresAt });
637
- }
638
-
639
- for (const cb of this.tokenCallbacks) {
640
- cb(message.token, message.tokenExpiresAt);
641
- }
642
- }
643
-
644
- private handleGenerateTokenResult(message: HostGenerateTokenResultMessage): void {
645
- const pending = this.pendingGenerateTokenResolvers.get(message.requestId);
646
- if (pending) {
647
- this.pendingGenerateTokenResolvers.delete(message.requestId);
648
- pending.resolve({ accepted: message.accepted });
649
- }
650
- }
651
-
652
- private getHashPath(): string {
653
- const hash = window.location.hash;
654
- if (!hash || hash === '#') return '/';
655
- // Strip leading '#' (and optional leading '#/')
656
- return hash.startsWith('#/') ? hash.slice(1) : hash.slice(1);
657
- }
658
-
659
- private onHashChanged(isPush: boolean): void {
660
- if (this.destroyed || this.suppressHashChange) return;
661
-
662
- const newPath = this.getHashPath();
663
- if (newPath === this.currentPath) return;
664
-
665
- this.currentPath = newPath;
666
-
667
- const msg: IframeHistoryPushMessage | IframeHistoryReplaceMessage = isPush
668
- ? { type: 'uplim:historyPush', path: newPath }
669
- : { type: 'uplim:historyReplace', path: newPath };
670
- this.postToHost(msg);
671
-
672
- for (const cb of this.navigateCallbacks) {
673
- cb(newPath);
674
- }
675
- }
676
-
677
- private setupHashTracking(): void {
678
- if (this.hashChangeListener) return;
679
-
680
- // Listen for direct hash changes (e.g. window.location.hash = '...' or <a href="#...">)
681
- this.hashChangeListener = () => this.onHashChanged(true);
682
- window.addEventListener('hashchange', this.hashChangeListener);
683
-
684
- // Patch pushState/replaceState — hash routers use these instead of setting
685
- // window.location.hash directly, and they don't fire hashchange.
686
- this.originalPushState = window.history.pushState.bind(window.history);
687
- this.originalReplaceState = window.history.replaceState.bind(window.history);
688
-
689
- const self = this;
690
- window.history.pushState = function (...args: Parameters<typeof history.pushState>) {
691
- self.originalPushState!(...args);
692
- self.onHashChanged(true);
693
- };
694
- window.history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
695
- self.originalReplaceState!(...args);
696
- self.onHashChanged(false);
697
- };
698
- this.historyPatched = true;
699
- }
700
-
701
- private postToHost(message: object): void {
702
- if (this.destroyed) return;
703
- window.parent.postMessage(message, this.targetOrigin);
704
- }
705
-
706
- /**
707
- * Install window-level crash hooks the moment the client is constructed
708
- * (i.e. as soon as a renderer touches the SDK). We deliberately do this
709
- * here — not in a React-only entry point — so that even renderers that
710
- * forget to wrap their root in `<RendererErrorBoundary>` still report
711
- * async / global failures to the host.
712
- *
713
- * The two hooks cover the failure modes a React boundary can't see:
714
- * - `window.error` — synchronous throws outside React's tree (raw
715
- * <script> bugs, event-handler exceptions, image onerror handlers).
716
- * - `unhandledrejection` — Promise chains with no `.catch()`, which
717
- * are surprisingly common in fetch-heavy renderers.
718
- *
719
- * Both fire-and-forget via `postCrashToHost`; the host decides whether
720
- * to render the overlay (a single render-time crash usually warrants
721
- * it, a stray unhandled rejection may not).
722
- */
723
- private installCrashHooks(): void {
724
- if (typeof window === 'undefined') return;
725
-
726
- this.errorListener = (event: ErrorEvent) => {
727
- if (this.destroyed) return;
728
- // Prefer event.error for the real stack; fall back to a synthetic
729
- // shape when the browser only gave us message/filename/lineno
730
- // (cross-origin scripts strip event.error to null).
731
- const thrown = event.error ?? {
732
- name: 'ErrorEvent',
733
- message: event.message || 'Uncaught error',
734
- stack: event.filename
735
- ? `at ${event.filename}:${event.lineno ?? '?'}:${event.colno ?? '?'}`
736
- : '',
737
- };
738
- this.reportCrash('error', thrown);
739
- };
740
- window.addEventListener('error', this.errorListener);
741
-
742
- this.rejectionListener = (event: PromiseRejectionEvent) => {
743
- if (this.destroyed) return;
744
- this.reportCrash('unhandled-rejection', event.reason);
745
- };
746
- window.addEventListener('unhandledrejection', this.rejectionListener);
747
- }
748
-
749
- /**
750
- * Forward a crash to the host. Called by the global hooks and by
751
- * `<RendererErrorBoundary>`; renderers may also call it explicitly if
752
- * they catch something themselves and want it surfaced (e.g. data-load
753
- * failure that wipes the UI even though no exception escaped).
754
- *
755
- * No-throw: telemetry must never make the underlying crash worse.
756
- */
757
- reportCrash(kind: RendererCrashKind, thrown: unknown): void {
758
- try {
759
- const msg: IframeCrashMessage = buildCrashMessage(kind, thrown);
760
- postCrashToHost(msg, this.targetOrigin);
761
- } catch {
762
- // Swallowed on purpose — see above.
763
- }
764
- }
765
- }