@xeonr/renderer-sdk 1.0.4

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/src/client.ts ADDED
@@ -0,0 +1,426 @@
1
+ import type {
2
+ HostInitMessage,
3
+ HostMessage,
4
+ HostThemeUpdateMessage,
5
+ HostTokenRefreshMessage,
6
+ HostGenerateTokenResultMessage,
7
+ IframeHistoryPushMessage,
8
+ IframeHistoryReplaceMessage,
9
+ } from './protocol.js';
10
+ import { isHostMessage } from './protocol.js';
11
+ import type { InitPayload, RendererApiAdapter, RendererConfig, RendererScope, RenderingType } from './types.js';
12
+
13
+ export interface RendererClientOptions {
14
+ /**
15
+ * Expected origin of the host application.
16
+ * If set, messages from other origins are silently ignored.
17
+ * If omitted, all origins are accepted (suitable for local dev).
18
+ */
19
+ targetOrigin?: string;
20
+ }
21
+
22
+ type InitCallback = (payload: InitPayload) => void;
23
+ type ThemeCallback = (theme: 'light' | 'dark') => void;
24
+ type TokenCallback = (token: string, expiresAt: number) => void;
25
+ type NavigateCallback = (path: string) => void;
26
+
27
+ /**
28
+ * Client SDK for custom renderers running inside a sandboxed iframe.
29
+ *
30
+ * Usage:
31
+ * ```ts
32
+ * import { RendererClient } from '@xeonr/renderer-sdk';
33
+ *
34
+ * const client = new RendererClient();
35
+ *
36
+ * client.onInit((payload) => {
37
+ * console.log('Received scope:', payload.scope);
38
+ * console.log('Token:', payload.token);
39
+ * // Bootstrap your app here
40
+ * });
41
+ *
42
+ * client.onThemeChange((theme) => {
43
+ * document.documentElement.setAttribute('data-theme', theme);
44
+ * });
45
+ * ```
46
+ */
47
+ export class RendererClient {
48
+ private targetOrigin: string;
49
+ private initCallbacks: InitCallback[] = [];
50
+ private themeCallbacks: ThemeCallback[] = [];
51
+ private tokenCallbacks: TokenCallback[] = [];
52
+ private navigateCallbacks: NavigateCallback[] = [];
53
+
54
+ private currentToken: string | null = null;
55
+ private currentTokenExpiresAt: number | null = null;
56
+ private currentScope: RendererScope | null = null;
57
+ private currentRenderingType: RenderingType | null = null;
58
+ private currentConfig: RendererConfig | null = null;
59
+ private currentTheme: 'light' | 'dark' = 'light';
60
+ private currentApiBaseUrl: string | null = null;
61
+ private currentPath: string = '/';
62
+
63
+ private listener: ((event: MessageEvent) => void) | null = null;
64
+ private hashChangeListener: (() => void) | null = null;
65
+ private historyPatched = false;
66
+ private originalPushState: typeof history.pushState | null = null;
67
+ private originalReplaceState: typeof history.replaceState | null = null;
68
+ private destroyed = false;
69
+ private suppressHashChange = false;
70
+ private pendingTokenResolvers: Array<(result: { token: string; expiresAt: number }) => void> = [];
71
+ private pendingGenerateTokenResolvers: Map<string, { resolve: (result: { accepted: boolean }) => void; reject: (error: Error) => void }> = new Map();
72
+ private apiAdapter: RendererApiAdapter | null = null;
73
+
74
+ constructor(options?: RendererClientOptions) {
75
+ this.targetOrigin = options?.targetOrigin ?? '*';
76
+ this.setup();
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Lifecycle
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Register a callback for when the host sends the init message. */
84
+ onInit(cb: InitCallback): () => void {
85
+ this.initCallbacks.push(cb);
86
+ return () => {
87
+ this.initCallbacks = this.initCallbacks.filter(c => c !== cb);
88
+ };
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Receive from host
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** Register a callback for host theme changes. */
96
+ onThemeChange(cb: ThemeCallback): () => void {
97
+ this.themeCallbacks.push(cb);
98
+ return () => {
99
+ this.themeCallbacks = this.themeCallbacks.filter(c => c !== cb);
100
+ };
101
+ }
102
+
103
+ /** Register a callback for token refreshes from the host. */
104
+ onTokenRefresh(cb: TokenCallback): () => void {
105
+ this.tokenCallbacks.push(cb);
106
+ return () => {
107
+ this.tokenCallbacks = this.tokenCallbacks.filter(c => c !== cb);
108
+ };
109
+ }
110
+
111
+ /** Register a callback for hash-based navigation changes. */
112
+ onNavigate(cb: NavigateCallback): () => void {
113
+ this.navigateCallbacks.push(cb);
114
+ return () => {
115
+ this.navigateCallbacks = this.navigateCallbacks.filter(c => c !== cb);
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Send to host
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /** Request the host to open a specific upload. */
124
+ openUpload(uploadId: string): void {
125
+ this.postToHost({ type: 'uplim:openUpload', uploadId });
126
+ }
127
+
128
+ /** Request a fresh integration access token from the host. */
129
+ requestToken(): void {
130
+ this.postToHost({ type: 'uplim:tokenRequest' });
131
+ }
132
+
133
+ /** Request a fresh token and wait for the response. */
134
+ requestTokenAsync(): Promise<{ token: string; expiresAt: number }> {
135
+ return new Promise((resolve) => {
136
+ this.pendingTokenResolvers.push(resolve);
137
+ this.postToHost({ type: 'uplim:tokenRequest' });
138
+ });
139
+ }
140
+
141
+ /** Request the host to close this renderer (modal mode). */
142
+ close(): void {
143
+ this.postToHost({ type: 'uplim:close' });
144
+ }
145
+
146
+ /**
147
+ * Request the host to prompt the user to generate a long-lived token.
148
+ * The token is displayed to the user but never returned to the renderer.
149
+ * Resolves with `{ accepted: true }` if the user accepts, or `{ accepted: false }` if rejected.
150
+ */
151
+ generateToken(opts: { reason: string; duration: 'forever' | string }): Promise<{ accepted: boolean }> {
152
+ const requestId = crypto.randomUUID();
153
+ return new Promise((resolve, reject) => {
154
+ this.pendingGenerateTokenResolvers.set(requestId, { resolve, reject });
155
+ this.postToHost({
156
+ type: 'uplim:generateToken',
157
+ requestId,
158
+ reason: opts.reason,
159
+ duration: opts.duration,
160
+ });
161
+ });
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Getters
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /** Get the current integration access token. */
169
+ getToken(): string | null {
170
+ return this.currentToken;
171
+ }
172
+
173
+ /** Get the token expiry as a Unix timestamp (ms). */
174
+ getTokenExpiresAt(): number | null {
175
+ return this.currentTokenExpiresAt;
176
+ }
177
+
178
+ /** Check if the current token is expired or about to expire (within 30s). */
179
+ isTokenExpired(): boolean {
180
+ if (!this.currentTokenExpiresAt) return true;
181
+ return Date.now() >= this.currentTokenExpiresAt - 30_000;
182
+ }
183
+
184
+ /** Get the current scope. */
185
+ getScope(): RendererScope | null {
186
+ return this.currentScope;
187
+ }
188
+
189
+ /** Get the current rendering type. */
190
+ getRenderingType(): RenderingType | null {
191
+ return this.currentRenderingType;
192
+ }
193
+
194
+ /** Get the renderer config from config.json. */
195
+ getConfig(): RendererConfig | null {
196
+ return this.currentConfig;
197
+ }
198
+
199
+ /** Get the current theme. */
200
+ getTheme(): 'light' | 'dark' {
201
+ return this.currentTheme;
202
+ }
203
+
204
+ /** Get the current hash path (e.g. '/settings/advanced'). */
205
+ getPath(): string {
206
+ return this.currentPath;
207
+ }
208
+
209
+ /**
210
+ * Returns an API adapter compatible with `getUploadClientWithEnv()` from `@xeonr/uploads-sdk`.
211
+ * Handles authentication and automatic token refresh via the host bridge.
212
+ *
213
+ * ```ts
214
+ * import { getUploadClientWithEnv } from '@xeonr/uploads-sdk/api/base';
215
+ * import { BucketUploadsService } from '@xeonr/uploads-protocol/uplim/api/v1/uploads_pb';
216
+ *
217
+ * const adapter = client.getApiAdapter();
218
+ * const uploadsClient = getUploadClientWithEnv(BucketUploadsService, adapter);
219
+ * ```
220
+ */
221
+ getApiAdapter(): RendererApiAdapter {
222
+ if (this.apiAdapter) return this.apiAdapter;
223
+
224
+ // Use a getter so hostname reflects the latest apiBaseUrl (set on init)
225
+ const self = this;
226
+ this.apiAdapter = {
227
+ get hostname() { return self.currentApiBaseUrl ?? undefined; },
228
+ tokenHelper: async () => this.currentToken,
229
+ onAuthenticationExpired: async (retryCount: number) => {
230
+ if (retryCount > 0) return false;
231
+ const result = await this.requestTokenAsync();
232
+ return !!result.token;
233
+ },
234
+ };
235
+
236
+ return this.apiAdapter;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Cleanup
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /** Remove all listeners and stop the client. */
244
+ destroy(): void {
245
+ this.destroyed = true;
246
+ if (this.listener) {
247
+ window.removeEventListener('message', this.listener);
248
+ this.listener = null;
249
+ }
250
+ if (this.hashChangeListener) {
251
+ window.removeEventListener('hashchange', this.hashChangeListener);
252
+ this.hashChangeListener = null;
253
+ }
254
+ if (this.historyPatched) {
255
+ if (this.originalPushState) window.history.pushState = this.originalPushState;
256
+ if (this.originalReplaceState) window.history.replaceState = this.originalReplaceState;
257
+ this.historyPatched = false;
258
+ }
259
+ this.initCallbacks = [];
260
+ this.themeCallbacks = [];
261
+ this.tokenCallbacks = [];
262
+ this.navigateCallbacks = [];
263
+ for (const [, pending] of this.pendingGenerateTokenResolvers) {
264
+ pending.reject(new Error('Client destroyed'));
265
+ }
266
+ this.pendingGenerateTokenResolvers.clear();
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Internal
271
+ // ---------------------------------------------------------------------------
272
+
273
+ private setup(): void {
274
+ this.listener = (event: MessageEvent) => {
275
+ if (this.destroyed) return;
276
+ if (this.targetOrigin !== '*' && event.origin !== this.targetOrigin) return;
277
+ if (!isHostMessage(event.data)) return;
278
+
279
+ this.handleMessage(event.data);
280
+ };
281
+
282
+ window.addEventListener('message', this.listener);
283
+
284
+ // Tell the host we're ready
285
+ this.postToHost({ type: 'uplim:ready' });
286
+ }
287
+
288
+ private handleMessage(message: HostMessage): void {
289
+ switch (message.type) {
290
+ case 'uplim:init':
291
+ this.handleInit(message);
292
+ break;
293
+ case 'uplim:theme':
294
+ this.handleThemeUpdate(message);
295
+ break;
296
+ case 'uplim:token':
297
+ this.handleTokenRefresh(message);
298
+ break;
299
+ case 'uplim:generateTokenResult':
300
+ this.handleGenerateTokenResult(message);
301
+ break;
302
+ case 'uplim:historyBack':
303
+ window.history.back();
304
+ break;
305
+ case 'uplim:historyForward':
306
+ window.history.forward();
307
+ break;
308
+ }
309
+ }
310
+
311
+ private handleInit(message: HostInitMessage): void {
312
+ const { payload } = message;
313
+ this.currentToken = payload.token;
314
+ this.currentTokenExpiresAt = payload.tokenExpiresAt;
315
+ this.currentScope = payload.scope;
316
+ this.currentRenderingType = payload.renderingType;
317
+ this.currentConfig = payload.config;
318
+ this.currentTheme = payload.theme;
319
+ this.currentApiBaseUrl = payload.apiBaseUrl;
320
+
321
+ // Apply initial path from host URL fragment
322
+ if (payload.initialPath) {
323
+ this.suppressHashChange = true;
324
+ window.location.hash = payload.initialPath;
325
+ this.currentPath = payload.initialPath;
326
+ this.suppressHashChange = false;
327
+ } else {
328
+ this.currentPath = this.getHashPath();
329
+ }
330
+
331
+ // Start observing hash changes
332
+ this.setupHashTracking();
333
+
334
+ for (const cb of this.initCallbacks) {
335
+ cb(payload);
336
+ }
337
+
338
+ // Tell the host we've processed init and are ready to be displayed
339
+ this.postToHost({ type: 'uplim:ack' });
340
+ }
341
+
342
+ private handleThemeUpdate(message: HostThemeUpdateMessage): void {
343
+ this.currentTheme = message.theme;
344
+ for (const cb of this.themeCallbacks) {
345
+ cb(message.theme);
346
+ }
347
+ }
348
+
349
+ private handleTokenRefresh(message: HostTokenRefreshMessage): void {
350
+ this.currentToken = message.token;
351
+ this.currentTokenExpiresAt = message.tokenExpiresAt;
352
+
353
+ // Resolve any pending requestTokenAsync() calls
354
+ const resolvers = this.pendingTokenResolvers;
355
+ this.pendingTokenResolvers = [];
356
+ for (const resolve of resolvers) {
357
+ resolve({ token: message.token, expiresAt: message.tokenExpiresAt });
358
+ }
359
+
360
+ for (const cb of this.tokenCallbacks) {
361
+ cb(message.token, message.tokenExpiresAt);
362
+ }
363
+ }
364
+
365
+ private handleGenerateTokenResult(message: HostGenerateTokenResultMessage): void {
366
+ const pending = this.pendingGenerateTokenResolvers.get(message.requestId);
367
+ if (pending) {
368
+ this.pendingGenerateTokenResolvers.delete(message.requestId);
369
+ pending.resolve({ accepted: message.accepted });
370
+ }
371
+ }
372
+
373
+ private getHashPath(): string {
374
+ const hash = window.location.hash;
375
+ if (!hash || hash === '#') return '/';
376
+ // Strip leading '#' (and optional leading '#/')
377
+ return hash.startsWith('#/') ? hash.slice(1) : hash.slice(1);
378
+ }
379
+
380
+ private onHashChanged(isPush: boolean): void {
381
+ if (this.destroyed || this.suppressHashChange) return;
382
+
383
+ const newPath = this.getHashPath();
384
+ if (newPath === this.currentPath) return;
385
+
386
+ this.currentPath = newPath;
387
+
388
+ const msg: IframeHistoryPushMessage | IframeHistoryReplaceMessage = isPush
389
+ ? { type: 'uplim:historyPush', path: newPath }
390
+ : { type: 'uplim:historyReplace', path: newPath };
391
+ this.postToHost(msg);
392
+
393
+ for (const cb of this.navigateCallbacks) {
394
+ cb(newPath);
395
+ }
396
+ }
397
+
398
+ private setupHashTracking(): void {
399
+ if (this.hashChangeListener) return;
400
+
401
+ // Listen for direct hash changes (e.g. window.location.hash = '...' or <a href="#...">)
402
+ this.hashChangeListener = () => this.onHashChanged(true);
403
+ window.addEventListener('hashchange', this.hashChangeListener);
404
+
405
+ // Patch pushState/replaceState — hash routers use these instead of setting
406
+ // window.location.hash directly, and they don't fire hashchange.
407
+ this.originalPushState = window.history.pushState.bind(window.history);
408
+ this.originalReplaceState = window.history.replaceState.bind(window.history);
409
+
410
+ const self = this;
411
+ window.history.pushState = function (...args: Parameters<typeof history.pushState>) {
412
+ self.originalPushState!(...args);
413
+ self.onHashChanged(true);
414
+ };
415
+ window.history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
416
+ self.originalReplaceState!(...args);
417
+ self.onHashChanged(false);
418
+ };
419
+ this.historyPatched = true;
420
+ }
421
+
422
+ private postToHost(message: object): void {
423
+ if (this.destroyed) return;
424
+ window.parent.postMessage(message, this.targetOrigin);
425
+ }
426
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ export { RendererClient } from './client.js';
2
+ export type { RendererClientOptions } from './client.js';
3
+
4
+ export type {
5
+ RendererConfig,
6
+ RendererPermission,
7
+ RendererScope,
8
+ RendererBucketScope,
9
+ RendererFolderScope,
10
+ RendererUploadScope,
11
+ RendererVirtualFileScope,
12
+ RenderingType,
13
+ InitPayload,
14
+ RendererApiAdapter,
15
+ } from './types.js';
16
+
17
+ export {
18
+ MESSAGE_PREFIX,
19
+ PROTOCOL_VERSION,
20
+ isRendererMessage,
21
+ isHostMessage,
22
+ isIframeMessage,
23
+ } from './protocol.js';
24
+
25
+ export type {
26
+ HostMessage,
27
+ HostInitMessage,
28
+ HostThemeUpdateMessage,
29
+ HostTokenRefreshMessage,
30
+ HostGenerateTokenResultMessage,
31
+ HostHistoryBackMessage,
32
+ HostHistoryForwardMessage,
33
+ IframeMessage,
34
+ IframeReadyMessage,
35
+ IframeOpenUploadMessage,
36
+ IframeTokenRequestMessage,
37
+ IframeCloseMessage,
38
+ IframeInitAckMessage,
39
+ IframeGenerateTokenMessage,
40
+ IframeHistoryPushMessage,
41
+ IframeHistoryReplaceMessage,
42
+ RendererMessage,
43
+ } from './protocol.js';
@@ -0,0 +1,144 @@
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
+ export type IframeMessage =
108
+ | IframeReadyMessage
109
+ | IframeOpenUploadMessage
110
+ | IframeTokenRequestMessage
111
+ | IframeCloseMessage
112
+ | IframeInitAckMessage
113
+ | IframeGenerateTokenMessage
114
+ | IframeHistoryPushMessage
115
+ | IframeHistoryReplaceMessage;
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Union type for any message
119
+ // ---------------------------------------------------------------------------
120
+ export type RendererMessage = HostMessage | IframeMessage;
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Type guard helpers
124
+ // ---------------------------------------------------------------------------
125
+ export function isRendererMessage(data: unknown): data is RendererMessage {
126
+ return (
127
+ typeof data === 'object' &&
128
+ data !== null &&
129
+ 'type' in data &&
130
+ typeof (data as { type: unknown }).type === 'string' &&
131
+ (data as { type: string }).type.startsWith(MESSAGE_PREFIX)
132
+ );
133
+ }
134
+
135
+ export function isHostMessage(data: unknown): data is HostMessage {
136
+ if (!isRendererMessage(data)) return false;
137
+ const t = data.type;
138
+ return t === 'uplim:init' || t === 'uplim:theme' || t === 'uplim:token' || t === 'uplim:generateTokenResult' || t === 'uplim:historyBack' || t === 'uplim:historyForward';
139
+ }
140
+
141
+ export function isIframeMessage(data: unknown): data is IframeMessage {
142
+ if (!isRendererMessage(data)) return false;
143
+ return !isHostMessage(data);
144
+ }
@@ -0,0 +1,2 @@
1
+ export { useRendererClient } from './useRendererClient.js';
2
+ export type { UseRendererClientOptions, UseRendererClientResult } from './useRendererClient.js';