@lotics/app-sdk 0.40.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +19 -11
- package/dist/src/hooks.d.ts +6 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/router.js +53 -100
- package/dist/src/rpc.d.ts +16 -13
- package/dist/src/rpc.js +70 -42
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -39,7 +39,10 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
39
39
|
- **Mutate (the ONLY write path)** — **`useWorkflow(alias)`** → an async fn returning a typed
|
|
40
40
|
`WorkflowResult` `{ status, message?, files?, data? }`. `data` is whatever the workflow
|
|
41
41
|
`return({ data })`'d (typed automatically from the alias contract). After a known mutation,
|
|
42
|
-
call the owning query's `refetch()`.
|
|
42
|
+
call the owning query's `refetch()`. Handle failures by checking `result.status === "error"`,
|
|
43
|
+
not just `try/catch`: a transport/gateway failure (a 524 timeout on a long run, any 5xx, a
|
|
44
|
+
non-JSON error page) **resolves** with `{ status: "error", message }` (a friendly, body-free
|
|
45
|
+
message) — it does not throw an HTML body.
|
|
43
46
|
- **Upload a file** — **`useFileUpload()`** → `{ upload, uploading, error }`. Mints a presigned URL
|
|
44
47
|
and PUTs the bytes straight to storage; the file is inert until a workflow attaches it to a
|
|
45
48
|
`files` field. Emits `app_file_uploaded`.
|
|
@@ -78,8 +81,9 @@ Pick by intent. (→ open the `.d.ts` for the exact signature.)
|
|
|
78
81
|
keeping a declared slice of state (filters, search, sort, the active tab) in the **address bar** so a
|
|
79
82
|
view survives refresh and is shareable/bookmarkable. `setValues` replaces in place. Build `shape` from
|
|
80
83
|
**`urlParam`** codecs. See *Save view-state to the URL*.
|
|
81
|
-
- **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config
|
|
82
|
-
|
|
84
|
+
- **In-app routing** — **`AppRouter`** (from `@lotics/app-sdk/router`) → wrap a react-router route config;
|
|
85
|
+
the app owns its own url (embedded, that's the iframe's own url — never the host's), so navigation never
|
|
86
|
+
reloads the app, browser back/forward walk app screens, and screens stay shareable + refresh-survivable.
|
|
83
87
|
See *In-app navigation*.
|
|
84
88
|
- **Optimistic mutation glue** — **`useOptimistic()`** → reconcile a workflow mutation against the
|
|
85
89
|
query cache for an interactive (calendar/kanban/grid) app. See the data-bound recipe.
|
|
@@ -301,14 +305,18 @@ export default function App() {
|
|
|
301
305
|
|
|
302
306
|
Inside, use react-router normally — `useNavigate`, `useParams`, `<Link>`.
|
|
303
307
|
|
|
304
|
-
- **Embedded** (in the Lotics host): the
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
- **Embedded** (in the Lotics host): the app drives the **iframe's own url** (same-origin to itself) via
|
|
309
|
+
`pushState`. The user sees the host's address bar, never the iframe's, so the screen url is invisible — and
|
|
310
|
+
the host never sees it, so navigation never reloads the app. The iframe's history participates in the
|
|
311
|
+
session history, so the **browser Back/Forward buttons walk app screens** (then leave the app). `AppRouter`
|
|
312
|
+
also mirrors the current screen into the host url (a non-remounting write) and the host bakes it back into
|
|
313
|
+
the iframe src on load, so a screen is **shareable and survives a full refresh** — no reload, no extra code.
|
|
314
|
+
- **Standalone** (`<slug>.lotics.app`): a normal browser router — real path URLs, native browser back/forward,
|
|
315
|
+
refresh-survivable.
|
|
316
|
+
|
|
317
|
+
`useUrlState` is the complement: in-app *routing* (screens) is automatic via `AppRouter`; reach for
|
|
318
|
+
`useUrlState` for *filters/search* you want as first-class, typed, shareable query keys. `react-router-dom` is
|
|
319
|
+
an optional peer — only apps that import `@lotics/app-sdk/router` pull it in.
|
|
312
320
|
|
|
313
321
|
---
|
|
314
322
|
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -164,6 +164,12 @@ type UseWorkflowFn<K extends keyof AppWorkflows & string> = AppWorkflows[K] exte
|
|
|
164
164
|
*
|
|
165
165
|
* `data` is the structured value the workflow returned via `return({ data })`,
|
|
166
166
|
* typed per the alias's declared `outputs` schema (`unknown` when none was declared).
|
|
167
|
+
*
|
|
168
|
+
* A transport/gateway failure (a Cloudflare 524 timeout on a long run, any 5xx,
|
|
169
|
+
* or a non-JSON error page) **resolves** with `{ status: "error", message }` —
|
|
170
|
+
* a body-free, friendly message — rather than rejecting with a raw HTML body.
|
|
171
|
+
* So an app handles every failure (handled workflow error AND transport error)
|
|
172
|
+
* by checking `result.status === "error"`; it never receives gateway HTML.
|
|
167
173
|
*/
|
|
168
174
|
export interface WorkflowResult<TData = unknown> {
|
|
169
175
|
status: "success" | "error";
|
package/dist/src/hooks.js
CHANGED
|
@@ -26,7 +26,7 @@ export function useWorkflow(alias) {
|
|
|
26
26
|
return useCallback(async (inputs) => {
|
|
27
27
|
try {
|
|
28
28
|
const result = await rpc("workflow", { alias, inputs: inputs ?? {} });
|
|
29
|
-
captureAppEvent("app_workflow_run", { alias, ok:
|
|
29
|
+
captureAppEvent("app_workflow_run", { alias, ok: result.status !== "error" });
|
|
30
30
|
return result;
|
|
31
31
|
}
|
|
32
32
|
catch (err) {
|
package/dist/src/router.js
CHANGED
|
@@ -1,17 +1,29 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* In-app routing for custom-code apps — `AppRouter` lets an app use react-router
|
|
4
4
|
* normally (`useNavigate`, `useParams`, `<Link>`) while its screens become real,
|
|
5
|
-
* addressable URLs in both
|
|
5
|
+
* addressable URLs. The app owns its OWN url (a plain browser history) in both
|
|
6
|
+
* modes; the host url only ever *mirrors* the screen, it never drives the router:
|
|
6
7
|
*
|
|
7
|
-
* - **Standalone** (`<slug>.lotics.app`):
|
|
8
|
-
* URLs, native browser back/forward, deep-link/refresh via the app host's
|
|
9
|
-
* fallback.
|
|
10
|
-
* - **Embedded** (inside the Lotics host): the app
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
8
|
+
* - **Standalone** (`<slug>.lotics.app`): the page's own browser history — real
|
|
9
|
+
* path URLs, native browser back/forward, deep-link/refresh via the app host's
|
|
10
|
+
* SPA fallback.
|
|
11
|
+
* - **Embedded** (inside the Lotics host): the app drives the IFRAME's own url
|
|
12
|
+
* (the iframe is same-origin to itself) via `pushState`. The user sees the
|
|
13
|
+
* host's address bar, never the iframe's, and the host never sees the iframe's
|
|
14
|
+
* url — so it never navigates and never remounts the iframe. (Reflecting the
|
|
15
|
+
* screen into the host url with a *navigation* is what used to remount the app
|
|
16
|
+
* iframe and reload the whole app on every in-app navigation.) The iframe's
|
|
17
|
+
* history still participates in the session history, so browser Back/Forward
|
|
18
|
+
* walk app screens (then leave the app).
|
|
19
|
+
*
|
|
20
|
+
* To stay shareable + refresh-survivable, `AppRouter` *mirrors* the current
|
|
21
|
+
* screen into the host url under `_loc` via `setUrlParams` — a non-remounting
|
|
22
|
+
* `history.replaceState` on the host, never a navigation. The READ half is the
|
|
23
|
+
* host's job: on (re)load it bakes `_loc` into the iframe src, so the app boots
|
|
24
|
+
* *directly* at the saved screen. There's deliberately no async seed here —
|
|
25
|
+
* that would race the app's own first navigation; the iframe's initial url is
|
|
26
|
+
* the source of truth, set synchronously by the host.
|
|
15
27
|
*
|
|
16
28
|
* Shipped as a separate entry (`@lotics/app-sdk/router`) so apps that don't route
|
|
17
29
|
* never pull react-router into their bundle:
|
|
@@ -24,103 +36,44 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
24
36
|
* ]} />;
|
|
25
37
|
* }
|
|
26
38
|
*/
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
/** Host query key carrying the app's current screen
|
|
31
|
-
*
|
|
39
|
+
import { useEffect } from "react";
|
|
40
|
+
import { BrowserRouter, useLocation, useRoutes, } from "react-router-dom";
|
|
41
|
+
import { isEmbedded, setUrlParams } from "./rpc.js";
|
|
42
|
+
/** Host query key carrying the app's current screen, so it's shareable and the
|
|
43
|
+
* host can restore it on refresh. */
|
|
32
44
|
const LOC_KEY = "_loc";
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return { pathname: u.pathname, search: u.search, hash: u.hash, state: null, key: String(++locKey) };
|
|
45
|
+
/** The host handshake param the host puts on the iframe src — present on the
|
|
46
|
+
* initial url only, never part of a route, so it's stripped from the mirror. */
|
|
47
|
+
const HOST_KEY = "lotics_host";
|
|
48
|
+
function screenHref(loc) {
|
|
49
|
+
const search = new URLSearchParams(loc.search);
|
|
50
|
+
search.delete(HOST_KEY);
|
|
51
|
+
const qs = search.toString();
|
|
52
|
+
return loc.pathname + (qs ? `?${qs}` : "") + loc.hash;
|
|
42
53
|
}
|
|
43
54
|
/**
|
|
44
|
-
* Embedded
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
55
|
+
* Embedded only: mirror the current screen into the host url under `_loc` — a
|
|
56
|
+
* non-remounting `setUrlParams` → `history.replaceState`, never a navigation, so
|
|
57
|
+
* it never reloads the app. Write-only by design: the host reads `_loc` back and
|
|
58
|
+
* bakes it into the iframe src on (re)load, so the app already boots at the saved
|
|
59
|
+
* screen — no async read here, hence no seed-vs-navigation race. Standalone needs
|
|
60
|
+
* none of this — the app's own url already IS the screen.
|
|
49
61
|
*/
|
|
50
|
-
function
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
let seeded = false;
|
|
58
|
-
// `delta` is history metadata (scroll restoration / blockers), not used for
|
|
59
|
-
// rendering — best-effort per action, since the host doesn't report the exact
|
|
60
|
-
// index change on a broadcast.
|
|
61
|
-
function set(href, act, delta) {
|
|
62
|
-
seeded = true;
|
|
63
|
-
location = toLocation(href);
|
|
64
|
-
action = act;
|
|
65
|
-
for (const l of listeners)
|
|
66
|
-
l({ action, location, delta });
|
|
67
|
-
}
|
|
68
|
-
// Host back/forward (browser Back or our own `go`) → re-render to the broadcast
|
|
69
|
-
// location. Guarded so an already-current location doesn't re-fire.
|
|
70
|
-
subscribeUrlParams((params) => {
|
|
71
|
-
const raw = params[LOC_KEY];
|
|
72
|
-
const href = typeof raw === "string" ? raw : "/";
|
|
73
|
-
if (href !== hrefOf(location))
|
|
74
|
-
set(href, NavigationType.Pop, -1);
|
|
75
|
-
});
|
|
76
|
-
// Boot: seed from the host URL (resolves a tick after mount — first paint is
|
|
77
|
-
// "/"). Skip if the location was already driven, so a navigation that races the
|
|
78
|
-
// bridge read isn't clobbered.
|
|
79
|
-
void getUrlParams().then((params) => {
|
|
80
|
-
if (seeded)
|
|
81
|
-
return;
|
|
82
|
-
const raw = params[LOC_KEY];
|
|
83
|
-
if (typeof raw === "string" && raw !== hrefOf(location))
|
|
84
|
-
set(raw, NavigationType.Pop, 0);
|
|
85
|
-
});
|
|
86
|
-
return {
|
|
87
|
-
get action() {
|
|
88
|
-
return action;
|
|
89
|
-
},
|
|
90
|
-
get location() {
|
|
91
|
-
return location;
|
|
92
|
-
},
|
|
93
|
-
createHref: (to) => hrefOf(to),
|
|
94
|
-
createURL: (to) => new URL(hrefOf(to), window.location.origin),
|
|
95
|
-
encodeLocation: (to) => {
|
|
96
|
-
const u = new URL(hrefOf(to), "http://x");
|
|
97
|
-
return { pathname: u.pathname, search: u.search, hash: u.hash };
|
|
98
|
-
},
|
|
99
|
-
push: (to) => {
|
|
100
|
-
const href = hrefOf(to);
|
|
101
|
-
set(href, NavigationType.Push, 1);
|
|
102
|
-
void setUrlParams({ [LOC_KEY]: href }, true);
|
|
103
|
-
},
|
|
104
|
-
replace: (to) => {
|
|
105
|
-
const href = hrefOf(to);
|
|
106
|
-
set(href, NavigationType.Replace, 0);
|
|
107
|
-
void setUrlParams({ [LOC_KEY]: href }, false);
|
|
108
|
-
},
|
|
109
|
-
go: (delta) => {
|
|
110
|
-
void goUrlHistory(delta);
|
|
111
|
-
},
|
|
112
|
-
listen: (listener) => {
|
|
113
|
-
listeners.add(listener);
|
|
114
|
-
return () => listeners.delete(listener);
|
|
115
|
-
},
|
|
116
|
-
};
|
|
62
|
+
function HostScreenMirror() {
|
|
63
|
+
const location = useLocation();
|
|
64
|
+
const href = screenHref(location);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
void setUrlParams({ [LOC_KEY]: href });
|
|
67
|
+
}, [href]);
|
|
68
|
+
return null;
|
|
117
69
|
}
|
|
118
70
|
function RoutedRoutes({ routes }) {
|
|
119
71
|
return useRoutes(routes);
|
|
120
72
|
}
|
|
121
73
|
export function AppRouter({ routes }) {
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
const
|
|
125
|
-
return (
|
|
74
|
+
// `isEmbedded()` reads the `?lotics_host=` the host puts on the iframe src, so
|
|
75
|
+
// it's known synchronously at first render.
|
|
76
|
+
const embedded = isEmbedded();
|
|
77
|
+
return (_jsxs(BrowserRouter, { children: [embedded ? _jsx(HostScreenMirror, {}) : null, _jsx(RoutedRoutes, { routes: routes })
|
|
78
|
+
] }));
|
|
126
79
|
}
|
package/dist/src/rpc.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { type UrlParams, type UrlParamsPatch } from "./url_params.js";
|
|
|
19
19
|
* app → host: { id, op, payload }
|
|
20
20
|
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
21
21
|
*/
|
|
22
|
-
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "
|
|
22
|
+
export type RpcOp = "query" | "field_options" | "workflow" | "agentRuns" | "agentRun.get" | "agentRun.cancel" | "upload" | "members" | "context" | "openExternal" | "urlState.get" | "urlState.set" | "comments.list" | "comments.create" | "comments.update" | "comments.delete" | "comments.counts";
|
|
23
23
|
/** Payload for starting a streaming agent run. */
|
|
24
24
|
export interface AgentRunPayload {
|
|
25
25
|
alias: string;
|
|
@@ -60,8 +60,8 @@ export interface AppContext {
|
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
63
|
-
* own `<slug>.lotics.app`).
|
|
64
|
-
*
|
|
63
|
+
* own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
64
|
+
* pick the transport / behaviour; an app rarely needs it directly.
|
|
65
65
|
*/
|
|
66
66
|
export declare function isEmbedded(): boolean;
|
|
67
67
|
export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
|
|
@@ -69,20 +69,15 @@ export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T
|
|
|
69
69
|
* the page's own query string. */
|
|
70
70
|
export declare function getUrlParams(): Promise<UrlParams>;
|
|
71
71
|
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
72
|
-
* value is `undefined`), preserving every other param.
|
|
73
|
-
* history
|
|
74
|
-
|
|
75
|
-
export declare function setUrlParams(patch: UrlParamsPatch, push?: boolean): Promise<void>;
|
|
76
|
-
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
77
|
-
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
78
|
-
export declare function goUrlHistory(delta: number): Promise<void>;
|
|
72
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
73
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
74
|
+
export declare function setUrlParams(patch: UrlParamsPatch): Promise<void>;
|
|
79
75
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
80
76
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
81
77
|
* so it returns `{}` and the hook hydrates via `getUrlParams()` on mount. */
|
|
82
78
|
export declare function peekUrlParams(): UrlParams;
|
|
83
|
-
/** Subscribe to external query changes — back/forward
|
|
84
|
-
*
|
|
85
|
-
* `popstate`. */
|
|
79
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
80
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
86
81
|
export declare function subscribeUrlParams(cb: (params: UrlParams) => void): () => void;
|
|
87
82
|
/**
|
|
88
83
|
* Start a streaming agent run. Each raw SSE text chunk is handed to `onText`
|
|
@@ -91,3 +86,11 @@ export declare function subscribeUrlParams(cb: (params: UrlParams) => void): ()
|
|
|
91
86
|
* standalone: the SDK reads the public endpoint's body directly.
|
|
92
87
|
*/
|
|
93
88
|
export declare function rpcAgentRun(payload: AgentRunPayload, onText: (chunk: string) => void, onRunId?: (runId: string) => void): AgentRunHandle;
|
|
89
|
+
/**
|
|
90
|
+
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
91
|
+
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
|
92
|
+
* 5xx, or a JSON body without a `message` falls back to a body-free,
|
|
93
|
+
* status-derived message — so a raw HTML body never becomes the message.
|
|
94
|
+
* `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
|
|
95
|
+
*/
|
|
96
|
+
export declare function transportErrorMessage(status: number, parsed: unknown): string;
|
package/dist/src/rpc.js
CHANGED
|
@@ -20,8 +20,8 @@ function getHostOrigin() {
|
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Whether the app is running embedded in a Lotics host (vs. standalone at its
|
|
23
|
-
* own `<slug>.lotics.app`).
|
|
24
|
-
*
|
|
23
|
+
* own `<slug>.lotics.app`). `rpc()`, `useUrlState`, and `AppRouter` use this to
|
|
24
|
+
* pick the transport / behaviour; an app rarely needs it directly.
|
|
25
25
|
*/
|
|
26
26
|
export function isEmbedded() {
|
|
27
27
|
// Truthiness, not `!== null`: a present-but-empty `?lotics_host=` yields "",
|
|
@@ -35,31 +35,28 @@ export function rpc(op, payload) {
|
|
|
35
35
|
? rpcBridged(op, payload, hostOrigin)
|
|
36
36
|
: rpcStandalone(op, payload);
|
|
37
37
|
}
|
|
38
|
-
// ── URL state (the address bar as app view-state +
|
|
38
|
+
// ── URL state (the host address bar as shareable app view-state + screen) ────
|
|
39
39
|
//
|
|
40
|
-
// Two consumers
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// host URL directly, so reads/writes
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
40
|
+
// Two consumers: `useUrlState` (a declared, typed slice of filters/search) and
|
|
41
|
+
// `AppRouter`'s screen mirror (the current screen under `_loc`). Both keep app
|
|
42
|
+
// state in the host's address bar so it survives refresh and is shareable. An
|
|
43
|
+
// embedded app can't touch the cross-origin host URL directly, so reads/writes
|
|
44
|
+
// flow over the bridge: the host writes the params in place (a non-remounting
|
|
45
|
+
// `history.replaceState` — never a navigation, which would reload the iframe) and
|
|
46
|
+
// pushes external changes (browser back/forward) back via the `url-state`
|
|
47
|
+
// broadcast. A standalone app owns its top-level URL and the same ops resolve
|
|
48
|
+
// against `window.location`. (In-app *routing* is not here — the app owns its own
|
|
49
|
+
// url via `@lotics/app-sdk/router`; see that module.)
|
|
47
50
|
/** Read the current app-owned query params — bridged: ask the host; standalone:
|
|
48
51
|
* the page's own query string. */
|
|
49
52
|
export function getUrlParams() {
|
|
50
53
|
return rpc("urlState.get", {});
|
|
51
54
|
}
|
|
52
55
|
/** Merge `patch` into the host's address bar (each key set, or cleared when its
|
|
53
|
-
* value is `undefined`), preserving every other param.
|
|
54
|
-
* history
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return rpc("urlState.set", { params: patch, push });
|
|
58
|
-
}
|
|
59
|
-
/** Move the host's history by `delta` (the router adapter's back/forward). The
|
|
60
|
-
* resulting location arrives via the `url-state` broadcast, not the return. */
|
|
61
|
-
export function goUrlHistory(delta) {
|
|
62
|
-
return rpc("urlState.go", { delta });
|
|
56
|
+
* value is `undefined`), preserving every other param. The host writes in place
|
|
57
|
+
* (`history.replaceState`) — view-state changes don't add history entries. */
|
|
58
|
+
export function setUrlParams(patch) {
|
|
59
|
+
return rpc("urlState.set", { params: patch });
|
|
63
60
|
}
|
|
64
61
|
/** Synchronous best-effort snapshot for first paint. Standalone reads its own
|
|
65
62
|
* URL (no flash); bridged can't read the cross-origin host URL synchronously,
|
|
@@ -67,9 +64,8 @@ export function goUrlHistory(delta) {
|
|
|
67
64
|
export function peekUrlParams() {
|
|
68
65
|
return isEmbedded() ? {} : parseSearch(window.location.search);
|
|
69
66
|
}
|
|
70
|
-
/** Subscribe to external query changes — back/forward
|
|
71
|
-
*
|
|
72
|
-
* `popstate`. */
|
|
67
|
+
/** Subscribe to external query changes — browser back/forward and edited URLs.
|
|
68
|
+
* Embedded: the host's `url-state` broadcast; standalone: `popstate`. */
|
|
73
69
|
export function subscribeUrlParams(cb) {
|
|
74
70
|
if (isEmbedded()) {
|
|
75
71
|
ensureListener();
|
|
@@ -84,8 +80,8 @@ export function subscribeUrlParams(cb) {
|
|
|
84
80
|
}
|
|
85
81
|
const pending = new Map();
|
|
86
82
|
const streaming = new Map();
|
|
87
|
-
/** `useUrlState`
|
|
88
|
-
*
|
|
83
|
+
/** `useUrlState` subscribers — notified when the host broadcasts new params
|
|
84
|
+
* after browser back/forward. */
|
|
89
85
|
const urlStateSubscribers = new Set();
|
|
90
86
|
let nextRpcId = 0;
|
|
91
87
|
let listenerInstalled = false;
|
|
@@ -100,8 +96,8 @@ function ensureListener() {
|
|
|
100
96
|
const msg = event.data;
|
|
101
97
|
if (!msg)
|
|
102
98
|
return;
|
|
103
|
-
// Broadcast (no id): the host pushes new params after browser/
|
|
104
|
-
//
|
|
99
|
+
// Broadcast (no id): the host pushes new params after browser back/forward
|
|
100
|
+
// so `useUrlState` subscribers re-hydrate.
|
|
105
101
|
if (msg.type === "url-state" && msg.params && typeof msg.params === "object") {
|
|
106
102
|
for (const cb of urlStateSubscribers)
|
|
107
103
|
cb(msg.params);
|
|
@@ -338,6 +334,37 @@ async function acquireSessionToken(appId) {
|
|
|
338
334
|
},
|
|
339
335
|
});
|
|
340
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* A user-facing message for a transport/gateway failure — derived from the HTTP
|
|
339
|
+
* status, never from the response body. A 524 (Cloudflare edge timeout on a long
|
|
340
|
+
* run), any 5xx, or a non-JSON body (an HTML error page) must NOT surface its raw
|
|
341
|
+
* body as the error message. Kept in parity (by value, no shared dep) with the
|
|
342
|
+
* dev-loop transport in `packages/sdk/src/client.ts`.
|
|
343
|
+
*/
|
|
344
|
+
function gatewayErrorMessage(status) {
|
|
345
|
+
if (status === 524) {
|
|
346
|
+
return "The request took too long to finish (gateway timeout). It may still be running — check back in a moment, or try again.";
|
|
347
|
+
}
|
|
348
|
+
if (status >= 500) {
|
|
349
|
+
return "The service is temporarily unavailable. Please try again shortly.";
|
|
350
|
+
}
|
|
351
|
+
return "The service returned an unexpected response. Please try again.";
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The error message for a non-ok response. A genuine JSON error (a 4xx carrying
|
|
355
|
+
* a `message`) surfaces verbatim; a non-JSON body (a gateway HTML page), any
|
|
356
|
+
* 5xx, or a JSON body without a `message` falls back to a body-free,
|
|
357
|
+
* status-derived message — so a raw HTML body never becomes the message.
|
|
358
|
+
* `parsed` is the JSON.parse of the body, or `null` if it wasn't JSON.
|
|
359
|
+
*/
|
|
360
|
+
export function transportErrorMessage(status, parsed) {
|
|
361
|
+
const jsonMessage = parsed && typeof parsed.message === "string"
|
|
362
|
+
? parsed.message
|
|
363
|
+
: null;
|
|
364
|
+
return parsed === null || status >= 500 || jsonMessage === null
|
|
365
|
+
? gatewayErrorMessage(status)
|
|
366
|
+
: jsonMessage;
|
|
367
|
+
}
|
|
341
368
|
async function apiCall(method, path, body, opts) {
|
|
342
369
|
const headers = {};
|
|
343
370
|
if (body)
|
|
@@ -372,8 +399,9 @@ async function apiCall(method, path, body, opts) {
|
|
|
372
399
|
await acquireSessionToken(appId);
|
|
373
400
|
return apiCall(method, path, body, { ...opts, appId });
|
|
374
401
|
}
|
|
375
|
-
|
|
376
|
-
|
|
402
|
+
// Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
|
|
403
|
+
// the message — emit a body-free, status-derived message instead.
|
|
404
|
+
throw new Error(transportErrorMessage(res.status, parsed));
|
|
377
405
|
}
|
|
378
406
|
return parsed ?? (text ? text : {});
|
|
379
407
|
}
|
|
@@ -404,12 +432,6 @@ function rpcStandalone(op, payload) {
|
|
|
404
432
|
return Promise.resolve(parseSearch(window.location.search));
|
|
405
433
|
case "urlState.set":
|
|
406
434
|
return standaloneUrlStateSet(payload);
|
|
407
|
-
case "urlState.go": {
|
|
408
|
-
// Standalone owns its own history — move it; popstate re-hydrates subscribers.
|
|
409
|
-
const { delta } = payload;
|
|
410
|
-
window.history.go(delta);
|
|
411
|
-
return Promise.resolve();
|
|
412
|
-
}
|
|
413
435
|
case "comments.list":
|
|
414
436
|
case "comments.create":
|
|
415
437
|
case "comments.update":
|
|
@@ -461,12 +483,9 @@ async function standaloneOpenExternal(p) {
|
|
|
461
483
|
async function standaloneUrlStateSet(p) {
|
|
462
484
|
const search = serializeMerge(window.location.search, p.params ?? {});
|
|
463
485
|
const url = window.location.pathname + (search ? `?${search}` : "") + window.location.hash;
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
window.history.pushState(null, "", url);
|
|
468
|
-
else
|
|
469
|
-
window.history.replaceState(null, "", url);
|
|
486
|
+
// View-state writes never add a history entry; replaceState doesn't fire
|
|
487
|
+
// popstate, so there's no echo — the caller already updated optimistically.
|
|
488
|
+
window.history.replaceState(null, "", url);
|
|
470
489
|
}
|
|
471
490
|
async function standaloneContext() {
|
|
472
491
|
const info = await resolveAppInfo();
|
|
@@ -493,7 +512,16 @@ async function standaloneFieldOptions(p) {
|
|
|
493
512
|
}
|
|
494
513
|
async function standaloneWorkflow(p) {
|
|
495
514
|
const { app_id } = await boot();
|
|
496
|
-
|
|
515
|
+
try {
|
|
516
|
+
return await apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
// A transport/gateway failure resolves to a WorkflowResult error (never a
|
|
520
|
+
// rejection carrying a raw body) so an app reads `result.status === "error"`
|
|
521
|
+
// uniformly with a handled workflow error. `apiCall` already sanitized the
|
|
522
|
+
// message, so it never contains an HTML body.
|
|
523
|
+
return { status: "error", message: err instanceof Error ? err.message : "The workflow failed to run." };
|
|
524
|
+
}
|
|
497
525
|
}
|
|
498
526
|
async function standaloneAgentRuns(p) {
|
|
499
527
|
const { app_id } = await boot();
|