@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/package.json +47 -0
- package/src/auth-core.ts +269 -0
- package/src/auth-schemas.ts +58 -0
- package/src/compile-invoke.test.ts +228 -0
- package/src/compile.ts +113 -0
- package/src/deploy-files.test.ts +288 -0
- package/src/deploy-files.ts +529 -0
- package/src/handler-response.test.ts +43 -0
- package/src/handler-response.ts +95 -0
- package/src/handler-timeout.test.ts +23 -0
- package/src/handler-timeout.ts +26 -0
- package/src/ids.ts +10 -0
- package/src/index.ts +195 -0
- package/src/invoke.ts +225 -0
- package/src/load-modules.ts +164 -0
- package/src/local-auth.test.ts +412 -0
- package/src/local-auth.ts +215 -0
- package/src/multipart.ts +60 -0
- package/src/openapi.ts +396 -0
- package/src/password.ts +20 -0
- package/src/path-match.test.ts +155 -0
- package/src/path-match.ts +213 -0
- package/src/project-jwt.ts +57 -0
- package/src/reserved-paths.ts +25 -0
- package/src/schema-sync.ts +83 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
export type DeployFile = {
|
|
2
|
+
path: string;
|
|
3
|
+
content: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type FrontendKind = "react" | "static" | "none";
|
|
7
|
+
|
|
8
|
+
export type FileClass = "api" | "frontend" | "static" | "config" | "rejected";
|
|
9
|
+
|
|
10
|
+
export const MAX_FILE_COUNT = 400;
|
|
11
|
+
/** Prompt workspaces persist Tiny (`packages/be`, `apps/fe`, `.rulesync`) plus edits. */
|
|
12
|
+
export const MAX_WORKSPACE_FILE_COUNT = 2000;
|
|
13
|
+
export const MAX_FILE_BYTES = 1024 * 1024;
|
|
14
|
+
/** Hashed Vite JS and SPA fonts from a Tiny static upload can exceed 1MB. */
|
|
15
|
+
export const MAX_STATIC_FILE_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
export const MAX_TREE_BYTES = 25 * 1024 * 1024;
|
|
17
|
+
export const MAX_PATH_LENGTH = 240;
|
|
18
|
+
export const MAX_PATH_DEPTH = 12;
|
|
19
|
+
export const DEPLOY_BODY_LIMIT_BYTES = 30 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export const BINARY_EXTENSIONS = new Set([
|
|
22
|
+
".png",
|
|
23
|
+
".jpg",
|
|
24
|
+
".jpeg",
|
|
25
|
+
".gif",
|
|
26
|
+
".webp",
|
|
27
|
+
".ico",
|
|
28
|
+
".bmp",
|
|
29
|
+
".woff",
|
|
30
|
+
".woff2",
|
|
31
|
+
".ttf",
|
|
32
|
+
".eot",
|
|
33
|
+
".otf",
|
|
34
|
+
".wasm",
|
|
35
|
+
".zip",
|
|
36
|
+
".gz",
|
|
37
|
+
".tgz",
|
|
38
|
+
".7z",
|
|
39
|
+
".pdf",
|
|
40
|
+
".mp4",
|
|
41
|
+
".webm",
|
|
42
|
+
".mp3",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/** SPA dist binaries allowed only under `public/**`. Still denied at any other path. */
|
|
46
|
+
export const PUBLIC_BINARY_EXTENSIONS = new Set([
|
|
47
|
+
".woff",
|
|
48
|
+
".woff2",
|
|
49
|
+
".ttf",
|
|
50
|
+
".otf",
|
|
51
|
+
".png",
|
|
52
|
+
".jpg",
|
|
53
|
+
".jpeg",
|
|
54
|
+
".gif",
|
|
55
|
+
".webp",
|
|
56
|
+
".ico",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const SRC_EXTENSIONS = new Set([
|
|
60
|
+
".ts",
|
|
61
|
+
".tsx",
|
|
62
|
+
".js",
|
|
63
|
+
".jsx",
|
|
64
|
+
".mjs",
|
|
65
|
+
".cjs",
|
|
66
|
+
".css",
|
|
67
|
+
".json",
|
|
68
|
+
".svg",
|
|
69
|
+
]);
|
|
70
|
+
const PUBLIC_EXTENSIONS = new Set([...SRC_EXTENSIONS, ".html", ".txt", ".md"]);
|
|
71
|
+
const ROOT_STATIC_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".txt", ".md"]);
|
|
72
|
+
|
|
73
|
+
const CONFIG_FILES = new Set([
|
|
74
|
+
"package.json",
|
|
75
|
+
"package-lock.json",
|
|
76
|
+
"pnpm-lock.yaml",
|
|
77
|
+
"tsconfig.json",
|
|
78
|
+
"vite.config.ts",
|
|
79
|
+
"vite.config.js",
|
|
80
|
+
"vite.config.mts",
|
|
81
|
+
"vite.config.mjs",
|
|
82
|
+
"README.md",
|
|
83
|
+
".gitignore",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".robodev"]);
|
|
87
|
+
|
|
88
|
+
export type DeployFileError = {
|
|
89
|
+
statusCode: number;
|
|
90
|
+
code: string;
|
|
91
|
+
message: string;
|
|
92
|
+
path?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export class DeployValidationError extends Error {
|
|
96
|
+
statusCode: number;
|
|
97
|
+
code: string;
|
|
98
|
+
path?: string;
|
|
99
|
+
|
|
100
|
+
constructor(input: DeployFileError) {
|
|
101
|
+
super(input.message);
|
|
102
|
+
this.statusCode = input.statusCode;
|
|
103
|
+
this.code = input.code;
|
|
104
|
+
this.path = input.path;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeDeployPath(filePath: string): string {
|
|
109
|
+
return filePath.replaceAll("\\", "/");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function isUnsafeDeployPath(filePath: string): boolean {
|
|
113
|
+
return (
|
|
114
|
+
filePath.startsWith("/") ||
|
|
115
|
+
filePath.includes("..") ||
|
|
116
|
+
filePath.split("/").some((segment) => segment === "") ||
|
|
117
|
+
/^[A-Za-z]:/.test(filePath)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function extname(filePath: string): string {
|
|
122
|
+
const base = filePath.split("/").pop() ?? "";
|
|
123
|
+
const dot = base.lastIndexOf(".");
|
|
124
|
+
if (dot <= 0) return "";
|
|
125
|
+
return base.slice(dot).toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function formatLimitMb(bytes: number): string {
|
|
129
|
+
return `${Math.round(bytes / (1024 * 1024))}MB`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function isPublicBinaryPath(filePath: string): boolean {
|
|
133
|
+
return filePath.startsWith("public/") && PUBLIC_BINARY_EXTENSIONS.has(extname(filePath));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function deployFileStoredBytes(file: { path: string; content: string }): number {
|
|
137
|
+
if (isPublicBinaryPath(file.path)) {
|
|
138
|
+
return Buffer.from(file.content, "base64").byteLength;
|
|
139
|
+
}
|
|
140
|
+
return Buffer.byteLength(file.content, "utf8");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function basename(filePath: string): string {
|
|
144
|
+
return filePath.split("/").pop() ?? filePath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isTsconfigName(name: string): boolean {
|
|
148
|
+
return name === "tsconfig.json" || /^tsconfig\..+\.json$/.test(name);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isHiddenDenied(filePath: string): boolean {
|
|
152
|
+
const name = basename(filePath);
|
|
153
|
+
if (name === ".gitignore") return false;
|
|
154
|
+
return name.startsWith(".");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const ROBODEV_PREFIX = "robodev/";
|
|
158
|
+
export const ROBODEV_DATABASE_FILE = "robodev/database.ts";
|
|
159
|
+
export const LEGACY_DATABASE_FILE = "database.ts";
|
|
160
|
+
export const ROBODEV_PACKAGE_JSON = "robodev/package.json";
|
|
161
|
+
|
|
162
|
+
export function stripRobodevPrefix(filePath: string): string {
|
|
163
|
+
const path = normalizeDeployPath(filePath);
|
|
164
|
+
return path.startsWith(ROBODEV_PREFIX) ? path.slice(ROBODEV_PREFIX.length) : path;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function backendRootFromFiles(files: readonly { path: string }[]): "robodev" | "" {
|
|
168
|
+
return files.some((file) => normalizeDeployPath(file.path) === ROBODEV_DATABASE_FILE)
|
|
169
|
+
? "robodev"
|
|
170
|
+
: "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function databaseFilePath(files: readonly { path: string }[]): string | undefined {
|
|
174
|
+
const paths = new Set(files.map((file) => normalizeDeployPath(file.path)));
|
|
175
|
+
if (paths.has(ROBODEV_DATABASE_FILE)) return ROBODEV_DATABASE_FILE;
|
|
176
|
+
if (paths.has(LEGACY_DATABASE_FILE)) return LEGACY_DATABASE_FILE;
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function mixedBackendLayoutError(paths: Iterable<string>): string | null {
|
|
181
|
+
const list = [...paths].map((path) => normalizeDeployPath(path));
|
|
182
|
+
const hasNewDb = list.includes(ROBODEV_DATABASE_FILE);
|
|
183
|
+
const hasLegacyDb = list.includes(LEGACY_DATABASE_FILE);
|
|
184
|
+
const hasLegacyApiJobs = list.some(
|
|
185
|
+
(path) => path.startsWith("api/") || path.startsWith("jobs/") || path.startsWith("sockets/"),
|
|
186
|
+
);
|
|
187
|
+
const hasNewApiJobs = list.some(
|
|
188
|
+
(path) =>
|
|
189
|
+
path.startsWith("robodev/api/") ||
|
|
190
|
+
path.startsWith("robodev/jobs/") ||
|
|
191
|
+
path.startsWith("robodev/sockets/"),
|
|
192
|
+
);
|
|
193
|
+
if (hasNewDb && (hasLegacyDb || hasLegacyApiJobs)) {
|
|
194
|
+
return "Do not mix robodev/ backend files with root database.ts, api/, or jobs/";
|
|
195
|
+
}
|
|
196
|
+
if (hasLegacyDb && (hasNewDb || hasNewApiJobs)) {
|
|
197
|
+
return "Do not mix robodev/ backend files with root database.ts, api/, or jobs/";
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isRobodevLockfile(filePath: string): boolean {
|
|
203
|
+
return (
|
|
204
|
+
filePath === "robodev/package-lock.json" ||
|
|
205
|
+
filePath === "robodev/pnpm-lock.yaml" ||
|
|
206
|
+
filePath === "robodev/yarn.lock" ||
|
|
207
|
+
filePath === "robodev/npm-shrinkwrap.json"
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function isDeniedPath(filePath: string): boolean {
|
|
212
|
+
const parts = filePath.split("/");
|
|
213
|
+
if (parts[0] && SKIP_DIRS.has(parts[0])) return true;
|
|
214
|
+
if (filePath === "apps" || filePath.startsWith("apps/")) return true;
|
|
215
|
+
if (filePath === ".robodev" || filePath.startsWith(".robodev/")) return true;
|
|
216
|
+
if (filePath === ".env" || filePath.startsWith(".env.")) return true;
|
|
217
|
+
if (isRobodevLockfile(filePath)) return true;
|
|
218
|
+
if (isHiddenDenied(filePath)) return true;
|
|
219
|
+
if (BINARY_EXTENSIONS.has(extname(filePath)) && !isPublicBinaryPath(filePath)) return true;
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const WORKSPACE_HIDDEN_ALLOW = new Set([
|
|
224
|
+
".gitignore",
|
|
225
|
+
".rulesync",
|
|
226
|
+
".config",
|
|
227
|
+
".oxlintrc.json",
|
|
228
|
+
".oxlint-base.json",
|
|
229
|
+
]);
|
|
230
|
+
const WORKSPACE_ROOT_FILES = new Set([
|
|
231
|
+
"ROBODEV.md",
|
|
232
|
+
"openapi.json",
|
|
233
|
+
"rulesync.jsonc",
|
|
234
|
+
"oxfmt.config.js",
|
|
235
|
+
".oxlintrc.json",
|
|
236
|
+
".oxlint-base.json",
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
function isEnvWorkspacePath(filePath: string): boolean {
|
|
240
|
+
const parts = filePath.split("/");
|
|
241
|
+
return parts.some((part) => part === ".env" || part.startsWith(".env."));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function isDeniedWorkspacePath(filePath: string): boolean {
|
|
245
|
+
const parts = filePath.split("/");
|
|
246
|
+
if (parts.some((part) => SKIP_DIRS.has(part))) return true;
|
|
247
|
+
if (filePath === ".robodev" || filePath.startsWith(".robodev/")) return true;
|
|
248
|
+
if (isEnvWorkspacePath(filePath)) return true;
|
|
249
|
+
if (isRobodevLockfile(filePath)) return true;
|
|
250
|
+
if (BINARY_EXTENSIONS.has(extname(filePath))) return true;
|
|
251
|
+
const name = basename(filePath);
|
|
252
|
+
if (name.startsWith(".")) {
|
|
253
|
+
if (WORKSPACE_HIDDEN_ALLOW.has(name) || WORKSPACE_HIDDEN_ALLOW.has(parts[0] ?? ""))
|
|
254
|
+
return false;
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function isAllowedWorkspacePath(filePath: string): boolean {
|
|
261
|
+
const path = normalizeDeployPath(filePath);
|
|
262
|
+
if (isUnsafeDeployPath(path) || isDeniedWorkspacePath(path)) return false;
|
|
263
|
+
if (path.startsWith("packages/be/")) return true;
|
|
264
|
+
if (path.startsWith("apps/fe/")) return true;
|
|
265
|
+
if (path.startsWith(".rulesync/")) return true;
|
|
266
|
+
if (path.startsWith(".config/")) return true;
|
|
267
|
+
if (WORKSPACE_ROOT_FILES.has(path)) return true;
|
|
268
|
+
return isAllowedDeployPath(path);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function mapPackagesBePath(filePath: string): string | null {
|
|
272
|
+
const path = normalizeDeployPath(filePath);
|
|
273
|
+
if (path === "packages/be/database.ts") return LEGACY_DATABASE_FILE;
|
|
274
|
+
for (const prefix of [
|
|
275
|
+
"packages/be/api/",
|
|
276
|
+
"packages/be/jobs/",
|
|
277
|
+
"packages/be/hooks/",
|
|
278
|
+
"packages/be/sockets/",
|
|
279
|
+
] as const) {
|
|
280
|
+
if (path.startsWith(prefix) && path.endsWith(".ts")) {
|
|
281
|
+
return path.slice("packages/be/".length);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function mapWorkspaceToDeployFiles(
|
|
288
|
+
files: readonly { path: string; content: string }[],
|
|
289
|
+
): DeployFile[] {
|
|
290
|
+
const out: DeployFile[] = [];
|
|
291
|
+
const seen = new Set<string>();
|
|
292
|
+
for (const file of files) {
|
|
293
|
+
const mapped = mapPackagesBePath(file.path);
|
|
294
|
+
const path = mapped ?? normalizeDeployPath(file.path);
|
|
295
|
+
if (!mapped && (isDeniedPath(path) || !isAllowedDeployPath(path))) continue;
|
|
296
|
+
if (seen.has(path)) continue;
|
|
297
|
+
seen.add(path);
|
|
298
|
+
out.push({ path, content: file.content });
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function workspaceHasTinyFrontend(files: readonly { path: string }[]): boolean {
|
|
304
|
+
return files.some((file) => {
|
|
305
|
+
const path = normalizeDeployPath(file.path);
|
|
306
|
+
return path === "apps/fe/package.json" || path === "apps/fe/index.html";
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function workspaceHasPublishableFrontend(
|
|
311
|
+
workspace: readonly { path: string; content: string }[],
|
|
312
|
+
): boolean {
|
|
313
|
+
const mapped = mapWorkspaceToDeployFiles(workspace);
|
|
314
|
+
return mapped.some((file) => {
|
|
315
|
+
const path = normalizeDeployPath(file.path);
|
|
316
|
+
if (path !== "index.html") return false;
|
|
317
|
+
const cls = classifyDeployPath(path);
|
|
318
|
+
return cls === "frontend" || cls === "static";
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function workspaceHasFrontend(files: readonly { path: string; content: string }[]): boolean {
|
|
323
|
+
return workspaceHasTinyFrontend(files) || workspaceHasPublishableFrontend(files);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function isConfigPath(filePath: string): boolean {
|
|
327
|
+
const name = basename(filePath);
|
|
328
|
+
if (CONFIG_FILES.has(filePath) || CONFIG_FILES.has(name)) return true;
|
|
329
|
+
if (isTsconfigName(name) && !filePath.includes("/")) return true;
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* `*.test.ts` / `*.spec.ts` under the backend tree are the project's own tests. They ship and
|
|
335
|
+
* stay editable, but are never bundled or routed: they import test-only modules such as
|
|
336
|
+
* `node:test` that the deployed handler tree has no reason to pull in.
|
|
337
|
+
*/
|
|
338
|
+
export function isBackendTestPath(filePath: string): boolean {
|
|
339
|
+
return /\.(test|spec)\.ts$/.test(normalizeDeployPath(filePath));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function isApiPath(filePath: string): boolean {
|
|
343
|
+
const inner = stripRobodevPrefix(filePath);
|
|
344
|
+
return inner === "database.ts" || (inner.startsWith("api/") && inner.endsWith(".ts"));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function isJobPath(filePath: string): boolean {
|
|
348
|
+
const inner = stripRobodevPrefix(filePath);
|
|
349
|
+
return inner.startsWith("jobs/") && inner.endsWith(".ts");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function isHookPath(filePath: string): boolean {
|
|
353
|
+
const inner = stripRobodevPrefix(filePath);
|
|
354
|
+
return inner.startsWith("hooks/") && inner.endsWith(".ts");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function isSocketPath(filePath: string): boolean {
|
|
358
|
+
const inner = stripRobodevPrefix(filePath);
|
|
359
|
+
return inner.startsWith("sockets/") && inner.endsWith(".ts");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function isSrcPath(filePath: string): boolean {
|
|
363
|
+
return filePath.startsWith("src/") && SRC_EXTENSIONS.has(extname(filePath));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function isPublicPath(filePath: string): boolean {
|
|
367
|
+
if (!filePath.startsWith("public/")) return false;
|
|
368
|
+
return (
|
|
369
|
+
PUBLIC_EXTENSIONS.has(extname(filePath)) || PUBLIC_BINARY_EXTENSIONS.has(extname(filePath))
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isRootStaticPath(filePath: string): boolean {
|
|
374
|
+
if (filePath.includes("/")) return false;
|
|
375
|
+
if (isConfigPath(filePath)) return false;
|
|
376
|
+
return ROOT_STATIC_EXTENSIONS.has(extname(filePath));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function isAllowedDeployPath(filePath: string): boolean {
|
|
380
|
+
if (isRobodevLockfile(filePath)) return false;
|
|
381
|
+
const inner = stripRobodevPrefix(filePath);
|
|
382
|
+
const nested = filePath.startsWith(ROBODEV_PREFIX);
|
|
383
|
+
if (
|
|
384
|
+
inner === "database.ts" &&
|
|
385
|
+
(filePath === LEGACY_DATABASE_FILE || filePath === ROBODEV_DATABASE_FILE)
|
|
386
|
+
) {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (inner.startsWith("api/")) return inner.endsWith(".ts") && !inner.slice(4).includes("\\");
|
|
390
|
+
if (inner.startsWith("jobs/")) return inner.endsWith(".ts") && !inner.slice(5).includes("\\");
|
|
391
|
+
if (inner.startsWith("hooks/")) return inner.endsWith(".ts") && !inner.slice(6).includes("\\");
|
|
392
|
+
if (inner.startsWith("sockets/")) return inner.endsWith(".ts") && !inner.slice(8).includes("\\");
|
|
393
|
+
if (filePath === ROBODEV_PACKAGE_JSON) return true;
|
|
394
|
+
if (nested) return false;
|
|
395
|
+
if (filePath === "index.html") return true;
|
|
396
|
+
if (filePath.startsWith("src/")) return SRC_EXTENSIONS.has(extname(filePath));
|
|
397
|
+
if (filePath.startsWith("public/")) {
|
|
398
|
+
return (
|
|
399
|
+
PUBLIC_EXTENSIONS.has(extname(filePath)) || PUBLIC_BINARY_EXTENSIONS.has(extname(filePath))
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (isConfigPath(filePath)) return true;
|
|
403
|
+
if (isRootStaticPath(filePath)) return true;
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function classifyDeployPath(filePath: string): FileClass {
|
|
408
|
+
if (isDeniedPath(filePath) || isUnsafeDeployPath(filePath) || !isAllowedDeployPath(filePath)) {
|
|
409
|
+
return "rejected";
|
|
410
|
+
}
|
|
411
|
+
if (
|
|
412
|
+
isApiPath(filePath) ||
|
|
413
|
+
isJobPath(filePath) ||
|
|
414
|
+
isHookPath(filePath) ||
|
|
415
|
+
isSocketPath(filePath)
|
|
416
|
+
) {
|
|
417
|
+
return "api";
|
|
418
|
+
}
|
|
419
|
+
if (isConfigPath(filePath)) return "config";
|
|
420
|
+
if (filePath.startsWith("src/") || filePath === "index.html") return "frontend";
|
|
421
|
+
if (isPublicPath(filePath) || isRootStaticPath(filePath)) return "static";
|
|
422
|
+
return "rejected";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function detectFrontendKind(files: readonly { path: string }[]): FrontendKind {
|
|
426
|
+
const paths = new Set(files.map((file) => normalizeDeployPath(file.path)));
|
|
427
|
+
if (!paths.has("index.html")) return "none";
|
|
428
|
+
if (
|
|
429
|
+
paths.has("src/main.tsx") ||
|
|
430
|
+
paths.has("src/main.ts") ||
|
|
431
|
+
paths.has("src/main.jsx") ||
|
|
432
|
+
paths.has("src/main.js")
|
|
433
|
+
) {
|
|
434
|
+
return "react";
|
|
435
|
+
}
|
|
436
|
+
return "static";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function reactEntryPath(files: readonly { path: string }[]): string | null {
|
|
440
|
+
const paths = new Set(files.map((file) => normalizeDeployPath(file.path)));
|
|
441
|
+
for (const entry of ["src/main.tsx", "src/main.ts", "src/main.jsx", "src/main.js"]) {
|
|
442
|
+
if (paths.has(entry)) return entry;
|
|
443
|
+
}
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function isServedStaticPath(filePath: string): boolean {
|
|
448
|
+
if (filePath === "index.html") return true;
|
|
449
|
+
return isPublicPath(filePath) || isRootStaticPath(filePath);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function webServePath(filePath: string): string | null {
|
|
453
|
+
if (filePath === "index.html") return "index.html";
|
|
454
|
+
if (isRootStaticPath(filePath)) return filePath;
|
|
455
|
+
if (filePath.startsWith("public/")) return filePath.slice("public/".length);
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function fail(code: string, message: string, path?: string): never {
|
|
460
|
+
throw new DeployValidationError({ statusCode: 400, code, message, path });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function validateDeployFiles(input: { path: string; content: string }[]): DeployFile[] {
|
|
464
|
+
const files: DeployFile[] = [];
|
|
465
|
+
const seen = new Set<string>();
|
|
466
|
+
let totalBytes = 0;
|
|
467
|
+
|
|
468
|
+
if (input.length > MAX_FILE_COUNT) {
|
|
469
|
+
fail("too_many_files", `Deploy may include at most ${MAX_FILE_COUNT} files`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const raw of input) {
|
|
473
|
+
const path = normalizeDeployPath(raw.path);
|
|
474
|
+
if (
|
|
475
|
+
isUnsafeDeployPath(path) ||
|
|
476
|
+
path.length > MAX_PATH_LENGTH ||
|
|
477
|
+
path.split("/").length > MAX_PATH_DEPTH
|
|
478
|
+
) {
|
|
479
|
+
fail("invalid_file_path", `Invalid file path: ${path}`, path);
|
|
480
|
+
}
|
|
481
|
+
if (seen.has(path)) {
|
|
482
|
+
fail("invalid_file", `Duplicate file path: ${path}`, path);
|
|
483
|
+
}
|
|
484
|
+
seen.add(path);
|
|
485
|
+
if (isDeniedPath(path) || !isAllowedDeployPath(path)) {
|
|
486
|
+
fail("invalid_file", `File is not allowed: ${path}`, path);
|
|
487
|
+
}
|
|
488
|
+
const storedBytes = deployFileStoredBytes({ path, content: raw.content });
|
|
489
|
+
const payloadBytes = Buffer.byteLength(raw.content, "utf8");
|
|
490
|
+
const maxBytes =
|
|
491
|
+
isPublicPath(path) || isRootStaticPath(path) ? MAX_STATIC_FILE_BYTES : MAX_FILE_BYTES;
|
|
492
|
+
if (storedBytes > maxBytes) {
|
|
493
|
+
fail("file_too_large", `File must be under ${formatLimitMb(maxBytes)}: ${path}`, path);
|
|
494
|
+
}
|
|
495
|
+
totalBytes += payloadBytes;
|
|
496
|
+
if (totalBytes > MAX_TREE_BYTES) {
|
|
497
|
+
fail("tree_too_large", `Deploy tree must be under ${formatLimitMb(MAX_TREE_BYTES)}`);
|
|
498
|
+
}
|
|
499
|
+
files.push({ path, content: raw.content });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const mixed = mixedBackendLayoutError(files.map((file) => file.path));
|
|
503
|
+
if (mixed) {
|
|
504
|
+
fail("mixed_backend_layout", mixed);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!databaseFilePath(files)) {
|
|
508
|
+
throw new DeployValidationError({
|
|
509
|
+
statusCode: 400,
|
|
510
|
+
code: "database_required",
|
|
511
|
+
message: "database.ts is required",
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return files;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function apiCompilePaths(files: readonly DeployFile[]): string[] {
|
|
519
|
+
return files
|
|
520
|
+
.filter(
|
|
521
|
+
(file) =>
|
|
522
|
+
!isBackendTestPath(file.path) &&
|
|
523
|
+
(isApiPath(file.path) ||
|
|
524
|
+
isJobPath(file.path) ||
|
|
525
|
+
isHookPath(file.path) ||
|
|
526
|
+
isSocketPath(file.path)),
|
|
527
|
+
)
|
|
528
|
+
.map((file) => file.path);
|
|
529
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { encodeHandlerResult } from "./handler-response.js";
|
|
4
|
+
|
|
5
|
+
test("plain object stays JSON 200", () => {
|
|
6
|
+
const encoded = encodeHandlerResult({ ok: true });
|
|
7
|
+
assert.equal(encoded.status, 200);
|
|
8
|
+
assert.equal(encoded.contentType, "application/json; charset=utf-8");
|
|
9
|
+
assert.deepEqual(encoded.payload, { ok: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("envelope sets status, headers, and contentType", () => {
|
|
13
|
+
const encoded = encodeHandlerResult({
|
|
14
|
+
status: 201,
|
|
15
|
+
headers: { "x-one": "a", connection: "close", "content-type": "text/ignored" },
|
|
16
|
+
contentType: "text/plain; charset=utf-8",
|
|
17
|
+
body: "hello",
|
|
18
|
+
});
|
|
19
|
+
assert.equal(encoded.status, 201);
|
|
20
|
+
assert.equal(encoded.contentType, "text/plain; charset=utf-8");
|
|
21
|
+
assert.equal(encoded.payload, "hello");
|
|
22
|
+
assert.equal(encoded.headers["x-one"], "a");
|
|
23
|
+
assert.equal(encoded.headers.connection, undefined);
|
|
24
|
+
assert.equal(encoded.headers["content-type"], undefined);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("string, Buffer, and base64 bodies encode as bytes or text", () => {
|
|
28
|
+
assert.equal(encodeHandlerResult({ body: "Ad astra" }).contentType, "text/plain; charset=utf-8");
|
|
29
|
+
const fromBuffer = encodeHandlerResult({ body: Buffer.from("abc") });
|
|
30
|
+
assert.equal(fromBuffer.contentType, "application/octet-stream");
|
|
31
|
+
assert.deepEqual(fromBuffer.payload, Buffer.from("abc"));
|
|
32
|
+
const fromBase64 = encodeHandlerResult({
|
|
33
|
+
body: { base64: Buffer.from("xyz").toString("base64") },
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(fromBase64.payload, Buffer.from("xyz"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("extra key stays plain JSON", () => {
|
|
39
|
+
const encoded = encodeHandlerResult({ body: "x", extra: true });
|
|
40
|
+
assert.equal(encoded.status, 200);
|
|
41
|
+
assert.equal(encoded.contentType, "application/json; charset=utf-8");
|
|
42
|
+
assert.deepEqual(encoded.payload, { body: "x", extra: true });
|
|
43
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const ENVELOPE_KEYS = new Set(["status", "headers", "contentType", "body"]);
|
|
2
|
+
const HOP_BY_HOP = new Set([
|
|
3
|
+
"connection",
|
|
4
|
+
"keep-alive",
|
|
5
|
+
"proxy-authenticate",
|
|
6
|
+
"proxy-authorization",
|
|
7
|
+
"te",
|
|
8
|
+
"trailers",
|
|
9
|
+
"transfer-encoding",
|
|
10
|
+
"upgrade",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export type EncodedHandlerResult = {
|
|
14
|
+
status: number;
|
|
15
|
+
headers: Record<string, string>;
|
|
16
|
+
contentType: string;
|
|
17
|
+
payload: unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isEnvelope(value: unknown): value is {
|
|
21
|
+
status?: unknown;
|
|
22
|
+
headers?: unknown;
|
|
23
|
+
contentType?: unknown;
|
|
24
|
+
body: unknown;
|
|
25
|
+
} {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
27
|
+
if (!Object.hasOwn(value, "body")) return false;
|
|
28
|
+
return Object.keys(value).every((key) => ENVELOPE_KEYS.has(key));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isHopByHop(name: string): boolean {
|
|
32
|
+
return HOP_BY_HOP.has(name.toLowerCase());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function encodeBody(
|
|
36
|
+
body: unknown,
|
|
37
|
+
contentType?: string,
|
|
38
|
+
): {
|
|
39
|
+
contentType: string;
|
|
40
|
+
payload: unknown;
|
|
41
|
+
} {
|
|
42
|
+
if (body instanceof Uint8Array) {
|
|
43
|
+
return {
|
|
44
|
+
contentType: contentType ?? "application/octet-stream",
|
|
45
|
+
payload: Buffer.isBuffer(body) ? body : Buffer.from(body),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
typeof body === "object" &&
|
|
50
|
+
body !== null &&
|
|
51
|
+
!Array.isArray(body) &&
|
|
52
|
+
Object.keys(body).length === 1 &&
|
|
53
|
+
typeof (body as { base64?: unknown }).base64 === "string"
|
|
54
|
+
) {
|
|
55
|
+
return {
|
|
56
|
+
contentType: contentType ?? "application/octet-stream",
|
|
57
|
+
payload: Buffer.from((body as { base64: string }).base64, "base64"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (typeof body === "string") {
|
|
61
|
+
return {
|
|
62
|
+
contentType: contentType ?? "text/plain; charset=utf-8",
|
|
63
|
+
payload: body,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
contentType: contentType ?? "application/json; charset=utf-8",
|
|
68
|
+
payload: body,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function encodeHandlerResult(value: unknown): EncodedHandlerResult {
|
|
73
|
+
if (!isEnvelope(value)) {
|
|
74
|
+
return {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: {},
|
|
77
|
+
contentType: "application/json; charset=utf-8",
|
|
78
|
+
payload: value,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const status =
|
|
83
|
+
typeof value.status === "number" && Number.isFinite(value.status) ? value.status : 200;
|
|
84
|
+
const headers: Record<string, string> = {};
|
|
85
|
+
if (value.headers && typeof value.headers === "object" && !Array.isArray(value.headers)) {
|
|
86
|
+
for (const [name, headerValue] of Object.entries(value.headers)) {
|
|
87
|
+
if (typeof headerValue !== "string" || isHopByHop(name)) continue;
|
|
88
|
+
if (name.toLowerCase() === "content-type") continue;
|
|
89
|
+
headers[name] = headerValue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const contentType = typeof value.contentType === "string" ? value.contentType : undefined;
|
|
93
|
+
const encoded = encodeBody(value.body, contentType);
|
|
94
|
+
return { status, headers, ...encoded };
|
|
95
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
clampTimeoutMs,
|
|
5
|
+
DEFAULT_TIMEOUT_MS,
|
|
6
|
+
HandlerTimeoutError,
|
|
7
|
+
MAX_TIMEOUT_MS,
|
|
8
|
+
withHandlerTimeout,
|
|
9
|
+
} from "./handler-timeout.js";
|
|
10
|
+
|
|
11
|
+
test("defaults to 10000 and clamps to 30s", () => {
|
|
12
|
+
assert.equal(clampTimeoutMs(), DEFAULT_TIMEOUT_MS);
|
|
13
|
+
assert.equal(clampTimeoutMs(40_000), MAX_TIMEOUT_MS);
|
|
14
|
+
assert.equal(clampTimeoutMs(0), 1);
|
|
15
|
+
assert.equal(clampTimeoutMs(1500), 1500);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("overdue handler rejects with handler_timeout", async () => {
|
|
19
|
+
await assert.rejects(
|
|
20
|
+
() => withHandlerTimeout(() => new Promise((resolve) => setTimeout(resolve, 50)), 10),
|
|
21
|
+
(error: unknown) => error instanceof HandlerTimeoutError && error.message === "handler_timeout",
|
|
22
|
+
);
|
|
23
|
+
});
|