@lotics/app-sdk 0.100.0 → 0.101.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.
Files changed (108) hide show
  1. package/AGENTS.md +32 -47
  2. package/dist/agent_stream.d.ts +131 -0
  3. package/dist/ask_ai.d.ts +27 -0
  4. package/dist/attachments.d.ts +58 -0
  5. package/dist/chunk-ARV5FAU5.js +1132 -0
  6. package/dist/comments.d.ts +89 -0
  7. package/dist/error_report.d.ts +9 -0
  8. package/dist/folder_pick.d.ts +8 -0
  9. package/dist/geolocation.d.ts +42 -0
  10. package/dist/hooks.d.ts +251 -0
  11. package/dist/{src/index.d.ts → index.d.ts} +13 -22
  12. package/dist/index.js +31309 -0
  13. package/dist/index.js.LEGAL.txt +11 -0
  14. package/dist/members.d.ts +32 -0
  15. package/dist/mock.d.ts +37 -0
  16. package/dist/mount.d.ts +19 -0
  17. package/dist/new_record.d.ts +37 -0
  18. package/dist/open_app.d.ts +12 -0
  19. package/dist/open_external.d.ts +10 -0
  20. package/dist/overlay.d.ts +25 -0
  21. package/dist/queries.d.ts +231 -0
  22. package/dist/recording.d.ts +47 -0
  23. package/dist/recording_state.d.ts +43 -0
  24. package/dist/rename_file.d.ts +13 -0
  25. package/dist/router.d.ts +10 -0
  26. package/dist/router.js +97 -0
  27. package/dist/row.d.ts +87 -0
  28. package/dist/rpc.d.ts +114 -0
  29. package/dist/select.d.ts +24 -0
  30. package/dist/shared_types.d.ts +8 -0
  31. package/dist/store.d.ts +43 -0
  32. package/dist/types.d.ts +36 -0
  33. package/dist/upload/optimize.d.ts +30 -0
  34. package/dist/upload/pipeline.d.ts +36 -0
  35. package/dist/upload/transport.d.ts +19 -0
  36. package/dist/url_params.d.ts +55 -0
  37. package/dist/use_recents.d.ts +15 -0
  38. package/dist/{src/use_url_state.d.ts → use_url_state.d.ts} +0 -2
  39. package/dist/viewer.d.ts +41 -0
  40. package/dist/written.d.ts +77 -0
  41. package/docs/ai.md +74 -133
  42. package/docs/data_fetching.md +209 -290
  43. package/docs/files.md +61 -51
  44. package/docs/members_and_options.md +93 -63
  45. package/docs/mutations.md +135 -205
  46. package/docs/navigation_and_state.md +26 -35
  47. package/docs/queries.md +144 -207
  48. package/docs/recipes.md +21 -45
  49. package/docs/runtime.md +74 -137
  50. package/docs/security.md +8 -11
  51. package/docs/workflows.md +189 -174
  52. package/package.json +27 -28
  53. package/dist/src/agent_stream.d.ts +0 -200
  54. package/dist/src/agent_stream.js +0 -314
  55. package/dist/src/ask_ai.d.ts +0 -40
  56. package/dist/src/ask_ai.js +0 -35
  57. package/dist/src/attachments.d.ts +0 -68
  58. package/dist/src/attachments.js +0 -93
  59. package/dist/src/comments.d.ts +0 -127
  60. package/dist/src/comments.js +0 -192
  61. package/dist/src/download.js +0 -54
  62. package/dist/src/geolocation.d.ts +0 -64
  63. package/dist/src/geolocation.js +0 -96
  64. package/dist/src/hooks.d.ts +0 -781
  65. package/dist/src/hooks.js +0 -860
  66. package/dist/src/index.js +0 -34
  67. package/dist/src/members.d.ts +0 -105
  68. package/dist/src/members.js +0 -62
  69. package/dist/src/mock.d.ts +0 -118
  70. package/dist/src/mock.js +0 -124
  71. package/dist/src/mount.d.ts +0 -47
  72. package/dist/src/mount.js +0 -34
  73. package/dist/src/new_record.d.ts +0 -74
  74. package/dist/src/new_record.js +0 -117
  75. package/dist/src/open_app.d.ts +0 -15
  76. package/dist/src/open_app.js +0 -18
  77. package/dist/src/open_external.d.ts +0 -16
  78. package/dist/src/open_external.js +0 -19
  79. package/dist/src/recording.d.ts +0 -59
  80. package/dist/src/recording.js +0 -30
  81. package/dist/src/recording_state.d.ts +0 -59
  82. package/dist/src/recording_state.js +0 -94
  83. package/dist/src/router.d.ts +0 -17
  84. package/dist/src/router.js +0 -144
  85. package/dist/src/row.d.ts +0 -159
  86. package/dist/src/row.js +0 -254
  87. package/dist/src/rpc.d.ts +0 -207
  88. package/dist/src/rpc.js +0 -904
  89. package/dist/src/select.d.ts +0 -34
  90. package/dist/src/select.js +0 -40
  91. package/dist/src/types.d.ts +0 -115
  92. package/dist/src/types.js +0 -1
  93. package/dist/src/upload/optimize.d.ts +0 -54
  94. package/dist/src/upload/optimize.js +0 -207
  95. package/dist/src/upload/pipeline.d.ts +0 -55
  96. package/dist/src/upload/pipeline.js +0 -52
  97. package/dist/src/upload/transport.d.ts +0 -42
  98. package/dist/src/upload/transport.js +0 -128
  99. package/dist/src/url_params.d.ts +0 -93
  100. package/dist/src/url_params.js +0 -215
  101. package/dist/src/use_optimistic.d.ts +0 -27
  102. package/dist/src/use_optimistic.js +0 -27
  103. package/dist/src/use_recents.d.ts +0 -19
  104. package/dist/src/use_recents.js +0 -71
  105. package/dist/src/use_url_state.js +0 -73
  106. package/dist/src/viewer.d.ts +0 -26
  107. package/dist/src/viewer.js +0 -47
  108. /package/dist/{src/download.d.ts → download.d.ts} +0 -0
