@lotics/app-sdk 0.40.0 → 0.41.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/AGENTS.md CHANGED
@@ -78,8 +78,9 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
78
78
  keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
79
79
  view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
80
80
  **`urlParam`** codecs. See *Save view-state to the URL*.
81
- - **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config so
82
- screens become real, addressable URLs in both modes (browser back/forward walk app screens embedded).
81
+ - **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config;
82
+ the app owns its own url (embedded, that's the iframe's own url never the host's), so navigation never
83
+ reloads the app, browser back/forward walk app screens, and screens stay shareable + refresh-survivable.
83
84
  See *In-app navigation*.
84
85
  - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
85
86
  query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
@@ -301,14 +302,18 @@ export default function App() {
301
302
 
302
303
  Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
303
304
 
304
- - **Embedded** (in the Lotics host): the current screen lives in the host address bar (`?_loc=…`)
305
- shareable + refresh-survivable and the **browser Back/Forward buttons walk app screens** (then leave the
306
- app). The host owns the single history stack; the adapter proxies push/replace/go over the bridge.
307
- - **Standalone** (`<slug>.lotics.app`): a normal browser router real path URLs, native browser back/forward.
308
-
309
- `useUrlState` is the complement for *filters/search* you want as first-class, typed query keys; it composes
310
- with the router (different keys; the host merge preserves both). `react-router-dom` is an optional peer — only
311
- apps that import `@lotics/app-sdk/router` pull it in.
305
+ - **Embedded** (in the Lotics host): the app drives the **iframe's own url** (same-origin to itself) via
306
+ `pushState`. The user sees the host's address bar, never the iframe's, so the screen url is invisible and
307
+ the host never sees it, so navigation never reloads the app. The iframe's history participates in the
308
+ session history, so the **browser Back/Forward buttons walk app screens** (then leave the app). `AppRouter`
309
+ also mirrors the current screen into the host url (a non-remounting write) and the host bakes it back into
310
+ the iframe src on load, so a screen is **shareable and survives a full refresh** no reload, no extra code.
311
+ - **Standalone** (`<slug>.lotics.app`): a normal browser router real path URLs, native browser back/forward,
312
+ refresh-survivable.
313
+
314
+ `useUrlState` is the complement: in-app *routing* (screens) is automatic via `AppRouter`; reach for
315
+ `useUrlState` for *filters/search* you want as first-class, typed, shareable query keys. `react-router-dom` is
316
+ an optional peer — only apps that import `@lotics/app-sdk/router` pull it in.
312
317
 
313
318
  ---
314
319
 
@@ -1,17 +1,29 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * In-app routing for custom-code apps — `AppRouter` lets an app use react-router
4
4
  * normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
5
- * addressable URLs in both modes:
5
+ * addressable URLs. The app owns its OWN url (a plain browser history) in both
6
+ * modes; the host url only ever *mirrors* the screen, it never drives the router:
6
7
  *
7
- * - **Standalone** (`<slug>.lotics.app`): a normal browser history — real path
8
- * URLs, native browser back/forward, deep-link/refresh via the app host's SPA
9
- * fallback.
10
- * - **Embedded** (inside the Lotics host): the app can't touch the cross-origin
11
- * host URL, so a custom history proxies every push/replace/go to the host over
12
- * the bridge. The host owns the single history stack, so the screen lives in
13
- * its address bar (`?_loc=…`) shareable + refresh-survivable and the
14
- * browser Back/Forward buttons walk app screens (then leave the app).
8
+ * - **Standalone** (`<slug>.lotics.app`): the page's own browser history — real
9
+ * path URLs, native browser back/forward, deep-link/refresh via the app host's
10
+ * SPA fallback.
11
+ * - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
12
+ * (the iframe is same-origin to itself) via `pushState`. The user sees the
13
+ * host's address bar, never the iframe's, and the host never sees the iframe's
14
+ * url so it never navigates and never remounts the iframe. (Reflecting the
15
+ * screen into the host url with a *navigation* is what used to remount the app
16
+ * iframe and reload the whole app on every in-app navigation.) The iframe's
17
+ * history still participates in the session history, so browser Back/Forward
18
+ * walk app screens (then leave the app).
19
+ *
20
+ * To stay shareable + refresh-survivable, `AppRouter` *mirrors* the current
21
+ * screen into the host url under `_loc` via `setUrlParams` — a non-remounting
22
+ * `history.replaceState` on the host, never a navigation. The READ half is the
23
+ * host's job: on (re)load it bakes `_loc` into the iframe src, so the app boots
24
+ * *directly* at the saved screen. There's deliberately no async seed here —
25
+ * that would race the app's own first navigation; the iframe's initial url is
26
+ * the source of truth, set synchronously by the host.
15
27
  *
16
28
  * Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
17
29
  * never pull react-router into their bundle:
@@ -24,103 +36,44 @@ import { jsx as _jsx } from "react/jsx-runtime";
24
36
  * ]} />;
