@xeonr/renderer-sdk 1.3.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/client.d.ts +47 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +126 -0
- package/dist/client.js.map +1 -1
- 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 +9 -0
- package/dist/react/useRendererClient.d.ts.map +1 -1
- package/dist/react/useRendererClient.js +8 -7
- package/dist/react/useRendererClient.js.map +1 -1
- 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 +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -3
- package/renderer.css +34 -0
- package/src/client.ts +0 -619
- 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 -238
- package/src/react/useReportFatalError.tsx +0 -71
- package/src/types.ts +0 -138
- package/tsconfig.json +0 -18
- package/tsconfig.tsbuildinfo +0 -1
package/src/protocol.ts
DELETED
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
import type { InitPayload } from './types.js';
|
|
2
|
-
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
// Message namespace — all messages use this prefix
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
export const MESSAGE_PREFIX = 'uplim:' as const;
|
|
7
|
-
export const PROTOCOL_VERSION = 1 as const;
|
|
8
|
-
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
// Host → Iframe messages
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
|
|
13
|
-
/** Sent after the iframe reports 'ready'. Contains everything the renderer needs to bootstrap. */
|
|
14
|
-
export interface HostInitMessage {
|
|
15
|
-
type: 'uplim:init';
|
|
16
|
-
payload: InitPayload;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Sent when the host theme changes. */
|
|
20
|
-
export interface HostThemeUpdateMessage {
|
|
21
|
-
type: 'uplim:theme';
|
|
22
|
-
theme: 'light' | 'dark';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Sent proactively or in response to a token request. */
|
|
26
|
-
export interface HostTokenRefreshMessage {
|
|
27
|
-
type: 'uplim:token';
|
|
28
|
-
token: string;
|
|
29
|
-
tokenExpiresAt: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Sent in response to a generateToken request from the iframe. */
|
|
33
|
-
export interface HostGenerateTokenResultMessage {
|
|
34
|
-
type: 'uplim:generateTokenResult';
|
|
35
|
-
requestId: string;
|
|
36
|
-
accepted: boolean;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Tell the iframe to go back in its history. */
|
|
40
|
-
export interface HostHistoryBackMessage {
|
|
41
|
-
type: 'uplim:historyBack';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Tell the iframe to go forward in its history. */
|
|
45
|
-
export interface HostHistoryForwardMessage {
|
|
46
|
-
type: 'uplim:historyForward';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type HostMessage =
|
|
50
|
-
| HostInitMessage
|
|
51
|
-
| HostThemeUpdateMessage
|
|
52
|
-
| HostTokenRefreshMessage
|
|
53
|
-
| HostGenerateTokenResultMessage
|
|
54
|
-
| HostHistoryBackMessage
|
|
55
|
-
| HostHistoryForwardMessage;
|
|
56
|
-
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// Iframe → Host messages
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/** Iframe is loaded and ready to receive the init message. */
|
|
62
|
-
export interface IframeReadyMessage {
|
|
63
|
-
type: 'uplim:ready';
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Request the host to open a specific upload (e.g. in a preview modal). */
|
|
67
|
-
export interface IframeOpenUploadMessage {
|
|
68
|
-
type: 'uplim:openUpload';
|
|
69
|
-
uploadId: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Request a fresh delegated token. Host responds with HostTokenRefreshMessage. */
|
|
73
|
-
export interface IframeTokenRequestMessage {
|
|
74
|
-
type: 'uplim:tokenRequest';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Request the host to close the renderer (relevant for modal mode). */
|
|
78
|
-
export interface IframeCloseMessage {
|
|
79
|
-
type: 'uplim:close';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Acknowledge that init has been processed and the renderer is ready to be displayed. */
|
|
83
|
-
export interface IframeInitAckMessage {
|
|
84
|
-
type: 'uplim:ack';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Request the host to prompt the user to generate a long-lived token. */
|
|
88
|
-
export interface IframeGenerateTokenMessage {
|
|
89
|
-
type: 'uplim:generateToken';
|
|
90
|
-
requestId: string;
|
|
91
|
-
reason: string;
|
|
92
|
-
duration: 'forever' | string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Notify the host that the renderer navigated to a new hash path. */
|
|
96
|
-
export interface IframeHistoryPushMessage {
|
|
97
|
-
type: 'uplim:historyPush';
|
|
98
|
-
path: string;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Notify the host that the renderer replaced the current hash path. */
|
|
102
|
-
export interface IframeHistoryReplaceMessage {
|
|
103
|
-
type: 'uplim:historyReplace';
|
|
104
|
-
path: string;
|
|
105
|
-
}
|
|
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
|
-
|
|
154
|
-
export type IframeMessage =
|
|
155
|
-
| IframeReadyMessage
|
|
156
|
-
| IframeOpenUploadMessage
|
|
157
|
-
| IframeTokenRequestMessage
|
|
158
|
-
| IframeCloseMessage
|
|
159
|
-
| IframeInitAckMessage
|
|
160
|
-
| IframeGenerateTokenMessage
|
|
161
|
-
| IframeHistoryPushMessage
|
|
162
|
-
| IframeHistoryReplaceMessage
|
|
163
|
-
| IframeCrashMessage;
|
|
164
|
-
|
|
165
|
-
// ---------------------------------------------------------------------------
|
|
166
|
-
// Union type for any message
|
|
167
|
-
// ---------------------------------------------------------------------------
|
|
168
|
-
export type RendererMessage = HostMessage | IframeMessage;
|
|
169
|
-
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// Type guard helpers
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
export function isRendererMessage(data: unknown): data is RendererMessage {
|
|
174
|
-
return (
|
|
175
|
-
typeof data === 'object' &&
|
|
176
|
-
data !== null &&
|
|
177
|
-
'type' in data &&
|
|
178
|
-
typeof (data as { type: unknown }).type === 'string' &&
|
|
179
|
-
(data as { type: string }).type.startsWith(MESSAGE_PREFIX)
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function isHostMessage(data: unknown): data is HostMessage {
|
|
184
|
-
if (!isRendererMessage(data)) return false;
|
|
185
|
-
const t = data.type;
|
|
186
|
-
return t === 'uplim:init' || t === 'uplim:theme' || t === 'uplim:token' || t === 'uplim:generateTokenResult' || t === 'uplim:historyBack' || t === 'uplim:historyForward';
|
|
187
|
-
}
|
|
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
|
-
|
|
283
|
-
export function isIframeMessage(data: unknown): data is IframeMessage {
|
|
284
|
-
if (!isRendererMessage(data)) return false;
|
|
285
|
-
return !isHostMessage(data);
|
|
286
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { useRendererClient } from './useRendererClient.js';
|
|
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';
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useMemo } from 'react';
|
|
2
|
-
import { RendererClient } from '../client.js';
|
|
3
|
-
import type { RendererClientOptions } from '../client.js';
|
|
4
|
-
import type { RendererApiAdapter, RendererConfig, RendererScope, RenderingType, InitPayload } from '../types.js';
|
|
5
|
-
|
|
6
|
-
export interface UseRendererClientOptions extends RendererClientOptions {}
|
|
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
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Apply the host's theme to the renderer document. Two distinct effects:
|
|
70
|
-
*
|
|
71
|
-
* - `documentElement.dataset.theme` — surfaces the theme as a
|
|
72
|
-
* `[data-theme="dark"]` selector for renderer-authored CSS.
|
|
73
|
-
* - `documentElement.style.colorScheme` — tells the browser to render
|
|
74
|
-
* scrollbars, form controls, and (critically) the iframe's default
|
|
75
|
-
* canvas in the matching mode. Without this, a cross-origin iframe
|
|
76
|
-
* defaults to a white canvas regardless of what the host's
|
|
77
|
-
* `color-scheme` is — so `background: transparent` in the renderer
|
|
78
|
-
* reveals white, not the host's dark surface. This is the fix for
|
|
79
|
-
* "renderer shows light mode when host is dark".
|
|
80
|
-
*/
|
|
81
|
-
function applyThemeToDocument(theme: 'light' | 'dark'): void {
|
|
82
|
-
if (typeof document === 'undefined') return;
|
|
83
|
-
document.documentElement.dataset.theme = theme;
|
|
84
|
-
document.documentElement.style.colorScheme = theme;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface UseRendererClientResult {
|
|
88
|
-
/** Whether the host has sent the init message. */
|
|
89
|
-
connected: boolean;
|
|
90
|
-
/** What resource the renderer is scoped to. */
|
|
91
|
-
scope: RendererScope | null;
|
|
92
|
-
/** How the renderer is being displayed. */
|
|
93
|
-
renderingType: RenderingType | null;
|
|
94
|
-
/** The current integration access token. */
|
|
95
|
-
token: string | null;
|
|
96
|
-
/** Unix timestamp (ms) when the token expires. */
|
|
97
|
-
tokenExpiresAt: number | null;
|
|
98
|
-
/** The current host theme. */
|
|
99
|
-
theme: 'light' | 'dark';
|
|
100
|
-
/** The parsed config.json from the renderer archive. */
|
|
101
|
-
config: RendererConfig | null;
|
|
102
|
-
/** Which entrypoint was loaded (dashboard or portal). */
|
|
103
|
-
entrypoint: 'dashboard' | 'portal' | null;
|
|
104
|
-
/** Base URL for upl.im API calls. */
|
|
105
|
-
apiBaseUrl: string | null;
|
|
106
|
-
/** Pre-configured API adapter for use with `getUploadClientWithEnv()`. */
|
|
107
|
-
apiAdapter: RendererApiAdapter;
|
|
108
|
-
/** Current hash path within the renderer (e.g. '/settings'). Updated reactively on hash changes. */
|
|
109
|
-
path: string;
|
|
110
|
-
|
|
111
|
-
/** Request the host to open a specific upload. */
|
|
112
|
-
openUpload: (uploadId: string) => void;
|
|
113
|
-
/** Request a fresh integration access token. */
|
|
114
|
-
requestToken: () => void;
|
|
115
|
-
/** Request the host to close this renderer (modal mode). */
|
|
116
|
-
close: () => void;
|
|
117
|
-
/**
|
|
118
|
-
* When the renderer's `config.json` has `deferReady: true`, call
|
|
119
|
-
* this once the renderer's data is loaded and the UI is ready to
|
|
120
|
-
* be visible. The host overlay stays up until this fires (or until
|
|
121
|
-
* the SDK's safety timeout elapses — see
|
|
122
|
-
* `RendererClientOptions.readyTimeoutMs`). No-op when `deferReady`
|
|
123
|
-
* isn't set in config.
|
|
124
|
-
*/
|
|
125
|
-
markReady: () => void;
|
|
126
|
-
|
|
127
|
-
/** The underlying RendererClient instance for advanced use. */
|
|
128
|
-
client: RendererClient;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* React hook for building custom renderers.
|
|
133
|
-
*
|
|
134
|
-
* Creates a `RendererClient` instance, manages its lifecycle, and exposes
|
|
135
|
-
* reactive state that triggers re-renders when the host sends messages.
|
|
136
|
-
*
|
|
137
|
-
* ```tsx
|
|
138
|
-
* import { useRendererClient } from '@xeonr/renderer-sdk/react';
|
|
139
|
-
*
|
|
140
|
-
* function App() {
|
|
141
|
-
* const { connected, scope, renderingType, theme, token } = useRendererClient();
|
|
142
|
-
*
|
|
143
|
-
* if (!connected) return <div>Connecting...</div>;
|
|
144
|
-
*
|
|
145
|
-
* return (
|
|
146
|
-
* <div data-theme={theme}>
|
|
147
|
-
* <pre>{JSON.stringify(scope, null, 2)}</pre>
|
|
148
|
-
* </div>
|
|
149
|
-
* );
|
|
150
|
-
* }
|
|
151
|
-
* ```
|
|
152
|
-
*/
|
|
153
|
-
export function useRendererClient(options?: UseRendererClientOptions): UseRendererClientResult {
|
|
154
|
-
const client = getSharedClient(options);
|
|
155
|
-
|
|
156
|
-
// Initial state read directly from the client — handles late
|
|
157
|
-
// subscribers (a second `useRendererClient` mount that happens
|
|
158
|
-
// after init has already fired). Without this, the late mount
|
|
159
|
-
// would sit at `connected: false` forever because onInit only
|
|
160
|
-
// fires for NEW init events, not for already-received state.
|
|
161
|
-
const [connected, setConnected] = useState<boolean>(() => client.isConnected());
|
|
162
|
-
const [scope, setScope] = useState<RendererScope | null>(() => client.getScope());
|
|
163
|
-
const [renderingType, setRenderingType] = useState<RenderingType | null>(() => client.getRenderingType());
|
|
164
|
-
const [token, setToken] = useState<string | null>(() => client.getToken());
|
|
165
|
-
const [tokenExpiresAt, setTokenExpiresAt] = useState<number | null>(() => client.getTokenExpiresAt());
|
|
166
|
-
const [theme, setTheme] = useState<'light' | 'dark'>(() => client.getTheme());
|
|
167
|
-
const [config, setConfig] = useState<RendererConfig | null>(() => client.getConfig());
|
|
168
|
-
const [entrypoint, setEntrypoint] = useState<'dashboard' | 'portal' | null>(() => client.getEntrypoint());
|
|
169
|
-
const [apiBaseUrl, setApiBaseUrl] = useState<string | null>(() => client.getApiBaseUrl());
|
|
170
|
-
const [path, setPath] = useState<string>(() => client.getPath());
|
|
171
|
-
|
|
172
|
-
useEffect(() => {
|
|
173
|
-
const unsubInit = client.onInit((payload: InitPayload) => {
|
|
174
|
-
setConnected(true);
|
|
175
|
-
setScope(payload.scope);
|
|
176
|
-
setRenderingType(payload.renderingType);
|
|
177
|
-
setToken(payload.token);
|
|
178
|
-
setTokenExpiresAt(payload.tokenExpiresAt);
|
|
179
|
-
setTheme(payload.theme);
|
|
180
|
-
setConfig(payload.config);
|
|
181
|
-
setEntrypoint(payload.entrypoint);
|
|
182
|
-
setApiBaseUrl(payload.apiBaseUrl);
|
|
183
|
-
setPath(client.getPath());
|
|
184
|
-
applyThemeToDocument(payload.theme);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const unsubTheme = client.onThemeChange((newTheme) => {
|
|
188
|
-
setTheme(newTheme);
|
|
189
|
-
applyThemeToDocument(newTheme);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
const unsubToken = client.onTokenRefresh((newToken, newExpiresAt) => {
|
|
193
|
-
setToken(newToken);
|
|
194
|
-
setTokenExpiresAt(newExpiresAt);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const unsubNavigate = client.onNavigate((newPath) => {
|
|
198
|
-
setPath(newPath);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
return () => {
|
|
202
|
-
unsubInit();
|
|
203
|
-
unsubTheme();
|
|
204
|
-
unsubToken();
|
|
205
|
-
unsubNavigate();
|
|
206
|
-
// NB: do NOT destroy() here — the client is a per-page
|
|
207
|
-
// singleton, not a per-component instance. Component
|
|
208
|
-
// unmounts (route changes / strict-mode double-mount /
|
|
209
|
-
// React render-time tearing) shouldn't kill the shared
|
|
210
|
-
// state. The iframe full-reload tears the singleton down
|
|
211
|
-
// implicitly when the page goes away; SDK-level HMR is
|
|
212
|
-
// handled by import.meta.hot.dispose at the top of file.
|
|
213
|
-
};
|
|
214
|
-
}, [client]);
|
|
215
|
-
|
|
216
|
-
const methods = useMemo(() => ({
|
|
217
|
-
openUpload: (uploadId: string) => client.openUpload(uploadId),
|
|
218
|
-
requestToken: () => client.requestToken(),
|
|
219
|
-
close: () => client.close(),
|
|
220
|
-
markReady: () => client.signalReady(),
|
|
221
|
-
apiAdapter: client.getApiAdapter(),
|
|
222
|
-
}), [client]);
|
|
223
|
-
|
|
224
|
-
return {
|
|
225
|
-
connected,
|
|
226
|
-
scope,
|
|
227
|
-
renderingType,
|
|
228
|
-
token,
|
|
229
|
-
tokenExpiresAt,
|
|
230
|
-
theme,
|
|
231
|
-
config,
|
|
232
|
-
entrypoint,
|
|
233
|
-
apiBaseUrl,
|
|
234
|
-
path,
|
|
235
|
-
client,
|
|
236
|
-
...methods,
|
|
237
|
-
};
|
|
238
|
-
}
|