package/dist/src/rpc.js DELETED
@@ -1,904 +0,0 @@
1
- import { runUploadPipeline } from "./upload/pipeline.js";
2
- import { applyRecordingChange, readRecordingState, recordingStateOf } from "./recording_state.js";
3
- import { parseSearch, serializeMerge, } from "./url_params.js";
4
- /**
5
- * The embedding Lotics host's origin — present iff the app is bridged.
6
- *
7
- * Lazy + memoized so the module's top level doesn't touch `window`. A test
8
- * runner can pull this module in at evaluation time before its jsdom
9
- * environment finishes setting up `window.location` — an eager read would
10
- * crash every suite that transitively imports the SDK.
11
- *
12
- * `undefined` = not yet computed; `string | null` = computed result.
13
- */
14
- let hostOriginCache;
15
- function getHostOrigin() {
16
- if (hostOriginCache === undefined) {
17
- hostOriginCache = new URLSearchParams(window.location.search).get("lotics_host");
18
- }
19
- return hostOriginCache;
20
- }
21
- /**
22
- * Whether the app is running embedded in a Lotics host (vs. standalone at its
23
- * own origin). `rpc()`, `useUrlState`, and `AppRouter` use this to
24
- * pick the transport / behaviour; 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
- }
32
- export function rpc(op, payload) {
33
- const hostOrigin = getHostOrigin();
34
- return hostOrigin
35
- ? rpcBridged(op, payload, hostOrigin)
36
- : rpcStandalone(op, payload);
37
- }
38
- // ── URL state (the host address bar as shareable app view-state + screen) ────
39
- //
40
- // Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
41
- // `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
42
- // state in the host's address bar so it survives refresh and is shareable. An
43
- // embedded app can't touch the cross-origin host URL directly, so reads/writes
44
- // flow over the bridge: the host writes the params in place (a non-remounting
45
- // `history.replaceState` — never a navigation, which would reload the iframe) and
46
- // pushes external changes (browser back/forward) back via the `url-state`
47
- // broadcast. A standalone app owns its top-level URL and the same ops resolve
48
- // against `window.location`. (In-app *routing* is not here — the app owns its own
49
- // url via `@lotics/app-sdk/router`; see that module.)
50
- /** Read the current app-owned query params — bridged: ask the host; standalone:
51
- * the page's own query string. */
52
- export function getUrlParams() {
53
- return rpc("urlState.get", {});
54
- }
55
- /** Merge `patch` into the host's address bar (each key set, or cleared when its
56
- * value is `undefined`), preserving every other param. The host writes in place
57
- * (`history.replaceState`) — view-state changes don't add history entries. */
58
- export function setUrlParams(patch) {
59
- return rpc("urlState.set", { params: patch });
60
- }
61
- /** Synchronous best-effort snapshot for first paint. Standalone reads its own
62
- * URL (no flash); bridged can't read the cross-origin host URL synchronously,
63
- * so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
64
- export function peekUrlParams() {
65
- return isEmbedded() ? {} : parseSearch(window.location.search);
66
- }
67
- /** Subscribe to external query changes — browser back/forward and edited URLs.
68
- * Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
69
- export function subscribeUrlParams(cb) {
70
- if (isEmbedded()) {
71
- ensureListener();
72
- urlStateSubscribers.add(cb);
73
- return () => {
74
- urlStateSubscribers.delete(cb);
75
- };
76
- }
77
- const handler = () => cb(parseSearch(window.location.search));
78
- window.addEventListener("popstate", handler);
79
- return () => window.removeEventListener("popstate", handler);
80
- }
81
- // ── Host notifications (app → host, fire-and-forget) ─────────────────────────
82
- /**
83
- * Push a fire-and-forget notification to the embedding host — no id, no reply.
84
- * Only the embedded host can receive it (it owns the chat surface), so this
85
- * no-ops standalone (the app's own origin has no host to inform) and never
86
- * throws. Distinct from `rpc()`: this is one-way, app → host.
87
- */
88
- export function postHostNotification(message) {
89
- const hostOrigin = getHostOrigin();
90
- if (!hostOrigin)
91
- return;
92
- window.parent.postMessage(message, hostOrigin);
93
- }
94
- /**
95
- * Subscribe to the host's `queriesChanged` push, which names the aliases whose
96
- * underlying tables moved. Returns an unsubscribe fn.
97
- *
98
- * The host owns the realtime connection — one per tab, shared by every surface
99
- * in it — because the app frame deliberately holds no platform credentials, and
100
- * a second socket per app would only duplicate a subscription the host already
101
- * has. The host resolves changed tables to aliases (it holds the query ASTs)
102
- * and names them here, so a mounted hook refetches only when ITS query is
103
- * affected rather than on every change anywhere in the workspace.
104
- *
105
- * A standalone (public) app has no host, so this is inert there — those apps
106
- * stay on pull-based freshness.
107
- */
108
- export function subscribeQueriesChanged(cb) {
109
- ensureListener();
110
- refetchSubscribers.add(cb);
111
- return () => {
112
- refetchSubscribers.delete(cb);
113
- };
114
- }
115
- /**
116
- * Every alias with a mounted query hook, so a local write can name them.
117
- *
118
- * Registered by the hooks themselves rather than derived from the manifest: what
119
- * matters is what is ON SCREEN, and a screen mounts a handful of an app's
120
- * queries. Keyed by alias with a count, because two hooks can read one alias at
121
- * once (a register and the drawer over it) and the first to unmount must not
122
- * retract the second's registration.
123
- */
124
- const mountedAliases = new Map();
125
- /** Register a mounted query's alias. Returns the matching unregister. */
126
- export function registerQueryAlias(alias) {
127
- mountedAliases.set(alias, (mountedAliases.get(alias) ?? 0) + 1);
128
- return () => {
129
- const n = (mountedAliases.get(alias) ?? 1) - 1;
130
- if (n > 0)
131
- mountedAliases.set(alias, n);
132
- else
133
- mountedAliases.delete(alias);
134
- };
135
- }
136
- /**
137
- * Re-read the mounted queries because THIS app just wrote.
138
- *
139
- * The host's `queriesChanged` push is for changes the app did not make — it
140
- * travels the realtime path (a version counter, a socket the host owns, and
141
- * coalescing that is deliberately lazy under load), which is right for another
142
- * member's edit and far too slow for your own. A writer already knows, so it
143
- * rings the same bell locally instead of waiting to be told.
144
- *
145
- * It names every mounted alias rather than only the ones the write touched: the
146
- * app cannot know which tables a workflow wrote, and the alternative — asking
147
- * every call site to declare what it invalidates — is a list that goes stale
148
- * silently the first time a workflow body grows a second write. The cost is
149
- * bounded by what is on screen, and it is the work the app was going to do a
150
- * moment later anyway.
151
- */
152
- export function notifyLocalWrite() {
153
- if (mountedAliases.size === 0)
154
- return;
155
- const aliases = [...mountedAliases.keys()];
156
- for (const cb of refetchSubscribers)
157
- cb(aliases);
158
- }
159
- const pending = new Map();
160
- const streaming = new Map();
161
- /** `useUrlState` subscribers — notified when the host broadcasts new params
162
- * after browser back/forward. */
163
- const urlStateSubscribers = new Set();
164
- /** Query hooks subscribed to the host's `queriesChanged` push — the host sends
165
- * it after an ambient app-chat agent turn mutated records. */
166
- const refetchSubscribers = new Set();
167
- let nextRpcId = 0;
168
- let listenerInstalled = false;
169
- function ensureListener() {
170
- if (listenerInstalled)
171
- return;
172
- listenerInstalled = true;
173
- window.addEventListener("message", (event) => {
174
- // Accept only messages from the parent window, at the host origin.
175
- if (event.source !== window.parent || event.origin !== getHostOrigin())
176
- return;
177
- const msg = event.data;
178
- if (!msg)
179
- return;
180
- // Broadcast (no id): the host pushes new params after browser back/forward
181
- // so `useUrlState` subscribers re-hydrate.
182
- if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
183
- for (const cb of urlStateSubscribers)
184
- cb(msg.params);
185
- return;
186
- }
187
- // Broadcast (no id): the host names the aliases whose tables changed, from
188
- // the realtime channel it holds for the whole tab. Push-only freshness; the
189
- // host never reads app data through this path.
190
- if (msg.type === "queriesChanged" && Array.isArray(msg.aliases)) {
191
- const aliases = msg.aliases.filter((a) => typeof a === "string");
192
- for (const cb of refetchSubscribers)
193
- cb(aliases);
194
- return;
195
- }
196
- // Broadcast (no id): a recording THIS app started moved — the host sends
197
- // no other app's and not the chat's.
198
- if (msg.type === "recordingChanged") {
199
- const state = readRecordingState(msg.state);
200
- if (typeof msg.alias !== "string" || state === null) {
201
- console.error("The host sent a malformed recordingChanged push; it is ignored.", msg);
202
- return;
203
- }
204
- const before = recordingStateOf(msg.alias);
205
- applyRecordingChange(msg.alias, state);
206
- // A filed recording is this app's own successful write, so the screen
207
- // re-reads now, as after any workflow, not on the realtime push.
208
- const newlyFiled = state.phase === "filed" &&
209
- !(before.phase === "filed" && before.execution_id === state.execution_id);
210
- if (newlyFiled)
211
- notifyLocalWrite();
212
- return;
213
- }
214
- if (typeof msg.id !== "number")
215
- return;
216
- // Single-response ops.
217
- const handler = pending.get(msg.id);
218
- if (handler) {
219
- pending.delete(msg.id);
220
- if (msg.type === "result")
221
- handler.resolve(msg.data);
222
- else
223
- handler.reject(new Error(msg.message ?? "RPC failed"));
224
- return;
225
- }
226
- // Streaming op (agentRun): run-id? chunk* → end | error.
227
- const stream = streaming.get(msg.id);
228
- if (!stream)
229
- return;
230
- if (msg.type === "run-id") {
231
- if (typeof msg.runId === "string")
232
- stream.onRunId?.(msg.runId);
233
- }
234
- else if (msg.type === "stream-chunk") {
235
- if (typeof msg.chunk === "string")
236
- stream.onText(msg.chunk);
237
- }
238
- else if (msg.type === "stream-end") {
239
- streaming.delete(msg.id);
240
- stream.resolve();
241
- }
242
- else if (msg.type === "error") {
243
- streaming.delete(msg.id);
244
- stream.reject(new Error(msg.message ?? "Run failed"));
245
- }
246
- });
247
- }
248
- function rpcBridged(op, payload, host) {
249
- ensureListener();
250
- return new Promise((resolve, reject) => {
251
- const id = nextRpcId++;
252
- pending.set(id, { resolve: resolve, reject });
253
- window.parent.postMessage({ id, op, payload }, host);
254
- });
255
- }
256
- // ── Streaming transport (agent runs) ─────────────────────────────────────────
257
- /**
258
- * Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
259
- * (the caller parses it via `agent_stream`); `done` settles when the stream
260
- * ends. Bridged: the host opens the SSE with its session and forwards chunks;
261
- * standalone: the SDK reads the public endpoint's body directly.
262
- */
263
- export function rpcAgentRun(payload, onText, onRunId) {
264
- const hostOrigin = getHostOrigin();
265
- return hostOrigin
266
- ? agentRunBridged(payload, onText, hostOrigin, onRunId)
267
- : agentRunStandalone(payload, onText, onRunId);
268
- }
269
- function agentRunBridged(payload, onText, host, onRunId) {
270
- ensureListener();
271
- const id = nextRpcId++;
272
- let settleDone = () => { };
273
- const done = new Promise((resolve, reject) => {
274
- settleDone = resolve;
275
- streaming.set(id, { onText, onRunId, resolve, reject });
276
- window.parent.postMessage({ id, op: "agentRun", payload }, host);
277
- });
278
- return {
279
- done,
280
- abort: () => {
281
- if (!streaming.has(id))
282
- return;
283
- streaming.delete(id);
284
- window.parent.postMessage({ id, type: "abort" }, host);
285
- // The caller stopped the run — resolve `done` cleanly so it never hangs
286
- // (an abort is not a failure). The host tears down the SSE.
287
- settleDone();
288
- },
289
- };
290
- }
291
- /**
292
- * Continue a PARKED (`awaiting_input`) agent run with the user's answer to its
293
- * pending `ask_user_choice` call — streams the continuation leg exactly like
294
- * `rpcAgentRun` streams the first.
295
- */
296
- export function rpcAgentRunContinue(payload, onText) {
297
- const hostOrigin = getHostOrigin();
298
- if (hostOrigin) {
299
- ensureListener();
300
- const id = nextRpcId++;
301
- let settleDone = () => { };
302
- const done = new Promise((resolve, reject) => {
303
- settleDone = resolve;
304
- streaming.set(id, { onText, resolve, reject });
305
- window.parent.postMessage({ id, op: "agentRunContinue", payload }, hostOrigin);
306
- });
307
- return {
308
- done,
309
- abort: () => {
310
- if (!streaming.has(id))
311
- return;
312
- streaming.delete(id);
313
- window.parent.postMessage({ id, type: "abort" }, hostOrigin);
314
- settleDone();
315
- },
316
- };
317
- }
318
- const controller = new AbortController();
319
- const done = (async () => {
320
- const { app_id } = await boot();
321
- const headers = { "content-type": "application/json" };
322
- if (sessionToken)
323
- headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
324
- // Continuing an anonymous parked run is authorized by the same per-run
325
- // capability that reads it — there is no member to authorize instead.
326
- const continueToken = runTokens.get(payload.run_id);
327
- if (continueToken)
328
- headers[APP_AGENT_RUN_TOKEN_HEADER] = continueToken;
329
- const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agent-runs/${encodeURIComponent(payload.run_id)}/continue`, {
330
- method: "POST",
331
- headers,
332
- body: JSON.stringify({ tool_call_id: payload.tool_call_id, output: payload.output }),
333
- signal: controller.signal,
334
- });
335
- if (!res.ok || !res.body) {
336
- throw await streamStartError(res);
337
- }
338
- const reader = res.body.getReader();
339
- const decoder = new TextDecoder();
340
- try {
341
- for (;;) {
342
- const { value, done: streamDone } = await reader.read();
343
- if (streamDone)
344
- break;
345
- onText(decoder.decode(value, { stream: true }));
346
- }
347
- const tail = decoder.decode();
348
- if (tail)
349
- onText(tail);
350
- }
351
- catch (err) {
352
- if (controller.signal.aborted)
353
- return;
354
- throw err;
355
- }
356
- })();
357
- return { done, abort: () => controller.abort() };
358
- }
359
- function agentRunStandalone(payload, onText, onRunId) {
360
- const controller = new AbortController();
361
- const done = (async () => {
362
- const { app_id } = await boot();
363
- const headers = { "content-type": "application/json" };
364
- if (sessionToken)
365
- headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
366
- const res = await fetch(`${apiBase()}/v1/apps/${app_id}/agents/${encodeURIComponent(payload.alias)}/runs`, {
367
- method: "POST",
368
- headers,
369
- body: JSON.stringify({ session_id: payload.session_id, input: payload.input }),
370
- signal: controller.signal,
371
- });
372
- if (!res.ok || !res.body) {
373
- throw await streamStartError(res);
374
- }
375
- const runId = res.headers.get("x-app-agent-run-id");
376
- if (runId) {
377
- // Anonymous runs carry their retrieval capability here; a member run does
378
- // not, and `rememberRunToken` no-ops on the null.
379
- rememberRunToken(runId, res.headers.get(APP_AGENT_RUN_TOKEN_HEADER));
380
- onRunId?.(runId);
381
- }
382
- const reader = res.body.getReader();
383
- const decoder = new TextDecoder();
384
- try {
385
- for (;;) {
386
- const { value, done: streamDone } = await reader.read();
387
- if (streamDone)
388
- break;
389
- onText(decoder.decode(value, { stream: true }));
390
- }
391
- const tail = decoder.decode(); // flush any bytes held across the final read
392
- if (tail)
393
- onText(tail);
394
- }
395
- catch (err) {
396
- if (controller.signal.aborted)
397
- return; // the caller stopped the run — clean, not an error
398
- throw err;
399
- }
400
- })();
401
- return { done, abort: () => controller.abort() };
402
- }
403
- // ── Standalone transport ────────────────────────────────────────────────────
404
- /**
405
- * Meta the host serving a standalone bundle injects into `index.html`, naming
406
- * the API the app calls.
407
- *
408
- * It is the serving host's to state, not the SDK's to assume: the same bundle
409
- * is served by whoever runs the instance, and an app that carried a compiled-in
410
- * API address would call somebody else's server. A bridged app never reads it —
411
- * there the host's own origin arrives on `?lotics_host=`.
412
- */
413
- const API_BASE_META = "lotics-api-base";
414
- let apiBaseCache = null;
415
- function apiBase() {
416
- if (apiBaseCache !== null)
417
- return apiBaseCache;
418
- const content = document
419
- .querySelector(`meta[name="${API_BASE_META}"]`)
420
- ?.getAttribute("content")
421
- ?.trim();
422
- if (!content) {
423
- // No default: guessing would send this app's data to whatever address the
424
- // guess named. Say what is missing and who puts it there.
425
- throw new Error(`This app has no API address. A standalone app reads it from <meta name="${API_BASE_META}"> ` +
426
- "in the page the app host serves, and this page carries none.");
427
- }
428
- apiBaseCache = content.replace(/\/+$/, "");
429
- return apiBaseCache;
430
- }
431
- const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
432
- /**
433
- * Boot-time resolution result. Promise is shared so concurrent first calls
434
- * (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
435
- * `/by-subdomain` fetch and at most one password prompt.
436
- */
437
- let bootPromise = null;
438
- let appInfoPromise = null;
439
- let sessionToken = null;
440
- /**
441
- * The password-session header. It is deliberately NOT `Authorization`: this
442
- * token authenticates nobody — it proves the visitor knows the app's shared
443
- * link password — and the credential header already carries API keys and OAuth
444
- * bearers. Sharing it meant the server's auth middleware rejected the request
445
- * as an unknown bearer before the password gate ever ran, which took every
446
- * password-gated public app offline. Wire constant, mirrored server-side by
447
- * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
448
- */
449
- export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
450
- /**
451
- * Per-run retrieval credentials for ANONYMOUS agent runs, keyed by run id.
452
- *
453
- * A member's run is authorized by their identity, so reading it back needs
454
- * nothing extra. An anonymous run on a publicly-shared app has no member to key
455
- * on (`triggered_by_member_id` is null by design), so the server mints a
456
- * capability token bound to that one run and returns it on the run response.
457
- * Without it the recovery poll 404s and a completed answer is lost to any
458
- * dropped connection — routine on mobile for a run measured in tens of seconds.
459
- *
460
- * Transport-level, like `sessionToken` above and for the same reason: it is a
461
- * credential, not app state. Threading it through the hook would put a bearer
462
- * into the app's typed surface for every app to forward by hand.
463
- *
464
- * Only the STANDALONE transport needs this — an embedded app always runs under
465
- * a member session, so it never produces an anonymous run.
466
- */
467
- const runTokens = new Map();
468
- /** Bound the map so a long-lived page cannot accumulate tokens without limit.
469
- * Insertion-ordered, so the oldest entry is the first key. */
470
- const MAX_TRACKED_RUN_TOKENS = 8;
471
- function rememberRunToken(runId, token) {
472
- if (!token)
473
- return;
474
- runTokens.set(runId, token);
475
- while (runTokens.size > MAX_TRACKED_RUN_TOKENS) {
476
- const oldest = runTokens.keys().next();
477
- if (oldest.done)
478
- break;
479
- runTokens.delete(oldest.value);
480
- }
481
- }
482
- /** The run token header — mirrored server-side by `APP_AGENT_RUN_TOKEN_HEADER`. */
483
- export const APP_AGENT_RUN_TOKEN_HEADER = "x-app-agent-run-token";
484
- /**
485
- * The cookie the app-host Worker sets after the visitor clears the password
486
- * gate. Readable by design — the SDK forwards its value as the session header
487
- * on every data call. Same exposure as the `localStorage` token it replaced,
488
- * minus the password prompt the SDK no longer owns.
489
- */
490
- const SESSION_COOKIE = "lotics_app_session";
491
- function readSessionCookie() {
492
- for (const part of document.cookie.split(";")) {
493
- const eq = part.indexOf("=");
494
- if (eq < 0)
495
- continue;
496
- if (part.slice(0, eq).trim() === SESSION_COOKIE) {
497
- return decodeURIComponent(part.slice(eq + 1).trim()) || null;
498
- }
499
- }
500
- return null;
501
- }
502
- /**
503
- * Resolve the app's identity from its own subdomain. Shared
504
- * promise so the context bootstrap and the first data call coalesce into one
505
- * `/by-subdomain` fetch. Does NOT touch the password gate — the app's own
506
- * identity is not the gated thing, so a password-gated app still resolves it
507
- * before the prompt.
508
- */
509
- function resolveAppInfo() {
510
- if (appInfoPromise)
511
- return appInfoPromise;
512
- const slug = window.location.hostname.split(".")[0];
513
- const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`);
514
- // Don't cache a rejection — a transient failure on first load would brick
515
- // every later data call. Clear the slot so the next call retries.
516
- attempt.catch(() => {
517
- if (appInfoPromise === attempt)
518
- appInfoPromise = null;
519
- });
520
- appInfoPromise = attempt;
521
- return attempt;
522
- }
523
- async function boot() {
524
- if (bootPromise)
525
- return bootPromise;
526
- const attempt = (async () => {
527
- const info = await resolveAppInfo();
528
- if (info.requires_password) {
529
- // The visitor already passed the gate — the app-host Worker refuses to
530
- // serve a single byte of this bundle otherwise — and handed us the session
531
- // in a cookie on this origin. The SDK does not prompt: a password screen
532
- // is the serving layer's job, which is why it can be one styled, localized
533
- // page for every public app instead of hand-rolled DOM in a data library.
534
- sessionToken = readSessionCookie();
535
- }
536
- return info;
537
- })();
538
- attempt.catch(() => {
539
- if (bootPromise === attempt)
540
- bootPromise = null;
541
- });
542
- bootPromise = attempt;
543
- return attempt;
544
- }
545
- /**
546
- * A user-facing message for a transport/gateway failure — derived from the HTTP
547
- * status, never from the response body. A 524 (Cloudflare edge timeout on a long
548
- * run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
549
- * body as the error message.
550
- *
551
- * A by-value MIRROR of `@lotics/shared/transport_error`, which is canonical. This
552
- * package ships to npm with zero internal dependencies, so it cannot import it;
553
- * every other transport does. Change one, change both.
554
- */
555
- function gatewayErrorMessage(status) {
556
- if (status === 524) {
557
- return "The request took too long to finish (gateway timeout). It may still be running — check back in a moment, or try again.";
558
- }
559
- if (status >= 500) {
560
- return "The service is temporarily unavailable. Please try again shortly.";
561
- }
562
- return "The service returned an unexpected response. Please try again.";
563
- }
564
- /**
565
- * The error message for a non-ok response. A JSON error the API AUTHORED surfaces
566
- * verbatim — a 4xx carrying a `message`, or a 5xx that also carries `code`, the
567
- * discriminator every error the API emits is built with. A non-JSON body (a
568
- * gateway HTML page), a 5xx from something that is not us, or a body without a
569
- * `message` falls back to a body-free, status-derived message — so a raw HTML
570
- * body never becomes the message. `parsed` is the JSON.parse of the body, or
571
- * `null` if it wasn't JSON.
572
- */
573
- export function transportErrorMessage(status, parsed) {
574
- const jsonMessage = parsed && typeof parsed.message === "string"
575
- ? parsed.message
576
- : null;
577
- if (jsonMessage === null)
578
- return gatewayErrorMessage(status);
579
- // Below 500 the body is ours. At 500 and above it is ours only if it carries
580
- // `code` — the discriminator every error the API emits is built with. Status
581
- // alone was the wrong test: it threw away the 503 our OWN backpressure gate
582
- // authors and replaced it with a different English sentence, so no wording we
583
- // choose for a 5xx could ever reach an app member (GAP-300). A gateway's HTML
584
- // does not parse at all, and a proxy's JSON does not carry `code`, so both
585
- // still get the body-free message.
586
- const authored = typeof parsed.code === "string" &&
587
- parsed.code.length > 0;
588
- return status < 500 || authored ? jsonMessage : gatewayErrorMessage(status);
589
- }
590
- /**
591
- * A failed request, carrying the per-input refusals when the API named any.
592
- *
593
- * A body that does not match what an alias declares is answered 400 with
594
- * top-level `field_errors` — one sentence per INPUT name, which is what a form
595
- * puts on the control that is wrong, where the message can only say it at the
596
- * screen's scope. It rides the error because the transport's own callers are
597
- * what turn a failure into the result an app reads. Optional throughout: a
598
- * server that predates them sends none.
599
- */
600
- class ApiRefusal extends Error {
601
- field_errors;
602
- constructor(message, field_errors) {
603
- super(message);
604
- this.field_errors = field_errors;
605
- }
606
- }
607
- /** The `field_errors` of an error body, when it carries a well-formed one. */
608
- function readFieldErrors(parsed) {
609
- if (parsed === null || typeof parsed !== "object" || !("field_errors" in parsed)) {
610
- return undefined;
611
- }
612
- const raw = parsed.field_errors;
613
- if (raw === null || typeof raw !== "object" || Array.isArray(raw))
614
- return undefined;
615
- const named = Object.entries(raw).filter((entry) => typeof entry[1] === "string");
616
- return named.length > 0 ? Object.fromEntries(named) : undefined;
617
- }
618
- /**
619
- * The error a stream that never started should throw.
620
- *
621
- * A streaming endpoint fails BEFORE the first byte like any other request — a 402 when the
622
- * workspace is out of credits, a 403, a 404. Those bodies are structured errors, and throwing
623
- * the body TEXT put the whole JSON on screen: `{"message":"Credit quota exceeded…","error":
624
- * "quota_exceeded","plan_id":"free",…}`. Same rule as every other call: the `message` is the
625
- * message, and a non-JSON or 5xx body never becomes one.
626
- */
627
- export async function streamStartError(res) {
628
- const text = await res.text().catch(() => "");
629
- let parsed = null;
630
- if (text) {
631
- try {
632
- parsed = JSON.parse(text);
633
- }
634
- catch {
635
- // not JSON — a gateway HTML page; transportErrorMessage will not surface it
636
- }
637
- }
638
- return new Error(transportErrorMessage(res.status, parsed));
639
- }
640
- // Standalone requests carried no timeout: a hung or slow backend pinned the
641
- // fetch — and one of the browser's few per-origin connections — indefinitely,
642
- // surfacing as an app that "loads forever". Bound every standalone call the way
643
- // the bridged host path already bounds its own (`apiFetch`, 30s): a stalled
644
- // request fails fast with a gateway-timeout message instead of spinning.
645
- const API_TIMEOUT_MS = 30_000;
646
- async function apiCall(method, path, body, opts) {
647
- const headers = {};
648
- if (body)
649
- headers["content-type"] = "application/json";
650
- if (sessionToken && !opts?.skipAuth) {
651
- headers[APP_PUBLIC_SESSION_HEADER] = sessionToken;
652
- }
653
- // The per-run capability for an ANONYMOUS run — the only thing that authorizes
654
- // reading or cancelling it, since there is no member identity to check.
655
- if (opts?.runToken) {
656
- headers[APP_AGENT_RUN_TOKEN_HEADER] = opts.runToken;
657
- }
658
- const controller = new AbortController();
659
- let didTimeout = false;
660
- const timeoutId = setTimeout(() => {
661
- didTimeout = true;
662
- controller.abort();
663
- }, API_TIMEOUT_MS);
664
- let res;
665
- try {
666
- res = await fetch(`${apiBase()}${path}`, {
667
- method,
668
- headers,
669
- body: body ? JSON.stringify(body) : undefined,
670
- signal: controller.signal,
671
- });
672
- }
673
- catch (err) {
674
- // A timeout abort surfaces as an AbortError — convert it to the same
675
- // body-free gateway-timeout message a 524 produces, never a raw abort.
676
- if (didTimeout && err instanceof DOMException && err.name === "AbortError") {
677
- throw new Error(gatewayErrorMessage(524));
678
- }
679
- throw err;
680
- }
681
- finally {
682
- clearTimeout(timeoutId);
683
- }
684
- const text = await res.text();
685
- let parsed = null;
686
- if (text) {
687
- try {
688
- parsed = JSON.parse(text);
689
- }
690
- catch {
691
- // not JSON — body left as raw text
692
- }
693
- }
694
- if (!res.ok) {
695
- const errorBody = parsed;
696
- // The session went stale — the owner rotated or cleared the password, so the
697
- // token's fingerprint no longer matches. Reloading is the whole recovery:
698
- // the app-host Worker re-checks its own cookie and, if that is stale too,
699
- // serves the gate. The SDK deliberately owns no re-prompt — the gate is a
700
- // page at the serving layer, not a modal inside the data layer.
701
- if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
702
- sessionToken = null;
703
- // `/__gate`, not a plain reload: the edge cookie outlives the API session
704
- // (rotation kills the session immediately, the cookie only on expiry), so
705
- // reloading would be served the bundle again and loop forever. This route
706
- // clears both cookies and re-presents the gate.
707
- window.location.assign("/__gate");
708
- // Never resolves — navigation replaces the document. Returning would let
709
- // callers render an error state during teardown.
710
- return new Promise(() => { });
711
- }
712
- // Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
713
- // the message — emit a body-free, status-derived message instead. The
714
- // per-input refusals ride along, for the caller that can place them.
715
- throw new ApiRefusal(transportErrorMessage(res.status, parsed), readFieldErrors(parsed));
716
- }
717
- return parsed ?? (text ? text : {});
718
- }
719
- function rpcStandalone(op, payload) {
720
- switch (op) {
721
- case "query":
722
- return standaloneQuery(payload);
723
- case "field_options":
724
- return standaloneFieldOptions(payload);
725
- case "workflow":
726
- return standaloneWorkflow(payload);
727
- case "agentRuns":
728
- return standaloneAgentRuns(payload);
729
- case "agentRun.get":
730
- return standaloneAgentRunGet(payload);
731
- case "agentRun.cancel":
732
- return standaloneAgentRunCancel(payload);
733
- case "upload":
734
- return standaloneUpload(payload.file, payload.fidelity);
735
- case "members":
736
- return standaloneMembers(payload);
737
- case "context":
738
- return standaloneContext();
739
- case "openExternal":
740
- return standaloneOpenExternal(payload);
741
- case "openApp":
742
- // A standalone app is one page at its own address: no host routing
743
- // between apps, no sibling to reach.
744
- return Promise.reject(new Error("openApp needs the Lotics host: a standalone app has no sibling apps to open. Gate the control on isEmbedded()."));
745
- case "askAi":
746
- // The chat surface lives in the Lotics host — a standalone page has
747
- // nowhere to hand off to.
748
- return Promise.reject(new Error("askAi is only available when the app runs inside Lotics"));
749
- case "urlState.get":
750
- // Standalone is a top-level page — its own query string IS the store.
751
- return Promise.resolve(parseSearch(window.location.search));
752
- case "urlState.set":
753
- return standaloneUrlStateSet(payload);
754
- case "comments.list":
755
- case "comments.create":
756
- case "comments.update":
757
- case "comments.delete":
758
- case "comments.counts":
759
- return rejectCommentsStandalone();
760
- case "recording.start":
761
- case "recording.stop":
762
- // The host records, as the signed-in member; a standalone visitor is
763
- // anonymous and has no host to capture in.
764
- return Promise.reject(new Error("Recording needs the Lotics host and a signed-in member. Gate the control on useRecording's `available`."));
765
- }
766
- }
767
- /**
768
- * Comments are a members-only collaboration surface. A comment must have an
769
- * authenticated member as its author, and a thread can carry member-to-member
770
- * discussion — so a standalone (public, anonymous) app has no member to author
771
- * as and no business reading other members' threads. `useComments` detects this
772
- * up front via the null context `member_id` and never sends a comment op; this
773
- * rejection is the belt-and-braces backstop for any direct `rpc()` caller.
774
- */
775
- function rejectCommentsStandalone() {
776
- return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
777
- }
778
- async function standaloneMembers(p) {
779
- const { app_id } = await boot();
780
- const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
781
- const r = (await apiCall("GET", `/v1/apps/${app_id}/members${qs}`, undefined, {
782
- appId: app_id,
783
- }));
784
- return { members: r.members ?? [] };
785
- }
786
- /**
787
- * Open an external URL in a new tab, scheme-validated. In standalone mode the
788
- * app is a normal top-level page on its own origin, so `window.open` is not
789
- * sandbox-blocked — open directly. (Bridged apps route this op to the host,
790
- * which opens it in the un-sandboxed parent frame; see `app_iframe_host`.)
791
- * The scheme is re-validated wherever the open actually happens — never trust a
792
- * URL handed across the bridge.
793
- */
794
- function openValidatedUrl(url) {
795
- if (typeof url !== "string")
796
- throw new Error("openExternal requires a url string");
797
- const parsed = new URL(url);
798
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
799
- throw new Error(`openExternal: unsupported URL scheme "${parsed.protocol}"`);
800
- }
801
- window.open(parsed.href, "_blank", "noopener,noreferrer");
802
- }
803
- // `async` so a synchronous validation throw surfaces as a rejected Promise.
804
- async function standaloneOpenExternal(p) {
805
- openValidatedUrl(p.url);
806
- }
807
- async function standaloneUrlStateSet(p) {
808
- const search = serializeMerge(window.location.search, p.params ?? {});
809
- const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
810
- // View-state writes never add a history entry; replaceState doesn't fire
811
- // popstate, so there's no echo — the caller already updated optimistically.
812
- window.history.replaceState(null, "", url);
813
- }
814
- async function standaloneContext() {
815
- const info = await resolveAppInfo();
816
- return {
817
- // No host session in standalone mode — the visitor is anonymous, so
818
- // comments are unavailable regardless of the capability flag.
819
- member_id: null,
820
- comments_enabled: info.comments_enabled,
821
- recording_enabled: false,
822
- };
823
- }
824
- /**
825
- * The op payload IS the endpoint's body, and the endpoint's answer IS the op's
826
- * result: both pass through untouched, so `sort`/`filter`/`count`/`keyset` on
827
- * the way out and `total`/`truncated`/`next_cursor` on the way back reach a
828
- * standalone app exactly as they reach a bridged one. Naming fields here is
829
- * what once made `truncated` a host-only field.
830
- */
831
- async function standaloneQuery(payload) {
832
- const { app_id } = await boot();
833
- return apiCall("POST", `/v1/apps/${app_id}/query`, payload, { appId: app_id });
834
- }
835
- async function standaloneFieldOptions(p) {
836
- const { app_id } = await boot();
837
- const r = (await apiCall("POST", `/v1/apps/${app_id}/field-options`, { alias: p.alias }, { appId: app_id }));
838
- return { fields: r.fields ?? {} };
839
- }
840
- async function standaloneWorkflow(p) {
841
- const { app_id } = await boot();
842
- try {
843
- return await apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
844
- }
845
- catch (err) {
846
- // A transport/gateway failure resolves to a WorkflowResult error (never a
847
- // rejection carrying a raw body) so an app reads `result.status === "error"`
848
- // uniformly with a handled workflow error. `apiCall` already sanitized the
849
- // message, so it never contains an HTML body.
850
- //
851
- // A payload the alias refuses is that same shape plus the inputs it named,
852
- // which is the half a form can act on — the same key a workflow's own
853
- // `return({ field_errors })` arrives under, so a screen wires one control
854
- // once whichever half answered.
855
- return {
856
- status: "error",
857
- message: err instanceof Error ? err.message : "The workflow failed to run.",
858
- ...(err instanceof ApiRefusal && err.field_errors ? { field_errors: err.field_errors } : {}),
859
- };
860
- }
861
- }
862
- async function standaloneAgentRuns(p) {
863
- const { app_id } = await boot();
864
- const qs = new URLSearchParams({ session_id: p.session_id });
865
- if (p.limit != null)
866
- qs.set("limit", String(p.limit));
867
- if (p.offset != null)
868
- qs.set("offset", String(p.offset));
869
- const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs?${qs.toString()}`, undefined, {
870
- appId: app_id,
871
- }));
872
- return { runs: r.runs ?? [] };
873
- }
874
- /** Poll read for a single run — follows a run to completion after its stream
875
- * connection drops. The caller types the run shape via `rpc<T>`. */
876
- async function standaloneAgentRunGet(p) {
877
- const { app_id } = await boot();
878
- const r = (await apiCall("GET", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}`, undefined, {
879
- appId: app_id,
880
- runToken: runTokens.get(p.run_id),
881
- }));
882
- return { run: r.run };
883
- }
884
- /** Request server-side cancellation of an in-flight run (an explicit user stop,
885
- * distinct from merely closing the stream). */
886
- async function standaloneAgentRunCancel(p) {
887
- const { app_id } = await boot();
888
- await apiCall("POST", `/v1/apps/${app_id}/agent-runs/${encodeURIComponent(p.run_id)}/cancel`, {}, {
889
- appId: app_id,
890
- runToken: runTokens.get(p.run_id),
891
- });
892
- return { ok: true };
893
- }
894
- async function standaloneUpload(file, fidelity) {
895
- if (!(file instanceof File)) {
896
- throw new Error("upload payload must include a File");
897
- }
898
- const { app_id } = await boot();
899
- const uploaded = await runUploadPipeline(file, {
900
- initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
901
- completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
902
- }, { fidelity });
903
- return uploaded;
904
- }