@sybilion/uilib 1.0.29 → 1.0.31
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 +2 -1
- package/dist/esm/mini-app/MiniAppRoot.js +2 -18
- package/dist/esm/mini-app/miniAppDataClient.js +98 -0
- package/dist/esm/mini-app/miniAppProtocol.js +50 -1
- package/dist/esm/types/src/mini-app/index.d.ts +5 -2
- package/dist/esm/types/src/mini-app/miniAppDataClient.d.ts +16 -0
- package/dist/esm/types/src/mini-app/miniAppDataTypes.d.ts +50 -0
- package/dist/esm/types/src/mini-app/miniAppProtocol.d.ts +29 -0
- package/docs/workspace-mini-apps.md +28 -0
- package/package.json +1 -1
- package/src/mini-app/MiniAppRoot.tsx +2 -16
- package/src/mini-app/index.ts +19 -0
- package/src/mini-app/miniAppDataClient.ts +165 -0
- package/src/mini-app/miniAppDataTypes.ts +42 -0
- package/src/mini-app/miniAppProtocol.ts +85 -0
package/dist/esm/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildReadyMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer } from './mini-app/miniAppProtocol.js';
|
|
1
|
+
export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildDataRequestMessage, buildReadyMessage, isTrustedMiniAppParentMessage, 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';
|
|
3
4
|
export { MiniAppRoot, useMiniAppShellTheme } from './mini-app/MiniAppRoot.js';
|
|
4
5
|
export { ChatContext, ChatProvider, useChat, useChats, useChatsForDataset, useChatsForScopeId, useCurrentChat } from './contexts/chat-context.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 (!
|
|
60
|
+
if (!isTrustedMiniAppParentMessage(event))
|
|
77
61
|
return;
|
|
78
62
|
const parsed = parseThemeSyncMessage(event.data);
|
|
79
63
|
if (!parsed)
|
|
@@ -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 };
|
|
@@ -53,5 +53,54 @@ function applyThemeToDocument(mode) {
|
|
|
53
53
|
root.classList.remove('light', 'dark');
|
|
54
54
|
root.classList.add(mode);
|
|
55
55
|
}
|
|
56
|
+
/** Only accept shell messages that appear to come from the real embedding parent. */
|
|
57
|
+
function isTrustedMiniAppParentMessage(event) {
|
|
58
|
+
if (event.source !== window.parent)
|
|
59
|
+
return false;
|
|
60
|
+
const fromReferrer = resolveParentOriginFromReferrer();
|
|
61
|
+
if (fromReferrer && event.origin !== fromReferrer)
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
function buildDataRequestMessage(payload) {
|
|
66
|
+
return {
|
|
67
|
+
channel: MINIAPP_CHANNEL,
|
|
68
|
+
version: MINIAPP_VERSION,
|
|
69
|
+
type: 'DATA_REQUEST',
|
|
70
|
+
payload,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Parse parent → child DATA_RESPONSE. */
|
|
74
|
+
function parseDataResponseMessage(data) {
|
|
75
|
+
if (!isRecord(data))
|
|
76
|
+
return null;
|
|
77
|
+
if (data.channel !== MINIAPP_CHANNEL)
|
|
78
|
+
return null;
|
|
79
|
+
if (data.version !== MINIAPP_VERSION)
|
|
80
|
+
return null;
|
|
81
|
+
if (data.type !== 'DATA_RESPONSE')
|
|
82
|
+
return null;
|
|
83
|
+
const payload = data.payload;
|
|
84
|
+
if (!isRecord(payload))
|
|
85
|
+
return null;
|
|
86
|
+
const requestId = payload.requestId;
|
|
87
|
+
if (typeof requestId !== 'string' || requestId.length === 0)
|
|
88
|
+
return null;
|
|
89
|
+
const ok = payload.ok === true;
|
|
90
|
+
const errOk = payload.ok === false;
|
|
91
|
+
if (!ok && !errOk)
|
|
92
|
+
return null;
|
|
93
|
+
const base = {
|
|
94
|
+
requestId,
|
|
95
|
+
ok,
|
|
96
|
+
};
|
|
97
|
+
if ('result' in payload) {
|
|
98
|
+
base.result = payload.result;
|
|
99
|
+
}
|
|
100
|
+
if (typeof payload.error === 'string') {
|
|
101
|
+
base.error = payload.error;
|
|
102
|
+
}
|
|
103
|
+
return base;
|
|
104
|
+
}
|
|
56
105
|
|
|
57
|
-
export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildReadyMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer };
|
|
106
|
+
export { MINIAPP_CHANNEL, MINIAPP_VERSION, applyThemeToDocument, buildDataRequestMessage, buildReadyMessage, isTrustedMiniAppParentMessage, parseDataResponseMessage, parseThemeSyncMessage, resolveParentOriginFromReferrer };
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { applyThemeToDocument, buildReadyMessage, MINIAPP_CHANNEL, MINIAPP_VERSION, parseThemeSyncMessage, resolveParentOriginFromReferrer, } from './miniAppProtocol';
|
|
2
|
-
export type { MiniAppMessageReady, MiniAppMessageThemeSync, ThemeSyncPayload, } from './miniAppProtocol';
|
|
1
|
+
export { applyThemeToDocument, buildDataRequestMessage, buildReadyMessage, MINIAPP_CHANNEL, MINIAPP_VERSION, parseDataResponseMessage, parseThemeSyncMessage, isTrustedMiniAppParentMessage, resolveParentOriginFromReferrer, } from './miniAppProtocol';
|
|
2
|
+
export type { MiniAppMessageReady, MiniAppMessageThemeSync, MiniAppMessageDataRequest, MiniAppMessageDataResponse, MiniAppDataRequestPayload, MiniAppDataResponsePayload, 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';
|
|
5
8
|
export { MiniAppRoot, useMiniAppShellTheme } from './MiniAppRoot';
|
|
@@ -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
|
+
};
|
|
@@ -22,9 +22,38 @@ export type MiniAppMessageThemeSync = {
|
|
|
22
22
|
type: 'THEME_SYNC';
|
|
23
23
|
payload: ThemeSyncPayload;
|
|
24
24
|
};
|
|
25
|
+
export type MiniAppDataOp = 'getDatasets' | 'getDataset' | 'getForecasts' | 'getForecast' | 'getDrivers' | 'getPerformanceData' | 'getDriversComparisonData';
|
|
26
|
+
export type MiniAppDataRequestPayload = {
|
|
27
|
+
requestId: string;
|
|
28
|
+
op: MiniAppDataOp;
|
|
29
|
+
params?: Record<string, number>;
|
|
30
|
+
};
|
|
31
|
+
export type MiniAppMessageDataRequest = {
|
|
32
|
+
channel: typeof MINIAPP_CHANNEL;
|
|
33
|
+
version: typeof MINIAPP_VERSION;
|
|
34
|
+
type: 'DATA_REQUEST';
|
|
35
|
+
payload: MiniAppDataRequestPayload;
|
|
36
|
+
};
|
|
37
|
+
export type MiniAppDataResponsePayload = {
|
|
38
|
+
requestId: string;
|
|
39
|
+
ok: boolean;
|
|
40
|
+
result?: unknown;
|
|
41
|
+
error?: string;
|
|
42
|
+
};
|
|
43
|
+
export type MiniAppMessageDataResponse = {
|
|
44
|
+
channel: typeof MINIAPP_CHANNEL;
|
|
45
|
+
version: typeof MINIAPP_VERSION;
|
|
46
|
+
type: 'DATA_RESPONSE';
|
|
47
|
+
payload: MiniAppDataResponsePayload;
|
|
48
|
+
};
|
|
25
49
|
/** Parse parent → child THEME_SYNC (shell uses `buildThemeSyncMessage`). */
|
|
26
50
|
export declare function parseThemeSyncMessage(data: unknown): ThemeSyncPayload | null;
|
|
27
51
|
/** Child → parent READY (optional `appId` for telemetry). */
|
|
28
52
|
export declare function buildReadyMessage(payload?: Record<string, unknown>): MiniAppMessageReady;
|
|
29
53
|
export declare function resolveParentOriginFromReferrer(): string | null;
|
|
30
54
|
export declare function applyThemeToDocument(mode: 'light' | 'dark'): void;
|
|
55
|
+
/** Only accept shell messages that appear to come from the real embedding parent. */
|
|
56
|
+
export declare function isTrustedMiniAppParentMessage(event: MessageEvent): boolean;
|
|
57
|
+
export declare function buildDataRequestMessage(payload: MiniAppDataRequestPayload): MiniAppMessageDataRequest;
|
|
58
|
+
/** Parse parent → child DATA_RESPONSE. */
|
|
59
|
+
export declare function parseDataResponseMessage(data: unknown): MiniAppDataResponsePayload | null;
|
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
The Sybilion client embeds mini-apps in an **iframe** and syncs theme with `postMessage`. Use **`MiniAppRoot`** so this document’s `<html>` gets **`light` / `dark`** (uilib uses `.dark { … }` for tokens), and so **`@homecode/ui`’s `<Theme />`** runs with vars matching that mode (`getDefaultMiniAppThemeConfig`; override via props if needed).
|
|
4
4
|
|
|
5
|
+
## Design system (`@sybilion/uilib`)
|
|
6
|
+
|
|
7
|
+
**Prefer exported components from `@sybilion/uilib`** for structure, typography, forms, feedback, data display, and charts **when they fit the need**. That keeps spacing, tokens, and behavior aligned with Sybilion and avoids one-off layouts. Use custom markup or CSS **only** when uilib has no suitable primitive.
|
|
8
|
+
|
|
9
|
+
**For humans and coding agents:** Default to uilib as the component library—do not invent new layout patterns or duplicate design tokens when an existing uilib component (or composition of a few) can do the job. Browse the uilib docs app / package exports before building bespoke UI.
|
|
10
|
+
|
|
11
|
+
**Page inset:** Do **not** add your own **left/right** padding or margin on the root page or a full-viewport wrapper (no random `px-*` / `mx-*` / `container` max-width hacks for “page margins”). Use uilib layout primitives and spacing tokens—or patterns documented for mini-apps—so horizontal gutters and max width stay consistent with the shell.
|
|
12
|
+
|
|
5
13
|
1. Import **`@sybilion/uilib/mini-app-global.css`** (slim tokens + font imports; ships in this package).
|
|
6
14
|
2. Wrap the React root:
|
|
7
15
|
|
|
@@ -21,3 +29,23 @@ createRoot(document.getElementById('root')!).render(
|
|
|
21
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.
|
|
22
30
|
|
|
23
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
|
@@ -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 (!
|
|
116
|
+
if (!isTrustedMiniAppParentMessage(event)) return;
|
|
131
117
|
const parsed = parseThemeSyncMessage(event.data);
|
|
132
118
|
if (!parsed) return;
|
|
133
119
|
setTheme(parsed);
|
package/src/mini-app/index.ts
CHANGED
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
export {
|
|
2
2
|
applyThemeToDocument,
|
|
3
|
+
buildDataRequestMessage,
|
|
3
4
|
buildReadyMessage,
|
|
4
5
|
MINIAPP_CHANNEL,
|
|
5
6
|
MINIAPP_VERSION,
|
|
7
|
+
parseDataResponseMessage,
|
|
6
8
|
parseThemeSyncMessage,
|
|
9
|
+
isTrustedMiniAppParentMessage,
|
|
7
10
|
resolveParentOriginFromReferrer,
|
|
8
11
|
} from './miniAppProtocol';
|
|
9
12
|
export type {
|
|
10
13
|
MiniAppMessageReady,
|
|
11
14
|
MiniAppMessageThemeSync,
|
|
15
|
+
MiniAppMessageDataRequest,
|
|
16
|
+
MiniAppMessageDataResponse,
|
|
17
|
+
MiniAppDataRequestPayload,
|
|
18
|
+
MiniAppDataResponsePayload,
|
|
19
|
+
MiniAppDataOp,
|
|
12
20
|
ThemeSyncPayload,
|
|
13
21
|
} from './miniAppProtocol';
|
|
22
|
+
export { createMiniAppDataClient } from './miniAppDataClient';
|
|
23
|
+
export type {
|
|
24
|
+
MiniAppDataClientOptions,
|
|
25
|
+
MiniAppDataClient,
|
|
26
|
+
} from './miniAppDataClient';
|
|
27
|
+
export type {
|
|
28
|
+
MiniAppDataset,
|
|
29
|
+
MiniAppDriversComparisonSnapshot,
|
|
30
|
+
MiniAppForecastMap,
|
|
31
|
+
MiniAppPerformanceBundle,
|
|
32
|
+
} from './miniAppDataTypes';
|
|
14
33
|
export { getDefaultMiniAppThemeConfig } from './miniAppThemeConfig';
|
|
15
34
|
export type { MiniAppThemeConfig } from './miniAppThemeConfig';
|
|
16
35
|
export { MiniAppRoot, useMiniAppShellTheme } from './MiniAppRoot';
|
|
@@ -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
|
+
};
|
|
@@ -25,6 +25,42 @@ export type MiniAppMessageThemeSync = {
|
|
|
25
25
|
payload: ThemeSyncPayload;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
export type MiniAppDataOp =
|
|
29
|
+
| 'getDatasets'
|
|
30
|
+
| 'getDataset'
|
|
31
|
+
| 'getForecasts'
|
|
32
|
+
| 'getForecast'
|
|
33
|
+
| 'getDrivers'
|
|
34
|
+
| 'getPerformanceData'
|
|
35
|
+
| 'getDriversComparisonData';
|
|
36
|
+
|
|
37
|
+
export type MiniAppDataRequestPayload = {
|
|
38
|
+
requestId: string;
|
|
39
|
+
op: MiniAppDataOp;
|
|
40
|
+
params?: Record<string, number>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type MiniAppMessageDataRequest = {
|
|
44
|
+
channel: typeof MINIAPP_CHANNEL;
|
|
45
|
+
version: typeof MINIAPP_VERSION;
|
|
46
|
+
type: 'DATA_REQUEST';
|
|
47
|
+
payload: MiniAppDataRequestPayload;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type MiniAppDataResponsePayload = {
|
|
51
|
+
requestId: string;
|
|
52
|
+
ok: boolean;
|
|
53
|
+
result?: unknown;
|
|
54
|
+
error?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type MiniAppMessageDataResponse = {
|
|
58
|
+
channel: typeof MINIAPP_CHANNEL;
|
|
59
|
+
version: typeof MINIAPP_VERSION;
|
|
60
|
+
type: 'DATA_RESPONSE';
|
|
61
|
+
payload: MiniAppDataResponsePayload;
|
|
62
|
+
};
|
|
63
|
+
|
|
28
64
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
29
65
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
66
|
}
|
|
@@ -77,3 +113,52 @@ export function applyThemeToDocument(mode: 'light' | 'dark'): void {
|
|
|
77
113
|
root.classList.remove('light', 'dark');
|
|
78
114
|
root.classList.add(mode);
|
|
79
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Only accept shell messages that appear to come from the real embedding parent. */
|
|
118
|
+
export function isTrustedMiniAppParentMessage(event: MessageEvent): boolean {
|
|
119
|
+
if (event.source !== window.parent) return false;
|
|
120
|
+
const fromReferrer = resolveParentOriginFromReferrer();
|
|
121
|
+
if (fromReferrer && event.origin !== fromReferrer) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildDataRequestMessage(
|
|
126
|
+
payload: MiniAppDataRequestPayload,
|
|
127
|
+
): MiniAppMessageDataRequest {
|
|
128
|
+
return {
|
|
129
|
+
channel: MINIAPP_CHANNEL,
|
|
130
|
+
version: MINIAPP_VERSION,
|
|
131
|
+
type: 'DATA_REQUEST',
|
|
132
|
+
payload,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse parent → child DATA_RESPONSE. */
|
|
137
|
+
export function parseDataResponseMessage(
|
|
138
|
+
data: unknown,
|
|
139
|
+
): MiniAppDataResponsePayload | null {
|
|
140
|
+
if (!isRecord(data)) return null;
|
|
141
|
+
if (data.channel !== MINIAPP_CHANNEL) return null;
|
|
142
|
+
if (data.version !== MINIAPP_VERSION) return null;
|
|
143
|
+
if (data.type !== 'DATA_RESPONSE') return null;
|
|
144
|
+
const payload = data.payload;
|
|
145
|
+
if (!isRecord(payload)) return null;
|
|
146
|
+
const requestId = payload.requestId;
|
|
147
|
+
if (typeof requestId !== 'string' || requestId.length === 0) return null;
|
|
148
|
+
const ok = payload.ok === true;
|
|
149
|
+
const errOk = payload.ok === false;
|
|
150
|
+
if (!ok && !errOk) return null;
|
|
151
|
+
|
|
152
|
+
const base: MiniAppDataResponsePayload = {
|
|
153
|
+
requestId,
|
|
154
|
+
ok,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if ('result' in payload) {
|
|
158
|
+
base.result = payload.result;
|
|
159
|
+
}
|
|
160
|
+
if (typeof payload.error === 'string') {
|
|
161
|
+
base.error = payload.error;
|
|
162
|
+
}
|
|
163
|
+
return base;
|
|
164
|
+
}
|