@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.
Files changed (44) hide show
  1. package/dist/client.d.ts +62 -0
  2. package/dist/client.d.ts.map +1 -1
  3. package/dist/client.js +150 -2
  4. package/dist/client.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/protocol.d.ts +65 -1
  10. package/dist/protocol.d.ts.map +1 -1
  11. package/dist/protocol.js +91 -0
  12. package/dist/protocol.js.map +1 -1
  13. package/dist/react/RendererErrorBoundary.d.ts +60 -0
  14. package/dist/react/RendererErrorBoundary.d.ts.map +1 -0
  15. package/dist/react/RendererErrorBoundary.js +37 -0
  16. package/dist/react/RendererErrorBoundary.js.map +1 -0
  17. package/dist/react/dom.d.ts +23 -0
  18. package/dist/react/dom.d.ts.map +1 -0
  19. package/dist/react/dom.js +36 -0
  20. package/dist/react/dom.js.map +1 -0
  21. package/dist/react/index.d.ts +3 -0
  22. package/dist/react/index.d.ts.map +1 -1
  23. package/dist/react/index.js +2 -0
  24. package/dist/react/index.js.map +1 -1
  25. package/dist/react/useRendererClient.d.ts +9 -0
  26. package/dist/react/useRendererClient.d.ts.map +1 -1
  27. package/dist/react/useRendererClient.js +80 -18
  28. package/dist/react/useRendererClient.js.map +1 -1
  29. package/dist/react/useReportFatalError.d.ts +37 -0
  30. package/dist/react/useReportFatalError.d.ts.map +1 -0
  31. package/dist/react/useReportFatalError.js +71 -0
  32. package/dist/react/useReportFatalError.js.map +1 -0
  33. package/dist/types.d.ts +40 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +12 -2
  36. package/src/client.ts +166 -2
  37. package/src/index.ts +4 -0
  38. package/src/protocol.ts +143 -1
  39. package/src/react/RendererErrorBoundary.tsx +95 -0
  40. package/src/react/dom.ts +53 -0
  41. package/src/react/index.ts +3 -0
  42. package/src/react/useRendererClient.ts +95 -21
  43. package/src/react/useReportFatalError.tsx +71 -0
  44. package/src/types.ts +40 -0
@@ -1,10 +1,70 @@
1
- import { useState, useRef, useEffect, useMemo } from 'react';
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
  *
@@ -54,6 +114,15 @@ export interface UseRendererClientResult {
54
114
  requestToken: () => void;
55
115
  /** Request the host to close this renderer (modal mode). */
56
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;
57
126
 
58
127
  /** The underlying RendererClient instance for advanced use. */
59
128
  client: RendererClient;
@@ -82,25 +151,23 @@ export interface UseRendererClientResult {
82
151
  * ```
83
152
  */
84
153
  export function useRendererClient(options?: UseRendererClientOptions): UseRendererClientResult {
85
- const clientRef = useRef<RendererClient | null>(null);
86
-
87
- // Lazy-init the client (only once)
88
- if (clientRef.current === null) {
89
- clientRef.current = new RendererClient(options);
90
- }
91
-
92
- const client = clientRef.current;
93
-
94
- const [connected, setConnected] = useState(false);
95
- const [scope, setScope] = useState<RendererScope | null>(null);
96
- const [renderingType, setRenderingType] = useState<RenderingType | null>(null);
97
- const [token, setToken] = useState<string | null>(null);
98
- const [tokenExpiresAt, setTokenExpiresAt] = useState<number | null>(null);
99
- const [theme, setTheme] = useState<'light' | 'dark'>('light');
100
- const [config, setConfig] = useState<RendererConfig | null>(null);
101
- const [entrypoint, setEntrypoint] = useState<'dashboard' | 'portal' | null>(null);
102
- const [apiBaseUrl, setApiBaseUrl] = useState<string | null>(null);
103
- const [path, setPath] = useState<string>('/');
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());
104
171
 
105
172
  useEffect(() => {
106
173
  const unsubInit = client.onInit((payload: InitPayload) => {
@@ -136,7 +203,13 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
136
203
  unsubTheme();
137
204
  unsubToken();
138
205
  unsubNavigate();
139
- client.destroy();
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.
140
213
  };
141
214
  }, [client]);
142
215
 
@@ -144,6 +217,7 @@ export function useRendererClient(options?: UseRendererClientOptions): UseRender
144
217
  openUpload: (uploadId: string) => client.openUpload(uploadId),
145
218
  requestToken: () => client.requestToken(),
146
219
  close: () => client.close(),
220
+ markReady: () => client.signalReady(),
147
221
  apiAdapter: client.getApiAdapter(),
148
222
  }), [client]);
149
223
 
@@ -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/types.ts CHANGED
@@ -11,6 +11,46 @@ export interface RendererConfig {
11
11
  allowForms?: boolean;
12
12
  allowDownloads?: boolean;
13
13
  };
14
+ /**
15
+ * Extra origins this renderer needs to reach via `fetch` / `XMLHttpRequest`
16
+ * / WebSocket. Surfaced to the browser as additional entries in the CSP
17
+ * `connect-src` directive. The upl.im API + auth hosts are always allowed
18
+ * by the renderer-proxy; only list extras here (e.g. a third-party API
19
+ * the renderer integrates with).
20
+ *
21
+ * Each entry is a fully-qualified origin like `https://api.example.com`
22
+ * or a wildcarded host like `https://*.example.com`. No paths, no
23
+ * trailing slashes. Origins are validated at deploy time.
24
+ */
25
+ connectHosts?: string[];
26
+ /**
27
+ * Short build identifier the build pipeline stamps into config.json
28
+ * (e.g. the renderer-plugin-vite hash of the output directory).
29
+ * Surfaced in crash telemetry as a separate dimension so we can
30
+ * distinguish "crashed in build A" from "crashed in build B" even
31
+ * when both builds share the same archive_upload_id (the developer
32
+ * republished without bumping the archive). Bounded to 64 chars.
33
+ */
34
+ buildHash?: string;
35
+ /**
36
+ * When true, the SDK holds `uplim:ack` until the renderer explicitly
37
+ * calls `markReady()` from `useRendererClient()`. The host's loading
38
+ * overlay stays visible until that signal arrives, so the user
39
+ * never sees a blank canvas between bridge ack and the renderer's
40
+ * first paint.
41
+ *
42
+ * Leave unset (or `false`) for fast renderers where the gap is
43
+ * invisible — that's the right default and lowest-friction pattern
44
+ * (image, code, plain markdown). Set `true` when the renderer takes
45
+ * perceptible time to settle (large file fetch, video decode,
46
+ * multi-page paginate) and the blank-canvas flash is noticeable.
47
+ *
48
+ * The SDK auto-acks after 30s (configurable via
49
+ * `RendererClientOptions.readyTimeoutMs`) and logs a warning if
50
+ * `markReady()` never fires — so a forgotten signal can't trap
51
+ * users behind the overlay.
52
+ */
53
+ deferReady?: boolean;
14
54
  }
15
55
 
16
56
  export type RendererPermission =