@lotics/app-sdk 0.7.0 → 0.8.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/dist/src/hooks.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { useCallback, useEffect, useState } from "react";
17
17
  import { rpc } from "./rpc.js";
18
+ import { getMockRows } from "./mock.js";
18
19
  export function useWorkflow(alias) {
19
20
  return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
20
21
  }
@@ -26,12 +27,25 @@ export function useQuery(alias, params) {
26
27
  // refetch callback mutates only this counter; state transitions still
27
28
  // happen inside the effect so loading / error / rows flow consistently.
28
29
  const [refetchToken, setRefetchToken] = useState(0);
29
- const [state, setState] = useState({
30
- rows: [],
31
- loading: true,
32
- error: null,
30
+ // Initialize from the fixture when mock mode is on and the app registered
31
+ // rows for this alias — avoids a flash of `loading: true` on first paint.
32
+ // Computed lazily so subsequent renders don't re-read `window.location`.
33
+ const [state, setState] = useState(() => {
34
+ const mockRows = getMockRows(alias);
35
+ if (mockRows)
36
+ return { rows: mockRows, loading: false, error: null };
37
+ return { rows: [], loading: true, error: null };
33
38
  });
34
39
  useEffect(() => {
40
+ // Re-check on every effect run so HMR updates to the fixture (mount-time
41
+ // re-registration) propagate without a hard reload. When the fixture has
42
+ // an entry we short-circuit the RPC — partial mocking still works because
43
+ // aliases without fixture entries fall through to the live path below.
44
+ const mockRows = getMockRows(alias);
45
+ if (mockRows) {
46
+ setState({ rows: mockRows, loading: false, error: null });
47
+ return;
48
+ }
35
49
  let cancelled = false;
36
50
  setState((s) => ({ rows: s.rows, loading: true, error: null }));
37
51
  rpc("query", { alias, params: params ?? {} })
@@ -10,8 +10,10 @@
10
10
  * depending on packages/ui's React Native Web setup.
11
11
  */
12
12
  export { mount } from "./mount.js";
13
+ export type { MountOptions } from "./mount.js";
13
14
  export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
14
15
  export type { UploadedFile } from "./hooks.js";
15
16
  export { rpc } from "./rpc.js";
16
17
  export type { RpcOp } from "./rpc.js";
18
+ export type { AppFixture } from "./mock.js";
17
19
  export type { AppWorkflows, AppQueries } from "./types.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Demo / design-time fixture support for `useQuery`.
3
+ *
4
+ * Activation contract:
5
+ *
6
+ * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
+ * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
+ * app declared in `package.json#lotics.queries`.
9
+ * 2. At runtime, the user (or a screenshot script) loads the app with the
10
+ * `?__mock=1` URL search param. Without that param the SDK ignores the
11
+ * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ *
13
+ * The two-step gate keeps demo data shipping in the bundle from leaking into
14
+ * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
+ * unlikely to collide with app-side query state. Apps that don't pass a
16
+ * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
+ * hook path is unchanged.
18
+ *
19
+ * What's deliberately *not* mocked yet:
20
+ * - `useWorkflow` — mutations have side effects (notifications, audit
21
+ * trail). A workflow mock would have to also produce realistic followup
22
+ * state, which is more design than this iteration warrants.
23
+ * - `useFileUpload` — same reason.
24
+ *
25
+ * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
+ * route from each hook.
27
+ */
28
+ export interface AppFixture {
29
+ /** Map of query alias → rows the hook should return when mock mode is on. */
30
+ queries?: Record<string, Array<Record<string, unknown>>>;
31
+ }
32
+ /**
33
+ * Called by `mount({ fixture })`. Module-level state because the SDK has no
34
+ * React context boundary around the iframe — every hook resolves against the
35
+ * same registration. Calling twice replaces (last-write-wins) which is fine
36
+ * for HMR.
37
+ */
38
+ export declare function registerMockFixture(fixture: AppFixture | undefined): void;
39
+ /**
40
+ * Returns the fixture rows for an alias when mock mode is active *and* the
41
+ * fixture has an entry for that alias. Otherwise null — the hook falls
42
+ * through to the real RPC path. The distinction matters: an app may mock
43
+ * only some queries and let the rest flow through to real data.
44
+ */
45
+ export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Demo / design-time fixture support for `useQuery`.
3
+ *
4
+ * Activation contract:
5
+ *
6
+ * 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
7
+ * is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
8
+ * app declared in `package.json#lotics.queries`.
9
+ * 2. At runtime, the user (or a screenshot script) loads the app with the
10
+ * `?__mock=1` URL search param. Without that param the SDK ignores the
11
+ * fixture entirely and `useQuery` flows through the RPC bridge as usual.
12
+ *
13
+ * The two-step gate keeps demo data shipping in the bundle from leaking into
14
+ * normal traffic — the param namespace (`__mock` prefix) is reserved and
15
+ * unlikely to collide with app-side query state. Apps that don't pass a
16
+ * fixture pay nothing: `getMockRows` returns `null` for every alias and the
17
+ * hook path is unchanged.
18
+ *
19
+ * What's deliberately *not* mocked yet:
20
+ * - `useWorkflow` — mutations have side effects (notifications, audit
21
+ * trail). A workflow mock would have to also produce realistic followup
22
+ * state, which is more design than this iteration warrants.
23
+ * - `useFileUpload` — same reason.
24
+ *
25
+ * If those become needed, extend `AppFixture` with `workflows`/`uploads` and
26
+ * route from each hook.
27
+ */
28
+ let registeredFixture;
29
+ /**
30
+ * Called by `mount({ fixture })`. Module-level state because the SDK has no
31
+ * React context boundary around the iframe — every hook resolves against the
32
+ * same registration. Calling twice replaces (last-write-wins) which is fine
33
+ * for HMR.
34
+ */
35
+ export function registerMockFixture(fixture) {
36
+ registeredFixture = fixture;
37
+ }
38
+ /**
39
+ * True iff the iframe URL carries the `__mock=1` activation flag AND a
40
+ * fixture was registered. Safe to call before mount — returns false when no
41
+ * fixture exists. Throws never; a malformed URL or missing `window` (SSR /
42
+ * jsdom without location) silently returns false.
43
+ */
44
+ function isMockMode() {
45
+ if (!registeredFixture)
46
+ return false;
47
+ try {
48
+ return new URLSearchParams(window.location.search).get("__mock") === "1";
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Returns the fixture rows for an alias when mock mode is active *and* the
56
+ * fixture has an entry for that alias. Otherwise null — the hook falls
57
+ * through to the real RPC path. The distinction matters: an app may mock
58
+ * only some queries and let the rest flow through to real data.
59
+ */
60
+ export function getMockRows(alias) {
61
+ if (!isMockMode())
62
+ return null;
63
+ const rows = registeredFixture?.queries?.[alias];
64
+ return rows ?? null;
65
+ }
@@ -10,9 +10,23 @@
10
10
  * mount(<App />);
11
11
  * ```
12
12
  *
13
+ * Optional second argument registers a demo/design-time fixture. When the
14
+ * iframe is loaded with `?__mock=1`, `useQuery` returns rows from the
15
+ * fixture instead of hitting the RPC bridge. Without the URL flag the
16
+ * fixture is inert — useful for taking screenshots or iterating on
17
+ * dashboard layouts before real data exists.
18
+ *
19
+ * ```tsx
20
+ * mount(<App />, {
21
+ * fixture: {
22
+ * queries: { customers: MOCK_CUSTOMERS, deals: MOCK_DEALS },
23
+ * },
24
+ * });
25
+ * ```
26
+ *
13
27
  * `mount` wires up React 19's createRoot against `#root` in the iframe shell,
14
28
  * sets up window.onerror/unhandledrejection forwarding so runtime crashes
15
- * surface in the parent's debug pane (PR 2 of the debug-loop phase), and
29
+ * surface in the parent's debug pane, registers the fixture (if any), and
16
30
  * renders the user's tree.
17
31
  *
18
32
  * If the bundler doesn't ship #root in the user's `index.html`, we create it
@@ -20,4 +34,14 @@
20
34
  * mount resilient.
21
35
  */
22
36
  import type { ReactNode } from "react";
23
- export declare function mount(element: ReactNode): void;
37
+ import { type AppFixture } from "./mock.js";
38
+ export interface MountOptions {
39
+ /**
40
+ * Optional demo fixture. When the iframe URL contains `?__mock=1`,
41
+ * `useQuery(alias)` returns `fixture.queries[alias]` instead of calling
42
+ * the RPC bridge. Aliases not present in the fixture still flow through
43
+ * the real path — partial mocking is supported.
44
+ */
45
+ fixture?: AppFixture;
46
+ }
47
+ export declare function mount(element: ReactNode, options?: MountOptions): void;
package/dist/src/mount.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createRoot } from "react-dom/client";
2
- export function mount(element) {
2
+ import { registerMockFixture } from "./mock.js";
3
+ export function mount(element, options = {}) {
4
+ registerMockFixture(options.fixture);
3
5
  let container = document.getElementById("root");
4
6
  if (!container) {
5
7
  container = document.createElement("div");
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Vanilla DOM password overlay for password-protected public apps.
3
+ *
4
+ * The SDK can't assume the host app's React tree has mounted yet (this fires
5
+ * before any data call resolves), so the overlay is built with plain DOM
6
+ * APIs and self-contained inline styles. It rejects the boot promise only
7
+ * on `cancel()` — which is currently never wired to a button; the visitor
8
+ * either enters the correct password or leaves the page.
9
+ *
10
+ * One overlay at a time. Concurrent calls reuse the in-flight promise so a
11
+ * burst of failing API calls produces a single modal, not several stacked.
12
+ */
13
+ interface OverlayController {
14
+ showError(message: string): void;
15
+ setSubmitting(submitting: boolean): void;
16
+ }
17
+ export interface PasswordPromptArgs {
18
+ /**
19
+ * Validates the entered password. Resolve to keep the overlay open on a
20
+ * wrong password (the SDK calls `controller.showError(...)` afterwards),
21
+ * or `accept()` to dismiss the overlay and resolve the prompt with the
22
+ * password the visitor typed.
23
+ */
24
+ authenticate: (password: string, controller: OverlayController) => Promise<{
25
+ ok: boolean;
26
+ }>;
27
+ }
28
+ export declare function promptForPassword(args: PasswordPromptArgs): Promise<string>;
29
+ export {};
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Vanilla DOM password overlay for password-protected public apps.
3
+ *
4
+ * The SDK can't assume the host app's React tree has mounted yet (this fires
5
+ * before any data call resolves), so the overlay is built with plain DOM
6
+ * APIs and self-contained inline styles. It rejects the boot promise only
7
+ * on `cancel()` — which is currently never wired to a button; the visitor
8
+ * either enters the correct password or leaves the page.
9
+ *
10
+ * One overlay at a time. Concurrent calls reuse the in-flight promise so a
11
+ * burst of failing API calls produces a single modal, not several stacked.
12
+ */
13
+ let activePrompt = null;
14
+ export function promptForPassword(args) {
15
+ if (activePrompt)
16
+ return activePrompt;
17
+ activePrompt = new Promise((resolve) => {
18
+ const root = document.createElement("div");
19
+ root.dataset.loticsAppPasswordGate = "true";
20
+ root.style.cssText = [
21
+ "position:fixed",
22
+ "inset:0",
23
+ "z-index:2147483647",
24
+ "background:rgba(15,23,42,0.55)",
25
+ "backdrop-filter:blur(8px)",
26
+ "-webkit-backdrop-filter:blur(8px)",
27
+ "display:flex",
28
+ "align-items:center",
29
+ "justify-content:center",
30
+ "padding:24px",
31
+ "font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Inter,sans-serif",
32
+ "color:#0f172a",
33
+ ].join(";");
34
+ const card = document.createElement("form");
35
+ card.setAttribute("role", "dialog");
36
+ card.setAttribute("aria-modal", "true");
37
+ card.setAttribute("aria-labelledby", "lotics-app-password-title");
38
+ card.style.cssText = [
39
+ "background:#fff",
40
+ "border-radius:14px",
41
+ "padding:28px",
42
+ "max-width:380px",
43
+ "width:100%",
44
+ "box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)",
45
+ "display:flex",
46
+ "flex-direction:column",
47
+ "gap:16px",
48
+ ].join(";");
49
+ const title = document.createElement("h2");
50
+ title.id = "lotics-app-password-title";
51
+ title.textContent = "Password required";
52
+ title.style.cssText = "margin:0;font-size:18px;font-weight:600;letter-spacing:-0.01em;";
53
+ const subtitle = document.createElement("p");
54
+ subtitle.textContent = "Enter the password to access this app.";
55
+ subtitle.style.cssText = "margin:0;color:#475569;font-size:13.5px;";
56
+ const input = document.createElement("input");
57
+ input.type = "password";
58
+ input.autocomplete = "current-password";
59
+ input.required = true;
60
+ input.placeholder = "Password";
61
+ input.setAttribute("aria-label", "Password");
62
+ input.style.cssText = [
63
+ "width:100%",
64
+ "box-sizing:border-box",
65
+ "padding:10px 12px",
66
+ "border:1px solid #cbd5e1",
67
+ "border-radius:8px",
68
+ "font-size:14px",
69
+ "outline:none",
70
+ "transition:border-color 0.15s,box-shadow 0.15s",
71
+ ].join(";");
72
+ input.addEventListener("focus", () => {
73
+ input.style.borderColor = "#2563eb";
74
+ input.style.boxShadow = "0 0 0 3px rgba(37,99,235,0.18)";
75
+ });
76
+ input.addEventListener("blur", () => {
77
+ input.style.borderColor = "#cbd5e1";
78
+ input.style.boxShadow = "none";
79
+ });
80
+ const error = document.createElement("div");
81
+ error.setAttribute("role", "alert");
82
+ error.style.cssText = "color:#dc2626;font-size:13px;min-height:18px;";
83
+ const submit = document.createElement("button");
84
+ submit.type = "submit";
85
+ submit.textContent = "Continue";
86
+ submit.style.cssText = [
87
+ "padding:10px 12px",
88
+ "background:#0f172a",
89
+ "color:#fff",
90
+ "border:none",
91
+ "border-radius:8px",
92
+ "font-size:14px",
93
+ "font-weight:500",
94
+ "cursor:pointer",
95
+ "transition:background 0.15s,opacity 0.15s",
96
+ ].join(";");
97
+ const setSubmitting = (s) => {
98
+ submit.disabled = s;
99
+ input.disabled = s;
100
+ submit.textContent = s ? "Checking…" : "Continue";
101
+ submit.style.opacity = s ? "0.7" : "1";
102
+ submit.style.cursor = s ? "wait" : "pointer";
103
+ };
104
+ const showError = (message) => {
105
+ error.textContent = message;
106
+ };
107
+ card.append(title, subtitle, input, error, submit);
108
+ root.append(card);
109
+ document.body.append(root);
110
+ // Defer focus until the element is in layout — some browsers ignore focus
111
+ // on freshly-attached inputs without a microtask break.
112
+ queueMicrotask(() => input.focus());
113
+ card.addEventListener("submit", (e) => {
114
+ e.preventDefault();
115
+ const value = input.value;
116
+ if (!value) {
117
+ showError("Enter a password.");
118
+ return;
119
+ }
120
+ showError("");
121
+ setSubmitting(true);
122
+ args
123
+ .authenticate(value, { showError, setSubmitting })
124
+ .then((res) => {
125
+ if (res.ok) {
126
+ root.remove();
127
+ activePrompt = null;
128
+ resolve(value);
129
+ }
130
+ else {
131
+ setSubmitting(false);
132
+ input.select();
133
+ }
134
+ })
135
+ .catch((err) => {
136
+ setSubmitting(false);
137
+ showError(err instanceof Error ? err.message : "Something went wrong.");
138
+ });
139
+ });
140
+ });
141
+ return activePrompt;
142
+ }
package/dist/src/rpc.js CHANGED
@@ -1,23 +1,4 @@
1
- /**
2
- * RPC bridge for a custom-code app's data operations.
3
- *
4
- * An app reaches the Lotics API one of two ways, chosen automatically:
5
- *
6
- * - **Bridged** — the app is embedded by a Lotics host (the authenticated
7
- * product, or `lotics app dev`), which passes its origin via the
8
- * `?lotics_host=` query param. The host holds the session; the app posts
9
- * ops over postMessage and the host makes the API call. Used by internal
10
- * apps — the member's credentials must never reach the app.
11
- *
12
- * - **Standalone** — the app is served on its own at `<slug>.lotics.app`
13
- * with no host. It calls the public `/v1/apps/{id}/*` endpoints directly;
14
- * those are anonymous-accessible for a publicly-shared app. The app holds
15
- * no credentials, so there is nothing to protect.
16
- *
17
- * Wire protocol (bridged — must match `app_iframe_host.tsx`):
18
- * app → host: { id, op, payload }
19
- * host → app: { id, type: "result", data } | { id, type: "error", message }
20
- */
1
+ import { promptForPassword } from "./password_gate.js";
21
2
  /** The embedding Lotics host's origin — present iff the app is bridged. */
22
3
  const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
23
4
  export function rpc(op, payload) {
@@ -63,41 +44,134 @@ function rpcBridged(op, payload, host) {
63
44
  // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
64
45
  // always bridged by the `lotics app dev` wrapper.
65
46
  const API_BASE = "https://api.lotics.ai";
66
- /** Resolved once — the app_id this standalone app's data calls are keyed by. */
67
- let appIdPromise = null;
68
- function resolveAppId() {
69
- if (!appIdPromise) {
70
- const slug = window.location.hostname.split(".")[0];
71
- const attempt = apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`).then((r) => r.app_id);
72
- // Don't cache a rejection — a transient failure on first load would
73
- // otherwise brick every later data call for the session. Clear the slot
74
- // so the next call retries.
75
- attempt.catch(() => {
76
- if (appIdPromise === attempt)
77
- appIdPromise = null;
78
- });
79
- appIdPromise = attempt;
47
+ const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
48
+ /**
49
+ * Boot-time resolution result. Promise is shared so concurrent first calls
50
+ * (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
51
+ * `/by-subdomain` fetch and at most one password prompt.
52
+ */
53
+ let bootPromise = null;
54
+ let sessionToken = null;
55
+ function sessionStorageKey(appId) {
56
+ return `lotics_app_session:${appId}`;
57
+ }
58
+ function readStoredSession(appId) {
59
+ try {
60
+ const raw = window.localStorage.getItem(sessionStorageKey(appId));
61
+ if (!raw)
62
+ return null;
63
+ const parsed = JSON.parse(raw);
64
+ if (!parsed.token || !parsed.expires_at)
65
+ return null;
66
+ if (Date.parse(parsed.expires_at) <= Date.now()) {
67
+ window.localStorage.removeItem(sessionStorageKey(appId));
68
+ return null;
69
+ }
70
+ return parsed.token;
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ function writeStoredSession(appId, token, expiresAt) {
77
+ try {
78
+ window.localStorage.setItem(sessionStorageKey(appId), JSON.stringify({ token, expires_at: expiresAt }));
79
+ }
80
+ catch {
81
+ // localStorage unavailable (Safari private mode, quota): the token still
82
+ // lives in memory for this page lifetime — that's good enough for a single
83
+ // session, the next reload simply re-prompts.
80
84
  }
81
- return appIdPromise;
82
85
  }
83
- async function apiCall(method, path, body) {
86
+ function clearStoredSession(appId) {
87
+ try {
88
+ window.localStorage.removeItem(sessionStorageKey(appId));
89
+ }
90
+ catch {
91
+ // ignore
92
+ }
93
+ }
94
+ async function boot() {
95
+ if (bootPromise)
96
+ return bootPromise;
97
+ const slug = window.location.hostname.split(".")[0];
98
+ const attempt = (async () => {
99
+ const info = (await apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`));
100
+ if (info.requires_password) {
101
+ const stored = readStoredSession(info.app_id);
102
+ if (stored) {
103
+ sessionToken = stored;
104
+ }
105
+ else {
106
+ await acquireSessionToken(info.app_id);
107
+ }
108
+ }
109
+ return info;
110
+ })();
111
+ // Don't cache a rejection — a transient failure on first load would brick
112
+ // every later data call. Clear the slot so the next call retries.
113
+ attempt.catch(() => {
114
+ if (bootPromise === attempt)
115
+ bootPromise = null;
116
+ });
117
+ bootPromise = attempt;
118
+ return attempt;
119
+ }
120
+ async function acquireSessionToken(appId) {
121
+ await promptForPassword({
122
+ authenticate: async (password, controller) => {
123
+ try {
124
+ const res = (await apiCall("POST", `/v1/apps/${appId}/public/authenticate`, { password }, { skipAuth: true }));
125
+ sessionToken = res.session_token;
126
+ writeStoredSession(appId, res.session_token, res.expires_at);
127
+ return { ok: true };
128
+ }
129
+ catch (err) {
130
+ const message = err instanceof Error && err.message ? err.message : "Incorrect password.";
131
+ controller.showError(message);
132
+ return { ok: false };
133
+ }
134
+ },
135
+ });
136
+ }
137
+ async function apiCall(method, path, body, opts) {
138
+ const headers = {};
139
+ if (body)
140
+ headers["content-type"] = "application/json";
141
+ if (sessionToken && !opts?.skipAuth) {
142
+ headers["authorization"] = `Bearer ${sessionToken}`;
143
+ }
84
144
  const res = await fetch(`${API_BASE}${path}`, {
85
145
  method,
86
- headers: body ? { "content-type": "application/json" } : undefined,
146
+ headers,
87
147
  body: body ? JSON.stringify(body) : undefined,
88
148
  });
89
149
  const text = await res.text();
90
- if (!res.ok) {
91
- let message = text;
150
+ let parsed = null;
151
+ if (text) {
92
152
  try {
93
- message = JSON.parse(text).message ?? text;
153
+ parsed = JSON.parse(text);
94
154
  }
95
155
  catch {
96
- // response body is not JSON — use it verbatim
156
+ // not JSON — body left as raw text
157
+ }
158
+ }
159
+ if (!res.ok) {
160
+ const errorBody = parsed;
161
+ // Password gate (re-)prompt: the stored token went stale because the owner
162
+ // rotated or cleared the password, or it was never present. Drop the
163
+ // current token, ask the visitor again, and retry the original call once.
164
+ if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
165
+ const appId = opts?.appId ?? (await boot()).app_id;
166
+ sessionToken = null;
167
+ clearStoredSession(appId);
168
+ await acquireSessionToken(appId);
169
+ return apiCall(method, path, body, { ...opts, appId });
97
170
  }
171
+ const message = errorBody?.message ?? text;
98
172
  throw new Error(message || `HTTP ${res.status}`);
99
173
  }
100
- return text ? JSON.parse(text) : {};
174
+ return parsed ?? (text ? text : {});
101
175
  }
102
176
  function rpcStandalone(op, payload) {
103
177
  switch (op) {
@@ -110,27 +184,24 @@ function rpcStandalone(op, payload) {
110
184
  }
111
185
  }
112
186
  async function standaloneQuery(p) {
113
- const appId = await resolveAppId();
114
- const r = (await apiCall("POST", `/v1/apps/${appId}/query`, {
115
- alias: p.alias,
116
- params: p.params,
117
- }));
187
+ const { app_id } = await boot();
188
+ const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
118
189
  return { rows: r.rows ?? [] };
119
190
  }
120
191
  async function standaloneWorkflow(p) {
121
- const appId = await resolveAppId();
122
- return apiCall("POST", `/v1/apps/${appId}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs });
192
+ const { app_id } = await boot();
193
+ return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
123
194
  }
124
195
  async function standaloneUpload(file) {
125
196
  if (!(file instanceof File)) {
126
197
  throw new Error("upload payload must include a File");
127
198
  }
128
- const appId = await resolveAppId();
129
- const init = (await apiCall("POST", `/v1/apps/${appId}/files/upload-url`, {
199
+ const { app_id } = await boot();
200
+ const init = (await apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, {
130
201
  filename: file.name,
131
202
  mime_type: file.type,
132
203
  file_size: file.size,
133
- }));
204
+ }, { appId: app_id }));
134
205
  const put = await fetch(init.upload_url, {
135
206
  method: "PUT",
136
207
  body: file,
@@ -139,10 +210,10 @@ async function standaloneUpload(file) {
139
210
  if (!put.ok) {
140
211
  throw new Error(`Storage upload failed (${put.status})`);
141
212
  }
142
- const done = (await apiCall("POST", `/v1/apps/${appId}/files/complete`, {
213
+ const done = (await apiCall("POST", `/v1/apps/${app_id}/files/complete`, {
143
214
  file_id: init.file_id,
144
215
  file_storage_key: init.file_storage_key,
145
216
  filename: file.name,
146
- }));
217
+ }, { appId: app_id }));
147
218
  return done.file;
148
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {