@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/dist/react/dom.d.ts +1 -0
- package/dist/react/dom.d.ts.map +1 -1
- package/dist/react/dom.js +9 -0
- package/dist/react/dom.js.map +1 -1
- package/dist/react/useRendererClient.d.ts.map +1 -1
- package/dist/react/useRendererClient.js +5 -7
- package/dist/react/useRendererClient.js.map +1 -1
- package/package.json +6 -1
- package/renderer.css +34 -0
- package/src/client.ts +0 -765
- package/src/index.ts +0 -47
- package/src/protocol.ts +0 -286
- package/src/react/RendererErrorBoundary.tsx +0 -95
- package/src/react/dom.ts +0 -53
- package/src/react/index.ts +0 -5
- package/src/react/useRendererClient.ts +0 -250
- package/src/react/useReportFatalError.tsx +0 -71
- package/src/resources.ts +0 -119
- package/src/types.ts +0 -156
- package/tsconfig.json +0 -18
- package/tsconfig.tsbuildinfo +0 -1
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
|
-
}
|