@sybilion/uilib 1.0.30 → 1.0.32

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/esm/index.js CHANGED
@@ -1,5 +1,7 @@
1
- export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildReadyMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer } from './mini-app/miniAppProtocol.js';
1
+ export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildChatSendMessage, buildDataRequestMessage, buildReadyMessage, isTrustedMiniAppParentMessage, parseChatSendResultMessage, parseDataResponseMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer } from './mini-app/miniAppProtocol.js';
2
+ export { createMiniAppDataClient } from './mini-app/miniAppDataClient.js';
2
3
  export { getDefaultMiniAppThemeConfig } from './mini-app/miniAppThemeConfig.js';
4
+ export { sendChatMessage } from './mini-app/miniAppChatBridge.js';
3
5
  export { MiniAppRoot, useMiniAppShellTheme } from './mini-app/MiniAppRoot.js';
4
6
  export { ChatContext, ChatProvider, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.js';
5
7
  export { AnalysesSelector } from './components/ui/AnalysesSelector/AnalysesSelector.js';
@@ -3,7 +3,7 @@ import cn from 'classnames';
3
3
  import { createContext, useContext, useState, useRef, useMemo, useCallback, useEffect } from 'react';
4
4
  import { Theme, Scroll } from '@homecode/ui';
5
5
  import S from './MiniAppRoot.styl.js';
6
- import { resolveParentOriginFromReferrer, applyThemeToDocument, buildReadyMessage, parseThemeSyncMessage } from './miniAppProtocol.js';
6
+ import { resolveParentOriginFromReferrer, applyThemeToDocument, buildReadyMessage, isTrustedMiniAppParentMessage, parseThemeSyncMessage } from './miniAppProtocol.js';
7
7
  import { getDefaultMiniAppThemeConfig } from './miniAppThemeConfig.js';
8
8
 
9
9
  const defaultTheme = {
@@ -30,22 +30,6 @@ function useMiniAppShellTheme() {
30
30
  }
31
31
  return v;
32
32
  }
33
- /**
34
- * Accept only messages that appear to come from the real embedding parent:
35
- * - `source` must be `window.parent` (same as shell’s iframe target).
36
- * - When `document.referrer` is present, `origin` must match it (Sybilion page that loaded this iframe).
37
- * If referrer was stripped (Referrer-Policy), we only rely on `source` matching `parent`.
38
- */
39
- function isTrustedParentMessage(event) {
40
- // Ignore messages from other windows (e.g. popups, other iframes).
41
- if (event.source !== window.parent)
42
- return false;
43
- const fromReferrer = resolveParentOriginFromReferrer();
44
- // Tighten to embedder origin when the browser exposes it.
45
- if (fromReferrer && event.origin !== fromReferrer)
46
- return false;
47
- return true;
48
- }
49
33
  function MiniAppRoot({ children, className, appId, onThemeChange, getThemeConfig, }) {
50
34
  const [theme, setTheme] = useState(() => isEmbeddedMiniApp() ? defaultTheme : themeFromDocument());
51
35
  const onThemeChangeRef = useRef(onThemeChange);
@@ -73,7 +57,7 @@ function MiniAppRoot({ children, className, appId, onThemeChange, getThemeConfig
73
57
  }, [theme.mode]);
74
58
  useEffect(() => {
75
59
  const onMessage = (event) => {
76
- if (!isTrustedParentMessage(event))
60
+ if (!isTrustedMiniAppParentMessage(event))
77
61
  return;
78
62
  const parsed = parseThemeSyncMessage(event.data);
79
63
  if (!parsed)
@@ -0,0 +1,45 @@
1
+ import { resolveParentOriginFromReferrer, buildChatSendMessage, isTrustedMiniAppParentMessage, parseChatSendResultMessage } from './miniAppProtocol.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 60_000;
4
+ /**
5
+ * Ask the Sybilion host to send a chat message with its auth token.
6
+ * Does not update host ChatSheet state — same session as `chatId` on the agent only.
7
+ */
8
+ async function sendChatMessage(chatId, message) {
9
+ if (typeof window === 'undefined' || window.parent === window) {
10
+ throw new Error('sendChatMessage requires an embedded mini-app (iframe)');
11
+ }
12
+ const requestId = crypto.randomUUID();
13
+ const target = resolveParentOriginFromReferrer();
14
+ const payload = { requestId, chatId, message };
15
+ const msg = buildChatSendMessage(payload);
16
+ return new Promise((resolve, reject) => {
17
+ const onMessage = (event) => {
18
+ if (!isTrustedMiniAppParentMessage(event))
19
+ return;
20
+ const parsed = parseChatSendResultMessage(event.data);
21
+ if (!parsed || parsed.requestId !== requestId)
22
+ return;
23
+ window.removeEventListener('message', onMessage);
24
+ clearTimeout(timer);
25
+ if (parsed.ok === true) {
26
+ resolve(parsed.result);
27
+ return;
28
+ }
29
+ reject(new Error(parsed.error));
30
+ };
31
+ window.addEventListener('message', onMessage);
32
+ const timer = setTimeout(() => {
33
+ window.removeEventListener('message', onMessage);
34
+ reject(new Error('Mini-app chat request timed out'));
35
+ }, DEFAULT_TIMEOUT_MS);
36
+ if (target) {
37
+ window.parent.postMessage(msg, target);
38
+ }
39
+ else {
40
+ window.parent.postMessage(msg, '*');
41
+ }
42
+ });
43
+ }
44
+
45
+ export { sendChatMessage };
@@ -0,0 +1,98 @@
1
+ import { buildDataRequestMessage, resolveParentOriginFromReferrer, isTrustedMiniAppParentMessage, parseDataResponseMessage } from './miniAppProtocol.js';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 10_000;
4
+ /** One listener per browsing context; tracks in-flight bridge requests by `requestId`. */
5
+ function createMiniAppDataClient(options) {
6
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
7
+ const pending = new Map();
8
+ let listening = false;
9
+ const settle = (rid, fn) => {
10
+ const p = pending.get(rid);
11
+ if (!p)
12
+ return;
13
+ clearTimeout(p.timer);
14
+ pending.delete(rid);
15
+ fn(p);
16
+ };
17
+ function ensureListener() {
18
+ if (listening || typeof window === 'undefined')
19
+ return;
20
+ listening = true;
21
+ window.addEventListener('message', (event) => {
22
+ if (!isTrustedMiniAppParentMessage(event))
23
+ return;
24
+ const msg = parseDataResponseMessage(event.data);
25
+ if (!msg)
26
+ return;
27
+ settle(msg.requestId, p => {
28
+ if (msg.ok) {
29
+ p.resolve(msg.result);
30
+ }
31
+ else {
32
+ p.reject(new Error(typeof msg.error === 'string'
33
+ ? msg.error
34
+ : 'Mini-app data request failed'));
35
+ }
36
+ });
37
+ });
38
+ }
39
+ function request(payload) {
40
+ if (typeof window === 'undefined' || window.parent === window) {
41
+ throw new Error('MiniAppDataClient works only inside an iframe embed');
42
+ }
43
+ ensureListener();
44
+ const requestId = typeof crypto !== 'undefined' && 'randomUUID' in crypto
45
+ ? crypto.randomUUID()
46
+ : `miniapp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
47
+ const envelope = buildDataRequestMessage({
48
+ ...payload,
49
+ requestId,
50
+ });
51
+ const target = resolveParentOriginFromReferrer();
52
+ return new Promise((resolve, reject) => {
53
+ const timer = setTimeout(() => {
54
+ settle(requestId, p => {
55
+ p.reject(new Error('Mini-app data request timed out'));
56
+ });
57
+ }, timeoutMs);
58
+ pending.set(requestId, {
59
+ resolve,
60
+ reject,
61
+ timer,
62
+ });
63
+ try {
64
+ if (target) {
65
+ window.parent.postMessage(envelope, target);
66
+ }
67
+ else {
68
+ window.parent.postMessage(envelope, '*');
69
+ }
70
+ }
71
+ catch (e) {
72
+ settle(requestId, p => {
73
+ p.reject(e instanceof Error ? e : new Error('Mini-app postMessage failed'));
74
+ });
75
+ }
76
+ });
77
+ }
78
+ async function rq(op, params) {
79
+ return request({ op, params });
80
+ }
81
+ return {
82
+ getDatasets: () => rq('getDatasets'),
83
+ getDataset: id => rq('getDataset', { id }),
84
+ getForecasts: datasetId => rq('getForecasts', { datasetId }),
85
+ getForecast: (datasetId, analysisId) => rq('getForecast', { datasetId, analysisId }),
86
+ getDrivers: (datasetId, analysisId) => rq('getDrivers', { datasetId, analysisId }),
87
+ getPerformanceData: (datasetId, analysisId) => rq('getPerformanceData', {
88
+ datasetId,
89
+ analysisId,
90
+ }),
91
+ getDriversComparisonData: (datasetId, analysisId) => rq('getDriversComparisonData', {
92
+ datasetId,
93
+ analysisId,
94
+ }),
95
+ };
96
+ }
97
+
98
+ export { createMiniAppDataClient };
@@ -1,7 +1,3 @@
1
- /**
2
- * postMessage protocol for Sybilion workspace mini-apps (iframe child).
3
- * Keep in sync with sybilion-client `src/workspace/miniAppBridge.ts` (channel, version, payloads).
4
- */
5
1
  const MINIAPP_CHANNEL = 'sybilion.miniapp';
6
2
  const MINIAPP_VERSION = 1;
7
3
  function isRecord(v) {
@@ -53,5 +49,105 @@ function applyThemeToDocument(mode) {
53
49
  root.classList.remove('light', 'dark');
54
50
  root.classList.add(mode);
55
51
  }
52
+ /** Only accept shell messages that appear to come from the real embedding parent. */
53
+ function isTrustedMiniAppParentMessage(event) {
54
+ if (event.source !== window.parent)
55
+ return false;
56
+ const fromReferrer = resolveParentOriginFromReferrer();
57
+ if (fromReferrer && event.origin !== fromReferrer)
58
+ return false;
59
+ return true;
60
+ }
61
+ function buildDataRequestMessage(payload) {
62
+ return {
63
+ channel: MINIAPP_CHANNEL,
64
+ version: MINIAPP_VERSION,
65
+ type: 'DATA_REQUEST',
66
+ payload,
67
+ };
68
+ }
69
+ function buildChatSendMessage(payload) {
70
+ return {
71
+ channel: MINIAPP_CHANNEL,
72
+ version: MINIAPP_VERSION,
73
+ type: 'CHAT_SEND',
74
+ payload,
75
+ };
76
+ }
77
+ /** Parse parent → child DATA_RESPONSE. */
78
+ function parseDataResponseMessage(data) {
79
+ if (!isRecord(data))
80
+ return null;
81
+ if (data.channel !== MINIAPP_CHANNEL)
82
+ return null;
83
+ if (data.version !== MINIAPP_VERSION)
84
+ return null;
85
+ if (data.type !== 'DATA_RESPONSE')
86
+ return null;
87
+ const payload = data.payload;
88
+ if (!isRecord(payload))
89
+ return null;
90
+ const requestId = payload.requestId;
91
+ if (typeof requestId !== 'string' || requestId.length === 0)
92
+ return null;
93
+ const ok = payload.ok === true;
94
+ const errOk = payload.ok === false;
95
+ if (!ok && !errOk)
96
+ return null;
97
+ const base = {
98
+ requestId,
99
+ ok,
100
+ };
101
+ if ('result' in payload) {
102
+ base.result = payload.result;
103
+ }
104
+ if (typeof payload.error === 'string') {
105
+ base.error = payload.error;
106
+ }
107
+ return base;
108
+ }
109
+ /** Parse parent → child CHAT_SEND_RESULT (shell uses `buildChatSendResultMessage`). */
110
+ function parseChatSendResultMessage(data) {
111
+ if (!isRecord(data))
112
+ return null;
113
+ if (data.channel !== MINIAPP_CHANNEL)
114
+ return null;
115
+ if (data.version !== MINIAPP_VERSION)
116
+ return null;
117
+ if (data.type !== 'CHAT_SEND_RESULT')
118
+ return null;
119
+ const payload = data.payload;
120
+ if (!isRecord(payload))
121
+ return null;
122
+ const requestId = payload.requestId;
123
+ if (typeof requestId !== 'string' || requestId.length === 0)
124
+ return null;
125
+ if (payload.ok === true) {
126
+ const result = payload.result;
127
+ if (!isRecord(result))
128
+ return null;
129
+ const responseText = result.response;
130
+ if (typeof responseText !== 'string')
131
+ return null;
132
+ const sidRaw = result.session_id;
133
+ if (sidRaw !== undefined && sidRaw !== null && typeof sidRaw !== 'string')
134
+ return null;
135
+ const session_id = typeof sidRaw === 'string' ? sidRaw : null;
136
+ return {
137
+ requestId,
138
+ ok: true,
139
+ result: {
140
+ response: responseText,
141
+ session_id,
142
+ },
143
+ };
144
+ }
145
+ if (payload.ok === false) {
146
+ if (typeof payload.error !== 'string')
147
+ return null;
148
+ return { requestId, ok: false, error: payload.error };
149
+ }
150
+ return null;
151
+ }
56
152
 
57
- export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildReadyMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer };
153
+ export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildChatSendMessage, buildDataRequestMessage, buildReadyMessage, isTrustedMiniAppParentMessage, parseChatSendResultMessage, parseDataResponseMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer };
@@ -1,6 +1,10 @@
1
- export { applyThemeToDocument, buildReadyMessage, MINIAPP_CHANNEL, MINIAPP_VERSION, parseThemeSyncMessage, resolveParentOriginFromReferrer, } from './miniAppProtocol';
2
- export type { MiniAppMessageReady, MiniAppMessageThemeSync, ThemeSyncPayload, } from './miniAppProtocol';
1
+ export { applyThemeToDocument, buildChatSendMessage, buildDataRequestMessage, buildReadyMessage, MINIAPP_CHANNEL, MINIAPP_VERSION, parseChatSendResultMessage, parseDataResponseMessage, parseThemeSyncMessage, isTrustedMiniAppParentMessage, resolveParentOriginFromReferrer, } from './miniAppProtocol';
2
+ export type { MiniAppMessageReady, MiniAppMessageThemeSync, MiniAppMessageDataRequest, MiniAppMessageDataResponse, MiniAppMessageChatSend, MiniAppMessageChatSendResult, MiniAppDataRequestPayload, MiniAppDataResponsePayload, MiniAppChatSendPayload, MiniAppChatSendResultPayload, MiniAppDataOp, ThemeSyncPayload, } from './miniAppProtocol';
3
+ export { createMiniAppDataClient } from './miniAppDataClient';
4
+ export type { MiniAppDataClientOptions, MiniAppDataClient, } from './miniAppDataClient';
5
+ export type { MiniAppDataset, MiniAppDriversComparisonSnapshot, MiniAppForecastMap, MiniAppPerformanceBundle, } from './miniAppDataTypes';
3
6
  export { getDefaultMiniAppThemeConfig } from './miniAppThemeConfig';
4
7
  export type { MiniAppThemeConfig } from './miniAppThemeConfig';
8
+ export { sendChatMessage } from './miniAppChatBridge';
5
9
  export { MiniAppRoot, useMiniAppShellTheme } from './MiniAppRoot';
6
10
  export type { MiniAppRootProps, MiniAppShellContextValue } from './MiniAppRoot';
@@ -0,0 +1,6 @@
1
+ import type { ChatResponse } from '#uilib/types/chat-api.types';
2
+ /**
3
+ * Ask the Sybilion host to send a chat message with its auth token.
4
+ * Does not update host ChatSheet state — same session as `chatId` on the agent only.
5
+ */
6
+ export declare function sendChatMessage(chatId: string, message: string): Promise<ChatResponse>;
@@ -0,0 +1,16 @@
1
+ import type { MiniAppDataset, MiniAppDriversComparisonSnapshot, MiniAppForecastMap, MiniAppPerformanceBundle } from './miniAppDataTypes';
2
+ export type MiniAppDataClientOptions = {
3
+ /** Default 10000 ms. */
4
+ timeoutMs?: number;
5
+ };
6
+ export type MiniAppDataClient = {
7
+ getDatasets(): Promise<MiniAppDataset[]>;
8
+ getDataset(id: number): Promise<MiniAppDataset | null>;
9
+ getForecasts(datasetId: number): Promise<MiniAppForecastMap>;
10
+ getForecast(datasetId: number, analysisId: number): Promise<unknown | null>;
11
+ getDrivers(datasetId: number, analysisId: number): Promise<unknown[]>;
12
+ getPerformanceData(datasetId: number, analysisId: number): Promise<MiniAppPerformanceBundle>;
13
+ getDriversComparisonData(datasetId: number, analysisId: number): Promise<MiniAppDriversComparisonSnapshot>;
14
+ };
15
+ /** One listener per browsing context; tracks in-flight bridge requests by `requestId`. */
16
+ export declare function createMiniAppDataClient(options?: MiniAppDataClientOptions): MiniAppDataClient;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Serializable shapes returned by the shell for mini-app data getters.
3
+ * Subset of Sybilion `Dataset` / API types — use `unknown` where payloads are large or evolving.
4
+ */
5
+ export type MiniAppDataset = {
6
+ id: number;
7
+ user_id?: number;
8
+ name: string;
9
+ description?: string;
10
+ status: string;
11
+ created_at: string;
12
+ updated_at: string;
13
+ keywords: string;
14
+ category: {
15
+ id: number;
16
+ name: string;
17
+ };
18
+ target_type_id: number;
19
+ target_type: {
20
+ id: number;
21
+ name: string;
22
+ };
23
+ trend: number;
24
+ regular_price: string;
25
+ sale_price: string;
26
+ regions: {
27
+ id: number;
28
+ name: string;
29
+ }[];
30
+ unit: {
31
+ id: number;
32
+ name: string;
33
+ };
34
+ version?: number;
35
+ };
36
+ /** `forecastData` slice: analysis id (string) → forecast series blob. */
37
+ export type MiniAppForecastMap = Record<string, unknown>;
38
+ export type MiniAppPerformanceBundle = {
39
+ /** Cached Performance-tab API payload, or null if user never opened Performance / cache empty. */
40
+ table: unknown;
41
+ /** In-memory spaghetti lines from shell context, or null. */
42
+ spaghetti: unknown;
43
+ };
44
+ export type MiniAppDriversComparisonSnapshot = {
45
+ byRegion: unknown;
46
+ byCountry: unknown;
47
+ byCategory: unknown;
48
+ byImportance: unknown;
49
+ driversCount: number | null;
50
+ };
@@ -2,6 +2,7 @@
2
2
  * postMessage protocol for Sybilion workspace mini-apps (iframe child).
3
3
  * Keep in sync with sybilion-client `src/workspace/miniAppBridge.ts` (channel, version, payloads).
4
4
  */
5
+ import type { ChatResponse } from '#uilib/types/chat-api.types';
5
6
  export declare const MINIAPP_CHANNEL: "sybilion.miniapp";
6
7
  export declare const MINIAPP_VERSION: 1;
7
8
  export type ThemeSyncPayload = {
@@ -22,9 +23,67 @@ export type MiniAppMessageThemeSync = {
22
23
  type: 'THEME_SYNC';
23
24
  payload: ThemeSyncPayload;
24
25
  };
26
+ export type MiniAppDataOp = 'getDatasets' | 'getDataset' | 'getForecasts' | 'getForecast' | 'getDrivers' | 'getPerformanceData' | 'getDriversComparisonData';
27
+ export type MiniAppDataRequestPayload = {
28
+ requestId: string;
29
+ op: MiniAppDataOp;
30
+ params?: Record<string, number>;
31
+ };
32
+ export type MiniAppMessageDataRequest = {
33
+ channel: typeof MINIAPP_CHANNEL;
34
+ version: typeof MINIAPP_VERSION;
35
+ type: 'DATA_REQUEST';
36
+ payload: MiniAppDataRequestPayload;
37
+ };
38
+ export type MiniAppDataResponsePayload = {
39
+ requestId: string;
40
+ ok: boolean;
41
+ result?: unknown;
42
+ error?: string;
43
+ };
44
+ export type MiniAppMessageDataResponse = {
45
+ channel: typeof MINIAPP_CHANNEL;
46
+ version: typeof MINIAPP_VERSION;
47
+ type: 'DATA_RESPONSE';
48
+ payload: MiniAppDataResponsePayload;
49
+ };
50
+ export type MiniAppChatSendPayload = {
51
+ requestId: string;
52
+ chatId: string;
53
+ message: string;
54
+ };
55
+ export type MiniAppMessageChatSend = {
56
+ channel: typeof MINIAPP_CHANNEL;
57
+ version: typeof MINIAPP_VERSION;
58
+ type: 'CHAT_SEND';
59
+ payload: MiniAppChatSendPayload;
60
+ };
61
+ export type MiniAppChatSendResultPayload = {
62
+ requestId: string;
63
+ ok: true;
64
+ result: ChatResponse;
65
+ } | {
66
+ requestId: string;
67
+ ok: false;
68
+ error: string;
69
+ };
70
+ export type MiniAppMessageChatSendResult = {
71
+ channel: typeof MINIAPP_CHANNEL;
72
+ version: typeof MINIAPP_VERSION;
73
+ type: 'CHAT_SEND_RESULT';
74
+ payload: MiniAppChatSendResultPayload;
75
+ };
25
76
  /** Parse parent → child THEME_SYNC (shell uses `buildThemeSyncMessage`). */
26
77
  export declare function parseThemeSyncMessage(data: unknown): ThemeSyncPayload | null;
27
78
  /** Child → parent READY (optional `appId` for telemetry). */
28
79
  export declare function buildReadyMessage(payload?: Record<string, unknown>): MiniAppMessageReady;
29
80
  export declare function resolveParentOriginFromReferrer(): string | null;
30
81
  export declare function applyThemeToDocument(mode: 'light' | 'dark'): void;
82
+ /** Only accept shell messages that appear to come from the real embedding parent. */
83
+ export declare function isTrustedMiniAppParentMessage(event: MessageEvent): boolean;
84
+ export declare function buildDataRequestMessage(payload: MiniAppDataRequestPayload): MiniAppMessageDataRequest;
85
+ export declare function buildChatSendMessage(payload: MiniAppChatSendPayload): MiniAppMessageChatSend;
86
+ /** Parse parent → child DATA_RESPONSE. */
87
+ export declare function parseDataResponseMessage(data: unknown): MiniAppDataResponsePayload | null;
88
+ /** Parse parent → child CHAT_SEND_RESULT (shell uses `buildChatSendResultMessage`). */
89
+ export declare function parseChatSendResultMessage(data: unknown): MiniAppChatSendResultPayload | null;
@@ -29,3 +29,23 @@ createRoot(document.getElementById('root')!).render(
29
29
  4. Optional: **`getThemeConfig`** on **`MiniAppRoot`** — passed **`(isDarkMode) => config`** like the Sybilion app’s **`getThemeConfig`** from **`src/lib/theme.ts`**, so iframe UI matches host accent/danger tokens.
30
30
 
31
31
  **Bridge:** `MiniAppRoot` handles `THEME_SYNC`, sends `READY` on mount/load, and checks `event.source === window.parent` plus `document.referrer` origin when present. If the referrer is missing (strict `Referrer-Policy`), `READY` may use `targetOrigin` `*` — document that for your host.
32
+
33
+ ## Cached data from the host (no extra APIs)
34
+
35
+ The Sybilion shell can answer read-only **data getters** over the same `postMessage` channel (`DATA_REQUEST` / `DATA_RESPONSE`). Use this when a mini-app should render **the user’s existing Sybilion data** (datasets, forecasts, drivers, performance, drivers comparison) **without** building duplicate backend calls.
36
+
37
+ - **Cache-only:** The shell returns whatever is **already in memory** (dataset context) or in the **same localStorage cache** the main app uses for Performance (e.g. `performance-<analysisId>`). It does **not** trigger network fetches. If the user never opened a dataset tab or the blob was never loaded, you get **empty objects, `null`, or `[]`** — handle that in UI.
38
+ - **Performance bundle:** `getPerformanceData` returns `{ table, spaghetti }`. `table` is the cached Performance-tab payload (often `null` until the user has opened Performance for that analysis). `spaghetti` is in-memory backtest lines from the host when present.
39
+ - **Usage:** Create one client (e.g. module singleton) with **`createMiniAppDataClient()`** from `@sybilion/uilib`. It only works **inside the iframe** (throws if `window.parent === window`). Methods: **`getDatasets()`**, **`getDataset(id)`**, **`getForecasts(datasetId)`**, **`getForecast(datasetId, analysisId)`**, **`getDrivers(datasetId, analysisId)`**, **`getPerformanceData(datasetId, analysisId)`**, **`getDriversComparisonData(datasetId, analysisId)`**. Types **`MiniAppDataset`**, **`MiniAppForecastMap`**, **`MiniAppPerformanceBundle`**, **`MiniAppDriversComparisonSnapshot`** describe the returned JSON loosely.
40
+
41
+ **For coding agents:** Prefer these getters when the task is “show the user’s current Sybilion data in the mini-app” so the iframe stays aligned with the host session and avoids inventing parallel data loading.
42
+
43
+ ```tsx
44
+ import { createMiniAppDataClient } from '@sybilion/uilib';
45
+
46
+ const data = createMiniAppDataClient();
47
+
48
+ // inside an effect or loader (iframe only)
49
+ const datasets = await data.getDatasets();
50
+ const forecasts = await data.getForecasts(datasetId);
51
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -17,6 +17,7 @@ import {
17
17
  type ThemeSyncPayload,
18
18
  applyThemeToDocument,
19
19
  buildReadyMessage,
20
+ isTrustedMiniAppParentMessage,
20
21
  parseThemeSyncMessage,
21
22
  resolveParentOriginFromReferrer,
22
23
  } from './miniAppProtocol';
@@ -59,21 +60,6 @@ export function useMiniAppShellTheme(): MiniAppShellContextValue {
59
60
  return v;
60
61
  }
61
62
 
62
- /**
63
- * Accept only messages that appear to come from the real embedding parent:
64
- * - `source` must be `window.parent` (same as shell’s iframe target).
65
- * - When `document.referrer` is present, `origin` must match it (Sybilion page that loaded this iframe).
66
- * If referrer was stripped (Referrer-Policy), we only rely on `source` matching `parent`.
67
- */
68
- function isTrustedParentMessage(event: MessageEvent): boolean {
69
- // Ignore messages from other windows (e.g. popups, other iframes).
70
- if (event.source !== window.parent) return false;
71
- const fromReferrer = resolveParentOriginFromReferrer();
72
- // Tighten to embedder origin when the browser exposes it.
73
- if (fromReferrer && event.origin !== fromReferrer) return false;
74
- return true;
75
- }
76
-
77
63
  export type MiniAppRootProps = {
78
64
  children: ReactNode;
79
65
  className?: string;
@@ -127,7 +113,7 @@ export function MiniAppRoot({
127
113
 
128
114
  useEffect(() => {
129
115
  const onMessage = (event: MessageEvent) => {
130
- if (!isTrustedParentMessage(event)) return;
116
+ if (!isTrustedMiniAppParentMessage(event)) return;
131
117
  const parsed = parseThemeSyncMessage(event.data);
132
118
  if (!parsed) return;
133
119
  setTheme(parsed);
@@ -1,17 +1,43 @@
1
1
  export {
2
2
  applyThemeToDocument,
3
+ buildChatSendMessage,
4
+ buildDataRequestMessage,
3
5
  buildReadyMessage,
4
6
  MINIAPP_CHANNEL,
5
7
  MINIAPP_VERSION,
8
+ parseChatSendResultMessage,
9
+ parseDataResponseMessage,
6
10
  parseThemeSyncMessage,
11
+ isTrustedMiniAppParentMessage,
7
12
  resolveParentOriginFromReferrer,
8
13
  } from './miniAppProtocol';
9
14
  export type {
10
15
  MiniAppMessageReady,
11
16
  MiniAppMessageThemeSync,
17
+ MiniAppMessageDataRequest,
18
+ MiniAppMessageDataResponse,
19
+ MiniAppMessageChatSend,
20
+ MiniAppMessageChatSendResult,
21
+ MiniAppDataRequestPayload,
22
+ MiniAppDataResponsePayload,
23
+ MiniAppChatSendPayload,
24
+ MiniAppChatSendResultPayload,
25
+ MiniAppDataOp,
12
26
  ThemeSyncPayload,
13
27
  } from './miniAppProtocol';
28
+ export { createMiniAppDataClient } from './miniAppDataClient';
29
+ export type {
30
+ MiniAppDataClientOptions,
31
+ MiniAppDataClient,
32
+ } from './miniAppDataClient';
33
+ export type {
34
+ MiniAppDataset,
35
+ MiniAppDriversComparisonSnapshot,
36
+ MiniAppForecastMap,
37
+ MiniAppPerformanceBundle,
38
+ } from './miniAppDataTypes';
14
39
  export { getDefaultMiniAppThemeConfig } from './miniAppThemeConfig';
15
40
  export type { MiniAppThemeConfig } from './miniAppThemeConfig';
41
+ export { sendChatMessage } from './miniAppChatBridge';
16
42
  export { MiniAppRoot, useMiniAppShellTheme } from './MiniAppRoot';
17
43
  export type { MiniAppRootProps, MiniAppShellContextValue } from './MiniAppRoot';
@@ -0,0 +1,55 @@
1
+ import type { ChatResponse } from '#uilib/types/chat-api.types';
2
+
3
+ import {
4
+ buildChatSendMessage,
5
+ isTrustedMiniAppParentMessage,
6
+ parseChatSendResultMessage,
7
+ resolveParentOriginFromReferrer,
8
+ } from './miniAppProtocol';
9
+
10
+ const DEFAULT_TIMEOUT_MS = 60_000;
11
+
12
+ /**
13
+ * Ask the Sybilion host to send a chat message with its auth token.
14
+ * Does not update host ChatSheet state — same session as `chatId` on the agent only.
15
+ */
16
+ export async function sendChatMessage(
17
+ chatId: string,
18
+ message: string,
19
+ ): Promise<ChatResponse> {
20
+ if (typeof window === 'undefined' || window.parent === window) {
21
+ throw new Error('sendChatMessage requires an embedded mini-app (iframe)');
22
+ }
23
+
24
+ const requestId = crypto.randomUUID();
25
+ const target = resolveParentOriginFromReferrer();
26
+ const payload = { requestId, chatId, message };
27
+ const msg = buildChatSendMessage(payload);
28
+
29
+ return new Promise((resolve, reject) => {
30
+ const onMessage = (event: MessageEvent) => {
31
+ if (!isTrustedMiniAppParentMessage(event)) return;
32
+ const parsed = parseChatSendResultMessage(event.data);
33
+ if (!parsed || parsed.requestId !== requestId) return;
34
+ window.removeEventListener('message', onMessage);
35
+ clearTimeout(timer);
36
+ if (parsed.ok === true) {
37
+ resolve(parsed.result);
38
+ return;
39
+ }
40
+ reject(new Error(parsed.error));
41
+ };
42
+
43
+ window.addEventListener('message', onMessage);
44
+ const timer = setTimeout(() => {
45
+ window.removeEventListener('message', onMessage);
46
+ reject(new Error('Mini-app chat request timed out'));
47
+ }, DEFAULT_TIMEOUT_MS);
48
+
49
+ if (target) {
50
+ window.parent.postMessage(msg, target);
51
+ } else {
52
+ window.parent.postMessage(msg, '*');
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,165 @@
1
+ import type {
2
+ MiniAppDataset,
3
+ MiniAppDriversComparisonSnapshot,
4
+ MiniAppForecastMap,
5
+ MiniAppPerformanceBundle,
6
+ } from './miniAppDataTypes';
7
+ import {
8
+ type MiniAppDataOp,
9
+ type MiniAppDataRequestPayload,
10
+ buildDataRequestMessage,
11
+ isTrustedMiniAppParentMessage,
12
+ parseDataResponseMessage,
13
+ resolveParentOriginFromReferrer,
14
+ } from './miniAppProtocol';
15
+
16
+ export type MiniAppDataClientOptions = {
17
+ /** Default 10000 ms. */
18
+ timeoutMs?: number;
19
+ };
20
+
21
+ export type MiniAppDataClient = {
22
+ getDatasets(): Promise<MiniAppDataset[]>;
23
+ getDataset(id: number): Promise<MiniAppDataset | null>;
24
+ getForecasts(datasetId: number): Promise<MiniAppForecastMap>;
25
+ getForecast(datasetId: number, analysisId: number): Promise<unknown | null>;
26
+ getDrivers(datasetId: number, analysisId: number): Promise<unknown[]>;
27
+ getPerformanceData(
28
+ datasetId: number,
29
+ analysisId: number,
30
+ ): Promise<MiniAppPerformanceBundle>;
31
+ getDriversComparisonData(
32
+ datasetId: number,
33
+ analysisId: number,
34
+ ): Promise<MiniAppDriversComparisonSnapshot>;
35
+ };
36
+
37
+ const DEFAULT_TIMEOUT_MS = 10_000;
38
+
39
+ type Pending = {
40
+ resolve: (value: unknown) => void;
41
+ reject: (err: Error) => void;
42
+ timer: ReturnType<typeof setTimeout>;
43
+ };
44
+
45
+ /** One listener per browsing context; tracks in-flight bridge requests by `requestId`. */
46
+ export function createMiniAppDataClient(
47
+ options?: MiniAppDataClientOptions,
48
+ ): MiniAppDataClient {
49
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
50
+ const pending = new Map<string, Pending>();
51
+ let listening = false;
52
+
53
+ const settle = (rid: string, fn: (p: Pending) => void): void => {
54
+ const p = pending.get(rid);
55
+ if (!p) return;
56
+ clearTimeout(p.timer);
57
+ pending.delete(rid);
58
+ fn(p);
59
+ };
60
+
61
+ function ensureListener(): void {
62
+ if (listening || typeof window === 'undefined') return;
63
+ listening = true;
64
+ window.addEventListener('message', (event: MessageEvent) => {
65
+ if (!isTrustedMiniAppParentMessage(event)) return;
66
+ const msg = parseDataResponseMessage(event.data);
67
+ if (!msg) return;
68
+ settle(msg.requestId, p => {
69
+ if (msg.ok) {
70
+ p.resolve(msg.result);
71
+ } else {
72
+ p.reject(
73
+ new Error(
74
+ typeof msg.error === 'string'
75
+ ? msg.error
76
+ : 'Mini-app data request failed',
77
+ ),
78
+ );
79
+ }
80
+ });
81
+ });
82
+ }
83
+
84
+ function request(
85
+ payload: Omit<MiniAppDataRequestPayload, 'requestId'>,
86
+ ): Promise<unknown> {
87
+ if (typeof window === 'undefined' || window.parent === window) {
88
+ throw new Error('MiniAppDataClient works only inside an iframe embed');
89
+ }
90
+ ensureListener();
91
+
92
+ const requestId =
93
+ typeof crypto !== 'undefined' && 'randomUUID' in crypto
94
+ ? crypto.randomUUID()
95
+ : `miniapp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
96
+
97
+ const envelope = buildDataRequestMessage({
98
+ ...payload,
99
+ requestId,
100
+ });
101
+ const target = resolveParentOriginFromReferrer();
102
+
103
+ return new Promise((resolve, reject) => {
104
+ const timer = setTimeout(() => {
105
+ settle(requestId, p => {
106
+ p.reject(new Error('Mini-app data request timed out'));
107
+ });
108
+ }, timeoutMs);
109
+
110
+ pending.set(requestId, {
111
+ resolve,
112
+ reject,
113
+ timer,
114
+ });
115
+
116
+ try {
117
+ if (target) {
118
+ window.parent.postMessage(envelope, target);
119
+ } else {
120
+ window.parent.postMessage(envelope, '*');
121
+ }
122
+ } catch (e) {
123
+ settle(requestId, p => {
124
+ p.reject(
125
+ e instanceof Error ? e : new Error('Mini-app postMessage failed'),
126
+ );
127
+ });
128
+ }
129
+ });
130
+ }
131
+
132
+ async function rq<R>(
133
+ op: MiniAppDataOp,
134
+ params?: Record<string, number>,
135
+ ): Promise<R> {
136
+ return request({ op, params }) as Promise<R>;
137
+ }
138
+
139
+ return {
140
+ getDatasets: () => rq<MiniAppDataset[]>('getDatasets'),
141
+
142
+ getDataset: id => rq<MiniAppDataset | null>('getDataset', { id }),
143
+
144
+ getForecasts: datasetId =>
145
+ rq<MiniAppForecastMap>('getForecasts', { datasetId }),
146
+
147
+ getForecast: (datasetId, analysisId) =>
148
+ rq<unknown | null>('getForecast', { datasetId, analysisId }),
149
+
150
+ getDrivers: (datasetId, analysisId) =>
151
+ rq<unknown[]>('getDrivers', { datasetId, analysisId }),
152
+
153
+ getPerformanceData: (datasetId, analysisId) =>
154
+ rq<MiniAppPerformanceBundle>('getPerformanceData', {
155
+ datasetId,
156
+ analysisId,
157
+ }),
158
+
159
+ getDriversComparisonData: (datasetId, analysisId) =>
160
+ rq<MiniAppDriversComparisonSnapshot>('getDriversComparisonData', {
161
+ datasetId,
162
+ analysisId,
163
+ }),
164
+ };
165
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Serializable shapes returned by the shell for mini-app data getters.
3
+ * Subset of Sybilion `Dataset` / API types — use `unknown` where payloads are large or evolving.
4
+ */
5
+
6
+ export type MiniAppDataset = {
7
+ id: number;
8
+ user_id?: number;
9
+ name: string;
10
+ description?: string;
11
+ status: string;
12
+ created_at: string;
13
+ updated_at: string;
14
+ keywords: string;
15
+ category: { id: number; name: string };
16
+ target_type_id: number;
17
+ target_type: { id: number; name: string };
18
+ trend: number;
19
+ regular_price: string;
20
+ sale_price: string;
21
+ regions: { id: number; name: string }[];
22
+ unit: { id: number; name: string };
23
+ version?: number;
24
+ };
25
+
26
+ /** `forecastData` slice: analysis id (string) → forecast series blob. */
27
+ export type MiniAppForecastMap = Record<string, unknown>;
28
+
29
+ export type MiniAppPerformanceBundle = {
30
+ /** Cached Performance-tab API payload, or null if user never opened Performance / cache empty. */
31
+ table: unknown;
32
+ /** In-memory spaghetti lines from shell context, or null. */
33
+ spaghetti: unknown;
34
+ };
35
+
36
+ export type MiniAppDriversComparisonSnapshot = {
37
+ byRegion: unknown;
38
+ byCountry: unknown;
39
+ byCategory: unknown;
40
+ byImportance: unknown;
41
+ driversCount: number | null;
42
+ };
@@ -2,6 +2,7 @@
2
2
  * postMessage protocol for Sybilion workspace mini-apps (iframe child).
3
3
  * Keep in sync with sybilion-client `src/workspace/miniAppBridge.ts` (channel, version, payloads).
4
4
  */
5
+ import type { ChatResponse } from '#uilib/types/chat-api.types';
5
6
 
6
7
  export const MINIAPP_CHANNEL = 'sybilion.miniapp' as const;
7
8
  export const MINIAPP_VERSION = 1 as const;
@@ -25,6 +26,74 @@ export type MiniAppMessageThemeSync = {
25
26
  payload: ThemeSyncPayload;
26
27
  };
27
28
 
29
+ export type MiniAppDataOp =
30
+ | 'getDatasets'
31
+ | 'getDataset'
32
+ | 'getForecasts'
33
+ | 'getForecast'
34
+ | 'getDrivers'
35
+ | 'getPerformanceData'
36
+ | 'getDriversComparisonData';
37
+
38
+ export type MiniAppDataRequestPayload = {
39
+ requestId: string;
40
+ op: MiniAppDataOp;
41
+ params?: Record<string, number>;
42
+ };
43
+
44
+ export type MiniAppMessageDataRequest = {
45
+ channel: typeof MINIAPP_CHANNEL;
46
+ version: typeof MINIAPP_VERSION;
47
+ type: 'DATA_REQUEST';
48
+ payload: MiniAppDataRequestPayload;
49
+ };
50
+
51
+ export type MiniAppDataResponsePayload = {
52
+ requestId: string;
53
+ ok: boolean;
54
+ result?: unknown;
55
+ error?: string;
56
+ };
57
+
58
+ export type MiniAppMessageDataResponse = {
59
+ channel: typeof MINIAPP_CHANNEL;
60
+ version: typeof MINIAPP_VERSION;
61
+ type: 'DATA_RESPONSE';
62
+ payload: MiniAppDataResponsePayload;
63
+ };
64
+
65
+ export type MiniAppChatSendPayload = {
66
+ requestId: string;
67
+ chatId: string;
68
+ message: string;
69
+ };
70
+
71
+ export type MiniAppMessageChatSend = {
72
+ channel: typeof MINIAPP_CHANNEL;
73
+ version: typeof MINIAPP_VERSION;
74
+ type: 'CHAT_SEND';
75
+ payload: MiniAppChatSendPayload;
76
+ };
77
+
78
+ export type MiniAppChatSendResultPayload =
79
+ | {
80
+ requestId: string;
81
+ ok: true;
82
+ result: ChatResponse;
83
+ }
84
+ | {
85
+ requestId: string;
86
+ ok: false;
87
+ error: string;
88
+ };
89
+
90
+ export type MiniAppMessageChatSendResult = {
91
+ channel: typeof MINIAPP_CHANNEL;
92
+ version: typeof MINIAPP_VERSION;
93
+ type: 'CHAT_SEND_RESULT';
94
+ payload: MiniAppChatSendResultPayload;
95
+ };
96
+
28
97
  function isRecord(v: unknown): v is Record<string, unknown> {
29
98
  return typeof v === 'object' && v !== null && !Array.isArray(v);
30
99
  }
@@ -77,3 +146,102 @@ export function applyThemeToDocument(mode: 'light' | 'dark'): void {
77
146
  root.classList.remove('light', 'dark');
78
147
  root.classList.add(mode);
79
148
  }
149
+
150
+ /** Only accept shell messages that appear to come from the real embedding parent. */
151
+ export function isTrustedMiniAppParentMessage(event: MessageEvent): boolean {
152
+ if (event.source !== window.parent) return false;
153
+ const fromReferrer = resolveParentOriginFromReferrer();
154
+ if (fromReferrer && event.origin !== fromReferrer) return false;
155
+ return true;
156
+ }
157
+
158
+ export function buildDataRequestMessage(
159
+ payload: MiniAppDataRequestPayload,
160
+ ): MiniAppMessageDataRequest {
161
+ return {
162
+ channel: MINIAPP_CHANNEL,
163
+ version: MINIAPP_VERSION,
164
+ type: 'DATA_REQUEST',
165
+ payload,
166
+ };
167
+ }
168
+
169
+ export function buildChatSendMessage(
170
+ payload: MiniAppChatSendPayload,
171
+ ): MiniAppMessageChatSend {
172
+ return {
173
+ channel: MINIAPP_CHANNEL,
174
+ version: MINIAPP_VERSION,
175
+ type: 'CHAT_SEND',
176
+ payload,
177
+ };
178
+ }
179
+
180
+ /** Parse parent → child DATA_RESPONSE. */
181
+ export function parseDataResponseMessage(
182
+ data: unknown,
183
+ ): MiniAppDataResponsePayload | null {
184
+ if (!isRecord(data)) return null;
185
+ if (data.channel !== MINIAPP_CHANNEL) return null;
186
+ if (data.version !== MINIAPP_VERSION) return null;
187
+ if (data.type !== 'DATA_RESPONSE') return null;
188
+ const payload = data.payload;
189
+ if (!isRecord(payload)) return null;
190
+ const requestId = payload.requestId;
191
+ if (typeof requestId !== 'string' || requestId.length === 0) return null;
192
+ const ok = payload.ok === true;
193
+ const errOk = payload.ok === false;
194
+ if (!ok && !errOk) return null;
195
+
196
+ const base: MiniAppDataResponsePayload = {
197
+ requestId,
198
+ ok,
199
+ };
200
+
201
+ if ('result' in payload) {
202
+ base.result = payload.result;
203
+ }
204
+ if (typeof payload.error === 'string') {
205
+ base.error = payload.error;
206
+ }
207
+ return base;
208
+ }
209
+
210
+ /** Parse parent → child CHAT_SEND_RESULT (shell uses `buildChatSendResultMessage`). */
211
+ export function parseChatSendResultMessage(
212
+ data: unknown,
213
+ ): MiniAppChatSendResultPayload | null {
214
+ if (!isRecord(data)) return null;
215
+ if (data.channel !== MINIAPP_CHANNEL) return null;
216
+ if (data.version !== MINIAPP_VERSION) return null;
217
+ if (data.type !== 'CHAT_SEND_RESULT') return null;
218
+ const payload = data.payload;
219
+ if (!isRecord(payload)) return null;
220
+ const requestId = payload.requestId;
221
+ if (typeof requestId !== 'string' || requestId.length === 0) return null;
222
+
223
+ if (payload.ok === true) {
224
+ const result = payload.result;
225
+ if (!isRecord(result)) return null;
226
+ const responseText = result.response;
227
+ if (typeof responseText !== 'string') return null;
228
+ const sidRaw = result.session_id;
229
+ if (sidRaw !== undefined && sidRaw !== null && typeof sidRaw !== 'string')
230
+ return null;
231
+ const session_id: string | null =
232
+ typeof sidRaw === 'string' ? sidRaw : null;
233
+ return {
234
+ requestId,
235
+ ok: true,
236
+ result: {
237
+ response: responseText,
238
+ session_id,
239
+ },
240
+ };
241
+ }
242
+ if (payload.ok === false) {
243
+ if (typeof payload.error !== 'string') return null;
244
+ return { requestId, ok: false, error: payload.error };
245
+ }
246
+ return null;
247
+ }