@lotics/app-sdk 0.37.0 → 0.38.1
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 +61 -9
- package/dist/src/hooks.d.ts +5 -0
- package/dist/src/hooks.js +57 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +2 -0
- package/dist/src/rpc.d.ts +17 -2
- package/dist/src/rpc.js +105 -8
- package/dist/src/url_params.d.ts +93 -0
- package/dist/src/url_params.js +215 -0
- package/dist/src/use_url_state.d.ts +16 -0
- package/dist/src/use_url_state.js +74 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,11 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
53
53
|
data→UI adapter; the SDK never imports `@lotics/ui`) for
|
|
54
54
|
`<FileThumbnail file={{ id, filename, mimeType, url }} uploading={f.status === "uploading"} />`;
|
|
55
55
|
`sendDisabled` gates on `uploading` and the send payload is `fileIds`. Don't hand-roll
|
|
56
|
-
`createObjectURL`/upload/revoke per app.
|
|
56
|
+
`createObjectURL`/upload/revoke per app. For a full add-files SCREEN (not the composer pill), map
|
|
57
|
+
each `AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file }`, else
|
|
58
|
+
`{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `@lotics/ui`
|
|
59
|
+
`FileGrid`'s `uploads` — it renders the uploading/error/retry tiles itself (see the kit AGENTS.md
|
|
60
|
+
Attachments recipe).
|
|
57
61
|
- **List members** — **`useMembers(opts?)`** → `{ members: {id,name,email,image}[], loading, error }`
|
|
58
62
|
— the candidate set for an assign/picker. Gated: the app must declare a `member`-typed input
|
|
59
63
|
(`opts.group` restricts to a declared group). Render with `@lotics/ui` `MemberSelect` / `MemberChip`.
|
|
@@ -70,6 +74,10 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
70
74
|
client-side advisory — pass `coords` to a workflow to record where an action happened.
|
|
71
75
|
- **Recents** — **`useRecents(key, max)`** → most-recent-first, deduped, capped, `localStorage`-backed
|
|
72
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(patch, { push })` makes it
|
|
80
|
+
back/forward-navigable. Build `shape` from **`urlParam`** codecs. See *Shareable filters in the URL*.
|
|
73
81
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
74
82
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
75
83
|
- **Infra** — **`mount(opts?)`** boots the app + analytics (call once at entry; PostHog is automatic,
|
|
@@ -140,8 +148,13 @@ compose these, don't hand-roll search:
|
|
|
140
148
|
- **`useRecents`** — persist the picked option; pass its list as `recentOptions`.
|
|
141
149
|
|
|
142
150
|
Fetch detail on select with a SECOND parameterized query (a unique-code `equals`, or a link
|
|
143
|
-
`has_any_of [record_id]`) — never a bare full-table load.
|
|
144
|
-
|
|
151
|
+
`has_any_of [record_id]`) — never a bare full-table load. To fetch a record by its **own id**, use the
|
|
152
|
+
field-less **`record_id` system condition** (a sibling of `locked`/`current_member`, NOT a `field_key`):
|
|
153
|
+
`useQuery(alias, {}, { filter: { node_type: "condition", type: "record_id", operator: "is_any_of", value:
|
|
154
|
+
[id] } })` (`is_none_of` excludes). Works on any row-level query; a *grouped* query collapses rows so it's
|
|
155
|
+
rejected there. A link `has_any_of [record_id]` matches a *related* record; `record_id` matches the row's
|
|
156
|
+
own id — the only way, since a record has no field holding its own id. **Prefer a link/join when an actual
|
|
157
|
+
relationship exists**; reach for `record_id` only when your starting point is a bare id (e.g. a drill row).
|
|
145
158
|
|
|
146
159
|
## Browse + sort + filter (the record picker)
|
|
147
160
|
|
|
@@ -183,15 +196,24 @@ Two of the most common cells render the platform way with zero hand-rolling; han
|
|
|
183
196
|
## Previewing files
|
|
184
197
|
|
|
185
198
|
Render any uploaded file inline — image, PDF, video, audio, Word, Excel, CSV — with `@lotics/ui`; never
|
|
186
|
-
hand-roll per-type rendering
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
199
|
+
hand-roll per-type rendering, and don't fall back to `openExternal` for a preview (that's "download /
|
|
200
|
+
open elsewhere", not viewing).
|
|
201
|
+
|
|
202
|
+
- **`FileGalleryModal`** (full-screen viewer: toolbar = filename · counter · ⋯actions · close-✕, plus
|
|
203
|
+
prev/next + ESC) delegates to **`FilePreview`** (single-file), dispatching by MIME; the frontend
|
|
204
|
+
gallery uses the same renderer. Wire `onFilePress` → a `number|null` `activeIndex`.
|
|
205
|
+
- **PDF renders INLINE** — bytes fetched and painted to a canvas (with a selectable text layer) via
|
|
206
|
+
`pdfjs-dist`, NOT a native `<iframe>` viewer: a nested PDF browsing context is blocked inside the
|
|
207
|
+
sandboxed, cross-origin app iframe; a canvas isn't, so preview works. `pdfjs-dist` ships in the app
|
|
208
|
+
starter (lazy-loaded — non-PDF apps pay no bundle cost).
|
|
190
209
|
- File cells already carry presigned URLs — decode with **`readFiles`** → `AppFile[]`, map to
|
|
191
210
|
`DisplayFile` (`mime_type`→`mimeType`, `thumbnail_url`→`thumbnailUrl`). No round-trip, no URL
|
|
192
211
|
derivation, no server-side doc→PDF (rendering is client-side).
|
|
193
|
-
-
|
|
194
|
-
app
|
|
212
|
+
- PDF (`pdfjs-dist`), Word (`@lotics/docx`), and Excel/CSV (`@lotics/xlsx`) all ship in the app starter,
|
|
213
|
+
lazy-imported — an app that never previews a type pays no bundle cost. Excel renders to a canvas with
|
|
214
|
+
live formula recalc; Word renders OOXML to the DOM. `@lotics/ui` is i18n/analytics-free: pass `labels`
|
|
215
|
+
+ an `onError`. The toolbar's "open in new tab" can't pop a window in the sandbox — pass `onOpenExternal`
|
|
216
|
+
wired to the SDK's `openExternal` (omit it and the action hides).
|
|
195
217
|
|
|
196
218
|
## Composable optional filters (one query, many scopes)
|
|
197
219
|
|
|
@@ -228,6 +250,36 @@ stops constraining instead of erroring (no-op for all-required queries).
|
|
|
228
250
|
independently. Keep a scope that must always apply `required` (a missing required param 400s). Typos
|
|
229
251
|
can't widen — deploy rejects a `{{params.x}}` with no declared param.
|
|
230
252
|
|
|
253
|
+
## Shareable filters in the URL (`useUrlState`)
|
|
254
|
+
|
|
255
|
+
The filters above are the query's *server* params; **`useUrlState`** is their *client* home — keep them in
|
|
256
|
+
the address bar and a filtered view survives refresh, is shareable/bookmarkable as a link, and (with
|
|
257
|
+
`{ push: true }`) is back/forward-navigable. The hook drives the host's URL over the bridge; the app
|
|
258
|
+
can't touch the cross-origin host URL itself. Standalone apps drive their own URL — same code.
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
const [filters, setFilters] = useUrlState({
|
|
262
|
+
q: urlParam.string.withDefault(""),
|
|
263
|
+
status: urlParam.enum(["open", "won", "lost"]), // optional → omitted when unset
|
|
264
|
+
tags: urlParam.arrayOf(urlParam.string).withDefault([]),
|
|
265
|
+
page: urlParam.number.withDefault(1),
|
|
266
|
+
});
|
|
267
|
+
// filters → { q: string; status?: "open"|"won"|"lost"; tags: string[]; page: number }
|
|
268
|
+
const { rows } = usePaginatedQuery("search", { keyword: filters.q, status: filters.status }, { pageSize: 50 });
|
|
269
|
+
setFilters({ status: "won" }); // merge, replace history → URL ?status=won
|
|
270
|
+
setFilters({ page: 2 }, { push: true }); // back-able navigation
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- **`urlParam`** codecs: `string` / `number` / `boolean` / `isoDate` / `enum([...])` / `arrayOf(inner)`,
|
|
274
|
+
each `.withDefault(v)` to make it required. **Defaults are omitted from the URL** (`page=1` never
|
|
275
|
+
appears) — links carry only what changed.
|
|
276
|
+
- **The URL is the only store** — `filters` is decoded fresh each render; don't mirror it into `useState`.
|
|
277
|
+
- **Declared keys only.** The app touches just the keys in `shape`; every other param (a second
|
|
278
|
+
`useUrlState`, the framework's own) is preserved on write — no namespace rule to remember.
|
|
279
|
+
- **Search box:** keep the live input in local `useState` and commit to `setFilters` on a **debounce** —
|
|
280
|
+
in an embedded app each `setFilters` is a cross-frame write. Default is *replace* (no history entry);
|
|
281
|
+
reserve `{ push: true }` for committed navigations (open a detail, switch a major tab).
|
|
282
|
+
|
|
231
283
|
---
|
|
232
284
|
|
|
233
285
|
## Recipes (app actions beyond the hooks)
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -414,7 +414,12 @@ export interface UseAgentRun<TInput, TOutput> {
|
|
|
414
414
|
* structured output (undefined for a free-text or failed run). Calling again
|
|
415
415
|
* aborts any run still in flight. */
|
|
416
416
|
run: (input: TInput, opts: AgentRunOptions) => Promise<TOutput | undefined>;
|
|
417
|
+
/** Stop listening locally (no server effect) — the run keeps executing
|
|
418
|
+
* server-side and its result lands in the session history. Used on unmount. */
|
|
417
419
|
abort: () => void;
|
|
420
|
+
/** Explicitly stop the run server-side (saves tokens) AND locally. Wire a
|
|
421
|
+
* user-facing "Stop" button to this, not `abort`. */
|
|
422
|
+
cancel: () => void;
|
|
418
423
|
status: "idle" | "streaming" | "completed" | "error";
|
|
419
424
|
/** The agent's streamed reasoning / answer text, accumulating live. */
|
|
420
425
|
text: string;
|
package/dist/src/hooks.js
CHANGED
|
@@ -351,6 +351,10 @@ export function useMembers(opts) {
|
|
|
351
351
|
export function useAgentRun(alias) {
|
|
352
352
|
const [state, setState] = useState(null);
|
|
353
353
|
const handleRef = useRef(null);
|
|
354
|
+
// The run id of the in-flight run (reported by the transport off the response
|
|
355
|
+
// header). Lets the hook poll the run to completion if the stream connection
|
|
356
|
+
// drops, and cancel it server-side on an explicit stop.
|
|
357
|
+
const runIdRef = useRef(null);
|
|
354
358
|
// Guards setState after unmount and aborts any in-flight run on unmount, so a
|
|
355
359
|
// stream never keeps writing to a dead component (or leaks the transport).
|
|
356
360
|
const mountedRef = useRef(true);
|
|
@@ -367,6 +371,7 @@ export function useAgentRun(alias) {
|
|
|
367
371
|
}, []);
|
|
368
372
|
const run = useCallback((input, opts) => {
|
|
369
373
|
handleRef.current?.abort();
|
|
374
|
+
runIdRef.current = null;
|
|
370
375
|
let acc = initialAgentRunState();
|
|
371
376
|
let buffer = "";
|
|
372
377
|
let aborted = false;
|
|
@@ -382,6 +387,8 @@ export function useAgentRun(alias) {
|
|
|
382
387
|
for (const c of chunks)
|
|
383
388
|
acc = reduceAgentChunk(acc, c);
|
|
384
389
|
safeSetState({ ...acc });
|
|
390
|
+
}, (runId) => {
|
|
391
|
+
runIdRef.current = runId;
|
|
385
392
|
});
|
|
386
393
|
// Wrap abort so a stop (user or unmount) marks the run cancelled and clears
|
|
387
394
|
// the partial state back to idle — `done` resolves cleanly, never an error.
|
|
@@ -406,9 +413,27 @@ export function useAgentRun(alias) {
|
|
|
406
413
|
captureAppEvent("app_agent_run", { alias, ok: acc.status !== "error" });
|
|
407
414
|
return acc.output;
|
|
408
415
|
})
|
|
409
|
-
.catch((err) => {
|
|
416
|
+
.catch(async (err) => {
|
|
410
417
|
if (aborted)
|
|
411
418
|
return undefined;
|
|
419
|
+
// The stream connection dropped, but the run is decoupled from it and
|
|
420
|
+
// keeps executing server-side. Poll the persisted run to completion and
|
|
421
|
+
// surface its result instead of a network error — work is never lost.
|
|
422
|
+
const runId = runIdRef.current;
|
|
423
|
+
if (runId) {
|
|
424
|
+
const settled = await pollAgentRunToSettle(runId, () => aborted || !mountedRef.current);
|
|
425
|
+
if (settled && !aborted) {
|
|
426
|
+
acc =
|
|
427
|
+
settled.status === "completed"
|
|
428
|
+
? { ...acc, status: "completed", output: settled.output ?? acc.output }
|
|
429
|
+
: { ...acc, status: "error", error: settled.error_message ?? "The run was stopped." };
|
|
430
|
+
safeSetState(acc);
|
|
431
|
+
captureAppEvent("app_agent_run", { alias, ok: settled.status === "completed" });
|
|
432
|
+
return acc.output;
|
|
433
|
+
}
|
|
434
|
+
if (aborted)
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
412
437
|
acc = { ...acc, status: "error", error: err.message };
|
|
413
438
|
safeSetState(acc);
|
|
414
439
|
captureAppEvent("app_agent_run", { alias, ok: false });
|
|
@@ -416,9 +441,17 @@ export function useAgentRun(alias) {
|
|
|
416
441
|
});
|
|
417
442
|
}, [alias, safeSetState]);
|
|
418
443
|
const abort = useCallback(() => handleRef.current?.abort(), []);
|
|
444
|
+
const cancel = useCallback(() => {
|
|
445
|
+
// Stop server-side too (saves tokens on an unwanted run), then locally.
|
|
446
|
+
const runId = runIdRef.current;
|
|
447
|
+
if (runId)
|
|
448
|
+
void rpc("agentRun.cancel", { run_id: runId }).catch(() => { });
|
|
449
|
+
handleRef.current?.abort();
|
|
450
|
+
}, []);
|
|
419
451
|
return {
|
|
420
452
|
run,
|
|
421
453
|
abort,
|
|
454
|
+
cancel,
|
|
422
455
|
status: state?.status ?? "idle",
|
|
423
456
|
text: state?.text ?? "",
|
|
424
457
|
steps: state?.steps ?? [],
|
|
@@ -426,6 +459,29 @@ export function useAgentRun(alias) {
|
|
|
426
459
|
error: state?.error,
|
|
427
460
|
};
|
|
428
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Poll a single run until it leaves `running` — the resume path when a run's
|
|
464
|
+
* stream connection drops mid-flight. Bounded just past the server-side max-run
|
|
465
|
+
* cap so a hung run can never poll forever, and bails the moment the caller
|
|
466
|
+
* aborts or unmounts. Returns the settled run, or null if it never settled.
|
|
467
|
+
*/
|
|
468
|
+
async function pollAgentRunToSettle(runId, cancelled) {
|
|
469
|
+
const deadline = Date.now() + 11 * 60_000;
|
|
470
|
+
while (Date.now() < deadline) {
|
|
471
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
472
|
+
if (cancelled())
|
|
473
|
+
return null;
|
|
474
|
+
try {
|
|
475
|
+
const { run } = await rpc("agentRun.get", { run_id: runId });
|
|
476
|
+
if (run.status !== "running")
|
|
477
|
+
return run;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// transient read failure — keep polling until the deadline
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
429
485
|
/**
|
|
430
486
|
* The run history of a session, oldest-first — the persisted outputs the app
|
|
431
487
|
* renders as a session log (NOT a chat: a flat list of past runs). Refetch
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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, SetUrlStateOptions } 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
|
@@ -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";
|
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" | "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" | "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;
|
|
@@ -58,10 +59,24 @@ export interface AppContext {
|
|
|
58
59
|
comments_enabled: boolean;
|
|
59
60
|
}
|
|
60
61
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
62
|
+
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
63
|
+
* the page's own query string. */
|
|
64
|
+
export declare function getUrlParams(): Promise<UrlParams>;
|
|
65
|
+
/** Merge `patch` into the query string (each key set, or cleared when its value
|
|
66
|
+
* is `undefined`). `push` adds a history entry (back-able); otherwise it
|
|
67
|
+
* replaces in place — the default, so filter churn doesn't flood history. */
|
|
68
|
+
export declare function setUrlParams(patch: UrlParamsPatch, push: boolean): Promise<void>;
|
|
69
|
+
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
70
|
+
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
71
|
+
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
72
|
+
export declare function peekUrlParams(): UrlParams;
|
|
73
|
+
/** Subscribe to external query changes — back/forward, or an edited URL.
|
|
74
|
+
* Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
75
|
+
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
61
76
|
/**
|
|
62
77
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
63
78
|
* (the caller parses it via `agent_stream`); `done` settles when the stream
|
|
64
79
|
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
65
80
|
* standalone: the SDK reads the public endpoint's body directly.
|
|
66
81
|
*/
|
|
67
|
-
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void): AgentRunHandle;
|
|
82
|
+
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
|
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
|
*
|
|
@@ -23,8 +24,49 @@ export function rpc(op, payload) {
|
|
|
23
24
|
? rpcBridged(op, payload, hostOrigin)
|
|
24
25
|
: rpcStandalone(op, payload);
|
|
25
26
|
}
|
|
27
|
+
// ── URL state (the address bar as shareable view-state) ─────────────────────
|
|
28
|
+
//
|
|
29
|
+
// `useUrlState` keeps a declared slice of app state in the host's address bar so
|
|
30
|
+
// a filtered view survives refresh and is shareable/bookmarkable. An embedded
|
|
31
|
+
// app can't read or write the cross-origin host URL directly, so reads/writes
|
|
32
|
+
// flow over the bridge (the host drives its own router); a standalone app owns
|
|
33
|
+
// its top-level URL and the same ops resolve against `window.location`.
|
|
34
|
+
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
35
|
+
* the page's own query string. */
|
|
36
|
+
export function getUrlParams() {
|
|
37
|
+
return rpc("urlState.get", {});
|
|
38
|
+
}
|
|
39
|
+
/** Merge `patch` into the query string (each key set, or cleared when its value
|
|
40
|
+
* is `undefined`). `push` adds a history entry (back-able); otherwise it
|
|
41
|
+
* replaces in place — the default, so filter churn doesn't flood history. */
|
|
42
|
+
export function setUrlParams(patch, push) {
|
|
43
|
+
return rpc("urlState.set", { params: patch, push });
|
|
44
|
+
}
|
|
45
|
+
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
46
|
+
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
47
|
+
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
48
|
+
export function peekUrlParams() {
|
|
49
|
+
return getHostOrigin() ? {} : parseSearch(window.location.search);
|
|
50
|
+
}
|
|
51
|
+
/** Subscribe to external query changes — back/forward, or an edited URL.
|
|
52
|
+
* Bridged: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
53
|
+
export function subscribeUrlParams(cb) {
|
|
54
|
+
if (getHostOrigin()) {
|
|
55
|
+
ensureListener();
|
|
56
|
+
urlStateSubscribers.add(cb);
|
|
57
|
+
return () => {
|
|
58
|
+
urlStateSubscribers.delete(cb);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const handler = () => cb(parseSearch(window.location.search));
|
|
62
|
+
window.addEventListener("popstate", handler);
|
|
63
|
+
return () => window.removeEventListener("popstate", handler);
|
|
64
|
+
}
|
|
26
65
|
const pending = new Map();
|
|
27
66
|
const streaming = new Map();
|
|
67
|
+
/** `useUrlState` subscribers (bridged transport) — notified when the host pushes
|
|
68
|
+
* a `url-state` broadcast after an external navigation. */
|
|
69
|
+
const urlStateSubscribers = new Set();
|
|
28
70
|
let nextRpcId = 0;
|
|
29
71
|
let listenerInstalled = false;
|
|
30
72
|
function ensureListener() {
|
|
@@ -36,7 +78,16 @@ function ensureListener() {
|
|
|
36
78
|
if (event.source !== window.parent || event.origin !== getHostOrigin())
|
|
37
79
|
return;
|
|
38
80
|
const msg = event.data;
|
|
39
|
-
if (!msg
|
|
81
|
+
if (!msg)
|
|
82
|
+
return;
|
|
83
|
+
// Broadcast (no id): the host pushes the new query params after an external
|
|
84
|
+
// navigation (back/forward, an edited URL) so `useUrlState` re-hydrates.
|
|
85
|
+
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
86
|
+
for (const cb of urlStateSubscribers)
|
|
87
|
+
cb(msg.params);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (typeof msg.id !== "number")
|
|
40
91
|
return;
|
|
41
92
|
// Single-response ops.
|
|
42
93
|
const handler = pending.get(msg.id);
|
|
@@ -48,11 +99,15 @@ function ensureListener() {
|
|
|
48
99
|
handler.reject(new Error(msg.message ?? "RPC failed"));
|
|
49
100
|
return;
|
|
50
101
|
}
|
|
51
|
-
// Streaming op (agentRun): chunk* → end | error.
|
|
102
|
+
// Streaming op (agentRun): run-id? chunk* → end | error.
|
|
52
103
|
const stream = streaming.get(msg.id);
|
|
53
104
|
if (!stream)
|
|
54
105
|
return;
|
|
55
|
-
if (msg.type === "
|
|
106
|
+
if (msg.type === "run-id") {
|
|
107
|
+
if (typeof msg.runId === "string")
|
|
108
|
+
stream.onRunId?.(msg.runId);
|
|
109
|
+
}
|
|
110
|
+
else if (msg.type === "stream-chunk") {
|
|
56
111
|
if (typeof msg.chunk === "string")
|
|
57
112
|
stream.onText(msg.chunk);
|
|
58
113
|
}
|
|
@@ -81,17 +136,19 @@ function rpcBridged(op, payload, host) {
|
|
|
81
136
|
* ends. Bridged: the host opens the SSE with its session and forwards chunks;
|
|
82
137
|
* standalone: the SDK reads the public endpoint's body directly.
|
|
83
138
|
*/
|
|
84
|
-
export function rpcAgentRun(payload, onText) {
|
|
139
|
+
export function rpcAgentRun(payload, onText, onRunId) {
|
|
85
140
|
const hostOrigin = getHostOrigin();
|
|
86
|
-
return hostOrigin
|
|
141
|
+
return hostOrigin
|
|
142
|
+
? agentRunBridged(payload, onText, hostOrigin, onRunId)
|
|
143
|
+
: agentRunStandalone(payload, onText, onRunId);
|
|
87
144
|
}
|
|
88
|
-
function agentRunBridged(payload, onText, host) {
|
|
145
|
+
function agentRunBridged(payload, onText, host, onRunId) {
|
|
89
146
|
ensureListener();
|
|
90
147
|
const id = nextRpcId++;
|
|
91
148
|
let settleDone = () => { };
|
|
92
149
|
const done = new Promise((resolve, reject) => {
|
|
93
150
|
settleDone = resolve;
|
|
94
|
-
streaming.set(id, { onText, resolve, reject });
|
|
151
|
+
streaming.set(id, { onText, onRunId, resolve, reject });
|
|
95
152
|
window.parent.postMessage({ id, op: "agentRun", payload }, host);
|
|
96
153
|
});
|
|
97
154
|
return {
|
|
@@ -107,7 +164,7 @@ function agentRunBridged(payload, onText, host) {
|
|
|
107
164
|
},
|
|
108
165
|
};
|
|
109
166
|
}
|
|
110
|
-
function agentRunStandalone(payload, onText) {
|
|
167
|
+
function agentRunStandalone(payload, onText, onRunId) {
|
|
111
168
|
const controller = new AbortController();
|
|
112
169
|
const done = (async () => {
|
|
113
170
|
const { app_id } = await boot();
|
|
@@ -124,6 +181,9 @@ function agentRunStandalone(payload, onText) {
|
|
|
124
181
|
const text = await res.text().catch(() => "");
|
|
125
182
|
throw new Error(text || `HTTP ${res.status}`);
|
|
126
183
|
}
|
|
184
|
+
const runId = res.headers.get("x-app-agent-run-id");
|
|
185
|
+
if (runId)
|
|
186
|
+
onRunId?.(runId);
|
|
127
187
|
const reader = res.body.getReader();
|
|
128
188
|
const decoder = new TextDecoder();
|
|
129
189
|
try {
|
|
@@ -307,6 +367,10 @@ function rpcStandalone(op, payload) {
|
|
|
307
367
|
return standaloneWorkflow(payload);
|
|
308
368
|
case "agentRuns":
|
|
309
369
|
return standaloneAgentRuns(payload);
|
|
370
|
+
case "agentRun.get":
|
|
371
|
+
return standaloneAgentRunGet(payload);
|
|
372
|
+
case "agentRun.cancel":
|
|
373
|
+
return standaloneAgentRunCancel(payload);
|
|
310
374
|
case "upload":
|
|
311
375
|
return standaloneUpload(payload.file);
|
|
312
376
|
case "members":
|
|
@@ -315,6 +379,11 @@ function rpcStandalone(op, payload) {
|
|
|
315
379
|
return standaloneContext();
|
|
316
380
|
case "openExternal":
|
|
317
381
|
return standaloneOpenExternal(payload);
|
|
382
|
+
case "urlState.get":
|
|
383
|
+
// Standalone is a top-level page — its own query string IS the store.
|
|
384
|
+
return Promise.resolve(parseSearch(window.location.search));
|
|
385
|
+
case "urlState.set":
|
|
386
|
+
return standaloneUrlStateSet(payload);
|
|
318
387
|
case "comments.list":
|
|
319
388
|
case "comments.create":
|
|
320
389
|
case "comments.update":
|
|
@@ -363,6 +432,16 @@ function openValidatedUrl(url) {
|
|
|
363
432
|
async function standaloneOpenExternal(p) {
|
|
364
433
|
openValidatedUrl(p.url);
|
|
365
434
|
}
|
|
435
|
+
async function standaloneUrlStateSet(p) {
|
|
436
|
+
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
437
|
+
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
438
|
+
// pushState/replaceState don't fire popstate, so subscribers aren't notified
|
|
439
|
+
// here — the hook updates its own state optimistically on write.
|
|
440
|
+
if (p.push)
|
|
441
|
+
window.history.pushState(null, "", url);
|
|
442
|
+
else
|
|
443
|
+
window.history.replaceState(null, "", url);
|
|
444
|
+
}
|
|
366
445
|
async function standaloneContext() {
|
|
367
446
|
const info = await resolveAppInfo();
|
|
368
447
|
return {
|
|
@@ -402,6 +481,24 @@ async function standaloneAgentRuns(p) {
|
|
|
402
481
|
}));
|
|
403
482
|
return { runs: r.runs ?? [] };
|
|
404
483
|
}
|
|
484
|
+
/** Poll read for a single run — follows a run to completion after its stream
|
|
485
|
+
* connection drops. The caller types the run shape via `rpc<T>`. */
|
|
486
|
+
async function standaloneAgentRunGet(p) {
|
|
487
|
+
const { app_id } = await boot();
|
|
488
|
+
const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
|
|
489
|
+
appId: app_id,
|
|
490
|
+
}));
|
|
491
|
+
return { run: r.run };
|
|
492
|
+
}
|
|
493
|
+
/** Request server-side cancellation of an in-flight run (an explicit user stop,
|
|
494
|
+
* distinct from merely closing the stream). */
|
|
495
|
+
async function standaloneAgentRunCancel(p) {
|
|
496
|
+
const { app_id } = await boot();
|
|
497
|
+
await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
|
|
498
|
+
appId: app_id,
|
|
499
|
+
});
|
|
500
|
+
return { ok: true };
|
|
501
|
+
}
|
|
405
502
|
async function standaloneUpload(file) {
|
|
406
503
|
if (!(file instanceof File)) {
|
|
407
504
|
throw new Error("upload payload must include a File");
|
|
@@ -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,16 @@
|
|
|
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 interface SetUrlStateOptions {
|
|
9
|
+
/** Add a browser history entry (back/forward navigable). Default `false` —
|
|
10
|
+
* replace in place, so high-frequency filter changes don't flood history. */
|
|
11
|
+
push?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function useUrlState<D extends UrlStateShape>(defs: D): readonly [
|
|
14
|
+
UrlStateValues<D>,
|
|
15
|
+
(patch: Partial<UrlStateValues<D>>, options?: SetUrlStateOptions) => void
|
|
16
|
+
];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep a declared slice of an app's view-state — filters, search, sort, the
|
|
3
|
+
* active tab — in the browser's address bar, with two-way sync. The result is
|
|
4
|
+
* real browser behaviour for an otherwise opaque cross-origin app: a filtered
|
|
5
|
+
* view survives refresh, is shareable/bookmarkable as a link, and (with
|
|
6
|
+
* `{ push: true }`) is back/forward-navigable.
|
|
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, replace history
|
|
16
|
+
* setFilters({ status: "won" }, { push: true }); // back-able navigation
|
|
17
|
+
*
|
|
18
|
+
* The app declares only the keys it owns; every other query param (a param a
|
|
19
|
+
* second `useUrlState` owns, the framework's `lotics_host`/`__mock`, a future
|
|
20
|
+
* host param) is read past and preserved on write. For a search box, keep the
|
|
21
|
+
* live input in local state and commit to `setFilters` on a debounce — each
|
|
22
|
+
* call is a cross-frame write in an embedded app.
|
|
23
|
+
*
|
|
24
|
+
* The address bar is the only store: nothing is persisted server-side. Values
|
|
25
|
+
* are decoded fresh from the current params each render; standalone reads the
|
|
26
|
+
* URL directly, while an embedded app keeps a local mirror the SDK syncs from
|
|
27
|
+
* the host (initial `urlState.get`, the app's own writes, and back/forward
|
|
28
|
+
* broadcasts) — so there's no independently-mutable second copy to drift.
|
|
29
|
+
*/
|
|
30
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import { getUrlParams, peekUrlParams, setUrlParams, subscribeUrlParams } from "./rpc.js";
|
|
32
|
+
import { applyPatch, decodeAll, encodePatch, paramsEqual, } from "./url_params.js";
|
|
33
|
+
export function useUrlState(defs) {
|
|
34
|
+
// `defs` is expected stable (declared inline once); read through a ref so the
|
|
35
|
+
// decoded values and `setValues` stay referentially stable across renders.
|
|
36
|
+
const defsRef = useRef(defs);
|
|
37
|
+
defsRef.current = defs;
|
|
38
|
+
const [params, setParams] = useState(peekUrlParams);
|
|
39
|
+
// Once the user edits, a late initial hydration (bridged `getUrlParams`
|
|
40
|
+
// resolves a tick after mount) must not clobber their write.
|
|
41
|
+
const editedRef = useRef(false);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
let active = true;
|
|
44
|
+
const apply = (next) => {
|
|
45
|
+
if (active)
|
|
46
|
+
setParams((prev) => (paramsEqual(prev, next) ? prev : next));
|
|
47
|
+
};
|
|
48
|
+
void getUrlParams().then((p) => {
|
|
49
|
+
if (active && !editedRef.current)
|
|
50
|
+
apply(p);
|
|
51
|
+
});
|
|
52
|
+
const unsubscribe = subscribeUrlParams(apply);
|
|
53
|
+
return () => {
|
|
54
|
+
active = false;
|
|
55
|
+
unsubscribe();
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
const values = useMemo(() => decodeAll(defsRef.current, params), [params]);
|
|
59
|
+
const setValues = useCallback((patch, options) => {
|
|
60
|
+
editedRef.current = true;
|
|
61
|
+
const encoded = encodePatch(defsRef.current, patch);
|
|
62
|
+
// Optimistic local mirror so the UI is responsive even before the write
|
|
63
|
+
// round-trips (and the only update path in standalone, where the history
|
|
64
|
+
// write fires no event).
|
|
65
|
+
setParams((prev) => applyPatch(prev, encoded));
|
|
66
|
+
// The optimistic mirror already reflects the change, so a failed write
|
|
67
|
+
// only loses cross-refresh persistence, never the session — keep the
|
|
68
|
+
// optimistic state, but surface the failure instead of swallowing it.
|
|
69
|
+
void setUrlParams(encoded, options?.push ?? false).catch((err) => {
|
|
70
|
+
console.error("useUrlState: failed to write state to the address bar", err);
|
|
71
|
+
});
|
|
72
|
+
}, []);
|
|
73
|
+
return [values, setValues];
|
|
74
|
+
}
|