@robodev-ai/runtime 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.
package/src/compile.ts ADDED
@@ -0,0 +1,113 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { builtinModules, isBuiltin } from "node:module";
3
+ import { dirname, join, relative } from "node:path";
4
+ import * as esbuild from "esbuild";
5
+ import { apiCompilePaths, type DeployFile } from "./deploy-files.js";
6
+
7
+ export const HOST_SDK = "@robodev-ai/sdk";
8
+
9
+ /**
10
+ * `builtinModules` omits the prefix-only builtins, which are importable as `node:test` and
11
+ * friends but have no bare alias. Spell them out so esbuild's externals list covers them;
12
+ * `isBuiltin` already knows about them.
13
+ */
14
+ const PREFIX_ONLY_BUILTINS = ["test", "test/reporters", "sea", "sqlite"];
15
+ const NODE_BUILTINS = new Set([
16
+ ...builtinModules,
17
+ ...builtinModules.map((name) => `node:${name}`),
18
+ ...PREFIX_ONLY_BUILTINS.map((name) => `node:${name}`),
19
+ ]);
20
+
21
+ export function isNodeBuiltin(spec: string): boolean {
22
+ return isBuiltin(spec);
23
+ }
24
+
25
+ /** Externals every backend bundle shares: the host SDK plus Node builtins. */
26
+ export function apiBundleExternals(): string[] {
27
+ return [HOST_SDK, ...NODE_BUILTINS];
28
+ }
29
+
30
+ export type CompileIssue = {
31
+ text: string;
32
+ path?: string;
33
+ line?: number;
34
+ column?: number;
35
+ };
36
+
37
+ export class ApiBuildError extends Error {
38
+ statusCode = 400;
39
+ code = "api_build_failed";
40
+ details: { errors: CompileIssue[] };
41
+
42
+ constructor(errors: CompileIssue[]) {
43
+ super("API build failed");
44
+ this.details = { errors };
45
+ }
46
+ }
47
+
48
+ function toDeployPath(file: string | undefined, srcDir: string): string | undefined {
49
+ if (!file) return undefined;
50
+ const rel = relative(srcDir, file).replaceAll("\\", "/");
51
+ if (rel && !rel.startsWith("..")) return rel;
52
+ return file.replaceAll("\\", "/").split(/[\\/]/).slice(-3).join("/");
53
+ }
54
+
55
+ export function mapEsbuildErrors(
56
+ errors: readonly esbuild.Message[],
57
+ srcDir: string,
58
+ ): CompileIssue[] {
59
+ return errors.map((error) => ({
60
+ text: error.text,
61
+ path: toDeployPath(error.location?.file, srcDir),
62
+ line: error.location?.line,
63
+ column: error.location?.column,
64
+ }));
65
+ }
66
+
67
+ export function isEsbuildFailure(error: unknown): error is esbuild.BuildFailure {
68
+ return Boolean(error && typeof error === "object" && "errors" in error);
69
+ }
70
+
71
+ /** Writes the deploy tree to `srcDir` so esbuild has real files to resolve against. */
72
+ export async function writeDeploySources(srcDir: string, files: DeployFile[]): Promise<void> {
73
+ for (const file of files) {
74
+ const dest = join(srcDir, file.path);
75
+ await mkdir(dirname(dest), { recursive: true });
76
+ await writeFile(dest, file.content, "utf8");
77
+ }
78
+ }
79
+
80
+ export type CompileApiOptions = {
81
+ /** Build root. Sources are read from `<dir>/src`, output goes to `<dir>/dist`. */
82
+ dir: string;
83
+ files: readonly DeployFile[];
84
+ /** Extra module directories esbuild may resolve bare specifiers from. */
85
+ nodePaths?: string[];
86
+ external?: string[];
87
+ plugins?: esbuild.Plugin[];
88
+ };
89
+
90
+ /**
91
+ * The single esbuild path for `database.ts`, `api/**`, `jobs/**`, `hooks/**`, and
92
+ * `sockets/**`. Hosted deploy and `robodev dev` both go through this so a bundle that
93
+ * compiles locally compiles the same way on Starbase.
94
+ */
95
+ export async function compileApiTree(options: CompileApiOptions): Promise<string> {
96
+ const srcDir = join(options.dir, "src");
97
+ const distDir = join(options.dir, "dist");
98
+ const entryPoints = apiCompilePaths(options.files).map((path) => join(srcDir, path));
99
+ await esbuild.build({
100
+ absWorkingDir: options.dir,
101
+ entryPoints,
102
+ outdir: distDir,
103
+ outbase: srcDir,
104
+ bundle: true,
105
+ format: "esm",
106
+ platform: "node",
107
+ target: "node20",
108
+ nodePaths: options.nodePaths ?? [],
109
+ external: options.external ?? apiBundleExternals(),
110
+ plugins: options.plugins ?? [],
111
+ });
112
+ return distDir;
113
+ }
@@ -0,0 +1,288 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ classifyDeployPath,
5
+ detectFrontendKind,
6
+ MAX_FILE_COUNT,
7
+ validateDeployFiles,
8
+ backendRootFromFiles,
9
+ isAllowedWorkspacePath,
10
+ isDeniedPath,
11
+ mapWorkspaceToDeployFiles,
12
+ apiCompilePaths,
13
+ isAllowedDeployPath,
14
+ isBackendTestPath,
15
+ workspaceHasTinyFrontend,
16
+ workspaceHasFrontend,
17
+ } from "./deploy-files.js";
18
+
19
+ const db = { path: "database.ts", content: "export default {}" };
20
+
21
+ test("allows official tree paths", () => {
22
+ const files = validateDeployFiles([
23
+ db,
24
+ { path: "api/planets.ts", content: "export {}" },
25
+ { path: "api/invoices/[id].ts", content: "export {}" },
26
+ { path: "jobs/x.ts", content: "export {}" },
27
+ { path: "sockets/chat.ts", content: "export {}" },
28
+ { path: "hooks/auth.ts", content: "export {}" },
29
+ { path: "hooks/lib.ts", content: "export {}" },
30
+ { path: "index.html", content: "<html></html>" },
31
+ { path: "src/main.tsx", content: "export {}" },
32
+ { path: "package.json", content: "{}" },
33
+ { path: "README.md", content: "# Hi" },
34
+ { path: "public/note.txt", content: "ok" },
35
+ ]);
36
+ assert.equal(files.length, 12);
37
+ assert.equal(backendRootFromFiles(files), "");
38
+ });
39
+
40
+ test("rejects unsafe and denied paths", () => {
41
+ for (const path of [
42
+ "../x.ts",
43
+ "/abs.ts",
44
+ "node_modules/x.js",
45
+ "dist/x.js",
46
+ ".env",
47
+ ".env.local",
48
+ "secret.png",
49
+ "apps/fe/package.json",
50
+ "apps/fe/src/main.tsx",
51
+ "robodev/.robodev",
52
+ ".robodev",
53
+ ]) {
54
+ assert.throws(
55
+ () => validateDeployFiles([db, { path, content: "x" }]),
56
+ (err: { code?: string }) => err.code === "invalid_file" || err.code === "invalid_file_path",
57
+ );
58
+ }
59
+ });
60
+
61
+ test("workspace allowlist persists Tiny paths and still denies .env", () => {
62
+ assert.equal(isAllowedWorkspacePath("packages/be/database.ts"), true);
63
+ assert.equal(isAllowedWorkspacePath("packages/be/api/items.ts"), true);
64
+ assert.equal(isAllowedWorkspacePath("packages/be/sockets/chat.ts"), true);
65
+ assert.equal(isAllowedWorkspacePath("apps/fe/package.json"), true);
66
+ assert.equal(isAllowedWorkspacePath("apps/fe/src/main.tsx"), true);
67
+ assert.equal(isAllowedWorkspacePath(".rulesync/rules/project-overview.md"), true);
68
+ assert.equal(isAllowedWorkspacePath("apps/fe/src/styles/fonts/fonts.tsx"), true);
69
+ assert.equal(isAllowedWorkspacePath("apps/fe/public/favicon.svg"), true);
70
+ assert.equal(isAllowedWorkspacePath(".env"), false);
71
+ assert.equal(isAllowedWorkspacePath("apps/fe/.env"), false);
72
+ assert.equal(isAllowedWorkspacePath("node_modules/x.js"), false);
73
+ assert.equal(isDeniedPath("apps/fe/src/main.tsx"), true);
74
+ });
75
+
76
+ test("maps packages/be to hosted database.ts and api/**", () => {
77
+ const mapped = mapWorkspaceToDeployFiles([
78
+ { path: "packages/be/database.ts", content: "db" },
79
+ { path: "packages/be/api/items.ts", content: "api" },
80
+ { path: "packages/be/sockets/chat.ts", content: "socket" },
81
+ { path: "apps/fe/src/main.tsx", content: "fe" },
82
+ { path: ".rulesync/rules/overview.md", content: "rules" },
83
+ ]);
84
+ assert.deepEqual(
85
+ mapped.map((file) => file.path),
86
+ ["database.ts", "api/items.ts", "sockets/chat.ts"],
87
+ );
88
+ });
89
+
90
+ test("allows robodev/ backend tree", () => {
91
+ const files = validateDeployFiles([
92
+ { path: "robodev/database.ts", content: "export default {}" },
93
+ { path: "robodev/api/planets.ts", content: "export {}" },
94
+ { path: "robodev/api/invoices/[id].ts", content: "export {}" },
95
+ { path: "robodev/jobs/x.ts", content: "export {}" },
96
+ { path: "robodev/sockets/chat.ts", content: "export {}" },
97
+ { path: "robodev/hooks/auth.ts", content: "export {}" },
98
+ { path: "robodev/package.json", content: '{"private":true,"dependencies":{}}' },
99
+ { path: "index.html", content: "<html></html>" },
100
+ { path: "src/main.tsx", content: "export {}" },
101
+ { path: "package.json", content: "{}" },
102
+ ]);
103
+ assert.equal(files.length, 10);
104
+ assert.equal(backendRootFromFiles(files), "robodev");
105
+ });
106
+
107
+ test("rejects mixed robodev/ and root backend layouts", () => {
108
+ assert.throws(
109
+ () =>
110
+ validateDeployFiles([
111
+ { path: "robodev/database.ts", content: "export default {}" },
112
+ { path: "database.ts", content: "export default {}" },
113
+ ]),
114
+ (err: { code?: string }) => err.code === "mixed_backend_layout",
115
+ );
116
+ assert.throws(
117
+ () =>
118
+ validateDeployFiles([
119
+ { path: "robodev/database.ts", content: "export default {}" },
120
+ { path: "api/x.ts", content: "export {}" },
121
+ ]),
122
+ (err: { code?: string }) => err.code === "mixed_backend_layout",
123
+ );
124
+ });
125
+
126
+ test("requires database.ts at either layout", () => {
127
+ assert.throws(
128
+ () => validateDeployFiles([{ path: "api/x.ts", content: "export {}" }]),
129
+ (err: { code?: string }) => err.code === "database_required",
130
+ );
131
+ });
132
+
133
+ test("rejects duplicate paths", () => {
134
+ assert.throws(
135
+ () => validateDeployFiles([db, db]),
136
+ (err: { code?: string }) => err.code === "invalid_file",
137
+ );
138
+ });
139
+
140
+ test("rejects api non-ts and unknown src extensions", () => {
141
+ assert.throws(
142
+ () => validateDeployFiles([db, { path: "api/x.tsx", content: "x" }]),
143
+ (err: { code?: string }) => err.code === "invalid_file",
144
+ );
145
+ assert.throws(
146
+ () => validateDeployFiles([db, { path: "src/photo.bmp", content: "x" }]),
147
+ (err: { code?: string }) => err.code === "invalid_file",
148
+ );
149
+ });
150
+
151
+ test("enforces file count and size limits", () => {
152
+ const many = [
153
+ db,
154
+ ...Array.from({ length: MAX_FILE_COUNT }, (_, i) => ({
155
+ path: `api/f${i}.ts`,
156
+ content: "x",
157
+ })),
158
+ ];
159
+ assert.throws(
160
+ () => validateDeployFiles(many),
161
+ (err: { code?: string }) => err.code === "too_many_files",
162
+ );
163
+ assert.throws(
164
+ () => validateDeployFiles([db, { path: "api/big.ts", content: "a".repeat(1024 * 1024 + 1) }]),
165
+ (err: { code?: string }) => err.code === "file_too_large",
166
+ );
167
+ assert.equal(
168
+ validateDeployFiles([
169
+ db,
170
+ { path: "index.html", content: "<html></html>" },
171
+ { path: "public/assets/app.js", content: "a".repeat(1024 * 1024 + 10) },
172
+ ]).length,
173
+ 3,
174
+ );
175
+ assert.throws(
176
+ () =>
177
+ validateDeployFiles([
178
+ db,
179
+ { path: "public/assets/app.js", content: "a".repeat(8 * 1024 * 1024 + 1) },
180
+ ]),
181
+ (err: { code?: string }) => err.code === "file_too_large",
182
+ );
183
+ });
184
+
185
+ test("classifies api vs frontend vs config", () => {
186
+ assert.equal(classifyDeployPath("database.ts"), "api");
187
+ assert.equal(classifyDeployPath("robodev/database.ts"), "api");
188
+ assert.equal(classifyDeployPath("api/x.ts"), "api");
189
+ assert.equal(classifyDeployPath("robodev/api/x.ts"), "api");
190
+ assert.equal(classifyDeployPath("api/invoices/[id].ts"), "api");
191
+ assert.equal(classifyDeployPath("jobs/x.ts"), "api");
192
+ assert.equal(classifyDeployPath("robodev/jobs/x.ts"), "api");
193
+ assert.equal(classifyDeployPath("sockets/chat.ts"), "api");
194
+ assert.equal(classifyDeployPath("robodev/sockets/chat.ts"), "api");
195
+ assert.equal(classifyDeployPath("hooks/auth.ts"), "api");
196
+ assert.equal(classifyDeployPath("robodev/hooks/auth.ts"), "api");
197
+ assert.equal(classifyDeployPath("hooks/lib.ts"), "api");
198
+ assert.equal(classifyDeployPath("src/main.tsx"), "frontend");
199
+ assert.equal(classifyDeployPath("index.html"), "frontend");
200
+ assert.equal(classifyDeployPath("package.json"), "config");
201
+ assert.equal(classifyDeployPath("robodev/package.json"), "config");
202
+ assert.equal(classifyDeployPath("styles.css"), "static");
203
+ assert.equal(classifyDeployPath("public/assets/font.woff2"), "static");
204
+ assert.equal(classifyDeployPath("apps/fe/src/main.tsx"), "rejected");
205
+ assert.equal(classifyDeployPath(".env"), "rejected");
206
+ });
207
+
208
+ test("validate accepts public SPA binaries as base64 and still denies apps/fe", () => {
209
+ const font = Buffer.from("woff2-bytes").toString("base64");
210
+ const files = validateDeployFiles([
211
+ db,
212
+ { path: "index.html", content: "<html></html>" },
213
+ { path: "public/assets/x.woff2", content: font },
214
+ ]);
215
+ assert.equal(files.find((file) => file.path === "public/assets/x.woff2")?.content, font);
216
+ assert.throws(
217
+ () => validateDeployFiles([db, { path: "apps/fe/src/main.tsx", content: "export {}" }]),
218
+ (err: { code?: string }) => err.code === "invalid_file",
219
+ );
220
+ assert.throws(
221
+ () => validateDeployFiles([db, { path: "public/assets/app.wasm", content: font }]),
222
+ (err: { code?: string }) => err.code === "invalid_file",
223
+ );
224
+ const over = Buffer.alloc(8 * 1024 * 1024 + 1).toString("base64");
225
+ assert.throws(
226
+ () => validateDeployFiles([db, { path: "public/assets/huge.woff2", content: over }]),
227
+ (err: { code?: string }) => err.code === "file_too_large",
228
+ );
229
+ });
230
+
231
+ test("detects react, static, and none", () => {
232
+ assert.equal(detectFrontendKind([{ path: "database.ts" }]), "none");
233
+ assert.equal(detectFrontendKind([{ path: "index.html" }]), "static");
234
+ assert.equal(detectFrontendKind([{ path: "index.html" }, { path: "src/main.tsx" }]), "react");
235
+ });
236
+
237
+ test("apiCompilePaths includes sockets as backend TS", () => {
238
+ assert.deepEqual(
239
+ apiCompilePaths([
240
+ { path: "database.ts", content: "" },
241
+ { path: "api/x.ts", content: "" },
242
+ { path: "jobs/x.ts", content: "" },
243
+ { path: "sockets/chat.ts", content: "" },
244
+ { path: "src/main.tsx", content: "" },
245
+ ]),
246
+ ["database.ts", "api/x.ts", "jobs/x.ts", "sockets/chat.ts"],
247
+ );
248
+ });
249
+
250
+ test("apiCompilePaths skips backend tests", () => {
251
+ assert.deepEqual(
252
+ apiCompilePaths([
253
+ { path: "robodev/database.ts", content: "" },
254
+ { path: "robodev/api/_lib.ts", content: "" },
255
+ { path: "robodev/api/_lib.seed.test.ts", content: "" },
256
+ { path: "robodev/jobs/resize.spec.ts", content: "" },
257
+ { path: "robodev/sockets/chat.test.ts", content: "" },
258
+ ]),
259
+ ["robodev/database.ts", "robodev/api/_lib.ts"],
260
+ );
261
+ });
262
+
263
+ test("backend tests stay uploadable even though they never compile", () => {
264
+ assert.equal(isBackendTestPath("robodev/api/_lib.seed.test.ts"), true);
265
+ assert.equal(isBackendTestPath("robodev/api/latest.ts"), false);
266
+ assert.equal(isAllowedDeployPath("robodev/api/_lib.seed.test.ts"), true);
267
+ });
268
+
269
+ test("Tiny frontend is package.json or index.html under apps/fe", () => {
270
+ assert.equal(workspaceHasTinyFrontend([{ path: "apps/fe/package.json" }]), true);
271
+ assert.equal(workspaceHasTinyFrontend([{ path: "apps/fe/index.html" }]), true);
272
+ assert.equal(workspaceHasTinyFrontend([{ path: "index.html" }]), false);
273
+ assert.equal(
274
+ workspaceHasFrontend([
275
+ { path: "packages/be/database.ts", content: "db" },
276
+ { path: "apps/fe/package.json", content: "{}" },
277
+ ]),
278
+ true,
279
+ );
280
+ assert.equal(workspaceHasFrontend([{ path: "database.ts", content: "db" }]), false);
281
+ assert.equal(
282
+ workspaceHasFrontend([
283
+ { path: "database.ts", content: "db" },
284
+ { path: "index.html", content: "<html></html>" },
285
+ ]),
286
+ true,
287
+ );
288
+ });