@xeonr/renderer-sdk 1.0.5 → 1.3.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 +62 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +150 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +65 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +91 -0
- package/dist/protocol.js.map +1 -1
- package/dist/react/RendererErrorBoundary.d.ts +60 -0
- package/dist/react/RendererErrorBoundary.d.ts.map +1 -0
- package/dist/react/RendererErrorBoundary.js +37 -0
- package/dist/react/RendererErrorBoundary.js.map +1 -0
- package/dist/react/dom.d.ts +23 -0
- package/dist/react/dom.d.ts.map +1 -0
- package/dist/react/dom.js +36 -0
- package/dist/react/dom.js.map +1 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useRendererClient.d.ts +9 -0
- package/dist/react/useRendererClient.d.ts.map +1 -1
- package/dist/react/useRendererClient.js +80 -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/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -2
- package/src/client.ts +166 -2
- package/src/index.ts +4 -0
- package/src/protocol.ts +143 -1
- package/src/react/RendererErrorBoundary.tsx +95 -0
- package/src/react/dom.ts +53 -0
- package/src/react/index.ts +3 -0
- package/src/react/useRendererClient.ts +95 -21
- package/src/react/useReportFatalError.tsx +71 -0
- package/src/types.ts +40 -0
package/src/client.ts
CHANGED
|
@@ -6,8 +6,10 @@ import type {
|
|
|
6
6
|
HostGenerateTokenResultMessage,
|
|
7
7
|
IframeHistoryPushMessage,
|
|
8
8
|
IframeHistoryReplaceMessage,
|
|
9
|
+
IframeCrashMessage,
|
|
10
|
+
RendererCrashKind,
|
|
9
11
|
} from './protocol.js';
|
|
10
|
-
import { isHostMessage } from './protocol.js';
|
|
12
|
+
import { buildCrashMessage, isHostMessage, postCrashToHost } from './protocol.js';
|
|
11
13
|
import type { InitPayload, RendererApiAdapter, RendererConfig, RendererScope, RenderingType } from './types.js';
|
|
12
14
|
|
|
13
15
|
export interface RendererClientOptions {
|
|
@@ -17,6 +19,16 @@ export interface RendererClientOptions {
|
|
|
17
19
|
* If omitted, all origins are accepted (suitable for local dev).
|
|
18
20
|
*/
|
|
19
21
|
targetOrigin?: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long after init to wait for `signalReady()` before auto-acking
|
|
25
|
+
* anyway, in milliseconds. Only honored when the renderer's
|
|
26
|
+
* `config.json` has `deferReady: true`. Default 30000 (30s).
|
|
27
|
+
* Matches the host bridge's 10s init-timeout × ~3 — enough headroom
|
|
28
|
+
* for slow data fetches without leaving the user staring at a
|
|
29
|
+
* loading overlay indefinitely.
|
|
30
|
+
*/
|
|
31
|
+
readyTimeoutMs?: number;
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
type InitCallback = (payload: InitPayload) => void;
|
|
@@ -58,10 +70,21 @@ export class RendererClient {
|
|
|
58
70
|
private currentConfig: RendererConfig | null = null;
|
|
59
71
|
private currentTheme: 'light' | 'dark' = 'light';
|
|
60
72
|
private currentApiBaseUrl: string | null = null;
|
|
73
|
+
private currentEntrypoint: 'dashboard' | 'portal' | null = null;
|
|
61
74
|
private currentPath: string = '/';
|
|
75
|
+
private currentConnected = false;
|
|
62
76
|
|
|
63
77
|
private listener: ((event: MessageEvent) => void) | null = null;
|
|
78
|
+
private errorListener: ((event: ErrorEvent) => void) | null = null;
|
|
79
|
+
private rejectionListener: ((event: PromiseRejectionEvent) => void) | null = null;
|
|
64
80
|
private hashChangeListener: (() => void) | null = null;
|
|
81
|
+
// `signalReady` machinery — see `deferReady` in RendererClientOptions.
|
|
82
|
+
// `ackDeferred` is set at construction; `ackSent` flips when we
|
|
83
|
+
// actually post the ack so signalReady stays idempotent.
|
|
84
|
+
private ackDeferred = false;
|
|
85
|
+
private ackSent = false;
|
|
86
|
+
private readyTimeoutMs = 30_000;
|
|
87
|
+
private readyTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
65
88
|
private readyRetryInterval: ReturnType<typeof setInterval> | null = null;
|
|
66
89
|
private readyRetryTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
67
90
|
private historyPatched = false;
|
|
@@ -75,6 +98,13 @@ export class RendererClient {
|
|
|
75
98
|
|
|
76
99
|
constructor(options?: RendererClientOptions) {
|
|
77
100
|
this.targetOrigin = options?.targetOrigin ?? '*';
|
|
101
|
+
// `ackDeferred` is decided by config.json (read in handleInit)
|
|
102
|
+
// rather than the constructor — the SDK can't know which mode
|
|
103
|
+
// the renderer wants until init lands, but that's fine because
|
|
104
|
+
// the ack itself only fires after init anyway.
|
|
105
|
+
if (typeof options?.readyTimeoutMs === 'number' && options.readyTimeoutMs > 0) {
|
|
106
|
+
this.readyTimeoutMs = options.readyTimeoutMs;
|
|
107
|
+
}
|
|
78
108
|
this.setup();
|
|
79
109
|
}
|
|
80
110
|
|
|
@@ -208,6 +238,23 @@ export class RendererClient {
|
|
|
208
238
|
return this.currentPath;
|
|
209
239
|
}
|
|
210
240
|
|
|
241
|
+
/** Has the init message been processed? Late subscribers (e.g. a
|
|
242
|
+
* second `useRendererClient` mount after init landed) read this to
|
|
243
|
+
* seed their initial state without waiting for the next onInit. */
|
|
244
|
+
isConnected(): boolean {
|
|
245
|
+
return this.currentConnected;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Which HTML entrypoint was loaded (dashboard or portal). */
|
|
249
|
+
getEntrypoint(): 'dashboard' | 'portal' | null {
|
|
250
|
+
return this.currentEntrypoint;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Base URL for upl.im API calls, as supplied by the host. */
|
|
254
|
+
getApiBaseUrl(): string | null {
|
|
255
|
+
return this.currentApiBaseUrl;
|
|
256
|
+
}
|
|
257
|
+
|
|
211
258
|
/**
|
|
212
259
|
* Returns an API adapter compatible with `getUploadClientWithEnv()` from `@xeonr/uploads-sdk`.
|
|
213
260
|
* Handles authentication and automatic token refresh via the host bridge.
|
|
@@ -246,10 +293,22 @@ export class RendererClient {
|
|
|
246
293
|
destroy(): void {
|
|
247
294
|
this.destroyed = true;
|
|
248
295
|
this.stopReadyRetry();
|
|
296
|
+
if (this.readyTimeoutHandle) {
|
|
297
|
+
clearTimeout(this.readyTimeoutHandle);
|
|
298
|
+
this.readyTimeoutHandle = null;
|
|
299
|
+
}
|
|
249
300
|
if (this.listener) {
|
|
250
301
|
window.removeEventListener('message', this.listener);
|
|
251
302
|
this.listener = null;
|
|
252
303
|
}
|
|
304
|
+
if (this.errorListener) {
|
|
305
|
+
window.removeEventListener('error', this.errorListener);
|
|
306
|
+
this.errorListener = null;
|
|
307
|
+
}
|
|
308
|
+
if (this.rejectionListener) {
|
|
309
|
+
window.removeEventListener('unhandledrejection', this.rejectionListener);
|
|
310
|
+
this.rejectionListener = null;
|
|
311
|
+
}
|
|
253
312
|
if (this.hashChangeListener) {
|
|
254
313
|
window.removeEventListener('hashchange', this.hashChangeListener);
|
|
255
314
|
this.hashChangeListener = null;
|
|
@@ -287,6 +346,8 @@ export class RendererClient {
|
|
|
287
346
|
|
|
288
347
|
window.addEventListener('message', this.listener);
|
|
289
348
|
|
|
349
|
+
this.installCrashHooks();
|
|
350
|
+
|
|
290
351
|
// Tell the host we're ready. We retry on an interval because a fast iframe can
|
|
291
352
|
// post 'ready' before the host has attached its message listener — the first
|
|
292
353
|
// ping is lost, the host times out, and the user has to click retry. Pinging
|
|
@@ -346,6 +407,8 @@ export class RendererClient {
|
|
|
346
407
|
this.currentConfig = payload.config;
|
|
347
408
|
this.currentTheme = payload.theme;
|
|
348
409
|
this.currentApiBaseUrl = payload.apiBaseUrl;
|
|
410
|
+
this.currentEntrypoint = payload.entrypoint;
|
|
411
|
+
this.currentConnected = true;
|
|
349
412
|
|
|
350
413
|
// Apply initial path from host URL fragment
|
|
351
414
|
if (payload.initialPath) {
|
|
@@ -364,7 +427,48 @@ export class RendererClient {
|
|
|
364
427
|
cb(payload);
|
|
365
428
|
}
|
|
366
429
|
|
|
367
|
-
//
|
|
430
|
+
// Ack semantics — driven by the renderer's config.json
|
|
431
|
+
// (`deferReady` field). Declaring it in config rather than as a
|
|
432
|
+
// constructor option keeps all renderer metadata in one place
|
|
433
|
+
// (alongside permissions / sandbox / connectHosts / buildHash)
|
|
434
|
+
// and avoids a code-vs-config-disagreement footgun.
|
|
435
|
+
this.ackDeferred = payload.config?.deferReady === true;
|
|
436
|
+
if (!this.ackDeferred) {
|
|
437
|
+
this.sendAck();
|
|
438
|
+
} else {
|
|
439
|
+
this.readyTimeoutHandle = setTimeout(() => {
|
|
440
|
+
if (this.destroyed || this.ackSent) return;
|
|
441
|
+
// Best-effort log so an operator can spot the late ack
|
|
442
|
+
// in dev tools; production console noise is acceptable
|
|
443
|
+
// here because this only fires when something is wrong.
|
|
444
|
+
console.warn(
|
|
445
|
+
'[uplim] signalReady() not called within ' +
|
|
446
|
+
this.readyTimeoutMs + 'ms — auto-acking so the host overlay can dismiss.',
|
|
447
|
+
);
|
|
448
|
+
this.sendAck();
|
|
449
|
+
}, this.readyTimeoutMs);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Post the deferred ack so the host overlay dismisses. No-op once
|
|
455
|
+
* the ack has been sent (the host-side bridge tolerates duplicates
|
|
456
|
+
* but we suppress them here for cleanliness). When `deferReady` was
|
|
457
|
+
* not set on construction this is also a no-op — the SDK already
|
|
458
|
+
* acked synchronously on init.
|
|
459
|
+
*/
|
|
460
|
+
signalReady(): void {
|
|
461
|
+
if (this.destroyed || this.ackSent || !this.ackDeferred) return;
|
|
462
|
+
this.sendAck();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private sendAck(): void {
|
|
466
|
+
if (this.ackSent) return;
|
|
467
|
+
this.ackSent = true;
|
|
468
|
+
if (this.readyTimeoutHandle) {
|
|
469
|
+
clearTimeout(this.readyTimeoutHandle);
|
|
470
|
+
this.readyTimeoutHandle = null;
|
|
471
|
+
}
|
|
368
472
|
this.postToHost({ type: 'uplim:ack' });
|
|
369
473
|
}
|
|
370
474
|
|
|
@@ -452,4 +556,64 @@ export class RendererClient {
|
|
|
452
556
|
if (this.destroyed) return;
|
|
453
557
|
window.parent.postMessage(message, this.targetOrigin);
|
|
454
558
|
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Install window-level crash hooks the moment the client is constructed
|
|
562
|
+
* (i.e. as soon as a renderer touches the SDK). We deliberately do this
|
|
563
|
+
* here — not in a React-only entry point — so that even renderers that
|
|
564
|
+
* forget to wrap their root in `<RendererErrorBoundary>` still report
|
|
565
|
+
* async / global failures to the host.
|
|
566
|
+
*
|
|
567
|
+
* The two hooks cover the failure modes a React boundary can't see:
|
|
568
|
+
* - `window.error` — synchronous throws outside React's tree (raw
|
|
569
|
+
* <script> bugs, event-handler exceptions, image onerror handlers).
|
|
570
|
+
* - `unhandledrejection` — Promise chains with no `.catch()`, which
|
|
571
|
+
* are surprisingly common in fetch-heavy renderers.
|
|
572
|
+
*
|
|
573
|
+
* Both fire-and-forget via `postCrashToHost`; the host decides whether
|
|
574
|
+
* to render the overlay (a single render-time crash usually warrants
|
|
575
|
+
* it, a stray unhandled rejection may not).
|
|
576
|
+
*/
|
|
577
|
+
private installCrashHooks(): void {
|
|
578
|
+
if (typeof window === 'undefined') return;
|
|
579
|
+
|
|
580
|
+
this.errorListener = (event: ErrorEvent) => {
|
|
581
|
+
if (this.destroyed) return;
|
|
582
|
+
// Prefer event.error for the real stack; fall back to a synthetic
|
|
583
|
+
// shape when the browser only gave us message/filename/lineno
|
|
584
|
+
// (cross-origin scripts strip event.error to null).
|
|
585
|
+
const thrown = event.error ?? {
|
|
586
|
+
name: 'ErrorEvent',
|
|
587
|
+
message: event.message || 'Uncaught error',
|
|
588
|
+
stack: event.filename
|
|
589
|
+
? `at ${event.filename}:${event.lineno ?? '?'}:${event.colno ?? '?'}`
|
|
590
|
+
: '',
|
|
591
|
+
};
|
|
592
|
+
this.reportCrash('error', thrown);
|
|
593
|
+
};
|
|
594
|
+
window.addEventListener('error', this.errorListener);
|
|
595
|
+
|
|
596
|
+
this.rejectionListener = (event: PromiseRejectionEvent) => {
|
|
597
|
+
if (this.destroyed) return;
|
|
598
|
+
this.reportCrash('unhandled-rejection', event.reason);
|
|
599
|
+
};
|
|
600
|
+
window.addEventListener('unhandledrejection', this.rejectionListener);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Forward a crash to the host. Called by the global hooks and by
|
|
605
|
+
* `<RendererErrorBoundary>`; renderers may also call it explicitly if
|
|
606
|
+
* they catch something themselves and want it surfaced (e.g. data-load
|
|
607
|
+
* failure that wipes the UI even though no exception escaped).
|
|
608
|
+
*
|
|
609
|
+
* No-throw: telemetry must never make the underlying crash worse.
|
|
610
|
+
*/
|
|
611
|
+
reportCrash(kind: RendererCrashKind, thrown: unknown): void {
|
|
612
|
+
try {
|
|
613
|
+
const msg: IframeCrashMessage = buildCrashMessage(kind, thrown);
|
|
614
|
+
postCrashToHost(msg, this.targetOrigin);
|
|
615
|
+
} catch {
|
|
616
|
+
// Swallowed on purpose — see above.
|
|
617
|
+
}
|
|
618
|
+
}
|
|
455
619
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ export {
|
|
|
20
20
|
isRendererMessage,
|
|
21
21
|
isHostMessage,
|
|
22
22
|
isIframeMessage,
|
|
23
|
+
buildCrashMessage,
|
|
24
|
+
postCrashToHost,
|
|
23
25
|
} from './protocol.js';
|
|
24
26
|
|
|
25
27
|
export type {
|
|
@@ -39,5 +41,7 @@ export type {
|
|
|
39
41
|
IframeGenerateTokenMessage,
|
|
40
42
|
IframeHistoryPushMessage,
|
|
41
43
|
IframeHistoryReplaceMessage,
|
|
44
|
+
IframeCrashMessage,
|
|
45
|
+
RendererCrashKind,
|
|
42
46
|
RendererMessage,
|
|
43
47
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -104,6 +104,53 @@ export interface IframeHistoryReplaceMessage {
|
|
|
104
104
|
path: string;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Which SDK code path observed the failure. Maps directly to the host's
|
|
109
|
+
* `RendererCrashKind` enum so the wire schema and the telemetry shape
|
|
110
|
+
* stay in sync.
|
|
111
|
+
*
|
|
112
|
+
* - `render` — React tree threw during render/commit; caught by the
|
|
113
|
+
* SDK's mandatory ErrorBoundary at the renderer root. The UI is gone.
|
|
114
|
+
* - `error` — `window.onerror`: synchronous uncaught exception outside
|
|
115
|
+
* React (event handler, top-level script). UI may or may not still
|
|
116
|
+
* be visible.
|
|
117
|
+
* - `unhandled-rejection` — `window.onunhandledrejection`: a Promise
|
|
118
|
+
* rejected with no `.catch()`. Almost always async, often non-fatal,
|
|
119
|
+
* but a useful signal that something is wrong.
|
|
120
|
+
*/
|
|
121
|
+
export type RendererCrashKind = 'render' | 'error' | 'unhandled-rejection';
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Tells the host the renderer just hit an unhandled exception. The host
|
|
125
|
+
* decides what to render in response (typically an overlay with reload
|
|
126
|
+
* and fallback options); the renderer never paints its own crash screen
|
|
127
|
+
* because (a) styling would be inconsistent with the host UI and (b) a
|
|
128
|
+
* render-time crash may have left the renderer's CSS / DOM unusable.
|
|
129
|
+
*
|
|
130
|
+
* Fields are deliberately string-bounded on the proto side (see
|
|
131
|
+
* `ReportRendererCrashRequest`) — keep payloads under those limits
|
|
132
|
+
* before posting.
|
|
133
|
+
*/
|
|
134
|
+
export interface IframeCrashMessage {
|
|
135
|
+
type: 'uplim:crash';
|
|
136
|
+
kind: RendererCrashKind;
|
|
137
|
+
/** Error.name (e.g. `TypeError`). Empty when the source threw a non-Error. */
|
|
138
|
+
name: string;
|
|
139
|
+
/** Error.message — user-facing description of the failure. */
|
|
140
|
+
message: string;
|
|
141
|
+
/** Stack trace if the browser exposed one. May be minified in prod builds. */
|
|
142
|
+
stack: string;
|
|
143
|
+
/** `window.location.href` at the time of the crash, for narrowing down the route. */
|
|
144
|
+
rendererLocation?: string;
|
|
145
|
+
/** Iframe-side wall-clock millis when the crash was observed. */
|
|
146
|
+
reportedAtMs: number;
|
|
147
|
+
// NOTE: things the parent can observe for itself (user agent, viewport,
|
|
148
|
+
// buildHash from the rendererConfig the host already holds) are
|
|
149
|
+
// captured host-side in useRendererBridge — we don't trust the iframe
|
|
150
|
+
// to populate them. The iframe only sends what *only* it can know:
|
|
151
|
+
// the crash facts.
|
|
152
|
+
}
|
|
153
|
+
|
|
107
154
|
export type IframeMessage =
|
|
108
155
|
| IframeReadyMessage
|
|
109
156
|
| IframeOpenUploadMessage
|
|
@@ -112,7 +159,8 @@ export type IframeMessage =
|
|
|
112
159
|
| IframeInitAckMessage
|
|
113
160
|
| IframeGenerateTokenMessage
|
|
114
161
|
| IframeHistoryPushMessage
|
|
115
|
-
| IframeHistoryReplaceMessage
|
|
162
|
+
| IframeHistoryReplaceMessage
|
|
163
|
+
| IframeCrashMessage;
|
|
116
164
|
|
|
117
165
|
// ---------------------------------------------------------------------------
|
|
118
166
|
// Union type for any message
|
|
@@ -138,6 +186,100 @@ export function isHostMessage(data: unknown): data is HostMessage {
|
|
|
138
186
|
return t === 'uplim:init' || t === 'uplim:theme' || t === 'uplim:token' || t === 'uplim:generateTokenResult' || t === 'uplim:historyBack' || t === 'uplim:historyForward';
|
|
139
187
|
}
|
|
140
188
|
|
|
189
|
+
// Proto-side limits (see ReportRendererCrashRequest) — clamp before sending so
|
|
190
|
+
// we don't get truncated mid-string by buf.validate and miss the crash entirely.
|
|
191
|
+
const CRASH_NAME_MAX = 256;
|
|
192
|
+
const CRASH_MESSAGE_MAX = 4096;
|
|
193
|
+
const CRASH_STACK_MAX = 32768;
|
|
194
|
+
const CRASH_LOCATION_MAX = 2048;
|
|
195
|
+
|
|
196
|
+
function clampString(value: string, max: number): string {
|
|
197
|
+
if (value.length <= max) return value;
|
|
198
|
+
// Reserve a marker so it's obvious in telemetry that we truncated.
|
|
199
|
+
const suffix = '…[truncated]';
|
|
200
|
+
return value.slice(0, max - suffix.length) + suffix;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build an `IframeCrashMessage` from an unknown thrown value. Centralised so
|
|
205
|
+
* the React boundary and the global `error` / `unhandledrejection` hooks all
|
|
206
|
+
* produce identically-shaped payloads regardless of what JS threw at them
|
|
207
|
+
* (strings, objects, DOMExceptions, real Errors, …).
|
|
208
|
+
*
|
|
209
|
+
* Only includes what the iframe is the sole source-of-truth for —
|
|
210
|
+
* `name`, `message`, `stack`, `rendererLocation`, `kind`. UA, viewport,
|
|
211
|
+
* buildHash are deliberately omitted: the parent observes them directly
|
|
212
|
+
* (and trusting the iframe to self-report those opens a small but
|
|
213
|
+
* pointless spoof surface).
|
|
214
|
+
*/
|
|
215
|
+
export function buildCrashMessage(
|
|
216
|
+
kind: RendererCrashKind,
|
|
217
|
+
thrown: unknown,
|
|
218
|
+
): IframeCrashMessage {
|
|
219
|
+
let name = '';
|
|
220
|
+
let message = '';
|
|
221
|
+
let stack = '';
|
|
222
|
+
if (thrown instanceof Error) {
|
|
223
|
+
name = thrown.name || 'Error';
|
|
224
|
+
message = thrown.message || String(thrown);
|
|
225
|
+
stack = thrown.stack ?? '';
|
|
226
|
+
} else if (typeof thrown === 'string') {
|
|
227
|
+
name = 'StringError';
|
|
228
|
+
message = thrown;
|
|
229
|
+
} else if (thrown && typeof thrown === 'object') {
|
|
230
|
+
const obj = thrown as { name?: unknown; message?: unknown; stack?: unknown };
|
|
231
|
+
name = typeof obj.name === 'string' ? obj.name : 'UnknownError';
|
|
232
|
+
message = typeof obj.message === 'string' ? obj.message : safeStringify(thrown);
|
|
233
|
+
stack = typeof obj.stack === 'string' ? obj.stack : '';
|
|
234
|
+
} else {
|
|
235
|
+
name = 'UnknownError';
|
|
236
|
+
message = String(thrown);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let rendererLocation: string | undefined;
|
|
240
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
241
|
+
rendererLocation = clampString(window.location.href, CRASH_LOCATION_MAX);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
type: 'uplim:crash',
|
|
246
|
+
kind,
|
|
247
|
+
name: clampString(name, CRASH_NAME_MAX),
|
|
248
|
+
message: clampString(message, CRASH_MESSAGE_MAX),
|
|
249
|
+
stack: clampString(stack, CRASH_STACK_MAX),
|
|
250
|
+
rendererLocation,
|
|
251
|
+
reportedAtMs: Date.now(),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function safeStringify(value: unknown): string {
|
|
256
|
+
try {
|
|
257
|
+
return JSON.stringify(value);
|
|
258
|
+
} catch {
|
|
259
|
+
return String(value);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Fire-and-forget post of a crash message to the host. Used by both the SDK's
|
|
265
|
+
* global hooks and the React boundary. Falls back silently when called outside
|
|
266
|
+
* an iframe (e.g. SSR tests) so the SDK never throws on `window.parent` being
|
|
267
|
+
* `undefined`.
|
|
268
|
+
*
|
|
269
|
+
* `targetOrigin` defaults to `'*'` — at crash time we deliberately do not
|
|
270
|
+
* filter, because the renderer may have lost the host origin context (e.g.
|
|
271
|
+
* if the crash happened before `init` arrived).
|
|
272
|
+
*/
|
|
273
|
+
export function postCrashToHost(message: IframeCrashMessage, targetOrigin: string = '*'): void {
|
|
274
|
+
if (typeof window === 'undefined' || !window.parent || window.parent === window) return;
|
|
275
|
+
try {
|
|
276
|
+
window.parent.postMessage(message, targetOrigin);
|
|
277
|
+
} catch {
|
|
278
|
+
// postMessage can throw on some browsers if the target origin is bad
|
|
279
|
+
// (cross-origin frame missing). Telemetry is best-effort.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
141
283
|
export function isIframeMessage(data: unknown): data is IframeMessage {
|
|
142
284
|
if (!isRendererMessage(data)) return false;
|
|
143
285
|
return !isHostMessage(data);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
import { buildCrashMessage, postCrashToHost } from '../protocol.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* React error boundary that catches render-time exceptions and forwards
|
|
6
|
+
* them to the host via `uplim:crash`. **Renderers should wrap their root
|
|
7
|
+
* in this component** — the SDK's global window hooks catch async
|
|
8
|
+
* failures, but they cannot catch errors thrown during React render or
|
|
9
|
+
* commit (those abort the tree before bubbling to `window.error`).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately renders **nothing** on crash. The host owns the overlay
|
|
12
|
+
* UI for two reasons:
|
|
13
|
+
* 1. Styling stays consistent with the dashboard chrome — a renderer's
|
|
14
|
+
* CSS may itself be the thing that broke, so painting an inline
|
|
15
|
+
* fallback inside the iframe risks an invisible / unreadable
|
|
16
|
+
* screen.
|
|
17
|
+
* 2. The host's overlay can offer recovery actions the renderer
|
|
18
|
+
* can't, like "open the built-in preview instead" (which involves
|
|
19
|
+
* tearing this iframe down and rendering something else).
|
|
20
|
+
*
|
|
21
|
+
* Renderers may pass a `fallback` if they want a minimal visible
|
|
22
|
+
* placeholder while the host's overlay paints (e.g. so users don't
|
|
23
|
+
* stare at a transparent iframe for a frame), but the host overlay
|
|
24
|
+
* remains the source of truth.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* import { createRoot } from 'react-dom/client';
|
|
30
|
+
* import { RendererErrorBoundary } from '@xeonr/renderer-sdk/react';
|
|
31
|
+
*
|
|
32
|
+
* createRoot(document.getElementById('root')!).render(
|
|
33
|
+
* <RendererErrorBoundary>
|
|
34
|
+
* <App />
|
|
35
|
+
* </RendererErrorBoundary>
|
|
36
|
+
* );
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface RendererErrorBoundaryProps {
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
/** Optional inline fallback rendered after the crash is reported. Default: nothing. */
|
|
42
|
+
fallback?: ReactNode;
|
|
43
|
+
/**
|
|
44
|
+
* Optional targetOrigin override for the postMessage. Matches
|
|
45
|
+
* `RendererClientOptions.targetOrigin`. Defaults to `'*'` because at
|
|
46
|
+
* crash time we don't want to gate telemetry on origin matching.
|
|
47
|
+
*/
|
|
48
|
+
targetOrigin?: string;
|
|
49
|
+
/** Optional hook called after the host has been notified — useful for sentry / console wiring. */
|
|
50
|
+
onCrash?: (error: unknown, info: ErrorInfo) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface State {
|
|
54
|
+
crashed: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class RendererErrorBoundary extends Component<RendererErrorBoundaryProps, State> {
|
|
58
|
+
state: State = { crashed: false };
|
|
59
|
+
|
|
60
|
+
static getDerivedStateFromError(): State {
|
|
61
|
+
return { crashed: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
componentDidCatch(error: unknown, info: ErrorInfo): void {
|
|
65
|
+
// Build the crash payload via the shared helper so global hooks and the
|
|
66
|
+
// boundary produce identical wire shapes.
|
|
67
|
+
const msg = buildCrashMessage('render', error);
|
|
68
|
+
|
|
69
|
+
// React stack is more useful than the JS stack for render-time bugs —
|
|
70
|
+
// it points at the component path, not just the throwing function. We
|
|
71
|
+
// append it so server-side logs preserve both.
|
|
72
|
+
if (info?.componentStack) {
|
|
73
|
+
const componentStack = `\n\nReact component stack:\n${info.componentStack}`;
|
|
74
|
+
// Respect the proto's stack limit (32768) — buildCrashMessage already
|
|
75
|
+
// clamped, but we have new content to add, so re-clamp the combined value.
|
|
76
|
+
const combined = (msg.stack ? `${msg.stack}\n` : '') + componentStack;
|
|
77
|
+
msg.stack = combined.length > 32768 ? combined.slice(0, 32768 - 12) + '…[truncated]' : combined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
postCrashToHost(msg, this.props.targetOrigin ?? '*');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
this.props.onCrash?.(error, info);
|
|
84
|
+
} catch {
|
|
85
|
+
// Best-effort: never let the consumer's handler resurface the crash.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
render(): ReactNode {
|
|
90
|
+
if (this.state.crashed) {
|
|
91
|
+
return this.props.fallback ?? null;
|
|
92
|
+
}
|
|
93
|
+
return this.props.children;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/react/dom.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop-in replacement for `react-dom/client` that auto-wraps the rendered
|
|
3
|
+
* tree in `<RendererErrorBoundary>`. Used by `@xeonr/renderer-plugin-vite`
|
|
4
|
+
* to rewrite renderer-archive build imports so the boundary is mandatory
|
|
5
|
+
* without renderer authors having to opt in.
|
|
6
|
+
*
|
|
7
|
+
* Renderer authors do NOT need to import this directly — the vite plugin
|
|
8
|
+
* substitutes `react-dom/client` specifiers in renderer source code at
|
|
9
|
+
* build time. If a renderer is built outside the plugin (e.g. a custom
|
|
10
|
+
* pipeline), authors can import from here explicitly instead.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors React 18+ `Root` / `RootOptions` surface; passes everything
|
|
13
|
+
* through unchanged except for `render` and `hydrateRoot`'s initial
|
|
14
|
+
* children, which are wrapped in the boundary so a single render-time
|
|
15
|
+
* crash is forwarded to the host overlay rather than tearing down the
|
|
16
|
+
* iframe silently.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
createRoot as origCreateRoot,
|
|
20
|
+
hydrateRoot as origHydrateRoot,
|
|
21
|
+
type Root,
|
|
22
|
+
type RootOptions,
|
|
23
|
+
type HydrationOptions,
|
|
24
|
+
} from 'react-dom/client';
|
|
25
|
+
import { createElement, type ReactNode } from 'react';
|
|
26
|
+
import { RendererErrorBoundary } from './RendererErrorBoundary.js';
|
|
27
|
+
|
|
28
|
+
function wrap(children: ReactNode): ReactNode {
|
|
29
|
+
return createElement(RendererErrorBoundary, null, children);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createRoot(container: Element | DocumentFragment, options?: RootOptions): Root {
|
|
33
|
+
const root = origCreateRoot(container, options);
|
|
34
|
+
const originalRender = root.render.bind(root);
|
|
35
|
+
root.render = (children: ReactNode) => originalRender(wrap(children));
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function hydrateRoot(
|
|
40
|
+
container: Element | Document,
|
|
41
|
+
initialChildren: ReactNode,
|
|
42
|
+
options?: HydrationOptions,
|
|
43
|
+
): Root {
|
|
44
|
+
const root = origHydrateRoot(container, wrap(initialChildren), options);
|
|
45
|
+
const originalRender = root.render.bind(root);
|
|
46
|
+
root.render = (children: ReactNode) => originalRender(wrap(children));
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Re-export every remaining symbol from `react-dom/client` so renderers
|
|
51
|
+
// importing other named bindings (e.g. `Root` type) continue to work
|
|
52
|
+
// after the specifier rewrite.
|
|
53
|
+
export type { Root, RootOptions, HydrationOptions } from 'react-dom/client';
|
package/src/react/index.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export { useRendererClient } from './useRendererClient.js';
|
|
2
2
|
export type { UseRendererClientOptions, UseRendererClientResult } from './useRendererClient.js';
|
|
3
|
+
export { RendererErrorBoundary } from './RendererErrorBoundary.js';
|
|
4
|
+
export type { RendererErrorBoundaryProps } from './RendererErrorBoundary.js';
|
|
5
|
+
export { useReportFatalError } from './useReportFatalError.js';
|