@handofclient/embed-js 0.0.0-stage → 0.1.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.
@@ -0,0 +1,133 @@
1
+ import { createHocClient } from "@handofclient/api";
2
+ import { PostMessageChannel } from "../channel.js";
3
+ import { MessageType, } from "../protocol.js";
4
+ import { defaultUiHandler } from "./defaultUi.js";
5
+ import { encodePackageId } from "./packageIdEncoding.js";
6
+ import { ReadyWatchdog } from "./watchdog.js";
7
+ export class HocMountError extends Error {
8
+ reason;
9
+ constructor(reason, message) {
10
+ super(message);
11
+ this.reason = reason;
12
+ this.name = "HocMountError";
13
+ }
14
+ }
15
+ let config = null;
16
+ export function configure(next) {
17
+ config = next;
18
+ }
19
+ function requireConfig() {
20
+ if (!config)
21
+ throw new Error("HandOfClient.configure({ apiBaseUrl, embedOrigin }) must be called before mount().");
22
+ return config;
23
+ }
24
+ const DEFAULT_READY_TIMEOUT_MS = 15_000;
25
+ export async function mount(container, options) {
26
+ const { apiBaseUrl, embedOrigin } = requireConfig();
27
+ let tokenResponse;
28
+ try {
29
+ const response = await fetch(options.tokenUrl, { credentials: "same-origin" });
30
+ if (!response.ok)
31
+ throw new Error(`tokenUrl responded ${response.status}`);
32
+ tokenResponse = (await response.json());
33
+ }
34
+ catch (cause) {
35
+ throw new HocMountError("token-fetch-failed", `Failed to fetch embed token: ${cause.message}`);
36
+ }
37
+ const registry = createHocClient({ baseUrl: apiBaseUrl }).packageRegistry;
38
+ const scope = { hostId: options.hostId, tenantId: options.tenantId, packageId: options.packageId };
39
+ let activeVersion;
40
+ try {
41
+ activeVersion = await registry.getActiveVersion({ scope, slotId: options.slotId });
42
+ }
43
+ catch (cause) {
44
+ throw new HocMountError("no-active-version", `No active version for this tenant/slot: ${cause.message}`);
45
+ }
46
+ const version = activeVersion.version.version;
47
+ const entryPoint = activeVersion.version.manifest.bundle.entryPoints[options.slotId] ?? "index.html";
48
+ const packageIdB64 = encodePackageId(options.packageId);
49
+ const iframe = document.createElement("iframe");
50
+ iframe.title = activeVersion.slot?.title || options.slotId;
51
+ iframe.sandbox.add("allow-scripts", "allow-same-origin", "allow-forms", "allow-popups");
52
+ iframe.style.border = "none";
53
+ iframe.style.width = "100%";
54
+ // "/embed/" is not decorative - it's BundleEndpoints.cs's actual mapped route
55
+ // ("/embed/{packageIdB64}/{version}/{**path}"). Found live: docs/postmessage-protocol.md section 4's
56
+ // ASCII sketch (written assuming the design doc's future per-package-subdomain scheme, where the whole
57
+ // origin IS the embed server) omits it, and this file originally matched the sketch instead of B7's
58
+ // real v1 path-based route - every mount() 404'd until this was caught by actually loading a plugin in
59
+ // a browser. Must stay in sync with B7 until D0/the subdomain scheme lands (see packageIdEncoding.ts).
60
+ iframe.src = `${embedOrigin}/embed/${packageIdB64}/${version}/${entryPoint}`;
61
+ return new Promise((resolve, reject) => {
62
+ let settled = false;
63
+ let watchdog = null;
64
+ const channel = new PostMessageChannel(() => (iframe.contentWindow ? { window: iframe.contentWindow, origin: embedOrigin } : null), (event) => event.source === iframe.contentWindow && event.origin === embedOrigin);
65
+ const fail = (reason, message) => {
66
+ if (settled)
67
+ return;
68
+ settled = true;
69
+ watchdog?.cancel();
70
+ channel.dispose();
71
+ iframe.remove();
72
+ const error = new HocMountError(reason, message);
73
+ options.onError?.(error);
74
+ reject(error);
75
+ };
76
+ const succeed = () => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ watchdog?.cancel();
81
+ resolve({
82
+ unmount: () => {
83
+ channel.dispose();
84
+ iframe.remove();
85
+ },
86
+ setContext: (update) => channel.send(MessageType.ContextChanged, update),
87
+ navigate: (path, state) => channel.send(MessageType.Navigate, { path, state }),
88
+ });
89
+ };
90
+ channel.onNotification(MessageType.Hello, (_payload, event) => {
91
+ const initPayload = {
92
+ token: tokenResponse.token,
93
+ tokenExpiresAt: tokenResponse.expiresAt,
94
+ tenantContext: { hostId: options.hostId, tenantId: options.tenantId, packageId: options.packageId, slotId: options.slotId, version },
95
+ user: { userId: tokenResponse.userId, displayName: tokenResponse.displayName },
96
+ theme: options.theme,
97
+ locale: options.locale,
98
+ launchParams: options.launchParams ?? {},
99
+ apiBaseUrl,
100
+ };
101
+ channel.send(MessageType.Init, initPayload, event.data.msgId);
102
+ watchdog = new ReadyWatchdog(options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS, () => fail("timeout", "Plugin did not signal ready/error in time"));
103
+ });
104
+ channel.onNotification(MessageType.Ready, succeed);
105
+ channel.onNotification(MessageType.Error, (payload) => {
106
+ fail("plugin-error", payload.message);
107
+ });
108
+ channel.onNotification(MessageType.Resize, (payload) => {
109
+ iframe.style.height = `${payload.height}px`;
110
+ });
111
+ channel.onNotification(MessageType.Navigate, (payload) => {
112
+ options.onNavigate?.(payload.path, payload.replace ?? false);
113
+ });
114
+ channel.onNotification(MessageType.Telemetry, (payload) => {
115
+ options.onTelemetry?.(payload);
116
+ });
117
+ channel.onRequest(MessageType.Ui, (payload) => (options.onUi ?? defaultUiHandler)(payload));
118
+ channel.onRequest(MessageType.TokenRefresh, async () => {
119
+ try {
120
+ const response = await fetch(options.tokenUrl, { credentials: "same-origin" });
121
+ if (!response.ok)
122
+ throw new Error(`tokenUrl responded ${response.status}`);
123
+ const refreshed = (await response.json());
124
+ return { token: refreshed.token, expiresAt: refreshed.expiresAt };
125
+ }
126
+ catch (cause) {
127
+ return { error: `Host session refresh failed: ${cause.message}` };
128
+ }
129
+ });
130
+ iframe.onerror = () => fail("iframe-load-failed", "The plugin iframe failed to load");
131
+ container.appendChild(iframe);
132
+ });
133
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Mirrors services/platform/HandOfClient.Platform/Bundles/BundleEndpoints.cs's EncodePackageId exactly
3
+ * - packageId contains a literal "/" (design doc's own example: "acme/wms-labels"), which cannot
4
+ * survive as a single ASP.NET route segment. Both sides must produce byte-identical output; this is
5
+ * the one place that requirement is load-bearing (a mismatch here 404s every embed).
6
+ */
7
+ export declare function encodePackageId(packageId: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Mirrors services/platform/HandOfClient.Platform/Bundles/BundleEndpoints.cs's EncodePackageId exactly
3
+ * - packageId contains a literal "/" (design doc's own example: "acme/wms-labels"), which cannot
4
+ * survive as a single ASP.NET route segment. Both sides must produce byte-identical output; this is
5
+ * the one place that requirement is load-bearing (a mismatch here 404s every embed).
6
+ */
7
+ export function encodePackageId(packageId) {
8
+ const bytes = new TextEncoder().encode(packageId);
9
+ let binary = "";
10
+ for (const byte of bytes)
11
+ binary += String.fromCharCode(byte);
12
+ return btoa(binary).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
13
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Fires exactly once, after `timeoutMs`, unless cancelled first. Deliberately dumb - see
3
+ * docs/postmessage-protocol.md section 4's "explicit non-heuristic rule": readiness is only ever
4
+ * decided by an explicit hoc:ready/hoc:error signal or this timeout, never by iframe.onload, a render
5
+ * count, or a MutationObserver (the false-positive-timeout lesson from DotNetShared.Extensibility's
6
+ * ExtensionBoundarySettled).
7
+ */
8
+ export declare class ReadyWatchdog {
9
+ private timer;
10
+ constructor(timeoutMs: number, onTimeout: () => void);
11
+ cancel(): void;
12
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Fires exactly once, after `timeoutMs`, unless cancelled first. Deliberately dumb - see
3
+ * docs/postmessage-protocol.md section 4's "explicit non-heuristic rule": readiness is only ever
4
+ * decided by an explicit hoc:ready/hoc:error signal or this timeout, never by iframe.onload, a render
5
+ * count, or a MutationObserver (the false-positive-timeout lesson from DotNetShared.Extensibility's
6
+ * ExtensionBoundarySettled).
7
+ */
8
+ export class ReadyWatchdog {
9
+ timer;
10
+ constructor(timeoutMs, onTimeout) {
11
+ this.timer = setTimeout(onTimeout, timeoutMs);
12
+ }
13
+ cancel() {
14
+ if (this.timer !== null) {
15
+ clearTimeout(this.timer);
16
+ this.timer = null;
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,87 @@
1
+ import { type HocClient as GeneratedHocClient } from "@handofclient/api";
2
+ import { type ContextChangedPayload, type NavigateHostToPluginPayload, type ThemeTokens, type UiReplyPayload } from "../protocol.js";
3
+ export interface HocContext {
4
+ hostId: string;
5
+ tenantId: string;
6
+ packageId: string;
7
+ slotId: string;
8
+ version: string;
9
+ user: {
10
+ userId: string;
11
+ displayName?: string;
12
+ };
13
+ theme: ThemeTokens;
14
+ locale: string;
15
+ launchParams: Record<string, string>;
16
+ }
17
+ export type UiRequestOptions = {
18
+ kind: "modal";
19
+ title?: string;
20
+ body: string;
21
+ } | {
22
+ kind: "toast";
23
+ message: string;
24
+ durationMs?: number;
25
+ } | {
26
+ kind: "confirm";
27
+ title?: string;
28
+ message: string;
29
+ };
30
+ declare class HocSdk {
31
+ private channel;
32
+ private tokenProvider;
33
+ private _context;
34
+ private _api;
35
+ private stopAutoResize;
36
+ private contextListeners;
37
+ private navigateListeners;
38
+ /**
39
+ * Runs the handshake (docs/postmessage-protocol.md section 4), invokes `callback` with the resolved
40
+ * context, and signals hoc:ready/hoc:error based on whether it completes without throwing. Must be
41
+ * called exactly once per iframe load.
42
+ */
43
+ init(callback: (context: HocContext) => void | Promise<void>): Promise<void>;
44
+ get context(): HocContext;
45
+ /** The six generated Platform API service clients, pre-authed with the current embed token and
46
+ * auto-refreshing (see HostRelayTokenProvider) - design doc's "hoc.api". */
47
+ get api(): GeneratedHocClient;
48
+ private get scope();
49
+ storage: {
50
+ get: (key: string) => Promise<import("@handofclient/gen-ts/handofclient/v1/tenant_storage_pb").GetResponse>;
51
+ set: (key: string, value: Uint8Array<ArrayBuffer>, ifMatchEtag?: string) => Promise<import("@handofclient/gen-ts/handofclient/v1/tenant_storage_pb").SetResponse>;
52
+ delete: (key: string) => Promise<import("@handofclient/gen-ts/handofclient/v1/tenant_storage_pb").DeleteResponse>;
53
+ fileExists: (path: string) => Promise<import("@handofclient/gen-ts/handofclient/v1/tenant_storage_pb").FileExistsResponse>;
54
+ };
55
+ http: {
56
+ send: (request: {
57
+ method: number;
58
+ url: string;
59
+ headers?: {
60
+ name: string;
61
+ value: string;
62
+ }[];
63
+ body?: Uint8Array<ArrayBuffer>;
64
+ timeoutMs?: number;
65
+ }) => Promise<import("@handofclient/gen-ts/handofclient/v1/egress_proxy_pb").SendResponse>;
66
+ };
67
+ navigate(path: string, replace?: boolean): void;
68
+ onContextChanged(listener: (update: ContextChangedPayload) => void): () => void;
69
+ onHostNavigate(listener: (payload: NavigateHostToPluginPayload) => void): () => void;
70
+ ui: {
71
+ modal: (body: string, title?: string) => Promise<UiReplyPayload & {
72
+ closed: true;
73
+ }>;
74
+ toast: (message: string, durationMs?: number) => Promise<UiReplyPayload & {
75
+ shown: true;
76
+ }>;
77
+ confirm: (message: string, title?: string) => Promise<boolean>;
78
+ };
79
+ private request;
80
+ /** Coalesces ResizeObserver callbacks to at most one hoc:resize per animation frame - see
81
+ * docs/postmessage-protocol.md section 5.5. Call once, after the plugin's root element exists. */
82
+ resizeAuto(rootElement: HTMLElement): void;
83
+ private performHandshake;
84
+ }
85
+ /** Singleton - one plugin bundle instance runs inside exactly one iframe for exactly one mount. */
86
+ export declare const hoc: HocSdk;
87
+ export {};
@@ -0,0 +1,131 @@
1
+ import { createHocClient } from "@handofclient/api";
2
+ import { PostMessageChannel } from "../channel.js";
3
+ import { isEnvelope, makeEnvelope, MessageType, } from "../protocol.js";
4
+ import { startAutoResize } from "./resize.js";
5
+ import { HostRelayTokenProvider } from "./tokenProvider.js";
6
+ const SDK_VERSION = "0.1.0";
7
+ class HocSdk {
8
+ channel = null;
9
+ tokenProvider = null;
10
+ _context = null;
11
+ _api = null;
12
+ stopAutoResize = null;
13
+ contextListeners = new Set();
14
+ navigateListeners = new Set();
15
+ /**
16
+ * Runs the handshake (docs/postmessage-protocol.md section 4), invokes `callback` with the resolved
17
+ * context, and signals hoc:ready/hoc:error based on whether it completes without throwing. Must be
18
+ * called exactly once per iframe load.
19
+ */
20
+ async init(callback) {
21
+ const { initPayload, trustedOrigin } = await this.performHandshake();
22
+ this.channel = new PostMessageChannel(() => (this.channel ? { window: window.parent, origin: trustedOrigin } : null), (event) => event.source === window.parent && event.origin === trustedOrigin);
23
+ // Registered only after the handshake completes and origin is pinned - see
24
+ // docs/postmessage-protocol.md section 3: no application-visible callback dispatches before init.
25
+ this.channel.onNotification(MessageType.ContextChanged, (payload) => {
26
+ for (const listener of this.contextListeners)
27
+ listener(payload);
28
+ });
29
+ this.channel.onNotification(MessageType.Navigate, (payload) => {
30
+ for (const listener of this.navigateListeners)
31
+ listener(payload);
32
+ });
33
+ this.tokenProvider = new HostRelayTokenProvider(this.channel, initPayload.token, initPayload.tokenExpiresAt);
34
+ this._api = createHocClient({ baseUrl: initPayload.apiBaseUrl, tokenProvider: this.tokenProvider });
35
+ this._context = {
36
+ hostId: initPayload.tenantContext.hostId,
37
+ tenantId: initPayload.tenantContext.tenantId,
38
+ packageId: initPayload.tenantContext.packageId,
39
+ slotId: initPayload.tenantContext.slotId,
40
+ version: initPayload.tenantContext.version,
41
+ user: initPayload.user,
42
+ theme: initPayload.theme,
43
+ locale: initPayload.locale,
44
+ launchParams: initPayload.launchParams,
45
+ };
46
+ try {
47
+ await callback(this._context);
48
+ this.channel.send(MessageType.Ready, {});
49
+ }
50
+ catch (cause) {
51
+ const error = cause instanceof Error ? cause : new Error(String(cause));
52
+ this.channel.send(MessageType.Error, { message: error.message, stack: error.stack, fatal: true });
53
+ throw error;
54
+ }
55
+ }
56
+ get context() {
57
+ if (!this._context)
58
+ throw new Error("hoc.context accessed before hoc.init(callback) resolved");
59
+ return this._context;
60
+ }
61
+ /** The six generated Platform API service clients, pre-authed with the current embed token and
62
+ * auto-refreshing (see HostRelayTokenProvider) - design doc's "hoc.api". */
63
+ get api() {
64
+ if (!this._api)
65
+ throw new Error("hoc.api accessed before hoc.init(callback) resolved");
66
+ return this._api;
67
+ }
68
+ get scope() {
69
+ const context = this.context;
70
+ return { hostId: context.hostId, tenantId: context.tenantId, packageId: context.packageId };
71
+ }
72
+ storage = {
73
+ get: async (key) => this.api.tenantStorage.get({ scope: this.scope, key }),
74
+ set: async (key, value, ifMatchEtag) => this.api.tenantStorage.set({ scope: this.scope, key, value, ifMatchEtag }),
75
+ delete: async (key) => this.api.tenantStorage.delete({ scope: this.scope, key }),
76
+ fileExists: async (path) => this.api.tenantStorage.fileExists({ scope: this.scope, path }),
77
+ };
78
+ http = {
79
+ send: async (request) => this.api.egressProxy.send({ scope: this.scope, ...request }),
80
+ };
81
+ navigate(path, replace = false) {
82
+ this.channel?.send(MessageType.Navigate, { path, replace });
83
+ }
84
+ onContextChanged(listener) {
85
+ this.contextListeners.add(listener);
86
+ return () => this.contextListeners.delete(listener);
87
+ }
88
+ onHostNavigate(listener) {
89
+ this.navigateListeners.add(listener);
90
+ return () => this.navigateListeners.delete(listener);
91
+ }
92
+ ui = {
93
+ modal: (body, title) => this.request({ kind: "modal", options: { title, body } }),
94
+ toast: (message, durationMs) => this.request({ kind: "toast", options: { message, durationMs } }),
95
+ confirm: (message, title) => this.request({ kind: "confirm", options: { title, message } }).then((r) => r.confirmed),
96
+ };
97
+ request(payload) {
98
+ if (!this.channel)
99
+ throw new Error("hoc.ui used before hoc.init(callback) resolved");
100
+ return this.channel.request(MessageType.Ui, payload);
101
+ }
102
+ /** Coalesces ResizeObserver callbacks to at most one hoc:resize per animation frame - see
103
+ * docs/postmessage-protocol.md section 5.5. Call once, after the plugin's root element exists. */
104
+ resizeAuto(rootElement) {
105
+ if (!this.channel)
106
+ throw new Error("hoc.resizeAuto used before hoc.init(callback) resolved");
107
+ this.stopAutoResize?.();
108
+ this.stopAutoResize = startAutoResize(this.channel, rootElement);
109
+ }
110
+ performHandshake() {
111
+ return new Promise((resolve) => {
112
+ const helloEnvelope = makeEnvelope(MessageType.Hello, { sdkVersion: SDK_VERSION, nonce: crypto.randomUUID() });
113
+ function onMessage(event) {
114
+ // Pre-pinning: accept only from window.parent, and dispatch only for the specific hoc:init
115
+ // that answers *this* hello - see docs/postmessage-protocol.md section 3.
116
+ if (event.source !== window.parent)
117
+ return;
118
+ if (!isEnvelope(event.data) || event.data.type !== MessageType.Init || event.data.replyTo !== helloEnvelope.msgId)
119
+ return;
120
+ window.removeEventListener("message", onMessage);
121
+ resolve({ initPayload: event.data.payload, trustedOrigin: event.origin });
122
+ }
123
+ window.addEventListener("message", onMessage);
124
+ // "*" is the one and only legitimate use of a wildcard targetOrigin in this protocol: the plugin
125
+ // cannot know the host's real origin before this exact reply tells it - see section 3.
126
+ window.parent.postMessage(helloEnvelope, "*");
127
+ });
128
+ }
129
+ }
130
+ /** Singleton - one plugin bundle instance runs inside exactly one iframe for exactly one mount. */
131
+ export const hoc = new HocSdk();
@@ -0,0 +1,7 @@
1
+ import { PostMessageChannel } from "../channel.js";
2
+ /**
3
+ * Observes `element`'s content height and sends hoc:resize, coalesced to at most one message per
4
+ * animation frame - ResizeObserver can fire many times per frame during a layout thrash, and posting
5
+ * once per intermediate value is wasteful (see docs/postmessage-protocol.md section 5.5).
6
+ */
7
+ export declare function startAutoResize(channel: PostMessageChannel, element: HTMLElement): () => void;
@@ -0,0 +1,26 @@
1
+ import { MessageType } from "../protocol.js";
2
+ /**
3
+ * Observes `element`'s content height and sends hoc:resize, coalesced to at most one message per
4
+ * animation frame - ResizeObserver can fire many times per frame during a layout thrash, and posting
5
+ * once per intermediate value is wasteful (see docs/postmessage-protocol.md section 5.5).
6
+ */
7
+ export function startAutoResize(channel, element) {
8
+ let scheduled = false;
9
+ let lastHeight = -1;
10
+ const flush = () => {
11
+ scheduled = false;
12
+ const height = Math.ceil(element.getBoundingClientRect().height);
13
+ if (height === lastHeight)
14
+ return;
15
+ lastHeight = height;
16
+ channel.send(MessageType.Resize, { height });
17
+ };
18
+ const observer = new ResizeObserver(() => {
19
+ if (scheduled)
20
+ return;
21
+ scheduled = true;
22
+ requestAnimationFrame(flush);
23
+ });
24
+ observer.observe(element);
25
+ return () => observer.disconnect();
26
+ }
@@ -0,0 +1,23 @@
1
+ import type { TokenProvider } from "@handofclient/api";
2
+ import { PostMessageChannel } from "../channel.js";
3
+ /**
4
+ * Implements @handofclient/api's TokenProvider by round-tripping hoc:token-refresh through the host
5
+ * page - see docs/postmessage-protocol.md section 5.7. Two independent triggers land on the same
6
+ * request: reactively, when the generated client's authInterceptor gets an Unauthenticated response
7
+ * (refreshToken()), and proactively, on a timer here at 80% of the token's remaining TTL, so a
8
+ * long-lived plugin session refreshes ahead of expiry instead of always paying a failed-call round
9
+ * trip first.
10
+ */
11
+ export declare class HostRelayTokenProvider implements TokenProvider {
12
+ private readonly channel;
13
+ private token;
14
+ private expiresAt;
15
+ private refreshPromise;
16
+ private proactiveTimer;
17
+ constructor(channel: PostMessageChannel, initialToken: string, initialExpiresAt: string);
18
+ getToken(): Promise<string>;
19
+ refreshToken(): Promise<string>;
20
+ dispose(): void;
21
+ private doRefresh;
22
+ private scheduleProactiveRefresh;
23
+ }
@@ -0,0 +1,62 @@
1
+ import { MessageType } from "../protocol.js";
2
+ const PROACTIVE_REFRESH_FRACTION = 0.8;
3
+ /**
4
+ * Implements @handofclient/api's TokenProvider by round-tripping hoc:token-refresh through the host
5
+ * page - see docs/postmessage-protocol.md section 5.7. Two independent triggers land on the same
6
+ * request: reactively, when the generated client's authInterceptor gets an Unauthenticated response
7
+ * (refreshToken()), and proactively, on a timer here at 80% of the token's remaining TTL, so a
8
+ * long-lived plugin session refreshes ahead of expiry instead of always paying a failed-call round
9
+ * trip first.
10
+ */
11
+ export class HostRelayTokenProvider {
12
+ channel;
13
+ token;
14
+ expiresAt;
15
+ refreshPromise = null;
16
+ proactiveTimer = null;
17
+ constructor(channel, initialToken, initialExpiresAt) {
18
+ this.channel = channel;
19
+ this.token = initialToken;
20
+ this.expiresAt = new Date(initialExpiresAt);
21
+ this.scheduleProactiveRefresh();
22
+ }
23
+ async getToken() {
24
+ return this.token;
25
+ }
26
+ async refreshToken() {
27
+ // Coalesce concurrent refresh calls (e.g. several in-flight requests all hitting 401 at once) into
28
+ // one hoc:token-refresh round trip rather than racing multiple requests to the host.
29
+ if (!this.refreshPromise) {
30
+ this.refreshPromise = this.doRefresh().finally(() => {
31
+ this.refreshPromise = null;
32
+ });
33
+ }
34
+ return this.refreshPromise;
35
+ }
36
+ dispose() {
37
+ if (this.proactiveTimer !== null)
38
+ clearTimeout(this.proactiveTimer);
39
+ }
40
+ async doRefresh() {
41
+ const reply = await this.channel.request(MessageType.TokenRefresh, {});
42
+ if ("error" in reply) {
43
+ throw new Error(reply.error);
44
+ }
45
+ this.token = reply.token;
46
+ this.expiresAt = new Date(reply.expiresAt);
47
+ this.scheduleProactiveRefresh();
48
+ return this.token;
49
+ }
50
+ scheduleProactiveRefresh() {
51
+ if (this.proactiveTimer !== null)
52
+ clearTimeout(this.proactiveTimer);
53
+ const remainingMs = this.expiresAt.getTime() - Date.now();
54
+ const delayMs = Math.max(remainingMs * PROACTIVE_REFRESH_FRACTION, 0);
55
+ this.proactiveTimer = setTimeout(() => {
56
+ this.refreshToken().catch(() => {
57
+ // A proactive refresh failure is not fatal here - the reactive path (authInterceptor retrying
58
+ // on Unauthenticated) is still there as a fallback for the next real API call.
59
+ });
60
+ }, delayMs);
61
+ }
62
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Wire types for the postMessage embed protocol - see docs/postmessage-protocol.md (task C1) for the
3
+ * normative spec. Shared verbatim between the host-side and plugin-side halves of this package so both
4
+ * sides can never drift on envelope/message shape.
5
+ */
6
+ export declare const PROTOCOL_VERSION = 1;
7
+ export interface Envelope<TPayload = unknown> {
8
+ v: 1;
9
+ type: string;
10
+ msgId: string;
11
+ replyTo?: string;
12
+ payload: TPayload;
13
+ }
14
+ export declare const MessageType: {
15
+ readonly Hello: "hoc:hello";
16
+ readonly Init: "hoc:init";
17
+ readonly Ready: "hoc:ready";
18
+ readonly Error: "hoc:error";
19
+ readonly Resize: "hoc:resize";
20
+ readonly Navigate: "hoc:navigate";
21
+ readonly TokenRefresh: "hoc:token-refresh";
22
+ readonly ContextChanged: "hoc:context-changed";
23
+ readonly Telemetry: "hoc:telemetry";
24
+ readonly Ui: "hoc:ui";
25
+ };
26
+ export declare function isEnvelope(data: unknown): data is Envelope;
27
+ export declare function makeMsgId(): string;
28
+ export declare function makeEnvelope<T>(type: string, payload: T, replyTo?: string): Envelope<T>;
29
+ export interface ThemeTokens {
30
+ colorScheme: "light" | "dark";
31
+ accentColor: string;
32
+ backgroundColor: string;
33
+ textColor: string;
34
+ fontFamily: string;
35
+ borderRadius: string;
36
+ }
37
+ export interface TenantContext {
38
+ hostId: string;
39
+ tenantId: string;
40
+ packageId: string;
41
+ slotId: string;
42
+ version: string;
43
+ }
44
+ export interface HelloPayload {
45
+ sdkVersion: string;
46
+ nonce: string;
47
+ }
48
+ export interface InitPayload {
49
+ token: string;
50
+ tokenExpiresAt: string;
51
+ tenantContext: TenantContext;
52
+ user: {
53
+ userId: string;
54
+ displayName?: string;
55
+ };
56
+ theme: ThemeTokens;
57
+ locale: string;
58
+ launchParams: Record<string, string>;
59
+ /** Platform API base URL for hoc.api/hoc.storage/hoc.http (see plugin/index.ts) - the host already
60
+ * knows this from its own HandOfClient.configure() call, so the plugin never has to guess or hardcode
61
+ * a domain (which would break the moment embed/API origins are split across subdomains - see D0). */
62
+ apiBaseUrl: string;
63
+ }
64
+ export type ReadyPayload = Record<string, never>;
65
+ export interface ErrorPayload {
66
+ message: string;
67
+ stack?: string;
68
+ fatal: boolean;
69
+ }
70
+ export interface ResizePayload {
71
+ height: number;
72
+ }
73
+ export interface NavigatePluginToHostPayload {
74
+ path: string;
75
+ replace?: boolean;
76
+ }
77
+ export interface NavigateHostToPluginPayload {
78
+ path: string;
79
+ state?: unknown;
80
+ }
81
+ export type TokenRefreshRequestPayload = Record<string, never>;
82
+ export type TokenRefreshReplyPayload = {
83
+ token: string;
84
+ expiresAt: string;
85
+ } | {
86
+ error: string;
87
+ };
88
+ export interface ContextChangedPayload {
89
+ tenantContext?: Partial<TenantContext>;
90
+ user?: {
91
+ userId: string;
92
+ displayName?: string;
93
+ };
94
+ theme?: ThemeTokens;
95
+ locale?: string;
96
+ }
97
+ export interface TelemetryPayload {
98
+ kind: "timing" | "error";
99
+ name: string;
100
+ value?: number;
101
+ detail?: Record<string, unknown>;
102
+ }
103
+ export type UiRequestPayload = {
104
+ kind: "modal";
105
+ options: {
106
+ title?: string;
107
+ body: string;
108
+ };
109
+ } | {
110
+ kind: "toast";
111
+ options: {
112
+ message: string;
113
+ durationMs?: number;
114
+ };
115
+ } | {
116
+ kind: "confirm";
117
+ options: {
118
+ title?: string;
119
+ message: string;
120
+ };
121
+ };
122
+ export type UiReplyPayload = {
123
+ closed: true;
124
+ } | {
125
+ shown: true;
126
+ } | {
127
+ confirmed: boolean;
128
+ };