@lotics/app-sdk 0.38.0 → 0.40.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
@@ -74,6 +74,13 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
74
74
  client-side advisory — pass `coords` to a workflow to record where an action happened.
75
75
  - **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
76
76
  (web-only, which is why it's here, not in `@lotics/ui`). Feeds a `Combobox`'s `recentOptions`.
77
+ - **Shareable view-state (filters in the URL)** — **`useUrlState(shape)`** → `[values, setValues]`,
78
+ keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
79
+ view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
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).
83
+ See *In-app navigation*.
77
84
  - **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
78
85
  query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
79
86
  - **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
@@ -144,8 +151,13 @@ compose these, don't hand-roll search:
144
151
  - **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
145
152
 
146
153
  Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
147
- `has_any_of [record_id]`) — never a bare full-table load. Filtering a record by its *own* id isn't a
148
- compilable AST shape; filter by a unique field value.
154
+ `has_any_of [record_id]`) — never a bare full-table load. To fetch a record by its **own id**, use the
155
+ field-less **`record_id` system condition** (a sibling of `locked`/`current_member`, NOT a `field_key`):
156
+ `useQuery(alias, {}, { filter: { node_type: "condition", type: "record_id", operator: "is_any_of", value:
157
+ [id] } })` (`is_none_of` excludes). Works on any row-level query; a *grouped* query collapses rows so it's
158
+ rejected there. A link `has_any_of [record_id]` matches a *related* record; `record_id` matches the row's
159
+ own id — the only way, since a record has no field holding its own id. **Prefer a link/join when an actual
160
+ relationship exists**; reach for `record_id` only when your starting point is a bare id (e.g. a drill row).
149
161
 
150
162
  ## Browse + sort + filter (the record picker)
151
163
 
@@ -241,6 +253,63 @@ stops constraining instead of erroring (no-op for all-required queries).
241
253
  independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
242
254
  can't widen — deploy rejects a `{{params.x}}` with no declared param.
243
255
 
