@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,26 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
2
|
+
export const MAX_TIMEOUT_MS = 30_000;
|
|
3
|
+
|
|
4
|
+
export class HandlerTimeoutError extends Error {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("handler_timeout");
|
|
7
|
+
this.name = "HandlerTimeoutError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function clampTimeoutMs(value?: number): number {
|
|
12
|
+
if (value == null || !Number.isFinite(value)) return DEFAULT_TIMEOUT_MS;
|
|
13
|
+
return Math.min(MAX_TIMEOUT_MS, Math.max(1, Math.floor(value)));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function withHandlerTimeout<T>(run: () => Promise<T>, timeoutMs: number): Promise<T> {
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
19
|
+
timer = setTimeout(() => reject(new HandlerTimeoutError()), timeoutMs);
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
return await Promise.race([run(), timeout]);
|
|
23
|
+
} finally {
|
|
24
|
+
if (timer) clearTimeout(timer);
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { customAlphabet } from "nanoid";
|
|
2
|
+
|
|
3
|
+
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
4
|
+
const nano = customAlphabet(alphabet, 12);
|
|
5
|
+
|
|
6
|
+
export function id(prefix: string): string {
|
|
7
|
+
// Project ids appear in hostnames; Let's Encrypt rejects underscores.
|
|
8
|
+
const sep = prefix === "prj" ? "-" : "_";
|
|
9
|
+
return `${prefix}${sep}${nano()}`;
|
|
10
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@robodev-ai/runtime` — the project runtime shared by hosted Starbase deploys and the
|
|
3
|
+
* offline `robodev dev` loop: deploy-file classification, the esbuild compile, module
|
|
4
|
+
* loading, schema push, route matching, OpenAPI, handler invocation, and Robodev Auth.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
BINARY_EXTENSIONS,
|
|
9
|
+
DEPLOY_BODY_LIMIT_BYTES,
|
|
10
|
+
DeployValidationError,
|
|
11
|
+
LEGACY_DATABASE_FILE,
|
|
12
|
+
MAX_FILE_BYTES,
|
|
13
|
+
MAX_FILE_COUNT,
|
|
14
|
+
MAX_PATH_DEPTH,
|
|
15
|
+
MAX_PATH_LENGTH,
|
|
16
|
+
MAX_STATIC_FILE_BYTES,
|
|
17
|
+
MAX_TREE_BYTES,
|
|
18
|
+
MAX_WORKSPACE_FILE_COUNT,
|
|
19
|
+
PUBLIC_BINARY_EXTENSIONS,
|
|
20
|
+
ROBODEV_DATABASE_FILE,
|
|
21
|
+
ROBODEV_PACKAGE_JSON,
|
|
22
|
+
ROBODEV_PREFIX,
|
|
23
|
+
apiCompilePaths,
|
|
24
|
+
backendRootFromFiles,
|
|
25
|
+
classifyDeployPath,
|
|
26
|
+
databaseFilePath,
|
|
27
|
+
deployFileStoredBytes,
|
|
28
|
+
detectFrontendKind,
|
|
29
|
+
formatLimitMb,
|
|
30
|
+
isAllowedDeployPath,
|
|
31
|
+
isAllowedWorkspacePath,
|
|
32
|
+
isApiPath,
|
|
33
|
+
isBackendTestPath,
|
|
34
|
+
isConfigPath,
|
|
35
|
+
isDeniedPath,
|
|
36
|
+
isDeniedWorkspacePath,
|
|
37
|
+
isHookPath,
|
|
38
|
+
isJobPath,
|
|
39
|
+
isPublicBinaryPath,
|
|
40
|
+
isPublicPath,
|
|
41
|
+
isRootStaticPath,
|
|
42
|
+
isServedStaticPath,
|
|
43
|
+
isSocketPath,
|
|
44
|
+
isSrcPath,
|
|
45
|
+
isUnsafeDeployPath,
|
|
46
|
+
mapPackagesBePath,
|
|
47
|
+
mapWorkspaceToDeployFiles,
|
|
48
|
+
mixedBackendLayoutError,
|
|
49
|
+
normalizeDeployPath,
|
|
50
|
+
reactEntryPath,
|
|
51
|
+
stripRobodevPrefix,
|
|
52
|
+
validateDeployFiles,
|
|
53
|
+
webServePath,
|
|
54
|
+
workspaceHasFrontend,
|
|
55
|
+
workspaceHasPublishableFrontend,
|
|
56
|
+
workspaceHasTinyFrontend,
|
|
57
|
+
type DeployFile,
|
|
58
|
+
type DeployFileError,
|
|
59
|
+
type FileClass,
|
|
60
|
+
type FrontendKind,
|
|
61
|
+
} from "./deploy-files.js";
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
fileToRoute,
|
|
65
|
+
fileToSocketRoute,
|
|
66
|
+
isAuthHookFile,
|
|
67
|
+
jobNameFromFile,
|
|
68
|
+
matchPathParams,
|
|
69
|
+
matchProjectRoute,
|
|
70
|
+
matchSocketRoute,
|
|
71
|
+
openApiDocumentedPath,
|
|
72
|
+
openApiPath,
|
|
73
|
+
pathParamNames,
|
|
74
|
+
socketNameFromFile,
|
|
75
|
+
type FileRoute,
|
|
76
|
+
type RouteCandidate,
|
|
77
|
+
} from "./path-match.js";
|
|
78
|
+
|
|
79
|
+
export { buildOpenApi, scalarHtml, swaggerHtml, type RuntimeRoute } from "./openapi.js";
|
|
80
|
+
|
|
81
|
+
export { applyPlan, buildPlan, type SchemaOp, type SchemaPlan } from "./schema-sync.js";
|
|
82
|
+
|
|
83
|
+
export { encodeHandlerResult, type EncodedHandlerResult } from "./handler-response.js";
|
|
84
|
+
|
|
85
|
+
export {
|
|
86
|
+
DEFAULT_TIMEOUT_MS,
|
|
87
|
+
HandlerTimeoutError,
|
|
88
|
+
MAX_TIMEOUT_MS,
|
|
89
|
+
clampTimeoutMs,
|
|
90
|
+
withHandlerTimeout,
|
|
91
|
+
} from "./handler-timeout.js";
|
|
92
|
+
|
|
93
|
+
export {
|
|
94
|
+
MAX_MULTIPART_FILE_BYTES,
|
|
95
|
+
mediaType,
|
|
96
|
+
parseMultipart,
|
|
97
|
+
parseUrlEncoded,
|
|
98
|
+
} from "./multipart.js";
|
|
99
|
+
|
|
100
|
+
export { hashPassword, verifyPassword } from "./password.js";
|
|
101
|
+
|
|
102
|
+
export { id } from "./ids.js";
|
|
103
|
+
|
|
104
|
+
export {
|
|
105
|
+
ApiBuildError,
|
|
106
|
+
HOST_SDK,
|
|
107
|
+
apiBundleExternals,
|
|
108
|
+
compileApiTree,
|
|
109
|
+
isEsbuildFailure,
|
|
110
|
+
isNodeBuiltin,
|
|
111
|
+
mapEsbuildErrors,
|
|
112
|
+
writeDeploySources,
|
|
113
|
+
type CompileApiOptions,
|
|
114
|
+
type CompileIssue,
|
|
115
|
+
} from "./compile.js";
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
loadModules,
|
|
119
|
+
type LoadedModules,
|
|
120
|
+
type RuntimeAuthHook,
|
|
121
|
+
type RuntimeJob,
|
|
122
|
+
type RuntimeSocket,
|
|
123
|
+
} from "./load-modules.js";
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
isReservedAuthPath,
|
|
127
|
+
isReservedProjectPath,
|
|
128
|
+
isReservedRobodevPath,
|
|
129
|
+
isReservedStoragePath,
|
|
130
|
+
} from "./reserved-paths.js";
|
|
131
|
+
|
|
132
|
+
export {
|
|
133
|
+
runRoute,
|
|
134
|
+
type MappedError,
|
|
135
|
+
type RouteClients,
|
|
136
|
+
type RouteOutcome,
|
|
137
|
+
type RunRouteInput,
|
|
138
|
+
} from "./invoke.js";
|
|
139
|
+
|
|
140
|
+
export {
|
|
141
|
+
PROJECT_ACCESS_TTL,
|
|
142
|
+
PROJECT_ACCESS_TYP,
|
|
143
|
+
bearerToken,
|
|
144
|
+
projectSecret,
|
|
145
|
+
signProjectUserToken,
|
|
146
|
+
verifyProjectUserToken,
|
|
147
|
+
} from "./project-jwt.js";
|
|
148
|
+
|
|
149
|
+
export {
|
|
150
|
+
NONCE_TTL_MS,
|
|
151
|
+
REFRESH_TTL_MS,
|
|
152
|
+
consumeNonce,
|
|
153
|
+
createNonce,
|
|
154
|
+
ensureAuthSchema,
|
|
155
|
+
hashToken,
|
|
156
|
+
insertPasswordUser,
|
|
157
|
+
issueProjectTokenPair,
|
|
158
|
+
loadRefreshSession,
|
|
159
|
+
loadUserByEmail,
|
|
160
|
+
loadUserById,
|
|
161
|
+
passwordMatches,
|
|
162
|
+
randomSecret,
|
|
163
|
+
revokeRefresh,
|
|
164
|
+
rotateRefresh,
|
|
165
|
+
toAuthUser,
|
|
166
|
+
updateUserPassword,
|
|
167
|
+
updateUserProfile,
|
|
168
|
+
type AuthQueryable,
|
|
169
|
+
type AuthRow,
|
|
170
|
+
type NonceType,
|
|
171
|
+
} from "./auth-core.js";
|
|
172
|
+
|
|
173
|
+
export {
|
|
174
|
+
GENERIC_STATUS_MESSAGE,
|
|
175
|
+
emailField,
|
|
176
|
+
forgotCallbackBody,
|
|
177
|
+
forgotRequestBody,
|
|
178
|
+
googleStartQuery,
|
|
179
|
+
loginBody,
|
|
180
|
+
magicConsumeQuery,
|
|
181
|
+
magicGenerateQuery,
|
|
182
|
+
meUpdateBody,
|
|
183
|
+
refreshBody,
|
|
184
|
+
registerBody,
|
|
185
|
+
statusOk,
|
|
186
|
+
} from "./auth-schemas.js";
|
|
187
|
+
|
|
188
|
+
export {
|
|
189
|
+
handleLocalAuth,
|
|
190
|
+
isLocalAuthPath,
|
|
191
|
+
resolveLocalUser,
|
|
192
|
+
type LocalAuthOptions,
|
|
193
|
+
type LocalAuthRequest,
|
|
194
|
+
type LocalAuthResult,
|
|
195
|
+
} from "./local-auth.js";
|
package/src/invoke.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentClient,
|
|
3
|
+
ApiHandlerContext,
|
|
4
|
+
AuthUser,
|
|
5
|
+
EmailClient,
|
|
6
|
+
JobsClient,
|
|
7
|
+
LlmClient,
|
|
8
|
+
PushClient,
|
|
9
|
+
RobodevDb,
|
|
10
|
+
SocketsClient,
|
|
11
|
+
StorageClient,
|
|
12
|
+
UploadedFile,
|
|
13
|
+
} from "@robodev-ai/sdk";
|
|
14
|
+
import { encodeHandlerResult, type EncodedHandlerResult } from "./handler-response.js";
|
|
15
|
+
import { clampTimeoutMs, HandlerTimeoutError, withHandlerTimeout } from "./handler-timeout.js";
|
|
16
|
+
import {
|
|
17
|
+
MAX_MULTIPART_FILE_BYTES,
|
|
18
|
+
mediaType,
|
|
19
|
+
parseMultipart,
|
|
20
|
+
parseUrlEncoded,
|
|
21
|
+
} from "./multipart.js";
|
|
22
|
+
import type { RuntimeRoute } from "./openapi.js";
|
|
23
|
+
import { matchProjectRoute } from "./path-match.js";
|
|
24
|
+
|
|
25
|
+
/** Everything a handler context needs that the host, not the runtime, provides. */
|
|
26
|
+
export type RouteClients = {
|
|
27
|
+
email: EmailClient;
|
|
28
|
+
llm: LlmClient;
|
|
29
|
+
agent: AgentClient;
|
|
30
|
+
storage: StorageClient;
|
|
31
|
+
push: PushClient;
|
|
32
|
+
jobs: JobsClient;
|
|
33
|
+
sockets: SocketsClient;
|
|
34
|
+
env: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type RouteOutcome =
|
|
38
|
+
| { kind: "ok"; encoded: EncodedHandlerResult }
|
|
39
|
+
| { kind: "error"; status: number; body: Record<string, unknown> };
|
|
40
|
+
|
|
41
|
+
export type MappedError = { status: number; body: Record<string, unknown> };
|
|
42
|
+
|
|
43
|
+
export type RunRouteInput = {
|
|
44
|
+
db: RobodevDb;
|
|
45
|
+
routes: readonly RuntimeRoute[];
|
|
46
|
+
method: string;
|
|
47
|
+
pathname: string;
|
|
48
|
+
query: unknown;
|
|
49
|
+
body: unknown;
|
|
50
|
+
rawBody?: Buffer;
|
|
51
|
+
headers: Record<string, string | string[] | undefined>;
|
|
52
|
+
authorization?: string;
|
|
53
|
+
/** Per-file multipart ceiling. Defaults to the runtime's own limit. */
|
|
54
|
+
maxUploadBytes?: number;
|
|
55
|
+
/** Resolves the Robodev Auth user for `auth: "required" | "optional"`. */
|
|
56
|
+
resolveUser: (authorization?: string) => Promise<AuthUser | null>;
|
|
57
|
+
/** Built after auth passes, right before the handler runs. */
|
|
58
|
+
clients: () => Promise<RouteClients> | RouteClients;
|
|
59
|
+
/** True when the request carries a valid schedule signature. */
|
|
60
|
+
verifySchedule?: (headers: Record<string, string | string[] | undefined>) => boolean;
|
|
61
|
+
scheduleId?: string;
|
|
62
|
+
/** Lets the host translate its own error types before the generic 500. */
|
|
63
|
+
mapError?: (error: unknown) => MappedError | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function invalid(status: number, body: Record<string, unknown>): RouteOutcome {
|
|
67
|
+
return { kind: "error", status, body };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Matches one request against the loaded file routes, validates it, runs the handler
|
|
72
|
+
* under the shared timeout, and encodes the result. Hosted project hosts and
|
|
73
|
+
* `robodev dev` share this so validation and response shapes cannot drift.
|
|
74
|
+
*/
|
|
75
|
+
export async function runRoute(input: RunRouteInput): Promise<RouteOutcome> {
|
|
76
|
+
const matched = matchProjectRoute(input.pathname, input.method, input.routes as RuntimeRoute[]);
|
|
77
|
+
if (!matched) {
|
|
78
|
+
return invalid(404, {
|
|
79
|
+
error: "route_not_found",
|
|
80
|
+
message: `${input.method} ${input.pathname} is not deployed`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { route, params } = matched;
|
|
85
|
+
const contentType = mediaType(
|
|
86
|
+
typeof input.headers["content-type"] === "string" ? input.headers["content-type"] : undefined,
|
|
87
|
+
);
|
|
88
|
+
let query: unknown = input.query ?? {};
|
|
89
|
+
let body: unknown = input.body;
|
|
90
|
+
let files: UploadedFile[] | undefined;
|
|
91
|
+
const rawBody = input.rawBody;
|
|
92
|
+
|
|
93
|
+
if (route.def.query) {
|
|
94
|
+
const parsed = route.def.query.safeParse(input.query ?? {});
|
|
95
|
+
if (!parsed.success) {
|
|
96
|
+
return invalid(400, { error: "invalid_query", details: parsed.error.flatten() });
|
|
97
|
+
}
|
|
98
|
+
query = parsed.data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (route.def.multipart) {
|
|
102
|
+
if (!rawBody) {
|
|
103
|
+
return invalid(400, { error: "invalid_body", message: "missing multipart body" });
|
|
104
|
+
}
|
|
105
|
+
const parsed = await parseMultipart(
|
|
106
|
+
rawBody,
|
|
107
|
+
typeof input.headers["content-type"] === "string"
|
|
108
|
+
? input.headers["content-type"]
|
|
109
|
+
: "multipart/form-data",
|
|
110
|
+
input.maxUploadBytes ?? MAX_MULTIPART_FILE_BYTES,
|
|
111
|
+
);
|
|
112
|
+
files = parsed.files;
|
|
113
|
+
body = parsed.fields;
|
|
114
|
+
if (route.def.body) {
|
|
115
|
+
const parsedBody = route.def.body.safeParse(body);
|
|
116
|
+
if (!parsedBody.success) {
|
|
117
|
+
return invalid(400, { error: "invalid_body", details: parsedBody.error.flatten() });
|
|
118
|
+
}
|
|
119
|
+
body = parsedBody.data;
|
|
120
|
+
}
|
|
121
|
+
} else if (route.def.rawBody) {
|
|
122
|
+
if (contentType === "application/json") {
|
|
123
|
+
try {
|
|
124
|
+
body = rawBody && rawBody.length ? JSON.parse(rawBody.toString("utf8")) : {};
|
|
125
|
+
} catch {
|
|
126
|
+
return invalid(400, { error: "invalid_body", message: "invalid JSON" });
|
|
127
|
+
}
|
|
128
|
+
} else if (contentType === "application/x-www-form-urlencoded") {
|
|
129
|
+
body = rawBody ? parseUrlEncoded(rawBody) : {};
|
|
130
|
+
} else if (contentType === "application/octet-stream") {
|
|
131
|
+
body = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (route.def.body && contentType !== "application/octet-stream") {
|
|
134
|
+
const parsed = route.def.body.safeParse(body);
|
|
135
|
+
if (!parsed.success) {
|
|
136
|
+
return invalid(400, { error: "invalid_body", details: parsed.error.flatten() });
|
|
137
|
+
}
|
|
138
|
+
body = parsed.data;
|
|
139
|
+
}
|
|
140
|
+
} else if (route.def.body && input.method !== "get") {
|
|
141
|
+
const parsed = route.def.body.safeParse(input.body);
|
|
142
|
+
if (!parsed.success) {
|
|
143
|
+
return invalid(400, { error: "invalid_body", details: parsed.error.flatten() });
|
|
144
|
+
}
|
|
145
|
+
body = parsed.data;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scheduleValid = input.verifySchedule ? input.verifySchedule(input.headers) : false;
|
|
149
|
+
const schedule = scheduleValid
|
|
150
|
+
? { id: input.scheduleId ?? "schedule" }
|
|
151
|
+
: input.scheduleId
|
|
152
|
+
? { id: input.scheduleId }
|
|
153
|
+
: null;
|
|
154
|
+
|
|
155
|
+
let user: AuthUser | null = null;
|
|
156
|
+
const auth = route.def.auth ?? false;
|
|
157
|
+
if (auth === "schedule") {
|
|
158
|
+
if (!scheduleValid) {
|
|
159
|
+
return invalid(401, {
|
|
160
|
+
error: "unauthorized",
|
|
161
|
+
message: "missing or invalid schedule signature",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
} else if (typeof auth === "function") {
|
|
165
|
+
try {
|
|
166
|
+
user = await auth({ headers: input.headers });
|
|
167
|
+
} catch {
|
|
168
|
+
user = null;
|
|
169
|
+
}
|
|
170
|
+
if (!user) {
|
|
171
|
+
return invalid(401, {
|
|
172
|
+
error: "unauthorized",
|
|
173
|
+
message: "auth verifier rejected the request",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
} else if (auth === "required" || auth === "optional") {
|
|
177
|
+
user = await input.resolveUser(input.authorization);
|
|
178
|
+
if (auth === "required" && !user) {
|
|
179
|
+
return invalid(401, {
|
|
180
|
+
error: "unauthorized",
|
|
181
|
+
message: "missing or invalid access token",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const clients = await input.clients();
|
|
188
|
+
const context: ApiHandlerContext<never, never> = {
|
|
189
|
+
db: input.db,
|
|
190
|
+
email: clients.email,
|
|
191
|
+
llm: clients.llm,
|
|
192
|
+
agent: clients.agent,
|
|
193
|
+
storage: clients.storage,
|
|
194
|
+
env: clients.env,
|
|
195
|
+
params,
|
|
196
|
+
query: query as never,
|
|
197
|
+
body: body as never,
|
|
198
|
+
headers: input.headers,
|
|
199
|
+
user,
|
|
200
|
+
...(rawBody ? { rawBody } : {}),
|
|
201
|
+
...(files ? { files } : {}),
|
|
202
|
+
push: clients.push,
|
|
203
|
+
jobs: clients.jobs,
|
|
204
|
+
sockets: clients.sockets,
|
|
205
|
+
schedule,
|
|
206
|
+
};
|
|
207
|
+
const result = await withHandlerTimeout(
|
|
208
|
+
() => Promise.resolve(route.def.handler(context)),
|
|
209
|
+
clampTimeoutMs(route.def.timeoutMs),
|
|
210
|
+
);
|
|
211
|
+
return { kind: "ok", encoded: encodeHandlerResult(result) };
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error instanceof HandlerTimeoutError) {
|
|
214
|
+
return invalid(504, { error: "handler_timeout" });
|
|
215
|
+
}
|
|
216
|
+
const mapped = input.mapError?.(error);
|
|
217
|
+
if (mapped) {
|
|
218
|
+
return invalid(mapped.status, mapped.body);
|
|
219
|
+
}
|
|
220
|
+
return invalid(500, {
|
|
221
|
+
error: "handler_failed",
|
|
222
|
+
message: error instanceof Error ? error.message : "Handler failed",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import {
|
|
4
|
+
HTTP_METHODS,
|
|
5
|
+
isApiDefinition,
|
|
6
|
+
isAuthHookDefinition,
|
|
7
|
+
isDatabaseDef,
|
|
8
|
+
isJobDefinition,
|
|
9
|
+
isSocketDefinition,
|
|
10
|
+
type AuthHookDefinition,
|
|
11
|
+
type DatabaseDef,
|
|
12
|
+
type JobDefinition,
|
|
13
|
+
type SocketDefinition,
|
|
14
|
+
} from "@robodev-ai/sdk";
|
|
15
|
+
import { databaseFilePath, type DeployFile } from "./deploy-files.js";
|
|
16
|
+
import type { RuntimeRoute } from "./openapi.js";
|
|
17
|
+
import {
|
|
18
|
+
fileToRoute,
|
|
19
|
+
fileToSocketRoute,
|
|
20
|
+
isAuthHookFile,
|
|
21
|
+
jobNameFromFile,
|
|
22
|
+
socketNameFromFile,
|
|
23
|
+
} from "./path-match.js";
|
|
24
|
+
import { isReservedProjectPath } from "./reserved-paths.js";
|
|
25
|
+
|
|
26
|
+
export type RuntimeJob = {
|
|
27
|
+
name: string;
|
|
28
|
+
file: string;
|
|
29
|
+
def: JobDefinition;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RuntimeSocket = {
|
|
33
|
+
name: string;
|
|
34
|
+
path: string;
|
|
35
|
+
file: string;
|
|
36
|
+
paramNames: string[];
|
|
37
|
+
def: SocketDefinition;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RuntimeAuthHook = {
|
|
41
|
+
file: string;
|
|
42
|
+
def: AuthHookDefinition;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type LoadedModules = {
|
|
46
|
+
database: DatabaseDef;
|
|
47
|
+
routes: RuntimeRoute[];
|
|
48
|
+
jobs: RuntimeJob[];
|
|
49
|
+
sockets: RuntimeSocket[];
|
|
50
|
+
authHook?: RuntimeAuthHook;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
async function importFresh(absPath: string): Promise<Record<string, unknown>> {
|
|
54
|
+
const url = `${pathToFileURL(absPath).href}?t=${Date.now()}`;
|
|
55
|
+
return (await import(url)) as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compiledPath(distDir: string, sourcePath: string): string {
|
|
59
|
+
return join(distDir, sourcePath.replace(/\.ts$/, ".js"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Imports the compiled bundle and turns it into routes, jobs, sockets, and the auth hook.
|
|
64
|
+
* `distDir` is the output of `compileApiTree`.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadModules(
|
|
67
|
+
distDir: string,
|
|
68
|
+
files: readonly DeployFile[],
|
|
69
|
+
): Promise<LoadedModules> {
|
|
70
|
+
const dbPath = databaseFilePath(files);
|
|
71
|
+
if (!dbPath) {
|
|
72
|
+
throw new Error("deployment must include database.ts");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dbMod = await importFresh(compiledPath(distDir, dbPath));
|
|
76
|
+
const database = dbMod.default;
|
|
77
|
+
if (!isDatabaseDef(database)) {
|
|
78
|
+
throw new Error("database.ts must default-export defineDatabase(...)");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const routes: RuntimeRoute[] = [];
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const mapped = fileToRoute(file.path);
|
|
84
|
+
if (!mapped || isReservedProjectPath(mapped.path)) continue;
|
|
85
|
+
const mod = await importFresh(compiledPath(distDir, file.path));
|
|
86
|
+
for (const method of HTTP_METHODS) {
|
|
87
|
+
const exported = mod[method];
|
|
88
|
+
if (isApiDefinition(exported)) {
|
|
89
|
+
if (exported.rawBody && exported.multipart) {
|
|
90
|
+
throw Object.assign(
|
|
91
|
+
new Error(`rawBody and multipart cannot both be set in ${file.path}`),
|
|
92
|
+
{ statusCode: 400, code: "invalid_api_definition", path: file.path },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
routes.push({
|
|
96
|
+
method,
|
|
97
|
+
path: mapped.path,
|
|
98
|
+
file: file.path,
|
|
99
|
+
def: exported,
|
|
100
|
+
paramNames: mapped.paramNames,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const jobs: RuntimeJob[] = [];
|
|
107
|
+
const seenJobs = new Set<string>();
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const name = jobNameFromFile(file.path);
|
|
110
|
+
if (!name) continue;
|
|
111
|
+
if (seenJobs.has(name)) {
|
|
112
|
+
throw Object.assign(new Error(`Duplicate job name: ${name}`), {
|
|
113
|
+
statusCode: 400,
|
|
114
|
+
code: "duplicate_job",
|
|
115
|
+
path: file.path,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const mod = await importFresh(compiledPath(distDir, file.path));
|
|
119
|
+
if (!isJobDefinition(mod.default)) continue;
|
|
120
|
+
seenJobs.add(name);
|
|
121
|
+
jobs.push({ name, file: file.path, def: mod.default });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const sockets: RuntimeSocket[] = [];
|
|
125
|
+
const seenSockets = new Set<string>();
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const name = socketNameFromFile(file.path);
|
|
128
|
+
const mapped = fileToSocketRoute(file.path);
|
|
129
|
+
if (!name || !mapped) continue;
|
|
130
|
+
if (seenSockets.has(name)) {
|
|
131
|
+
throw Object.assign(new Error(`Duplicate socket name: ${name}`), {
|
|
132
|
+
statusCode: 400,
|
|
133
|
+
code: "duplicate_socket",
|
|
134
|
+
path: file.path,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const mod = await importFresh(compiledPath(distDir, file.path));
|
|
138
|
+
if (!isSocketDefinition(mod.default)) continue;
|
|
139
|
+
seenSockets.add(name);
|
|
140
|
+
sockets.push({
|
|
141
|
+
name,
|
|
142
|
+
path: mapped.path,
|
|
143
|
+
file: file.path,
|
|
144
|
+
paramNames: mapped.paramNames,
|
|
145
|
+
def: mod.default,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let authHook: RuntimeAuthHook | undefined;
|
|
150
|
+
const authHookFile = files.find((file) => isAuthHookFile(file.path));
|
|
151
|
+
if (authHookFile) {
|
|
152
|
+
const mod = await importFresh(compiledPath(distDir, authHookFile.path));
|
|
153
|
+
if (!isAuthHookDefinition(mod.default)) {
|
|
154
|
+
throw Object.assign(new Error("hooks/auth.ts must default-export defineAuthHook(...)"), {
|
|
155
|
+
statusCode: 400,
|
|
156
|
+
code: "invalid_auth_hook",
|
|
157
|
+
path: authHookFile.path,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
authHook = { file: authHookFile.path, def: mod.default };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { database, routes, jobs, sockets, authHook };
|
|
164
|
+
}
|