25
37
  * }
26
38
  */
27
- import { useState } from "react";
28
- import { NavigationType, UNSAFE_createBrowserHistory, unstable_HistoryRouter as HistoryRouter, useRoutes, } from "react-router-dom";
29
- import { getUrlParams, goUrlHistory, isEmbedded, setUrlParams, subscribeUrlParams } from "./rpc.js";
30
- /** Host query key carrying the app's current screen when embedded, so it's
31
- * shareable and survives refresh. */
39
+ import { useEffect } from "react";
40
+ import { BrowserRouter, useLocation, useRoutes, } from "react-router-dom";
41
+ import { isEmbedded, setUrlParams } from "./rpc.js";
42
+ /** Host query key carrying the app's current screen, so it's shareable and the
43
+ * host can restore it on refresh. */
32
44
  const LOC_KEY = "_loc";
33
- function hrefOf(to) {
34
- if (typeof to === "string")
35
- return to || "/";
36
- return (to.pathname ?? "/") + (to.search ?? "") + (to.hash ?? "");
37
- }
38
- let locKey = 0;
39
- function toLocation(href) {
40
- const u = new URL(href || "/", "http://x");
41
- return { pathname: u.pathname, search: u.search, hash: u.hash, state: null, key: String(++locKey) };
45
+ /** The host handshake param the host puts on the iframe src — present on the
46
+ * initial url only, never part of a route, so it's stripped from the mirror. */
47
+ const HOST_KEY = "lotics_host";
48
+ function screenHref(loc) {
49
+ const search = new URLSearchParams(loc.search);
50
+ search.delete(HOST_KEY);
51
+ const qs = search.toString();
52
+ return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
42
53
  }
43
54
  /**
44
- * Embedded history: the host owns the single history stack.
45
- * - `push`/`replace` optimistically update the local location and tell the host
46
- * (pushState/replaceState don't fire popstate, so no echo back).
47
- * - `go` (and the browser Back/Forward buttons) move the host history; the
48
- * resulting location arrives via the `url-state` broadcast as an action POP.
55
+ * Embedded only: mirror the current screen into the host url under `_loc` — a
56
+ * non-remounting `setUrlParams` `history.replaceState`, never a navigation, so
57
+ * it never reloads the app. Write-only by design: the host reads `_loc` back and
58
+ * bakes it into the iframe src on (re)load, so the app already boots at the saved
59
+ * screen no async read here, hence no seed-vs-navigation race. Standalone needs
60
+ * none of this — the app's own url already IS the screen.
49
61
  */
50
- function createBridgedHistory() {
51
- const listeners = new Set();
52
- let location = toLocation("/");
53
- let action = NavigationType.Pop;
54
- // Once the location has been driven — by the app (push/replace) or the host
55
- // (a back/forward broadcast) — a late boot-seed must not clobber it; the
56
- // bridged read of the host URL resolves a tick after mount.
57
- let seeded = false;
58
- // `delta` is history metadata (scroll restoration / blockers), not used for
59
- // rendering — best-effort per action, since the host doesn't report the exact
60
- // index change on a broadcast.
61
- function set(href, act, delta) {
62
- seeded = true;
63
- location = toLocation(href);
64
- action = act;
65
- for (const l of listeners)
66
- l({ action, location, delta });
67
- }
68
- // Host back/forward (browser Back or our own `go`) → re-render to the broadcast
69
- // location. Guarded so an already-current location doesn't re-fire.
70
- subscribeUrlParams((params) => {
71
- const raw = params[LOC_KEY];
72
- const href = typeof raw === "string" ? raw : "/";
73
- if (href !== hrefOf(location))
74
- set(href, NavigationType.Pop, -1);
75
- });
76
- // Boot: seed from the host URL (resolves a tick after mount — first paint is
77
- // "/"). Skip if the location was already driven, so a navigation that races the
78
- // bridge read isn't clobbered.
79
- void getUrlParams().then((params) => {
80
- if (seeded)
81
- return;
82
- const raw = params[LOC_KEY];
83
- if (typeof raw === "string" && raw !== hrefOf(location))
84
- set(raw, NavigationType.Pop, 0);
85
- });
86
- return {
87
- get action() {
88
- return action;
89
- },
90
- get location() {
91
- return location;
92
- },
93
- createHref: (to) => hrefOf(to),
94
- createURL: (to) => new URL(hrefOf(to), window.location.origin),
95
- encodeLocation: (to) => {
96
- const u = new URL(hrefOf(to), "http://x");
97
- return { pathname: u.pathname, search: u.search, hash: u.hash };
98
- },
99
- push: (to) => {
100
- const href = hrefOf(to);
101
- set(href, NavigationType.Push, 1);
102
- void setUrlParams({ [LOC_KEY]: href }, true);
103
- },
104
- replace: (to) => {
105
- const href = hrefOf(to);
106
- set(href, NavigationType.Replace, 0);
107
- void setUrlParams({ [LOC_KEY]: href }, false);
108
- },
109
- go: (delta) => {
110
- void goUrlHistory(delta);
111
- },
112
- listen: (listener) => {
113
- listeners.add(listener);
114
- return () => listeners.delete(listener);
115
- },
116
- };
62
+ function HostScreenMirror() {
63
+ const location = useLocation();
64
+ const href = screenHref(location);
65
+ useEffect(() => {
66
+ void setUrlParams({ [LOC_KEY]: href });
67
+ }, [href]);
68
+ return null;
117
69
  }
118
70
  function RoutedRoutes({ routes }) {
119
71
  return useRoutes(routes);
120
72
  }
121
73
  export function AppRouter({ routes }) {
122
- // Created once (a lazy state initializer survives StrictMode's double render,
123
- // so the bridge subscription isn't installed twice).
124
- const [history] = useState(() => isEmbedded() ? createBridgedHistory() : UNSAFE_createBrowserHistory());
125
- return (_jsx(HistoryRouter, { history: history, children: _jsx(RoutedRoutes, { routes: routes }) }));
74
+ // `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
75
+ // it's known synchronously at first render.
76
+ const embedded = isEmbedded();
77
+ return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: routes })
78
+ ] }));
126
79
  }
package/dist/src/rpc.d.ts CHANGED
@@ -19,7 +19,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
19
19
  * app → host: { id, op, payload }
20
20
  * host → app: { id, type: "result", data } | { id, type: "error", message }
21
21
  */
22
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "urlState.go" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
+ export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
23
23
  /** Payload for starting a streaming agent run. */
24
24
  export interface AgentRunPayload {
25
25
  alias: string;
@@ -60,8 +60,8 @@ export interface AppContext {
60
60
  }
61
61
  /**
62
62
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
63
- * own `<slug>.lotics.app`). The router adapter (`@lotics/app-sdk/router`) uses
64
- * this to pick its history backend; an app rarely needs it directly.
63
+ * own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
64
+ * pick the transport / behaviour; an app rarely needs it directly.
65
65
  */
66
66
  export declare function isEmbedded(): boolean;
67
67
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
@@ -69,20 +69,15 @@ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T
69
69
  * the page's own query string. */
70
70
  export declare function getUrlParams(): Promise<UrlParams>;
71
71
  /** Merge `patch` into the host's address bar (each key set, or cleared when its
72
- * value is `undefined`), preserving every other param. `push` adds a back-able
73
- * history entry the router adapter uses it per in-app navigation; the default
74
- * replaces in place (filter changes shouldn't flood history). */
75
- export declare function setUrlParams(patch: UrlParamsPatch, push?: boolean): Promise<void>;
76
- /** Move the host's history by `delta` (the router adapter's back/forward). The
77
- * resulting location arrives via the `url-state` broadcast, not the return. */
78
- export declare function goUrlHistory(delta: number): Promise<void>;
72
+ * value is `undefined`), preserving every other param. The host writes in place
73
+ * (`history.replaceState`)view-state changes don't add history entries. */
74
+ export declare function setUrlParams(patch: UrlParamsPatch): Promise<void>;
79
75
  /** Synchronous best-effort snapshot for first paint. Standalone reads its own
80
76
  * URL (no flash); bridged can't read the cross-origin host URL synchronously,
81
77
  * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
82
78
  export declare function peekUrlParams(): UrlParams;
83
- /** Subscribe to external query changes — back/forward (browser or the adapter's
84
- * `go`) and edited URLs. Embedded: the host's `url-state` broadcast; standalone:
85
- * `popstate`. */
79
+ /** Subscribe to external query changes — browser back/forward and edited URLs.
80
+ * Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
86
81
  export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
87
82
  /**
88
83
  * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
package/dist/src/rpc.js CHANGED
@@ -20,8 +20,8 @@ function getHostOrigin() {
20
20
  }
21
21
  /**
22
22
  * Whether the app is running embedded in a Lotics host (vs. standalone at its
23
- * own `<slug>.lotics.app`). The router adapter (`@lotics/app-sdk/router`) uses
24
- * this to pick its history backend; an app rarely needs it directly.
23
+ * own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
24
+ * pick the transport / behaviour; an app rarely needs it directly.
25
25
  */