256
+ ## Save view-state to the URL (`useUrlState`)
257
+
258
+ The filters above are the query's *server* params; **`useUrlState`** is their *client* home — save them to
259
+ the host's address bar and a filtered view survives refresh and is shareable/bookmarkable as a link. Writes
260
+ always **replace** in place (filter changes shouldn't add history entries); screen-to-screen navigation and
261
+ history are the router's job (next section). The hook drives the host's URL over the bridge; the app can't
262
+ touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
263
+
264
+ ```tsx
265
+ const [filters, setFilters] = useUrlState({
266
+ q: urlParam.string.withDefault(""),
267
+ status: urlParam.enum(["open", "won", "lost"]), // optional → omitted when unset
268
+ tags: urlParam.arrayOf(urlParam.string).withDefault([]),
269
+ page: urlParam.number.withDefault(1),
270
+ });
271
+ // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
272
+ const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
273
+ setFilters({ status: "won" }); // merge into the address bar → ?status=won
274
+ setFilters({ page: 2 }); // merge (replace; no history entry)
275
+ ```
276
+
277
+ - **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
278
+ each `.withDefault(v)` to make it required. **Defaults are omitted from the URL** (`page=1` never
279
+ appears) — links carry only what changed.
280
+ - **The URL is the only store** — `filters` is decoded fresh each render; don't mirror it into `useState`.
281
+ - **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
282
+ `useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
283
+ - **Search box:** keep the live input in local `useState` and commit to `setFilters` on a **debounce** —
284
+ in an embedded app each `setFilters` is a cross-frame write.
285
+
286
+ ## In-app navigation (`AppRouter`)
287
+
288
+ For a multi-screen app, write plain react-router and wrap your routes in **`AppRouter`** (from
289
+ `@lotics/app-sdk/router`) — it makes screens real, addressable URLs in both modes, with no per-mode code:
290
+
291
+ ```tsx
292
+ import { AppRouter } from "@lotics/app-sdk/router";
293
+
294
+ export default function App() {
295
+ return <AppRouter routes={[
296
+ { path: "/", element: <List /> },
297
+ { path: "/item/:id", element: <Detail /> },
298
+ ]} />;
299
+ }
300
+ ```
301
+
302
+ Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
303
+
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.
312
+
244
313
  ---
245
314
 
246
315
  ## Recipes (app actions beyond the hooks)
@@ -23,7 +23,7 @@ export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs, Commen
23
23
  export { useViewer } from "./viewer.js";
24
24
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
25
25
  export type { GeofenceZone, GeoCoords, GeofenceOutcome, GeofenceOptions } from "./geolocation.js";
26
- export { rpc } from "./rpc.js";
26
+ export { rpc, isEmbedded } from "./rpc.js";
27
27
  export type { RpcOp } from "./rpc.js";
28
28
  export { openExternal } from "./open_external.js";
29
29
  export { downloadFile } from "./download.js";
@@ -39,3 +39,7 @@ export { useOptimistic } from "./use_optimistic.js";
39
39
  export type { OptimisticApi } from "./use_optimistic.js";
40
40
  export { useRecents } from "./use_recents.js";
41
41
  export type { RecentsApi, RecentsOptions } from "./use_recents.js";
42
+ export { useUrlState } from "./use_url_state.js";
43
+ export type { UrlStateShape, UrlStateValues } from "./use_url_state.js";
44
+ export { urlParam } from "./url_params.js";
45
+ export type { UrlParamCodec, OptionalUrlParamCodec, UrlParams, UrlParamValue, } from "./url_params.js";
package/dist/src/index.js CHANGED
@@ -19,7 +19,7 @@ export { useWorkflow, useQuery, useInfiniteQuery, usePaginatedQuery, useFieldOpt
19
19
  export { useComments, useCommentCounts } from "./comments.js";
20
20
  export { useViewer } from "./viewer.js";
21
21
  export { requestGeofencedLocation, isWithinZone } from "./geolocation.js";
22
- export { rpc } from "./rpc.js";
22
+ export { rpc, isEmbedded } from "./rpc.js";
23
23
  export { openExternal } from "./open_external.js";
24
24
  export { downloadFile } from "./download.js";
25
25
  export { readMembers } from "./members.js";
@@ -27,3 +27,5 @@ export { readSelect } from "./select.js";
27
27
  export { row, readLinks, readFiles, readLocked } from "./row.js";
28
28
  export { useOptimistic } from "./use_optimistic.js";
29
29
  export { useRecents } from "./use_recents.js";
30
+ export { useUrlState } from "./use_url_state.js";
31
+ export { urlParam } from "./url_params.js";
@@ -0,0 +1,4 @@
1
+ import { type RouteObject } from "react-router-dom";
2
+ export declare function AppRouter({ routes }: {
3
+ routes: RouteObject[];
4
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,126 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * In-app routing for custom-code apps — `AppRouter` lets an app use react-router
4
+ * normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
5
+ * addressable URLs in both modes:
6
+ *
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).
15
+ *
16
+ * Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
17
+ * never pull react-router into their bundle:
18
+ *
19
+ * import { AppRouter } from "@lotics/app-sdk/router";
20
+ * export default function App() {
21
+ * return <AppRouter routes={[
22
+ * { path: "/", element: <List /> },
23
+ * { path: "/item/:id", element: <Detail /> },
24
+ * ]} />;
25
+ * }
26
+ */
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. */
32
+ 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) };
42
+ }
43
+ /**
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.
49
+ */
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
+ };
117
+ }
118
+ function RoutedRoutes({ routes }) {
119
+ return useRoutes(routes);
120
+ }
121
+ 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 }) }));
126
+ }
package/dist/src/rpc.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
1
2
  /**
2
3
  * RPC bridge for a custom-code app's data operations.
3
4
  *
@@ -18,7 +19,7 @@
18
19
  * app → host: { id, op, payload }
19
20
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
21
  */
21
- export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "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" | "urlState.go" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
22
23
  /** Payload for starting a streaming agent run. */
23
24
  export interface AgentRunPayload {
24
25
  alias: string;
@@ -57,7 +58,32 @@ export interface AppContext {
57
58
  */
58
59
  comments_enabled: boolean;
59
60
  }
61
+ /**
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.
65
+ */
66
+ export declare function isEmbedded(): boolean;
60
67
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
68
+ /** Read the current app-owned query params — bridged: ask the host; standalone:
69
+ * the page's own query string. */
70
+ export declare function getUrlParams(): Promise<UrlParams>;
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>;
79
+ /** Synchronous best-effort snapshot for first paint. Standalone reads its own
80
+ * URL (no flash); bridged can't read the cross-origin host URL synchronously,
81
+ * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
82
+ 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`. */
86
+ export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
61
87
  /**
62
88
  * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
63
89
  * (the caller parses it via `agent_stream`); `done` settles when the stream
package/dist/src/rpc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { promptForPassword } from "./password_gate.js";
2
2
  import { runUploadPipeline } from "./upload/pipeline.js";
3
+ import { parseSearch, serializeMerge, } from "./url_params.js";
3
4
  /**
4
5
  * The embedding Lotics host's origin — present iff the app is bridged.
5
6
  *
@@ -17,14 +18,75 @@ function getHostOrigin() {
17
18
  }
18
19
  return hostOriginCache;
19
20
  }
21
+ /**
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.
25
+ */
26
+ export function isEmbedded() {
27
+ // Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
28
+ // which is no usable host origin — so it's standalone, matching how `rpc()`
29
+ // branches transports below.
30
+ return Boolean(getHostOrigin());
31
+ }
20
32
  export function rpc(op, payload) {
21
33
  const hostOrigin = getHostOrigin();
22
34
  return hostOrigin
23
35
  ? rpcBridged(op, payload, hostOrigin)
24
36
  : rpcStandalone(op, payload);
25
37
  }
38
+ // ── URL state (the address bar as app view-state + route) ───────────────────
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`.
47
+ /** Read the current app-owned query params — bridged: ask the host; standalone:
48
+ * the page's own query string. */
49
+ export function getUrlParams() {
50
+ return rpc("urlState.get", {});
51
+ }
52
+ /** 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 });
63
+ }
64
+ /** Synchronous best-effort snapshot for first paint. Standalone reads its own
65
+ * URL (no flash); bridged can't read the cross-origin host URL synchronously,
66
+ * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
67
+ export function peekUrlParams() {
68
+ return isEmbedded() ? {} : parseSearch(window.location.search);
69
+ }
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`. */
73
+ export function subscribeUrlParams(cb) {
74
+ if (isEmbedded()) {
75
+ ensureListener();
76
+ urlStateSubscribers.add(cb);
77
+ return () => {
78
+ urlStateSubscribers.delete(cb);
79
+ };
80
+ }
81
+ const handler = () => cb(parseSearch(window.location.search));
82
+ window.addEventListener("popstate", handler);
83
+ return () => window.removeEventListener("popstate", handler);
84
+ }
26
85
  const pending = new Map();
27
86
  const streaming = new Map();
87
+ /** `useUrlState` + the router adapter — notified when the host broadcasts new
88
+ * params after browser/adapter back/forward. */
89
+ const urlStateSubscribers = new Set();
28
90
  let nextRpcId = 0;
29
91
  let listenerInstalled = false;
30
92
  function ensureListener() {
@@ -36,7 +98,16 @@ function ensureListener() {
36
98
  if (event.source !== window.parent || event.origin !== getHostOrigin())
37
99
  return;
38
100
  const msg = event.data;
39
- if (!msg || typeof msg.id !== "number")
101
+ if (!msg)
102
+ return;
103
+ // Broadcast (no id): the host pushes new params after browser/adapter
104
+ // back/forward so subscribers (`useUrlState`, the router adapter) re-hydrate.
105
+ if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
106
+ for (const cb of urlStateSubscribers)
107
+ cb(msg.params);
108
+ return;
109
+ }
110
+ if (typeof msg.id !== "number")
40
111
  return;
41
112
  // Single-response ops.
42
113
  const handler = pending.get(msg.id);
@@ -328,6 +399,17 @@ function rpcStandalone(op, payload) {
328
399
  return standaloneContext();
329
400
  case "openExternal":
330
401
  return standaloneOpenExternal(payload);
402
+ case "urlState.get":
403
+ // Standalone is a top-level page — its own query string IS the store.
404
+ return Promise.resolve(parseSearch(window.location.search));
405
+ case "urlState.set":
406
+ 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
+ }
331
413
  case "comments.list":
332
414
  case "comments.create":
333
415
  case "comments.update":
@@ -376,6 +458,16 @@ function openValidatedUrl(url) {
376
458
  async function standaloneOpenExternal(p) {
377
459
  openValidatedUrl(p.url);
378
460
  }
461
+ async function standaloneUrlStateSet(p) {
462
+ const search = serializeMerge(window.location.search, p.params ?? {});
463
+ 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);
470
+ }
379
471
  async function standaloneContext() {
380
472
  const info = await resolveAppInfo();
381
473
  return {
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Typed codecs that map URL query-string values (always strings, or string
3
+ * arrays for repeated keys) to and from the typed view-state an app keeps in
4
+ * the address bar — the engine behind `useUrlState`.
5
+ *
6
+ * Two properties make the URLs clean and the round-trip lossless:
7
+ *
8
+ * - **Default-omission.** A codec built with `.withDefault(d)` encodes the
9
+ * default value to `undefined` — i.e. the key is dropped from the URL. So a
10
+ * filter at its default (`page=1`, `q=""`) never appears, and the shared
11
+ * link carries only what the user actually changed.
12
+ * - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
13
+ * app declared. Every other query param (`lotics_host`, `__mock`, a param a
14
+ * second `useUrlState` owns, a future host param) is read past and preserved
15
+ * on write — the merge is the namespace boundary, so no reserved-prefix rule
16
+ * is needed.
17
+ *
18
+ * Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
19
+ * so it carries no workspace deps (no `@lotics/shared`). These are plain pure
20
+ * functions — a frontend that later wants the same value⇄query codec can import
21
+ * them from here rather than growing a second copy.
22
+ */
23
+ /** A query value as it appears in the URL: a single string, or an array for a
24
+ * repeated key (`?tag=a&tag=b`). Absent keys are simply missing from the map. */
25
+ export type UrlParamValue = string | string[];
26
+ /** The current query string decoded to a map (present keys only). */
27
+ export type UrlParams = Record<string, UrlParamValue>;
28
+ /** A write: each key set to its encoded value, or `undefined` to clear it. Only
29
+ * the keys present are touched; others in the URL are preserved (a merge). */
30
+ export type UrlParamsPatch = Record<string, UrlParamValue | undefined>;
31
+ /**
32
+ * A reversible mapping between a typed value `T` and its URL representation.
33
+ * Methods (not function-typed properties) so a concrete `UrlParamCodec<string>`
34
+ * stays assignable to `UrlParamCodec<unknown>` for the `useUrlState` codec map.
35
+ */
36
+ export interface UrlParamCodec<T> {
37
+ /** Raw query value (or `undefined` when the key is absent) → typed value. */
38
+ decode(raw: UrlParamValue | undefined): T;
39
+ /** Typed value → raw query value, or `undefined` to omit the key. */
40
+ encode(value: T): UrlParamValue | undefined;
41
+ }
42
+ /** A codec whose value is optional (absent key → `undefined`), refinable to a
43
+ * required codec with a fallback via `.withDefault`. */
44
+ export interface OptionalUrlParamCodec<T> extends UrlParamCodec<T | undefined> {
45
+ /** Make the value required: an absent key decodes to `fallback`, and a value
46
+ * equal to `fallback` encodes to nothing (kept out of the URL). */
47
+ withDefault(fallback: T): UrlParamCodec<T>;
48
+ }
49
+ declare function enumCodec<const V extends readonly string[]>(values: V): OptionalUrlParamCodec<V[number]>;
50
+ declare function arrayOf<T>(inner: UrlParamCodec<T | undefined>): OptionalUrlParamCodec<T[]>;
51
+ /**
52
+ * The codec builders an app composes into a `useUrlState` shape. Each base
53
+ * builder yields an optional codec (absent key → `undefined`); add
54
+ * `.withDefault(v)` to make it required and keep the default out of the URL.
55
+ *
56
+ * useUrlState({
57
+ * q: urlParam.string.withDefault(""),
58
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
59
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
60
+ * from: urlParam.isoDate, // optional Date
61
+ * page: urlParam.number.withDefault(1),
62
+ * })
63
+ */
64
+ export declare const urlParam: {
65
+ readonly string: OptionalUrlParamCodec<string>;
66
+ readonly number: OptionalUrlParamCodec<number>;
67
+ readonly boolean: OptionalUrlParamCodec<boolean>;
68
+ readonly isoDate: OptionalUrlParamCodec<Date>;
69
+ readonly enum: typeof enumCodec;
70
+ readonly arrayOf: typeof arrayOf;
71
+ };
72
+ /** Decode the declared keys out of a params map into typed values. */
73
+ export declare function decodeAll<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, params: UrlParams): {
74
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
75
+ };
76
+ /** Encode a partial set of declared values into a patch (each key → value or
77
+ * `undefined` to clear). Only the keys present in `values` are emitted, so the
78
+ * write merges and leaves every other query param untouched. */
79
+ export declare function encodePatch<D extends Record<string, UrlParamCodec<unknown>>>(defs: D, values: Partial<{
80
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
81
+ }>): UrlParamsPatch;
82
+ /** Parse a `location.search` string into a params map (repeated keys → array). */
83
+ export declare function parseSearch(search: string): UrlParams;
84
+ /** Apply a patch to a `location.search` string and return the new query string
85
+ * (no leading `?`). Each patch key is replaced; `undefined` clears it; every
86
+ * other existing param is preserved. */
87
+ export declare function serializeMerge(search: string, patch: UrlParamsPatch): string;
88
+ /** Apply a patch to an in-memory params map (the optimistic local mirror). */
89
+ export declare function applyPatch(params: UrlParams, patch: UrlParamsPatch): UrlParams;
90
+ /** Shallow value-equality over two params maps — used to drop echoed updates so
91
+ * an app's own write doesn't re-render it a second time. */
92
+ export declare function paramsEqual(a: UrlParams, b: UrlParams): boolean;
93
+ export {};
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Typed codecs that map URL query-string values (always strings, or string
3
+ * arrays for repeated keys) to and from the typed view-state an app keeps in
4
+ * the address bar — the engine behind `useUrlState`.
5
+ *
6
+ * Two properties make the URLs clean and the round-trip lossless:
7
+ *
8
+ * - **Default-omission.** A codec built with `.withDefault(d)` encodes the
9
+ * default value to `undefined` — i.e. the key is dropped from the URL. So a
10
+ * filter at its default (`page=1`, `q=""`) never appears, and the shared
11
+ * link carries only what the user actually changed.
12
+ * - **Declared keys only.** `decodeAll`/`encodePatch` touch exactly the keys the
13
+ * app declared. Every other query param (`lotics_host`, `__mock`, a param a
14
+ * second `useUrlState` owns, a future host param) is read past and preserved
15
+ * on write — the merge is the namespace boundary, so no reserved-prefix rule
16
+ * is needed.
17
+ *
18
+ * Self-contained on purpose: the SDK publishes to npm as a bundle-free `dist/`,
19
+ * so it carries no workspace deps (no `@lotics/shared`). These are plain pure
20
+ * functions — a frontend that later wants the same value⇄query codec can import
21
+ * them from here rather than growing a second copy.
22
+ */
23
+ function first(raw) {
24
+ return Array.isArray(raw) ? raw[0] : raw;
25
+ }
26
+ function valueEqual(a, b) {
27
+ if (a === b)
28
+ return true;
29
+ if (a instanceof Date && b instanceof Date)
30
+ return a.getTime() === b.getTime();
31
+ if (Array.isArray(a) && Array.isArray(b)) {
32
+ return a.length === b.length && a.every((x, i) => valueEqual(x, b[i]));
33
+ }
34
+ return false;
35
+ }
36
+ /** Wrap an optional base codec with `.withDefault`. */
37
+ function optional(base) {
38
+ return {
39
+ decode: base.decode,
40
+ encode: base.encode,
41
+ withDefault(fallback) {
42
+ return {
43
+ decode: (raw) => {
44
+ const v = base.decode(raw);
45
+ return v === undefined ? fallback : v;
46
+ },
47
+ encode: (value) => (valueEqual(value, fallback) ? undefined : base.encode(value)),
48
+ };
49
+ },
50
+ };
51
+ }
52
+ const stringCodec = optional({
53
+ decode: (raw) => (raw === undefined ? undefined : first(raw)),
54
+ encode: (v) => v,
55
+ });
56
+ const numberCodec = optional({
57
+ decode: (raw) => {
58
+ const s = first(raw);
59
+ if (s === undefined || s === "")
60
+ return undefined;
61
+ const n = Number(s);
62
+ return Number.isFinite(n) ? n : undefined;
63
+ },
64
+ encode: (v) => (v === undefined ? undefined : String(v)),
65
+ });
66
+ const booleanCodec = optional({
67
+ decode: (raw) => {
68
+ const s = first(raw);
69
+ return s === "true" ? true : s === "false" ? false : undefined;
70
+ },
71
+ encode: (v) => (v === undefined ? undefined : v ? "true" : "false"),
72
+ });
73
+ const isoDateCodec = optional({
74
+ decode: (raw) => {
75
+ const s = first(raw);
76
+ if (s === undefined || !/^\d{4}-\d{2}-\d{2}$/.test(s))
77
+ return undefined;
78
+ const d = new Date(`${s}T00:00:00.000Z`);
79
+ return Number.isNaN(d.getTime()) ? undefined : d;
80
+ },
81
+ encode: (v) => (v === undefined ? undefined : v.toISOString().slice(0, 10)),
82
+ });
83
+ function enumCodec(values) {
84
+ return optional({
85
+ decode: (raw) => {
86
+ const s = first(raw);
87
+ return s === undefined ? undefined : values.find((v) => v === s);
88
+ },
89
+ encode: (v) => v,
90
+ });
91
+ }
92
+ function arrayOf(inner) {
93
+ return optional({
94
+ decode: (raw) => {
95
+ if (raw === undefined)
96
+ return undefined;
97
+ const items = Array.isArray(raw) ? raw : [raw];
98
+ return items.map((x) => inner.decode(x)).filter((x) => x !== undefined);
99
+ },
100
+ encode: (value) => {
101
+ if (value === undefined || value.length === 0)
102
+ return undefined;
103
+ const out = value
104
+ .map((x) => inner.encode(x))
105
+ .filter((x) => typeof x === "string");
106
+ return out.length > 0 ? out : undefined;
107
+ },
108
+ });
109
+ }
110
+ /**
111
+ * The codec builders an app composes into a `useUrlState` shape. Each base
112
+ * builder yields an optional codec (absent key → `undefined`); add
113
+ * `.withDefault(v)` to make it required and keep the default out of the URL.
114
+ *
115
+ * useUrlState({
116
+ * q: urlParam.string.withDefault(""),
117
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
118
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
119
+ * from: urlParam.isoDate, // optional Date
120
+ * page: urlParam.number.withDefault(1),
121
+ * })
122
+ */
123
+ export const urlParam = {
124
+ string: stringCodec,
125
+ number: numberCodec,
126
+ boolean: booleanCodec,
127
+ isoDate: isoDateCodec,
128
+ enum: enumCodec,
129
+ arrayOf,
130
+ };
131
+ /** Decode the declared keys out of a params map into typed values. */
132
+ export function decodeAll(defs, params) {
133
+ const out = {};
134
+ for (const key of Object.keys(defs)) {
135
+ out[key] = defs[key].decode(params[key]);
136
+ }
137
+ // Boundary: a per-key loop can't be expressed as the mapped result type.
138
+ return out;
139
+ }
140
+ /** Encode a partial set of declared values into a patch (each key → value or
141
+ * `undefined` to clear). Only the keys present in `values` are emitted, so the
142
+ * write merges and leaves every other query param untouched. */
143
+ export function encodePatch(defs, values) {
144
+ const out = {};
145
+ for (const key of Object.keys(values)) {
146
+ const codec = defs[key];
147
+ if (codec)
148
+ out[key] = codec.encode(values[key]);
149
+ }
150
+ return out;
151
+ }
152
+ /** Parse a `location.search` string into a params map (repeated keys → array). */
153
+ export function parseSearch(search) {
154
+ const sp = new URLSearchParams(search);
155
+ const out = {};
156
+ for (const key of sp.keys()) {
157
+ if (key in out)
158
+ continue;
159
+ const all = sp.getAll(key);
160
+ out[key] = all.length > 1 ? all : all[0];
161
+ }
162
+ return out;
163
+ }
164
+ /** Apply a patch to a `location.search` string and return the new query string
165
+ * (no leading `?`). Each patch key is replaced; `undefined` clears it; every
166
+ * other existing param is preserved. */
167
+ export function serializeMerge(search, patch) {
168
+ const sp = new URLSearchParams(search);
169
+ for (const [key, value] of Object.entries(patch)) {
170
+ sp.delete(key);
171
+ if (value === undefined)
172
+ continue;
173
+ if (Array.isArray(value)) {
174
+ for (const item of value)
175
+ sp.append(key, item);
176
+ }
177
+ else {
178
+ sp.append(key, value);
179
+ }
180
+ }
181
+ return sp.toString();
182
+ }
183
+ /** Apply a patch to an in-memory params map (the optimistic local mirror). */
184
+ export function applyPatch(params, patch) {
185
+ const next = { ...params };
186
+ for (const [key, value] of Object.entries(patch)) {
187
+ if (value === undefined)
188
+ delete next[key];
189
+ else
190
+ next[key] = value;
191
+ }
192
+ return next;
193
+ }
194
+ /** Shallow value-equality over two params maps — used to drop echoed updates so
195
+ * an app's own write doesn't re-render it a second time. */
196
+ export function paramsEqual(a, b) {
197
+ const ak = Object.keys(a);
198
+ const bk = Object.keys(b);
199
+ if (ak.length !== bk.length)
200
+ return false;
201
+ for (const key of ak) {
202
+ const av = a[key];
203
+ const bv = b[key];
204
+ if (Array.isArray(av) || Array.isArray(bv)) {
205
+ if (!Array.isArray(av) || !Array.isArray(bv))
206
+ return false;
207
+ if (av.length !== bv.length || !av.every((x, i) => x === bv[i]))
208
+ return false;
209
+ }
210
+ else if (av !== bv) {
211
+ return false;
212
+ }
213
+ }
214
+ return true;
215
+ }
@@ -0,0 +1,8 @@
1
+ import { type UrlParamCodec } from "./url_params.js";
2
+ /** A `useUrlState` shape: a map of param key → codec. */
3
+ export type UrlStateShape = Record<string, UrlParamCodec<unknown>>;
4
+ /** The decoded, typed values for a shape. */
5
+ export type UrlStateValues<D extends UrlStateShape> = {
6
+ [K in keyof D]: D[K] extends UrlParamCodec<infer T> ? T : never;
7
+ };
8
+ export declare function useUrlState<D extends UrlStateShape>(defs: D): readonly [UrlStateValues<D>, (patch: Partial<UrlStateValues<D>>) => void];
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Save a declared slice of an app's view-state — filters, search, sort, the
3
+ * active tab — into the host's address bar, so a filtered view survives refresh
4
+ * and is shareable/bookmarkable as a link. Writes always *replace* in place: the
5
+ * host owns no in-app history, and in-app navigation (and the browser back
6
+ * button) belong to the app's own router, not to this hook.
7
+ *
8
+ * const [filters, setFilters] = useUrlState({
9
+ * q: urlParam.string.withDefault(""),
10
+ * status: urlParam.enum(["open", "won", "lost"]), // optional
11
+ * tags: urlParam.arrayOf(urlParam.string).withDefault([]),
12
+ * page: urlParam.number.withDefault(1),
13
+ * });
14
+ * // filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
15
+ * setFilters({ q: "acme" }); // merge into the address bar (replace)
16
+ *
17
+ * The app declares only the keys it owns; every other query param (a param a
18
+ * second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
19
+ * host param) is read past and preserved on write. For a search box, keep the
20
+ * live input in local state and commit to `setFilters` on a debounce — each
21
+ * call is a cross-frame write in an embedded app.
22
+ *
23
+ * The address bar is the only store: nothing is persisted server-side. Values
24
+ * are decoded fresh from the current params each render; standalone reads the
25
+ * URL directly, while an embedded app keeps a local mirror seeded from the host
26
+ * (`urlState.get` on mount) and updated optimistically on its own writes — so
27
+ * there's no independently-mutable second copy to drift.
28
+ */
29
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
30
+ import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
31
+ import { applyPatch, decodeAll, encodePatch, paramsEqual, } from "./url_params.js";
32
+ export function useUrlState(defs) {
33
+ // `defs` is expected stable (declared inline once); read through a ref so the
34
+ // decoded values and `setValues` stay referentially stable across renders.
35
+ const defsRef = useRef(defs);
36
+ defsRef.current = defs;
37
+ const [params, setParams] = useState(peekUrlParams);
38
+ // Once the user edits, a late initial hydration (bridged `getUrlParams`
39
+ // resolves a tick after mount) must not clobber their write.
40
+ const editedRef = useRef(false);
41
+ useEffect(() => {
42
+ let active = true;
43
+ const apply = (next) => {
44
+ if (active)
45
+ setParams((prev) => (paramsEqual(prev, next) ? prev : next));
46
+ };
47
+ void getUrlParams().then((p) => {
48
+ if (active && !editedRef.current)
49
+ apply(p);
50
+ });
51
+ const unsubscribe = subscribeUrlParams(apply);
52
+ return () => {
53
+ active = false;
54
+ unsubscribe();
55
+ };
56
+ }, []);
57
+ const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
58
+ const setValues = useCallback((patch) => {
59
+ editedRef.current = true;
60
+ const encoded = encodePatch(defsRef.current, patch);
61
+ // Optimistic local mirror so the UI is responsive even before the write
62
+ // round-trips (and the only update path in standalone, where the history
63
+ // write fires no event).
64
+ setParams((prev) => applyPatch(prev, encoded));
65
+ // The optimistic mirror already reflects the change, so a failed write
66
+ // only loses cross-refresh persistence, never the session — keep the
67
+ // optimistic state, but surface the failure instead of swallowing it.
68
+ void setUrlParams(encoded).catch((err) => {
69
+ console.error("useUrlState: failed to write state to the address bar", err);
70
+ });
71
+ }, []);
72
+ return [values, setValues];
73
+ }
package/package.json CHANGED
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.38.0",
3
+ "version": "0.40.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": {
7
- ".": "./dist/src/index.js"
7
+ ".": {
8
+ "types": "./dist/src/index.d.ts",
9
+ "default": "./dist/src/index.js"
10
+ },
11
+ "./router": {
12
+ "types": "./dist/src/router.d.ts",
13
+ "default": "./dist/src/router.js"
14
+ }
8
15
  },
9
16
  "types": "./dist/src/index.d.ts",
10
17
  "files": [
@@ -23,13 +30,20 @@
23
30
  },
24
31
  "peerDependencies": {
25
32
  "react": "^19.2.0",
26
- "react-dom": "^19.2.0"
33
+ "react-dom": "^19.2.0",
34
+ "react-router-dom": "^7.0.0"
35
+ },
36
+ "peerDependenciesMeta": {
37
+ "react-router-dom": {
38
+ "optional": true
39
+ }
27
40
  },
28
41
  "devDependencies": {
29
42
  "@types/react": "^19.0.0",
30
43
  "@types/react-dom": "^19.0.0",
31
44
  "react": "^19.2.0",
32
- "react-dom": "^19.2.0"
45
+ "react-dom": "^19.2.0",
46
+ "react-router-dom": "^7.0.0"
33
47
  },
34
48
  "keywords": [
35
49
  "lotics",