@faclon-labs/app-sdk 0.2.0 → 0.4.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.
package/README.md CHANGED
@@ -12,6 +12,11 @@ Wrap the app in `<IOSenseApp>`. It takes the session IOSense hands over — the
12
12
  token (`?token=`, exchanged once), a bearer on the URL, or the `.iosense.io` cookie — takes the token
13
13
  out of the address, and keeps it in this browser. With no session it shows a sign-in screen.
14
14
 
15
+ Inside IOSense (the Studio's view of a deployed app) the app is framed by a page that already has the
16
+ viewer's session: from 0.3, `<IOSenseApp>` asks its parent for it (`postMessage`, about a second at
17
+ most) and the Studio answers with the bearer, sent to the app's own origin only. No SSO token is made
18
+ or exchanged. Only an IOSense origin (`*.iosense.io`, `*.iocompute.ai`, localhost) is believed.
19
+
15
20
  ```tsx
16
21
  import { IOSenseApp } from '@faclon-labs/app-sdk';
17
22
 
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from 'react';
3
- import { adoptHostSession, clearToken, exchangeSsoToken, getToken, setToken, takePendingSsoToken } from './auth.js';
3
+ import { adoptHostSession, clearToken, exchangeSsoToken, getToken, isEmbedded, requestHostSession, setToken, takePendingSsoToken } from './auth.js';
4
4
  import { configure, getConfig } from './config.js';
5
5
  import { publishSession } from './request.js';
6
6
  import { SAMPLE_NOTICE, SessionContext, useIOSense } from './session.js';
7
7
  import { SignIn } from './SignIn.js';
8
+ import { startHostLook } from './host-look.js';
8
9
  /**
9
10
  * The root of an IOSense app: the session, and the sign-in screen when there is none.
10
11
  *
@@ -18,12 +19,34 @@ export function IOSenseApp({ name, auth = 'required', sample, apiBaseUrl, signIn
18
19
  const api = getConfig().apiBaseUrl;
19
20
  // Once, before the first child renders, so the first paint already has the session.
20
21
  const [phase, setPhase] = useState(() => {
22
+ // Framed in I/O Sense, the app may wear its look (chosen in the sidebar): asked for at once.
23
+ startHostLook();
21
24
  if (auth === 'none')
22
25
  return { kind: 'ready', token: null };
23
- adoptHostSession();
26
+ const how = adoptHostSession();
24
27
  const sso = takePendingSsoToken();
25
- return sso ? { kind: 'exchanging', sso } : { kind: 'ready', token: getToken() };
28
+ if (sso)
29
+ return { kind: 'exchanging', sso };
30
+ // Framed, with nothing handed over on the URL or in the cookie: the parent may hold the session
31
+ // (IOSense does). A session kept from before is asked about too — the parent's viewer wins.
32
+ if (isEmbedded() && (how === 'none' || how === 'stored'))
33
+ return { kind: 'asking' };
34
+ return { kind: 'ready', token: getToken() };
26
35
  });
36
+ useEffect(() => {
37
+ if (phase.kind !== 'asking')
38
+ return;
39
+ let current = true;
40
+ requestHostSession().then((handed) => {
41
+ if (handed)
42
+ setToken(handed);
43
+ if (current)
44
+ setPhase({ kind: 'ready', token: getToken() });
45
+ });
46
+ return () => {
47
+ current = false;
48
+ };
49
+ }, [phase]);
27
50
  useEffect(() => {
28
51
  if (phase.kind !== 'exchanging')
29
52
  return;
@@ -55,9 +78,9 @@ export function IOSenseApp({ name, auth = 'required', sample, apiBaseUrl, signIn
55
78
  }), [name, token, api, sample, auth]);
56
79
  // For `request()`, which is called from event handlers where there is no hook to read from.
57
80
  publishSession({ token, apiBaseUrl: api });
58
- // A blank moment while the SSO token is exchanged: not the sign-in screen, which would be wrong
59
- // for the half second before the session lands.
60
- if (phase.kind === 'exchanging')
81
+ // A blank moment while the SSO token is exchanged or the parent is asked: not the sign-in screen,
82
+ // which would be wrong for the half second before the session lands.
83
+ if (phase.kind === 'exchanging' || phase.kind === 'asking')
61
84
  return null;
62
85
  if (auth === 'required' && !token) {
63
86
  const onSignedIn = (bearer) => {
package/dist/auth.d.ts CHANGED
@@ -15,6 +15,13 @@ export declare function setToken(token: string): void;
15
15
  export declare function clearToken(): void;
16
16
  /** True when this page runs inside another page's frame — the IOSense launchpad, normally. */
17
17
  export declare function isEmbedded(): boolean;
18
+ export declare const isTrustedHostOrigin: (origin: string) => boolean;
19
+ /**
20
+ * Ask the page this app is framed in for the viewer's session. Inside IOSense the parent answers at
21
+ * once with the bearer it already has, sent to this app's origin only; anywhere else nobody answers
22
+ * and this resolves null after `timeoutMs`. The question carries no secret, so it may go to any parent.
23
+ */
24
+ export declare function requestHostSession(timeoutMs?: number): Promise<string | null>;
18
25
  /**
19
26
  * Take the session the host handed over, and take the token out of the URL. Called once, before
20
27
  * the first render (`<IOSenseApp>` does it). Returns how the session arrived.
package/dist/auth.js CHANGED
@@ -2,7 +2,10 @@ import { getConfig } from './config.js';
2
2
  /**
3
3
  * The IOSense session: how an app gets it, and where it keeps it.
4
4
  *
5
- * Four ways in, in order:
5
+ * Five ways in, in order:
6
+ * 0. **Handed by the frame's parent** — inside IOSense (the Studio's view of a deployed app) the
7
+ * parent already holds the viewer's bearer and posts it to this frame (`requestHostSession`):
8
+ * no token to make, nothing to exchange, nothing in the URL. Only an IOSense origin is believed.
6
9
  * 1. **Launchpad, SSO mode** — IOSense opens the app as `…?token=<sso>`: a 64-hex SSO token,
7
10
  * exchanged ONCE for the bearer (`exchangeSsoToken`) before the app renders.
8
11
  * 2. **A bearer on the URL** (`?token=<jwt>`), taken as it is.
@@ -93,6 +96,37 @@ export function isEmbedded() {
93
96
  return true;
94
97
  }
95
98
  }
99
+ /** The pages that may hand this app a session: IOSense's own hosts, the Studio's, and a developer's machine. */
100
+ const HOST_ORIGIN = /^https:\/\/([a-z0-9-]+\.)*(iosense\.io|iocompute\.ai)$|^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i;
101
+ export const isTrustedHostOrigin = (origin) => HOST_ORIGIN.test(origin);
102
+ /**
103
+ * Ask the page this app is framed in for the viewer's session. Inside IOSense the parent answers at
104
+ * once with the bearer it already has, sent to this app's origin only; anywhere else nobody answers
105
+ * and this resolves null after `timeoutMs`. The question carries no secret, so it may go to any parent.
106
+ */
107
+ export function requestHostSession(timeoutMs = 1000) {
108
+ if (typeof window === 'undefined' || !isEmbedded())
109
+ return Promise.resolve(null);
110
+ return new Promise((resolve) => {
111
+ let timer;
112
+ const done = (token) => {
113
+ window.removeEventListener('message', heard);
114
+ clearTimeout(timer);
115
+ resolve(token);
116
+ };
117
+ const heard = (event) => {
118
+ if (event.source !== window.parent || !isTrustedHostOrigin(event.origin))
119
+ return;
120
+ const data = event.data;
121
+ if (!data || data.source !== 'iolens-studio' || data.type !== 'session')
122
+ return;
123
+ done(typeof data.token === 'string' && data.token.trim() ? data.token.trim() : null);
124
+ };
125
+ window.addEventListener('message', heard);
126
+ timer = setTimeout(() => done(null), timeoutMs);
127
+ window.parent.postMessage({ source: 'iosense-app', type: 'session-request', v: 1 }, '*');
128
+ });
129
+ }
96
130
  function readCookie(name) {
97
131
  const prefix = `${name}=`;
98
132
  for (const part of document.cookie.split(';')) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * I/O Sense's look, worn over the app's own design system when the person chose it for this app in
3
+ * their sidebar. The host works out the variables for the app's own library (shadcn's `--primary`,
4
+ * MUI's `--mui-palette-primary-main`, a company library's own token names) and sends them; this puts
5
+ * them on the page over the library's, and follows each change the host sends, with no reload.
6
+ * Outside I/O Sense, or with the app's own look chosen, it does nothing.
7
+ */
8
+ export interface HostLook {
9
+ mode: 'light' | 'dark';
10
+ vars: Record<string, string>;
11
+ /** More selectors that hold the library's variables themselves (Ant Design's `.css-var-*`). */
12
+ scope?: string[];
13
+ /** The `data-theme` the library's own light/dark switch reads. */
14
+ theme?: string;
15
+ /** A Google Fonts stylesheet. */
16
+ fonts?: string;
17
+ }
18
+ /** Read and clean a look from the host; null for "its own look", undefined for nonsense. */
19
+ export declare function hostLookOf(raw: unknown): HostLook | null | undefined;
20
+ /** The stylesheet a look makes: its variables over the library's, whatever the library's selectors. */
21
+ export declare function hostLookCss(look: HostLook): string;
22
+ /** Put a look on the page, or take it off (null). */
23
+ export declare function wearHostLook(look: HostLook | null): void;
24
+ /**
25
+ * Ask the host for its look and follow what it sends. Once per page; `<IOSenseApp>` calls it before
26
+ * its first render. The question carries nothing, so it may go to any parent; only a trusted host's
27
+ * answer is worn.
28
+ */
29
+ export declare function startHostLook(): void;
@@ -0,0 +1,115 @@
1
+ import { isEmbedded, isTrustedHostOrigin } from './auth.js';
2
+ const STYLE_ID = 'iosense-host-look';
3
+ const FONTS_ID = 'iosense-host-look-fonts';
4
+ const NAME = /^--[\w-]+$/;
5
+ /** A value can say a colour, a length or a font stack; never end the rule or open another. */
6
+ const VALUE = /^[^;{}<>\\]*$/;
7
+ const SELECTOR = /^[\w\s\-[\]="*^$.:#>+~(),]+$/;
8
+ let started = false;
9
+ let worn = null;
10
+ /** What the page had before the look, put back when it is taken off. */
11
+ let before = null;
12
+ let guard = null;
13
+ /** Read and clean a look from the host; null for "its own look", undefined for nonsense. */
14
+ export function hostLookOf(raw) {
15
+ if (raw === null)
16
+ return null;
17
+ if (!raw || typeof raw !== 'object')
18
+ return undefined;
19
+ const look = raw;
20
+ if (look.mode !== 'light' && look.mode !== 'dark')
21
+ return undefined;
22
+ const vars = {};
23
+ for (const [name, value] of Object.entries(look.vars ?? {}))
24
+ if (NAME.test(name) && typeof value === 'string' && VALUE.test(value))
25
+ vars[name] = value;
26
+ const scope = Array.isArray(look.scope) ? look.scope.filter((s) => typeof s === 'string' && SELECTOR.test(s)) : [];
27
+ const theme = typeof look.theme === 'string' && /^[\w-]+$/.test(look.theme) ? look.theme : undefined;
28
+ const fonts = typeof look.fonts === 'string' && look.fonts.startsWith('https://fonts.googleapis.com/') ? look.fonts : undefined;
29
+ return { mode: look.mode, vars, ...(scope.length ? { scope } : {}), ...(theme ? { theme } : {}), ...(fonts ? { fonts } : {}) };
30
+ }
31
+ /** The stylesheet a look makes: its variables over the library's, whatever the library's selectors. */
32
+ export function hostLookCss(look) {
33
+ const body = Object.entries(look.vars)
34
+ .map(([name, value]) => ` ${name}: ${value} !important;`)
35
+ .join('\n');
36
+ return body ? `${[':root', ...(look.scope ?? [])].join(',\n')} {\n${body}\n}\n` : '';
37
+ }
38
+ function setMode(look) {
39
+ const root = document.documentElement;
40
+ root.classList.toggle('dark', look.mode === 'dark');
41
+ root.classList.toggle('light', look.mode === 'light');
42
+ root.style.colorScheme = look.mode;
43
+ if (look.theme && root.getAttribute('data-theme') !== look.theme)
44
+ root.setAttribute('data-theme', look.theme);
45
+ }
46
+ /** Put a look on the page, or take it off (null). */
47
+ export function wearHostLook(look) {
48
+ const root = document.documentElement;
49
+ if (look) {
50
+ if (!before)
51
+ before = { theme: root.getAttribute('data-theme'), dark: root.classList.contains('dark'), light: root.classList.contains('light'), scheme: root.style.colorScheme };
52
+ let style = document.getElementById(STYLE_ID);
53
+ if (!style) {
54
+ style = document.createElement('style');
55
+ style.id = STYLE_ID;
56
+ }
57
+ style.textContent = hostLookCss(look);
58
+ // Last in <head>, after the library's own sheets (a dev server adds them later; they still lose to !important).
59
+ document.head.appendChild(style);
60
+ const fonts = document.getElementById(FONTS_ID);
61
+ if (look.fonts && fonts?.href !== look.fonts) {
62
+ const link = fonts ?? document.createElement('link');
63
+ link.id = FONTS_ID;
64
+ link.rel = 'stylesheet';
65
+ link.href = look.fonts;
66
+ document.head.appendChild(link);
67
+ }
68
+ else if (!look.fonts)
69
+ fonts?.remove();
70
+ worn = look;
71
+ setMode(look);
72
+ // The app's own switch (main.tsx follows the computer's light or dark) must not undo the host's.
73
+ if (!guard && typeof MutationObserver !== 'undefined') {
74
+ guard = new MutationObserver(() => worn && setMode(worn));
75
+ guard.observe(root, { attributes: true, attributeFilter: ['class', 'data-theme'] });
76
+ }
77
+ return;
78
+ }
79
+ guard?.disconnect();
80
+ guard = null;
81
+ worn = null;
82
+ document.getElementById(STYLE_ID)?.remove();
83
+ document.getElementById(FONTS_ID)?.remove();
84
+ if (before) {
85
+ if (before.theme === null)
86
+ root.removeAttribute('data-theme');
87
+ else
88
+ root.setAttribute('data-theme', before.theme);
89
+ root.classList.toggle('dark', before.dark);
90
+ root.classList.toggle('light', before.light);
91
+ root.style.colorScheme = before.scheme;
92
+ before = null;
93
+ }
94
+ }
95
+ /**
96
+ * Ask the host for its look and follow what it sends. Once per page; `<IOSenseApp>` calls it before
97
+ * its first render. The question carries nothing, so it may go to any parent; only a trusted host's
98
+ * answer is worn.
99
+ */
100
+ export function startHostLook() {
101
+ if (started || typeof window === 'undefined' || !isEmbedded())
102
+ return;
103
+ started = true;
104
+ window.addEventListener('message', (event) => {
105
+ if (event.source !== window.parent || !isTrustedHostOrigin(event.origin))
106
+ return;
107
+ const data = event.data;
108
+ if (!data || data.source !== 'iolens-studio' || data.type !== 'look')
109
+ return;
110
+ const look = hostLookOf(data.look);
111
+ if (look !== undefined)
112
+ wearHostLook(look);
113
+ });
114
+ window.parent.postMessage({ source: 'iosense-app', type: 'look-request', v: 1 }, '*');
115
+ }
package/dist/index.d.ts CHANGED
@@ -13,4 +13,5 @@ export { useLatest, useSeries, useUser, type LatestOptions, type LatestResult, t
13
13
  export { fetchLatest, fetchSeries, isSampleTopic, type SeriesPoint } from './data.js';
14
14
  export { request, useRequest, RequestError, isIOSenseHost, type RequestOptions, type UseRequestOptions, type UseRequestResult } from './request.js';
15
15
  export { configure, getConfig, type IOSenseConfig } from './config.js';
16
- export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded } from './auth.js';
16
+ export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded, requestHostSession, isTrustedHostOrigin } from './auth.js';
17
+ export { startHostLook, wearHostLook, type HostLook } from './host-look.js';
package/dist/index.js CHANGED
@@ -13,4 +13,5 @@ export { useLatest, useSeries, useUser } from './hooks.js';
13
13
  export { fetchLatest, fetchSeries, isSampleTopic } from './data.js';
14
14
  export { request, useRequest, RequestError, isIOSenseHost } from './request.js';
15
15
  export { configure, getConfig } from './config.js';
16
- export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded } from './auth.js';
16
+ export { adoptHostSession, exchangeSsoToken, getToken, setToken, clearToken, isSsoToken, isEmbedded, requestHostSession, isTrustedHostOrigin } from './auth.js';
17
+ export { startHostLook, wearHostLook } from './host-look.js';
@@ -2,8 +2,8 @@ import type { AuthContext, JournalProvider, LinkProvider, OntologySpatialResolve
2
2
  import { UnsSpatialProvider } from '../../vendor/map-provider-uns/index.js';
3
3
  import { type MockRecord } from '../../vendor/map-engine/testing/index.js';
4
4
  export interface UnsMapSourceOptions {
5
- /** The UNS workspace id (`ws_…`) whose spatial nodes the map shows. */
6
- workspace: string;
5
+ /** The UNS workspace id (`ws_…`) whose spatial nodes the map shows. None: the map shows `sample`. */
6
+ workspace?: string;
7
7
  /** Shown as the datasets' source. */
8
8
  workspaceName?: string;
9
9
  /** The UNS backend. Default: `configure({ unsBaseUrl })`, else worked out from the session's `apiBaseUrl`. */
@@ -11,8 +11,10 @@ export interface UnsMapSourceOptions {
11
11
  /** How often live values are read. Default 3000 ms. */
12
12
  pollMs?: number;
13
13
  /**
14
- * What the map shows while the app is on sample data (nobody signed in with `auth="optional"`). Give each record
15
- * the `ontologyType` your layers ask for. Default: nothing — the layers stay empty.
14
+ * What the map shows when there is no workspace, or while the app is on sample data (nobody signed in with
15
+ * `auth="optional"`). Give each record the `ontologyType` your layers ask for. A new array moves the map's
16
+ * objects to it (added, changed and removed by `objectId`), so simulated live data is just a new array.
17
+ * Default: nothing — the layers stay empty.
16
18
  */
17
19
  sample?: Array<MockRecord | SpatialObject>;
18
20
  }
@@ -1,4 +1,4 @@
1
- import { useMemo, useRef } from 'react';
1
+ import { useEffect, useMemo, useRef } from 'react';
2
2
  import { UnsClient, UnsSpatialProvider } from '../../vendor/map-provider-uns/index.js';
3
3
  import { MockOntologyResolver, MockSpatialProvider } from '../../vendor/map-engine/testing/index.js';
4
4
  import { getConfig } from '../config.js';
@@ -8,12 +8,13 @@ export function unsBaseUrlFor(apiBaseUrl) {
8
8
  const m = /^(https?:\/\/)appserver\.(.+?)\/?$/.exec(apiBaseUrl);
9
9
  return m ? `${m[1]}uns-backend-server.${m[2]}` : 'https://uns-backend-server.iosense.io';
10
10
  }
11
- function sampleOntology(records) {
12
- const out = {};
13
- for (const r of records)
14
- if ('object' in r && r.ontologyType)
15
- out[r.ontologyType] = r.object.geometry.type;
16
- return out;
11
+ const recordOf = (r) => ('object' in r ? r : { object: r });
12
+ /** Classes and their geometry, written into `into` — the resolver keeps that object, so later records count too. */
13
+ function learnOntology(records, into) {
14
+ for (const r of records.map(recordOf))
15
+ if (r.ontologyType)
16
+ into[r.ontologyType] = r.object.geometry.type;
17
+ return into;
17
18
  }
18
19
  /**
19
20
  * The UNS as a map data source, on the viewer's session: the token is read on every request, so a refreshed
@@ -24,18 +25,21 @@ function sampleOntology(records) {
24
25
  * pass-through that allows it.
25
26
  */
26
27
  export function useUnsMapSource(options) {
27
- const { token, apiBaseUrl, sample } = useIOSense();
28
+ const session = useIOSense();
29
+ const { token, apiBaseUrl } = session;
30
+ const sample = session.sample || !options.workspace;
28
31
  const tokenRef = useRef(token);
29
32
  tokenRef.current = token;
30
33
  const base = (options.unsBaseUrl || getConfig().unsBaseUrl || unsBaseUrlFor(apiBaseUrl)).replace(/\/$/, '');
31
- // Sample records are read once per switch into sample mode; a new array each render does not rebuild the map.
34
+ // The map is built once per switch into sample mode; later arrays are applied to it (below), never rebuilt.
32
35
  const sampleRef = useRef(options.sample);
33
36
  sampleRef.current = options.sample;
34
37
  const built = useMemo(() => {
35
38
  if (sample) {
36
39
  const records = sampleRef.current ?? [];
40
+ const types = learnOntology(records, {});
37
41
  const mock = new MockSpatialProvider(records, { latencyMs: 0 });
38
- return { data: mock, ontology: new MockOntologyResolver(sampleOntology(records)), uns: null };
42
+ return { data: mock, mock, types, applied: records, ontology: new MockOntologyResolver(types), uns: null };
39
43
  }
40
44
  const client = new UnsClient({ baseUrl: base, token: () => tokenRef.current });
41
45
  const uns = new UnsSpatialProvider({
@@ -44,8 +48,23 @@ export function useUnsMapSource(options) {
44
48
  ...(options.workspaceName ? { workspaceName: options.workspaceName } : {}),
45
49
  ...(options.pollMs ? { pollMs: options.pollMs } : {}),
46
50
  });
47
- return { data: uns, ontology: uns.ontology, uns };
51
+ return { data: uns, mock: null, types: {}, applied: [], ontology: uns.ontology, uns };
48
52
  }, [sample, base, options.workspace, options.workspaceName, options.pollMs]);
53
+ // A new sample array: what changed goes to the map as a live update.
54
+ useEffect(() => {
55
+ const next = options.sample;
56
+ if (!built.mock || !next || next === built.applied)
57
+ return;
58
+ const before = new Set(built.applied.map((r) => recordOf(r).object.objectId));
59
+ learnOntology(next, built.types);
60
+ for (const r of next.map(recordOf)) {
61
+ before.delete(r.object.objectId);
62
+ void built.mock.upsert(r.object, r.ontologyType ? { ontologyType: r.ontologyType } : undefined);
63
+ }
64
+ for (const id of before)
65
+ void built.mock.remove(id);
66
+ built.applied = next;
67
+ }, [built, options.sample]);
49
68
  // No dispose on unmount: polling stops by itself when the map's runtime unsubscribes, and React's dev double
50
69
  // effects would otherwise cut a live map's subscription.
51
70
  const auth = useMemo(() => (token && !sample ? { token } : null), [token, sample]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faclon-labs/app-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "IOSense sign-in, plant data and maps for React apps: the launchpad SSO session, UNS latest values and series, the signed-in user, requests to IOSense's APIs, and the Map Engine on UNS (./map).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/vendor/SOURCE.txt CHANGED
@@ -1 +1 @@
1
- Built from MapEngine bcf2a34 — do not edit; run npm run vendor.
1
+ Built from MapEngine c24b05d — do not edit; run npm run vendor.