26
26
  export function isEmbedded() {
27
27
  // Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
@@ -35,31 +35,28 @@ export function rpc(op, payload) {
35
35
  ? rpcBridged(op, payload, hostOrigin)
36
36
  : rpcStandalone(op, payload);
37
37
  }
38
- // ── URL state (the address bar as app view-state + route) ───────────────────
38
+ // ── URL state (the host address bar as shareable app view-state + screen) ────
39
39
  //
40
- // Two consumers share these ops: `useUrlState` (a declared, typed slice of
41
- // filters/search) and the router adapter (`@lotics/app-sdk/router`, the app's
42
- // current screen under `_loc`). An embedded app can't touch the cross-origin
43
- // host URL directly, so reads/writes/back-forward flow over the bridge: the host
44
- // drives its own history (`set` with `push`, `go` for back/forward) and pushes
45
- // the resulting params back via the `url-state` broadcast. A standalone app owns
46
- // its top-level URL and the same ops resolve against `window.location`.
40
+ // Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
41
+ // `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
42
+ // state in the host's address bar so it survives refresh and is shareable. An
43
+ // embedded app can't touch the cross-origin host URL directly, so reads/writes
44
+ // flow over the bridge: the host writes the params in place (a non-remounting
45
+ // `history.replaceState` never a navigation, which would reload the iframe) and
46
+ // pushes external changes (browser back/forward) back via the `url-state`
47
+ // broadcast. A standalone app owns its top-level URL and the same ops resolve
48
+ // against `window.location`. (In-app *routing* is not here — the app owns its own
49
+ // url via `@lotics/app-sdk/router`; see that module.)
47
50
  /** Read the current app-owned query params — bridged: ask the host; standalone:
48
51
  * the page's own query string. */
49
52
  export function getUrlParams() {
50
53
  return rpc("urlState.get", {});
51
54
  }
52
55
  /** Merge `patch` into the host's address bar (each key set, or cleared when its
53
- * value is `undefined`), preserving every other param. `push` adds a back-able
54
- * history entry the router adapter uses it per in-app navigation; the default
55
- * replaces in place (filter changes shouldn't flood history). */
56
- export function setUrlParams(patch, push = false) {
57
- return rpc("urlState.set", { params: patch, push });
58
- }
59
- /** Move the host's history by `delta` (the router adapter's back/forward). The
60
- * resulting location arrives via the `url-state` broadcast, not the return. */
61
- export function goUrlHistory(delta) {
62
- return rpc("urlState.go", { delta });
56
+ * value is `undefined`), preserving every other param. The host writes in place
57
+ * (`history.replaceState`)view-state changes don't add history entries. */
58
+ export function setUrlParams(patch) {
59
+ return rpc("urlState.set", { params: patch });
63
60
  }
64
61
  /** Synchronous best-effort snapshot for first paint. Standalone reads its own
65
62
  * URL (no flash); bridged can't read the cross-origin host URL synchronously,
@@ -67,9 +64,8 @@ export function goUrlHistory(delta) {
67
64
  export function peekUrlParams() {
68
65
  return isEmbedded() ? {} : parseSearch(window.location.search);
69
66
  }
70
- /** Subscribe to external query changes — back/forward (browser or the adapter's
71
- * `go`) and edited URLs. Embedded: the host's `url-state` broadcast; standalone:
72
- * `popstate`. */
67
+ /** Subscribe to external query changes — browser back/forward and edited URLs.
68
+ * Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
73
69
  export function subscribeUrlParams(cb) {
74
70
  if (isEmbedded()) {
75
71
  ensureListener();
@@ -84,8 +80,8 @@ export function subscribeUrlParams(cb) {
84
80
  }
85
81
  const pending = new Map();
86
82
  const streaming = new Map();
87
- /** `useUrlState` + the router adapter — notified when the host broadcasts new
88
- * params after browser/adapter back/forward. */
83
+ /** `useUrlState` subscribers — notified when the host broadcasts new params
84
+ * after browser back/forward. */
89
85
  const urlStateSubscribers = new Set();
90
86
  let nextRpcId = 0;
91
87
  let listenerInstalled = false;
@@ -100,8 +96,8 @@ function ensureListener() {
100
96
  const msg = event.data;
101
97
  if (!msg)
102
98
  return;
103
- // Broadcast (no id): the host pushes new params after browser/adapter
104
- // back/forward so subscribers (`useUrlState`, the router adapter) re-hydrate.
99
+ // Broadcast (no id): the host pushes new params after browser back/forward
100
+ // so `useUrlState` subscribers re-hydrate.
105
101
  if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
106
102
  for (const cb of urlStateSubscribers)
107
103
  cb(msg.params);
@@ -404,12 +400,6 @@ function rpcStandalone(op, payload) {
404
400
  return Promise.resolve(parseSearch(window.location.search));
405
401
  case "urlState.set":
406
402
  return standaloneUrlStateSet(payload);
407
- case "urlState.go": {
408
- // Standalone owns its own history — move it; popstate re-hydrates subscribers.
409
- const { delta } = payload;
410
- window.history.go(delta);
411
- return Promise.resolve();
412
- }
413
403
  case "comments.list":
414
404
  case "comments.create":
415
405
  case "comments.update":
@@ -461,12 +451,9 @@ async function standaloneOpenExternal(p) {
461
451
  async function standaloneUrlStateSet(p) {
462
452
  const search = serializeMerge(window.location.search, p.params ?? {});
463
453
  const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
464
- // pushState/replaceState don't fire popstate, so no echo the caller updated
465
- // optimistically on write.
466
- if (p.push)
467
- window.history.pushState(null, "", url);
468
- else
469
- window.history.replaceState(null, "", url);
454
+ // View-state writes never add a history entry; replaceState doesn't fire
455
+ // popstate, so there's no echo — the caller already updated optimistically.
456
+ window.history.replaceState(null, "", url);
470
457
  }
471
458
  async function standaloneContext() {
472
459
  const info = await resolveAppInfo();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {