@lotics/app-sdk 0.6.0 → 0.7.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/rpc.d.ts CHANGED
@@ -1,17 +1,22 @@
1
1
  /**
2
- * postMessage RPC bridge to the parent Lotics frontend.
2
+ * RPC bridge for a custom-code app's data operations.
3
3
  *
4
- * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
- * direct fetch with credentials isn't possible. Every data operation flows
6
- * through the parent: the iframe posts an op + payload, the parent makes
7
- * the authenticated API call and posts the result back.
4
+ * An app reaches the Lotics API one of two ways, chosen automatically:
8
5
  *
9
- * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
- * iframe parent: { id: number, op: string, payload: unknown }
11
- * parent iframe: { id: number, type: "result", data } | { id, type: "error", message }
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.
12
11
  *
13
- * Bumping protocol version requires a coordinated change in the parent.
14
- * Add new ops alongside existing ones; never repurpose them.
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 }
15
20
  */
16
21
  export type RpcOp = "query" | "workflow" | "upload";
17
22
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
package/dist/src/rpc.js CHANGED
@@ -1,18 +1,30 @@
1
1
  /**
2
- * postMessage RPC bridge to the parent Lotics frontend.
2
+ * RPC bridge for a custom-code app's data operations.
3
3
  *
4
- * The iframe runs `sandbox="allow-scripts"` with a unique (null) origin, so
5
- * direct fetch with credentials isn't possible. Every data operation flows
6
- * through the parent: the iframe posts an op + payload, the parent makes
7
- * the authenticated API call and posts the result back.
4
+ * An app reaches the Lotics API one of two ways, chosen automatically:
8
5
  *
9
- * Wire protocol (must match `frontend/features/app_ui/app_iframe_host.tsx`):
10
- * iframe parent: { id: number, op: string, payload: unknown }
11
- * parent iframe: { id: number, type: "result", data } | { id, type: "error", message }
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.
12
11
  *
13
- * Bumping protocol version requires a coordinated change in the parent.
14
- * Add new ops alongside existing ones; never repurpose them.
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 }
15
20
  */
21
+ /** The embedding Lotics host's origin — present iff the app is bridged. */
22
+ const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
23
+ export function rpc(op, payload) {
24
+ return hostOrigin
25
+ ? rpcBridged(op, payload, hostOrigin)
26
+ : rpcStandalone(op, payload);
27
+ }
16
28
  const pending = new Map();
17
29
  let nextRpcId = 0;
18
30
  let listenerInstalled = false;
@@ -21,10 +33,8 @@ function ensureListener() {
21
33
  return;
22
34
  listenerInstalled = true;
23
35
  window.addEventListener("message", (event) => {
24
- // Trust only messages from the parent window. The iframe's origin is null
25
- // (sandboxed), so we can't compare origins meaningfully — we trust the
26
- // window reference instead.
27
- if (event.source !== window.parent)
36
+ // Accept only messages from the parent window, at the host origin.
37
+ if (event.source !== window.parent || event.origin !== hostOrigin)
28
38
  return;
29
39
  const msg = event.data;
30
40
  if (!msg || typeof msg.id !== "number")
@@ -41,11 +51,98 @@ function ensureListener() {
41
51
  }
42
52
  });
43
53
  }
44
- export function rpc(op, payload) {
54
+ function rpcBridged(op, payload, host) {
45
55
  ensureListener();
46
56
  return new Promise((resolve, reject) => {
47
57
  const id = nextRpcId++;
48
58
  pending.set(id, { resolve: resolve, reject });
49
- window.parent.postMessage({ id, op, payload }, "*");
59
+ window.parent.postMessage({ id, op, payload }, host);
60
+ });
61
+ }
62
+ // ── Standalone transport ────────────────────────────────────────────────────
63
+ // Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
64
+ // always bridged by the `lotics app dev` wrapper.
65
+ 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;
80
+ }
81
+ return appIdPromise;
82
+ }
83
+ async function apiCall(method, path, body) {
84
+ const res = await fetch(`${API_BASE}${path}`, {
85
+ method,
86
+ headers: body ? { "content-type": "application/json" } : undefined,
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ });
89
+ const text = await res.text();
90
+ if (!res.ok) {
91
+ let message = text;
92
+ try {
93
+ message = JSON.parse(text).message ?? text;
94
+ }
95
+ catch {
96
+ // response body is not JSON — use it verbatim
97
+ }
98
+ throw new Error(message || `HTTP ${res.status}`);
99
+ }
100
+ return text ? JSON.parse(text) : {};
101
+ }
102
+ function rpcStandalone(op, payload) {
103
+ switch (op) {
104
+ case "query":
105
+ return standaloneQuery(payload);
106
+ case "workflow":
107
+ return standaloneWorkflow(payload);
108
+ case "upload":
109
+ return standaloneUpload(payload.file);
110
+ }
111
+ }
112
+ 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
+ }));
118
+ return { rows: r.rows ?? [] };
119
+ }
120
+ async function standaloneWorkflow(p) {
121
+ const appId = await resolveAppId();
122
+ return apiCall("POST", `/v1/apps/${appId}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs });
123
+ }
124
+ async function standaloneUpload(file) {
125
+ if (!(file instanceof File)) {
126
+ throw new Error("upload payload must include a File");
127
+ }
128
+ const appId = await resolveAppId();
129
+ const init = (await apiCall("POST", `/v1/apps/${appId}/files/upload-url`, {
130
+ filename: file.name,
131
+ mime_type: file.type,
132
+ file_size: file.size,
133
+ }));
134
+ const put = await fetch(init.upload_url, {
135
+ method: "PUT",
136
+ body: file,
137
+ headers: { "Content-Type": file.type },
50
138
  });
139
+ if (!put.ok) {
140
+ throw new Error(`Storage upload failed (${put.status})`);
141
+ }
142
+ const done = (await apiCall("POST", `/v1/apps/${appId}/files/complete`, {
143
+ file_id: init.file_id,
144
+ file_storage_key: init.file_storage_key,
145
+ filename: file.name,
146
+ }));
147
+ return done.file;
51
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {