@lotics/app-sdk 0.5.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/hooks.d.ts +33 -0
- package/dist/src/hooks.js +38 -4
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -1
- package/dist/src/rpc.d.ts +16 -11
- package/dist/src/rpc.js +113 -16
- package/package.json +1 -1
package/dist/src/hooks.d.ts
CHANGED
|
@@ -56,4 +56,37 @@ type UseQueryParams<K extends keyof AppQueries & string> = AppQueries[K] extends
|
|
|
56
56
|
*/
|
|
57
57
|
export declare function useQuery<K extends keyof AppQueries & string>(alias: K, ...rest: UseQueryParams<K>): QueryState<Record<string, unknown>>;
|
|
58
58
|
export declare function useQuery(alias: string, params?: Record<string, unknown>): QueryState<Record<string, unknown>>;
|
|
59
|
+
/** A file the host has stored and resolved serving URLs for. */
|
|
60
|
+
export interface UploadedFile {
|
|
61
|
+
id: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
mime_type: string;
|
|
64
|
+
url?: string;
|
|
65
|
+
thumbnail_url?: string;
|
|
66
|
+
}
|
|
67
|
+
interface FileUploadState {
|
|
68
|
+
/**
|
|
69
|
+
* Upload one file. Resolves to the stored file; pass `UploadedFile.id` into
|
|
70
|
+
* a `useWorkflow` call to attach it to a record. Rejects on failure — the
|
|
71
|
+
* file is never partially stored.
|
|
72
|
+
*/
|
|
73
|
+
upload: (file: File) => Promise<UploadedFile>;
|
|
74
|
+
/** True while any upload from this hook is in flight. */
|
|
75
|
+
uploading: boolean;
|
|
76
|
+
/** Message of the most recent failed upload, cleared when a new one starts. */
|
|
77
|
+
error: string | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Upload files from an app. The bytes are stored via a presigned
|
|
81
|
+
* direct-to-storage upload the host mediates; the API server never proxies
|
|
82
|
+
* them. Works the same in a public (anonymous) app and a member-facing one.
|
|
83
|
+
*
|
|
84
|
+
* ```tsx
|
|
85
|
+
* const { upload, uploading } = useFileUpload();
|
|
86
|
+
* const submit = useWorkflow("submitApplication");
|
|
87
|
+
* const cccd = await upload(file);
|
|
88
|
+
* await submit({ ...fields, cccd_file_id: cccd.id });
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export declare function useFileUpload(): FileUploadState;
|
|
59
92
|
export {};
|
package/dist/src/hooks.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Typed React hooks for Lotics app data access.
|
|
3
3
|
*
|
|
4
|
-
* The SDK surface is
|
|
5
|
-
* mutation
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The SDK surface is three hooks: `useQuery` for reads, `useWorkflow` for
|
|
5
|
+
* every mutation, and `useFileUpload` for attaching files. App code never
|
|
6
|
+
* writes records directly — all writes flow through declared workflows, which
|
|
7
|
+
* gives the app owner a typed, audited chokepoint and means a publicly-shared
|
|
8
|
+
* app exposes no anonymous direct-write path. An uploaded file is inert until
|
|
9
|
+
* a workflow attaches it, so file upload keeps that same property.
|
|
8
10
|
*
|
|
9
11
|
* Every hook is a thin wrapper over the postMessage RPC bridge — the parent
|
|
10
12
|
* does the actual API calls, results stream back through `rpc()`. Hooks manage
|
|
@@ -53,3 +55,35 @@ export function useQuery(alias, params) {
|
|
|
53
55
|
}, []);
|
|
54
56
|
return { ...state, refetch };
|
|
55
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Upload files from an app. The bytes are stored via a presigned
|
|
60
|
+
* direct-to-storage upload the host mediates; the API server never proxies
|
|
61
|
+
* them. Works the same in a public (anonymous) app and a member-facing one.
|
|
62
|
+
*
|
|
63
|
+
* ```tsx
|
|
64
|
+
* const { upload, uploading } = useFileUpload();
|
|
65
|
+
* const submit = useWorkflow("submitApplication");
|
|
66
|
+
* const cccd = await upload(file);
|
|
67
|
+
* await submit({ ...fields, cccd_file_id: cccd.id });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function useFileUpload() {
|
|
71
|
+
const [inFlight, setInFlight] = useState(0);
|
|
72
|
+
const [error, setError] = useState(null);
|
|
73
|
+
const upload = useCallback(async (file) => {
|
|
74
|
+
setInFlight((n) => n + 1);
|
|
75
|
+
setError(null);
|
|
76
|
+
try {
|
|
77
|
+
return await rpc("upload", { file });
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const message = err instanceof Error ? err.message : "Upload failed";
|
|
81
|
+
setError(message);
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
setInFlight((n) => n - 1);
|
|
86
|
+
}
|
|
87
|
+
}, []);
|
|
88
|
+
return { upload, uploading: inFlight > 0, error };
|
|
89
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* depending on packages/ui's React Native Web setup.
|
|
11
11
|
*/
|
|
12
12
|
export { mount } from "./mount.js";
|
|
13
|
-
export { useWorkflow, useQuery } from "./hooks.js";
|
|
13
|
+
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
14
|
+
export type { UploadedFile } from "./hooks.js";
|
|
14
15
|
export { rpc } from "./rpc.js";
|
|
15
16
|
export type { RpcOp } from "./rpc.js";
|
|
16
17
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
package/dist/src/index.js
CHANGED
package/dist/src/rpc.d.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* RPC bridge for a custom-code app's data operations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
export type RpcOp = "query" | "workflow";
|
|
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
|
-
*
|
|
2
|
+
* RPC bridge for a custom-code app's data operations.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
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
|
}
|