@xeonr/renderer-sdk 1.1.0 → 1.5.0
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/client.d.ts +79 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +204 -1
- package/dist/client.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useRendererClient.d.ts +18 -0
- package/dist/react/useRendererClient.d.ts.map +1 -1
- package/dist/react/useRendererClient.js +83 -18
- package/dist/react/useRendererClient.js.map +1 -1
- package/dist/react/useReportFatalError.d.ts +37 -0
- package/dist/react/useReportFatalError.d.ts.map +1 -0
- package/dist/react/useReportFatalError.js +71 -0
- package/dist/react/useReportFatalError.js.map +1 -0
- package/dist/resources.d.ts +25 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +104 -0
- package/dist/resources.js.map +1 -0
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -3
- package/src/client.ts +237 -1
- package/src/react/index.ts +1 -0
- package/src/react/useRendererClient.ts +107 -21
- package/src/react/useReportFatalError.tsx +71 -0
- package/src/resources.ts +119 -0
- package/src/types.ts +37 -0
package/src/client.ts
CHANGED
|
@@ -10,6 +10,20 @@ import type {
|
|
|
10
10
|
RendererCrashKind,
|
|
11
11
|
} from './protocol.js';
|
|
12
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';
|
|
13
27
|
import type { InitPayload, RendererApiAdapter, RendererConfig, RendererScope, RenderingType } from './types.js';
|
|
14
28
|
|
|
15
29
|
export interface RendererClientOptions {
|
|
@@ -19,6 +33,16 @@ export interface RendererClientOptions {
|
|
|
19
33
|
* If omitted, all origins are accepted (suitable for local dev).
|
|
20
34
|
*/
|
|
21
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;
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
type InitCallback = (payload: InitPayload) => void;
|
|
@@ -60,12 +84,30 @@ export class RendererClient {
|
|
|
60
84
|
private currentConfig: RendererConfig | null = null;
|
|
61
85
|
private currentTheme: 'light' | 'dark' = 'light';
|
|
62
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;
|
|
63
97
|
private currentPath: string = '/';
|
|
98
|
+
private currentConnected = false;
|
|
64
99
|
|
|
65
100
|
private listener: ((event: MessageEvent) => void) | null = null;
|
|
66
101
|
private errorListener: ((event: ErrorEvent) => void) | null = null;
|
|
67
102
|
private rejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
|
|
68
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;
|
|
69
111
|
private readyRetryInterval: ReturnType<typeof setInterval> | null = null;
|
|
70
112
|
private readyRetryTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
71
113
|
private historyPatched = false;
|
|
@@ -79,6 +121,13 @@ export class RendererClient {
|
|
|
79
121
|
|
|
80
122
|
constructor(options?: RendererClientOptions) {
|
|
81
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
|
+
}
|
|
82
131
|
this.setup();
|
|
83
132
|
}
|
|
84
133
|
|
|
@@ -212,6 +261,124 @@ export class RendererClient {
|
|
|
212
261
|
return this.currentPath;
|
|
213
262
|
}
|
|
214
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
|
+
|
|
215
382
|
/**
|
|
216
383
|
* Returns an API adapter compatible with `getUploadClientWithEnv()` from `@xeonr/uploads-sdk`.
|
|
217
384
|
* Handles authentication and automatic token refresh via the host bridge.
|
|
@@ -250,6 +417,10 @@ export class RendererClient {
|
|
|
250
417
|
destroy(): void {
|
|
251
418
|
this.destroyed = true;
|
|
252
419
|
this.stopReadyRetry();
|
|
420
|
+
if (this.readyTimeoutHandle) {
|
|
421
|
+
clearTimeout(this.readyTimeoutHandle);
|
|
422
|
+
this.readyTimeoutHandle = null;
|
|
423
|
+
}
|
|
253
424
|
if (this.listener) {
|
|
254
425
|
window.removeEventListener('message', this.listener);
|
|
255
426
|
this.listener = null;
|
|
@@ -360,6 +531,30 @@ export class RendererClient {
|
|
|
360
531
|
this.currentConfig = payload.config;
|
|
361
532
|
this.currentTheme = payload.theme;
|
|
362
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;
|
|
363
558
|
|
|
364
559
|
// Apply initial path from host URL fragment
|
|
365
560
|
if (payload.initialPath) {
|
|
@@ -378,7 +573,48 @@ export class RendererClient {
|
|
|
378
573
|
cb(payload);
|
|
379
574
|
}
|
|
380
575
|
|
|
381
|
-
//
|
|
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
|
+
}
|
|
382
618
|
this.postToHost({ type: 'uplim:ack' });
|
|
383
619
|
}
|
|
384
620
|
|
package/src/react/index.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { useRendererClient } from './useRendererClient.js';
|
|
|
2
2
|
export type { UseRendererClientOptions, UseRendererClientResult } from './useRendererClient.js';
|
|
3
3
|
export { RendererErrorBoundary } from './RendererErrorBoundary.js';
|
|
4
4
|
export type { RendererErrorBoundaryProps } from './RendererErrorBoundary.js';
|
|
5
|
+
export { useReportFatalError } from './useReportFatalError.js';
|
|
@@ -1,10 +1,70 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
2
2
|
import { RendererClient } from '../client.js';
|
|
3
3
|
import type { RendererClientOptions } from '../client.js';
|
|
4
4
|
import type { RendererApiAdapter, RendererConfig, RendererScope, RenderingType, InitPayload } from '../types.js';
|
|
5
5
|
|
|
6
6
|
export interface UseRendererClientOptions extends RendererClientOptions {}
|
|
7
7
|
|
|
8
|
+
// One RendererClient per page. Multiple `useRendererClient()` calls
|
|
9
|
+
// (different components subscribing to the same state) all share the
|
|
10
|
+
// singleton — previously each call spawned its own client, which
|
|
11
|
+
// caused duplicate `window.error` / `unhandledrejection` listeners
|
|
12
|
+
// (so every crash got reported N times), duplicate `uplim:ready`
|
|
13
|
+
// pings, and state divergence between instances.
|
|
14
|
+
//
|
|
15
|
+
// Page lifetime: the iframe is full-reloaded for HMR / test, so the
|
|
16
|
+
// module-level singleton lives exactly as long as it should. SDK-level
|
|
17
|
+
// HMR (developer working on the SDK in monorepo, hot-replacing this
|
|
18
|
+
// module) is handled via `import.meta.hot.dispose` at the bottom of
|
|
19
|
+
// the file.
|
|
20
|
+
let sharedClient: RendererClient | null = null;
|
|
21
|
+
let sharedClientOptionsKey: string | null = null;
|
|
22
|
+
|
|
23
|
+
function optionsKey(options: UseRendererClientOptions | undefined): string {
|
|
24
|
+
if (!options) return '';
|
|
25
|
+
// Stable order so { a, b } and { b, a } produce the same key. Used
|
|
26
|
+
// only for the dev-mode mismatch warning — never for behavior.
|
|
27
|
+
const entries = Object.entries(options).sort(([a], [b]) => a.localeCompare(b));
|
|
28
|
+
return JSON.stringify(entries);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getSharedClient(options: UseRendererClientOptions | undefined): RendererClient {
|
|
32
|
+
if (sharedClient) {
|
|
33
|
+
const key = optionsKey(options);
|
|
34
|
+
// Best-effort dev nudge: if a later mount passes different
|
|
35
|
+
// options than the first one, those options are silently
|
|
36
|
+
// ignored. Only flag when keys differ AND the later caller
|
|
37
|
+
// passed non-empty options (passing nothing is the common
|
|
38
|
+
// case for downstream subscribers).
|
|
39
|
+
if (key && sharedClientOptionsKey !== null && key !== sharedClientOptionsKey) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.warn(
|
|
42
|
+
'[uplim] useRendererClient called with different options after first call; ' +
|
|
43
|
+
'later options are ignored (the client is a per-page singleton).',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return sharedClient;
|
|
47
|
+
}
|
|
48
|
+
sharedClient = new RendererClient(options);
|
|
49
|
+
sharedClientOptionsKey = optionsKey(options);
|
|
50
|
+
return sharedClient;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// SDK-dev HMR cleanup: when this module is hot-replaced (e.g. someone
|
|
54
|
+
// editing the SDK with the renderer harness running), detach the old
|
|
55
|
+
// client's listeners before the new module evaluates. Prod / non-HMR
|
|
56
|
+
// builds skip the entire block — `import.meta.hot` is undefined.
|
|
57
|
+
const maybeHot = (import.meta as ImportMeta & { hot?: { dispose: (cb: () => void) => void } }).hot;
|
|
58
|
+
if (maybeHot) {
|
|
59
|
+
maybeHot.dispose(() => {
|
|
60
|
+
if (sharedClient) {
|
|
61
|
+
sharedClient.destroy();
|
|
62
|
+
sharedClient = null;
|
|
63
|
+
sharedClientOptionsKey = null;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
8
68
|
/**
|
|
9
69
|
* Apply the host's theme to the renderer document. Two distinct effects:
|
|
10
70
|
*
|
|
@@ -45,6 +105,15 @@ export interface UseRendererClientResult {
|
|
|
45
105
|
apiBaseUrl: string | null;
|
|
46
106
|
/** Pre-configured API adapter for use with `getUploadClientWithEnv()`. */
|
|
47
107
|
apiAdapter: RendererApiAdapter;
|
|
108
|
+
/**
|
|
109
|
+
* Pre-loaded resource snapshot (typically the Upload proto's JSON
|
|
110
|
+
* form for upload-scoped renderers). Renderers should cast to the
|
|
111
|
+
* expected proto JSON type and use this directly to avoid an extra
|
|
112
|
+
* GetUpload round trip on first paint. `null` when the host didn't
|
|
113
|
+
* supply one (older hosts, folder/virtual-file scopes); fall back
|
|
114
|
+
* to a fetch in that case.
|
|
115
|
+
*/
|
|
116
|
+
resource: unknown;
|
|
48
117
|
/** Current hash path within the renderer (e.g. '/settings'). Updated reactively on hash changes. */
|
|
49
118
|
path: string;
|
|
50
119
|
|
|
@@ -54,6 +123,15 @@ export interface UseRendererClientResult {
|
|
|
54
123
|
requestToken: () => void;
|
|
55
124
|
/** Request the host to close this renderer (modal mode). */
|
|
56
125
|
close: () => void;
|
|
126
|
+
/**
|
|
127
|
+
* When the renderer's `config.json` has `deferReady: true`, call
|
|
128
|
+
* this once the renderer's data is loaded and the UI is ready to
|
|
129
|
+
* be visible. The host overlay stays up until this fires (or until
|
|
130
|
+
* the SDK's safety timeout elapses — see
|
|
131
|
+
* `RendererClientOptions.readyTimeoutMs`). No-op when `deferReady`
|
|
132
|
+
* isn't set in config.
|
|
133
|
+
*/
|
|
134
|
+
markReady: () => void;
|
|
57
135
|
|
|
58
136
|
/** The underlying RendererClient instance for advanced use. */
|
|
59
137
|
client: RendererClient;
|
|
@@ -82,25 +160,24 @@ export interface UseRendererClientResult {
|
|
|
82
160
|
* ```
|
|
83
161
|
*/
|
|
84
162
|
export function useRendererClient(options?: UseRendererClientOptions): UseRendererClientResult {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
const [
|
|
95
|
-
const [
|
|
96
|
-
const [
|
|
97
|
-
const [
|
|
98
|
-
const [
|
|
99
|
-
const [
|
|
100
|
-
const [
|
|
101
|
-
const [
|
|
102
|
-
const [
|
|
103
|
-
const [path, setPath] = useState<string>('/');
|
|
163
|
+
const client = getSharedClient(options);
|
|
164
|
+
|
|
165
|
+
// Initial state read directly from the client — handles late
|
|
166
|
+
// subscribers (a second `useRendererClient` mount that happens
|
|
167
|
+
// after init has already fired). Without this, the late mount
|
|
168
|
+
// would sit at `connected: false` forever because onInit only
|
|
169
|
+
// fires for NEW init events, not for already-received state.
|
|
170
|
+
const [connected, setConnected] = useState<boolean>(() => client.isConnected());
|
|
171
|
+
const [scope, setScope] = useState<RendererScope | null>(() => client.getScope());
|
|
172
|
+
const [renderingType, setRenderingType] = useState<RenderingType | null>(() => client.getRenderingType());
|
|
173
|
+
const [token, setToken] = useState<string | null>(() => client.getToken());
|
|
174
|
+
const [tokenExpiresAt, setTokenExpiresAt] = useState<number | null>(() => client.getTokenExpiresAt());
|
|
175
|
+
const [theme, setTheme] = useState<'light' | 'dark'>(() => client.getTheme());
|
|
176
|
+
const [config, setConfig] = useState<RendererConfig | null>(() => client.getConfig());
|
|
177
|
+
const [entrypoint, setEntrypoint] = useState<'dashboard' | 'portal' | null>(() => client.getEntrypoint());
|
|
178
|
+
const [apiBaseUrl, setApiBaseUrl] = useState<string | null>(() => client.getApiBaseUrl());
|
|
179
|
+
const [resource, setResource] = useState<unknown>(() => client.getResource());
|
|
180
|
+
const [path, setPath] = useState<string>(() => client.getPath());
|
|
104
181
|
|
|
105
182
|
useEffect(() => {
|
|
106
183
|
const unsubInit = client.onInit((payload: InitPayload) => {
|
|
@@ -113,6 +190,7 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
|
|
|
113
190
|
setConfig(payload.config);
|
|
114
191
|
setEntrypoint(payload.entrypoint);
|
|
115
192
|
setApiBaseUrl(payload.apiBaseUrl);
|
|
193
|
+
setResource(payload.resource ?? null);
|
|
116
194
|
setPath(client.getPath());
|
|
117
195
|
applyThemeToDocument(payload.theme);
|
|
118
196
|
});
|
|
@@ -136,7 +214,13 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
|
|
|
136
214
|
unsubTheme();
|
|
137
215
|
unsubToken();
|
|
138
216
|
unsubNavigate();
|
|
139
|
-
|
|
217
|
+
// NB: do NOT destroy() here — the client is a per-page
|
|
218
|
+
// singleton, not a per-component instance. Component
|
|
219
|
+
// unmounts (route changes / strict-mode double-mount /
|
|
220
|
+
// React render-time tearing) shouldn't kill the shared
|
|
221
|
+
// state. The iframe full-reload tears the singleton down
|
|
222
|
+
// implicitly when the page goes away; SDK-level HMR is
|
|
223
|
+
// handled by import.meta.hot.dispose at the top of file.
|
|
140
224
|
};
|
|
141
225
|
}, [client]);
|
|
142
226
|
|
|
@@ -144,6 +228,7 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
|
|
|
144
228
|
openUpload: (uploadId: string) => client.openUpload(uploadId),
|
|
145
229
|
requestToken: () => client.requestToken(),
|
|
146
230
|
close: () => client.close(),
|
|
231
|
+
markReady: () => client.signalReady(),
|
|
147
232
|
apiAdapter: client.getApiAdapter(),
|
|
148
233
|
}), [client]);
|
|
149
234
|
|
|
@@ -157,6 +242,7 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
|
|
|
157
242
|
config,
|
|
158
243
|
entrypoint,
|
|
159
244
|
apiBaseUrl,
|
|
245
|
+
resource,
|
|
160
246
|
path,
|
|
161
247
|
client,
|
|
162
248
|
...methods,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hook for surfacing async failures to `<RendererErrorBoundary>` (and from
|
|
5
|
+
* there to the host's crash overlay + telemetry).
|
|
6
|
+
*
|
|
7
|
+
* Renderers shouldn't render their own "Upload not found" / "Failed to
|
|
8
|
+
* load" inline UI — those are still crashes from a telemetry standpoint,
|
|
9
|
+
* and the host already owns a polished overlay with reload + fallback
|
|
10
|
+
* actions. This hook lets a renderer take an error caught in an async
|
|
11
|
+
* context (fetch `.catch()`, useEffect cleanup, etc.) and route it to
|
|
12
|
+
* the host instead of rendering a half-broken inline state.
|
|
13
|
+
*
|
|
14
|
+
* Pattern:
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const reportFatal = useReportFatalError();
|
|
18
|
+
* useEffect(() => {
|
|
19
|
+
* client.getUpload(...).catch(reportFatal);
|
|
20
|
+
* }, []);
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Mechanics: we keep an error in component state. When set, the *next*
|
|
24
|
+
* render throws it — React's error-handling pipeline catches the throw
|
|
25
|
+
* and bubbles to the nearest boundary. From there
|
|
26
|
+
* `RendererErrorBoundary.componentDidCatch` reports the crash to the
|
|
27
|
+
* host and renders nothing, and the host overlay (Reload / Copy /
|
|
28
|
+
* Fallback) takes over the visible iframe area.
|
|
29
|
+
*
|
|
30
|
+
* Why state-then-throw rather than a direct `throw err`? You can't
|
|
31
|
+
* throw to React from async code — React only sees throws that happen
|
|
32
|
+
* during render or in event handlers. Routing through state ensures
|
|
33
|
+
* the throw lands inside the render phase.
|
|
34
|
+
*
|
|
35
|
+
* The returned callback is stable across renders so it's safe to drop
|
|
36
|
+
* straight into a `.catch()`.
|
|
37
|
+
*/
|
|
38
|
+
export function useReportFatalError(): (err: unknown) => void {
|
|
39
|
+
// Generic state slot — we never read the value, only use the
|
|
40
|
+
// updater. We narrow to `never` so TypeScript stops us from
|
|
41
|
+
// accidentally using it as data anywhere.
|
|
42
|
+
const [, setState] = useState<never>();
|
|
43
|
+
return useCallback((err: unknown) => {
|
|
44
|
+
// Functional updater throws — React invokes it during the next
|
|
45
|
+
// render attempt, which puts the throw inside the render phase
|
|
46
|
+
// where boundaries can see it.
|
|
47
|
+
setState(() => {
|
|
48
|
+
if (err instanceof Error) {
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
// Wrap non-Error throws so the boundary always sees a real
|
|
52
|
+
// Error instance with a meaningful .name / .message. Without
|
|
53
|
+
// this, `client.reportCrash` would synthesise a generic
|
|
54
|
+
// 'UnknownError' which loses the original payload's shape.
|
|
55
|
+
if (typeof err === 'string') {
|
|
56
|
+
throw new Error(err);
|
|
57
|
+
}
|
|
58
|
+
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
|
59
|
+
const wrapped = new Error(err.message);
|
|
60
|
+
if ('name' in err && typeof err.name === 'string') {
|
|
61
|
+
wrapped.name = err.name;
|
|
62
|
+
}
|
|
63
|
+
if ('stack' in err && typeof err.stack === 'string') {
|
|
64
|
+
wrapped.stack = err.stack;
|
|
65
|
+
}
|
|
66
|
+
throw wrapped;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('Unknown renderer error');
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
}
|
package/src/resources.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed resource accessors that read from the host-seeded `resource`
|
|
3
|
+
* cache first and fall back to a real API call when the cache is
|
|
4
|
+
* absent or stale.
|
|
5
|
+
*
|
|
6
|
+
* Lives in its own file so the imports against `@xeonr/uploads-protocol`
|
|
7
|
+
* and `@xeonr/uploads-sdk` are kept out of `client.ts`. Those packages
|
|
8
|
+
* are declared as optional peer dependencies — a renderer that never
|
|
9
|
+
* calls `getUpload()` / `getFolder()` / `getBucket()` doesn't need to
|
|
10
|
+
* install them. The fetch helpers throw at call time when the deps are
|
|
11
|
+
* missing.
|
|
12
|
+
*/
|
|
13
|
+
import { fromJson } from '@bufbuild/protobuf';
|
|
14
|
+
import {
|
|
15
|
+
BucketUploadsService,
|
|
16
|
+
BucketFoldersService,
|
|
17
|
+
UploadSchema,
|
|
18
|
+
FolderSchema,
|
|
19
|
+
type Upload,
|
|
20
|
+
type Folder,
|
|
21
|
+
} from '@xeonr/uploads-protocol/uplim/api/v1/uploads_pb';
|
|
22
|
+
import {
|
|
23
|
+
BucketsService,
|
|
24
|
+
BucketSchema,
|
|
25
|
+
type Bucket,
|
|
26
|
+
} from '@xeonr/uploads-protocol/uplim/api/v1/buckets_pb';
|
|
27
|
+
import { getUploadClientWithEnv } from '@xeonr/uploads-sdk/api/base';
|
|
28
|
+
import type { RendererApiAdapter, RendererScope } from './types.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort decoder for the host's pre-loaded `resource` field. The
|
|
32
|
+
* host serialises via `toJson(UploadSchema, …)`/etc and posts the JSON
|
|
33
|
+
* across the iframe boundary, so we run it back through `fromJson` to
|
|
34
|
+
* land at a real Message instance with the right prototype methods.
|
|
35
|
+
*
|
|
36
|
+
* Returns null on any failure (schema mismatch, missing fields, etc.)
|
|
37
|
+
* so callers fall through to a fresh fetch.
|
|
38
|
+
*/
|
|
39
|
+
export function decodeUploadResource(value: unknown): Upload | null {
|
|
40
|
+
if (!value || typeof value !== 'object') return null;
|
|
41
|
+
try {
|
|
42
|
+
return fromJson(UploadSchema, value as Parameters<typeof fromJson<typeof UploadSchema>>[1]);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function decodeFolderResource(value: unknown): Folder | null {
|
|
49
|
+
if (!value || typeof value !== 'object') return null;
|
|
50
|
+
try {
|
|
51
|
+
return fromJson(FolderSchema, value as Parameters<typeof fromJson<typeof FolderSchema>>[1]);
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeBucketResource(value: unknown): Bucket | null {
|
|
58
|
+
if (!value || typeof value !== 'object') return null;
|
|
59
|
+
try {
|
|
60
|
+
return fromJson(BucketSchema, value as Parameters<typeof fromJson<typeof BucketSchema>>[1]);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fetch the upload for the current scope via the API. Caller already
|
|
68
|
+
* narrowed the scope; we just translate to the BucketUploadsService
|
|
69
|
+
* RPC. Throws when the API returns no upload (treated as a not-found
|
|
70
|
+
* by the renderer).
|
|
71
|
+
*/
|
|
72
|
+
export async function fetchUpload(
|
|
73
|
+
scope: RendererScope,
|
|
74
|
+
apiAdapter: RendererApiAdapter,
|
|
75
|
+
): Promise<Upload> {
|
|
76
|
+
if (scope.type !== 'upload') {
|
|
77
|
+
throw new Error('fetchUpload requires an upload-typed scope');
|
|
78
|
+
}
|
|
79
|
+
const client = getUploadClientWithEnv(BucketUploadsService, apiAdapter);
|
|
80
|
+
const res = await client.getUpload({
|
|
81
|
+
bucketRef: { type: { case: 'bucketId', value: scope.bucketId } },
|
|
82
|
+
uploadRef: { type: { case: 'uploadId', value: scope.uploadId } },
|
|
83
|
+
});
|
|
84
|
+
if (!res.upload) throw new Error('Upload not found');
|
|
85
|
+
return res.upload;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function fetchFolder(
|
|
89
|
+
scope: RendererScope,
|
|
90
|
+
apiAdapter: RendererApiAdapter,
|
|
91
|
+
): Promise<Folder> {
|
|
92
|
+
// Folder scopes ('folder' or 'virtual-file' with a folderId) carry
|
|
93
|
+
// the identifier; everything else is a misuse.
|
|
94
|
+
let folderId: string | undefined;
|
|
95
|
+
if (scope.type === 'folder') folderId = scope.folderId;
|
|
96
|
+
else if (scope.type === 'virtual-file') folderId = scope.folderId;
|
|
97
|
+
if (!folderId) {
|
|
98
|
+
throw new Error('fetchFolder requires a folder-typed scope with a folderId');
|
|
99
|
+
}
|
|
100
|
+
const client = getUploadClientWithEnv(BucketFoldersService, apiAdapter);
|
|
101
|
+
const res = await client.getFolder({
|
|
102
|
+
bucketRef: { type: { case: 'bucketId', value: scope.bucketId } },
|
|
103
|
+
folderRef: { type: { case: 'folderId', value: folderId } },
|
|
104
|
+
});
|
|
105
|
+
if (!res.folder) throw new Error('Folder not found');
|
|
106
|
+
return res.folder;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function fetchBucket(
|
|
110
|
+
scope: RendererScope,
|
|
111
|
+
apiAdapter: RendererApiAdapter,
|
|
112
|
+
): Promise<Bucket> {
|
|
113
|
+
const client = getUploadClientWithEnv(BucketsService, apiAdapter);
|
|
114
|
+
const res = await client.getBucket({
|
|
115
|
+
bucketRef: { type: { case: 'bucketId', value: scope.bucketId } },
|
|
116
|
+
});
|
|
117
|
+
if (!res.bucket) throw new Error('Bucket not found');
|
|
118
|
+
return res.bucket;
|
|
119
|
+
}
|