@digitornai/sdk 0.1.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.
@@ -0,0 +1,81 @@
1
+ /** One pending change reported by a `workspace_changes` event. */
2
+ export interface FileChange {
3
+ /** Workdir-relative path. */
4
+ path: string;
5
+ status: "added" | "modified" | "deleted" | (string & {});
6
+ }
7
+ /** One directory entry from `readDir`. */
8
+ export interface DirEntry {
9
+ name: string;
10
+ /** Workdir-relative path. */
11
+ path: string;
12
+ type: "file" | "dir" | (string & {});
13
+ }
14
+ /**
15
+ * What to react to in `useWatch`:
16
+ * - `"*"` (or `""`) every change
17
+ * - `"scene.json"` one exact path
18
+ * - `"pages/"` trailing slash = prefix (a whole folder)
19
+ * - `"pages/*.md"` glob (`*` matches any chars except `/`)
20
+ * - `(path) => boolean` custom predicate
21
+ */
22
+ export type WatchMatch = "*" | (string & {}) | ((path: string) => boolean);
23
+ export interface WorkspaceApi {
24
+ /** Raw text of a file, or `undefined` when missing/unreadable. */
25
+ readFile: (path: string) => Promise<string | undefined>;
26
+ /** Overwrite a file (creates it if absent). Text only - use `writeBinary` for PNG/etc. */
27
+ writeFile: (path: string, content: string) => Promise<void>;
28
+ /**
29
+ * Overwrite a file with raw bytes (PNG, JPEG, …). Uses `content_b64` on the
30
+ * daemon so binary survives JSON transport - required for agent vision reads.
31
+ */
32
+ writeBinary: (path: string, data: Uint8Array | ArrayBuffer) => Promise<void>;
33
+ /** List a directory (non-recursive). */
34
+ readDir: (path: string) => Promise<DirEntry[]>;
35
+ }
36
+ /** Session-scoped file endpoint. Shared by every file hook so the URL/auth
37
+ * logic lives in exactly one place.
38
+ *
39
+ * Two auth modes, transparent to callers:
40
+ * - Embedded preview (`?t=` present): the public `preview/files` routes,
41
+ * authenticated by the per-session preview token in the query. No JWT,
42
+ * no cookie - this is the only mode the sandboxed iframe can use.
43
+ * - Host app (JWT present): the `workspace/files` routes with a Bearer token.
44
+ */
45
+ export declare function useWorkspaceEndpoint(): {
46
+ root: string;
47
+ headers: Record<string, string>;
48
+ preview: boolean;
49
+ /** Build a read/write URL for a workdir file in the active auth mode. */
50
+ fileUrl: (path: string) => string;
51
+ /** Build a directory-listing URL for the active auth mode. */
52
+ treeUrl: (path: string) => string;
53
+ };
54
+ /**
55
+ * Imperative access to the session workdir over the (stable) daemon file
56
+ * routes. The app owns its own state shape - one JSON, a folder of files,
57
+ * many files - and reads/writes whatever it needs.
58
+ */
59
+ export declare function useWorkspace(): WorkspaceApi;
60
+ /**
61
+ * Fire `handler` whenever the agent (or anyone) changes workdir files matching
62
+ * `match`. The single, unopinionated primitive: it tells you WHAT changed; you
63
+ * re-read your own state and update the UI however your app wants.
64
+ *
65
+ * ```tsx
66
+ * // a whole folder
67
+ * useWatch("pages/", async () => setPages(await readDir("pages")));
68
+ * // one file
69
+ * useWatch("scene.json", () => reloadScene());
70
+ * // everything
71
+ * useWatch("*", (changes) => console.log(changes));
72
+ * ```
73
+ *
74
+ * The handler identity need not be stable (it's read via a ref).
75
+ */
76
+ export declare function useWatch(match: WatchMatch, handler: (changes: FileChange[]) => void): void;
77
+ /** A file's text, re-read automatically whenever the agent edits it. */
78
+ export declare function useFile(path: string): string | undefined;
79
+ /** A file parsed as JSON, re-parsed on every agent edit. `undefined` when the
80
+ * file is missing or not valid JSON. */
81
+ export declare function useFileJson<T = unknown>(path: string): T | undefined;
@@ -0,0 +1,187 @@
1
+ import * as React from "react";
2
+ import { useDigitorn } from "./provider.js";
3
+ // ── Internals ──────────────────────────────────────────────────────────
4
+ function encodePath(p) {
5
+ return p.split("/").map(encodeURIComponent).join("/");
6
+ }
7
+ /** Encode raw bytes as standard base64 for `content_b64` writes. */
8
+ function bytesToBase64(data) {
9
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
10
+ let binary = "";
11
+ const chunk = 0x8000;
12
+ for (let i = 0; i < bytes.length; i += chunk) {
13
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
14
+ }
15
+ return btoa(binary);
16
+ }
17
+ function compileMatch(match) {
18
+ if (typeof match === "function")
19
+ return match;
20
+ if (match === "*" || match === "")
21
+ return () => true;
22
+ if (match.includes("*")) {
23
+ const re = new RegExp("^" +
24
+ match.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") +
25
+ "$");
26
+ return (p) => re.test(p);
27
+ }
28
+ if (match.endsWith("/"))
29
+ return (p) => p.startsWith(match);
30
+ return (p) => p === match;
31
+ }
32
+ /** Session-scoped file endpoint. Shared by every file hook so the URL/auth
33
+ * logic lives in exactly one place.
34
+ *
35
+ * Two auth modes, transparent to callers:
36
+ * - Embedded preview (`?t=` present): the public `preview/files` routes,
37
+ * authenticated by the per-session preview token in the query. No JWT,
38
+ * no cookie - this is the only mode the sandboxed iframe can use.
39
+ * - Host app (JWT present): the `workspace/files` routes with a Bearer token.
40
+ */
41
+ export function useWorkspaceEndpoint() {
42
+ const { session } = useDigitorn();
43
+ const root = `${session.baseUrl}/api/apps/${encodeURIComponent(session.appId)}/sessions/${encodeURIComponent(session.sessionId)}`;
44
+ const preview = Boolean(session.previewToken);
45
+ const headers = React.useMemo(() => {
46
+ const h = {};
47
+ if (!preview && session.token)
48
+ h.Authorization = `Bearer ${session.token}`;
49
+ return h;
50
+ }, [preview, session.token]);
51
+ const fileUrl = React.useCallback((path) => preview
52
+ ? // getPreviewFile already returns raw bytes - no `?raw=1` needed.
53
+ `${root}/preview/files/${encodePath(path)}?t=${encodeURIComponent(session.previewToken)}`
54
+ : `${root}/workspace/files/${encodePath(path)}`, [preview, root, session.previewToken]);
55
+ const treeUrl = React.useCallback((path) => preview
56
+ ? `${root}/preview/tree?path=${encodeURIComponent(path)}&t=${encodeURIComponent(session.previewToken)}`
57
+ : `${root}/workspace/tree?path=${encodeURIComponent(path)}`, [preview, root, session.previewToken]);
58
+ return { root, headers, preview, fileUrl, treeUrl };
59
+ }
60
+ // ── useWorkspace - imperative file access ──────────────────────────────
61
+ /**
62
+ * Imperative access to the session workdir over the (stable) daemon file
63
+ * routes. The app owns its own state shape - one JSON, a folder of files,
64
+ * many files - and reads/writes whatever it needs.
65
+ */
66
+ export function useWorkspace() {
67
+ const { headers, preview, fileUrl, treeUrl } = useWorkspaceEndpoint();
68
+ return React.useMemo(() => ({
69
+ async readFile(path) {
70
+ try {
71
+ // The workspace route wraps content in JSON unless `?raw=1`; the
72
+ // preview route always returns raw bytes.
73
+ const url = preview ? fileUrl(path) : `${fileUrl(path)}?raw=1`;
74
+ const res = await fetch(url, { headers });
75
+ if (!res.ok)
76
+ return undefined;
77
+ return await res.text();
78
+ }
79
+ catch {
80
+ return undefined;
81
+ }
82
+ },
83
+ async writeFile(path, content) {
84
+ await fetch(fileUrl(path), {
85
+ method: "PUT",
86
+ headers: { ...headers, "Content-Type": "application/json" },
87
+ body: JSON.stringify({ content }),
88
+ }).catch(() => { });
89
+ },
90
+ async writeBinary(path, data) {
91
+ await fetch(fileUrl(path), {
92
+ method: "PUT",
93
+ headers: { ...headers, "Content-Type": "application/json" },
94
+ body: JSON.stringify({ content_b64: bytesToBase64(data) }),
95
+ }).catch(() => { });
96
+ },
97
+ async readDir(path) {
98
+ try {
99
+ const res = await fetch(treeUrl(path), { headers });
100
+ if (!res.ok)
101
+ return [];
102
+ const json = (await res.json());
103
+ return json?.entries ?? [];
104
+ }
105
+ catch {
106
+ return [];
107
+ }
108
+ },
109
+ }), [headers, preview, fileUrl, treeUrl]);
110
+ }
111
+ /**
112
+ * Fire `handler` whenever the agent (or anyone) changes workdir files matching
113
+ * `match`. The single, unopinionated primitive: it tells you WHAT changed; you
114
+ * re-read your own state and update the UI however your app wants.
115
+ *
116
+ * ```tsx
117
+ * // a whole folder
118
+ * useWatch("pages/", async () => setPages(await readDir("pages")));
119
+ * // one file
120
+ * useWatch("scene.json", () => reloadScene());
121
+ * // everything
122
+ * useWatch("*", (changes) => console.log(changes));
123
+ * ```
124
+ *
125
+ * The handler identity need not be stable (it's read via a ref).
126
+ */
127
+ export function useWatch(match, handler) {
128
+ const { onEvent, state } = useDigitorn();
129
+ const matcher = React.useMemo(() => compileMatch(match), [match]);
130
+ const handlerRef = React.useRef(handler);
131
+ handlerRef.current = handler;
132
+ // Primary: the daemon PUSHES a `workspace_changes` envelope (debounced) over
133
+ // the same `/events` socket the host uses - for both the agent's filesystem
134
+ // writes and our own `preview/files` writes. Instant, zero polling.
135
+ React.useEffect(() => onEvent((env) => {
136
+ const e = env;
137
+ if (e.type !== "workspace_changes")
138
+ return;
139
+ const files = e.payload?.files ?? [];
140
+ const matched = files.filter((f) => matcher(f.path));
141
+ if (matched.length > 0)
142
+ handlerRef.current(matched);
143
+ }), [onEvent, matcher]);
144
+ // Fallback: while the socket is NOT connected (e.g. the sandboxed iframe
145
+ // couldn't open it), poll gently so updates still land. This never runs when
146
+ // the socket is up - no hammering - and stops the moment it connects.
147
+ React.useEffect(() => {
148
+ if (state.connected)
149
+ return;
150
+ const id = setInterval(() => handlerRef.current([]), 1500);
151
+ return () => clearInterval(id);
152
+ }, [state.connected]);
153
+ }
154
+ // ── useFile / useFileJson - reactive single-file reads ─────────────────
155
+ /** A file's text, re-read automatically whenever the agent edits it. */
156
+ export function useFile(path) {
157
+ const { readFile } = useWorkspace();
158
+ const [content, setContent] = React.useState(undefined);
159
+ const reload = React.useCallback(() => {
160
+ let alive = true;
161
+ void readFile(path).then((c) => {
162
+ if (alive)
163
+ setContent(c);
164
+ });
165
+ return () => {
166
+ alive = false;
167
+ };
168
+ }, [readFile, path]);
169
+ React.useEffect(reload, [reload]);
170
+ useWatch(path, () => void readFile(path).then(setContent));
171
+ return content;
172
+ }
173
+ /** A file parsed as JSON, re-parsed on every agent edit. `undefined` when the
174
+ * file is missing or not valid JSON. */
175
+ export function useFileJson(path) {
176
+ const content = useFile(path);
177
+ return React.useMemo(() => {
178
+ if (content === undefined)
179
+ return undefined;
180
+ try {
181
+ return JSON.parse(content);
182
+ }
183
+ catch {
184
+ return undefined;
185
+ }
186
+ }, [content]);
187
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@digitornai/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Lean React SDK for Digitorn preview apps — one provider, a few hooks, the live agent runtime over Socket.IO.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "module": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "dev": "tsc -p tsconfig.json --watch",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "peerDependencies": {
29
+ "react": ">=18",
30
+ "react-dom": ">=18",
31
+ "socket.io-client": ">=4.7"
32
+ },
33
+ "devDependencies": {
34
+ "@types/react": "^19",
35
+ "typescript": "^6"
36
+ }
37
+ }