robodev 0.34.0 → 0.36.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/dev-server.ts CHANGED
@@ -1,22 +1,43 @@
1
1
  import { Readable } from "node:stream";
2
2
  import cors from "@fastify/cors";
3
- import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
3
+ import websocket from "@fastify/websocket";
4
+ import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
5
+ import type { WebSocket } from "ws";
4
6
  import {
7
+ SOCKET_CLOSE_POLICY,
8
+ SOCKET_IDLE_PING_MS,
9
+ bindSocketSession,
5
10
  buildOpenApi,
6
11
  handleLocalAuth,
7
12
  isLocalAuthPath,
13
+ isReservedStoragePath,
8
14
  matchProjectRoute,
15
+ matchSocketRoute,
16
+ parseLogicalKey,
17
+ resolveLocalObject,
9
18
  resolveLocalUser,
10
19
  runRoute,
11
20
  scalarHtml,
12
21
  swaggerHtml,
22
+ verifyStorageServeToken,
13
23
  type AuthQueryable,
14
24
  type RuntimeRoute,
25
+ type RuntimeSocket,
15
26
  } from "@robodev-ai/runtime";
16
- import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
27
+ import type {
28
+ JobEnqueueInput,
29
+ RobodevDb,
30
+ SocketDefinition,
31
+ SocketHandlerContext,
32
+ } from "@robodev-ai/sdk";
17
33
  import type { RuntimeJob } from "@robodev-ai/runtime";
18
34
  import { devRouteClients } from "./dev-clients.js";
19
- import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
35
+ import {
36
+ buildDashboardState,
37
+ isInspectPath,
38
+ shouldCountRequestHistogram,
39
+ type DevInspectStore,
40
+ } from "./dev-inspect.js";
20
41
 
21
42
  /** One compiled generation of the project. Swapped wholesale on reload. */
22
43
  export type DevRuntime = {
@@ -24,6 +45,7 @@ export type DevRuntime = {
24
45
  pool: AuthQueryable & { end: () => Promise<void> };
25
46
  routes: RuntimeRoute[];
26
47
  jobs: RuntimeJob[];
48
+ sockets: RuntimeSocket[];
27
49
  openapi: Record<string, unknown>;
28
50
  };
29
51
 
@@ -39,6 +61,7 @@ export type DevServerOptions = {
39
61
  brokerBaseUrl: string;
40
62
  googleClientId?: string;
41
63
  googleClientSecret?: string;
64
+ storageDir: string;
42
65
  };
43
66
 
44
67
  const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
@@ -57,10 +80,242 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
57
80
  return (request as FastifyRequest & { rawBody?: Buffer }).rawBody;
58
81
  }
59
82
 
83
+ function queryRecord(query: unknown): Record<string, string> {
84
+ if (!query || typeof query !== "object") return {};
85
+ const out: Record<string, string> = {};
86
+ for (const [key, value] of Object.entries(query as Record<string, unknown>)) {
87
+ if (typeof value === "string") out[key] = value;
88
+ else if (Array.isArray(value) && typeof value[0] === "string") out[key] = value[0];
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function socketAuthorization(request: FastifyRequest): string | undefined {
94
+ const header = request.headers.authorization;
95
+ if (typeof header === "string" && header.length > 0) return header;
96
+ const token = queryRecord(request.query).accessToken;
97
+ if (token) return `Bearer ${token}`;
98
+ return undefined;
99
+ }
100
+
101
+ function codedErrorBody(error: unknown): { status: number; body: Record<string, unknown> } | null {
102
+ if (!error || typeof error !== "object" || !("statusCode" in error)) return null;
103
+ const statusCode = (error as { statusCode: unknown }).statusCode;
104
+ if (typeof statusCode !== "number") return null;
105
+ const code = (error as { code?: unknown }).code;
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ return {
108
+ status: statusCode,
109
+ body: {
110
+ error: typeof code === "string" ? code : message,
111
+ message,
112
+ },
113
+ };
114
+ }
115
+
116
+ function routeClients(options: DevServerOptions, getRuntime: () => DevRuntime | null) {
117
+ return devRouteClients({
118
+ env: options.env,
119
+ log: options.log,
120
+ enqueue: options.enqueue,
121
+ inspect: options.inspect,
122
+ query: (sql, values) => {
123
+ const runtime = getRuntime();
124
+ if (!runtime) throw new Error("dev runtime not ready");
125
+ return runtime.pool.query(sql, values);
126
+ },
127
+ objectsDir: options.storageDir,
128
+ publicBaseUrl: options.listenOrigin,
129
+ projectId: options.projectId,
130
+ jwtSecret: options.jwtSecret,
131
+ });
132
+ }
133
+
134
+ async function resolveSocketUser(
135
+ options: DevServerOptions,
136
+ def: SocketDefinition,
137
+ request: FastifyRequest,
138
+ ): Promise<{ ok: true; user: SocketHandlerContext["user"] } | { ok: false }> {
139
+ const auth = def.auth ?? false;
140
+ if (auth === false) return { ok: true, user: null };
141
+ if (typeof auth === "function") {
142
+ try {
143
+ const user = await auth({ headers: request.headers });
144
+ if (!user) return { ok: false };
145
+ return { ok: true, user };
146
+ } catch {
147
+ return { ok: false };
148
+ }
149
+ }
150
+ if (auth !== "required" && auth !== "optional") return { ok: false };
151
+ const user = await resolveLocalUser(
152
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
153
+ socketAuthorization(request),
154
+ );
155
+ if (auth === "required" && !user) return { ok: false };
156
+ return { ok: true, user };
157
+ }
158
+
159
+ async function handleLocalSocket(
160
+ socket: WebSocket,
161
+ request: FastifyRequest,
162
+ options: DevServerOptions,
163
+ getRuntime: () => DevRuntime | null,
164
+ ): Promise<void> {
165
+ const runtime = getRuntime();
166
+ if (!runtime) {
167
+ socket.close(SOCKET_CLOSE_POLICY, "not_found");
168
+ return;
169
+ }
170
+ const pathname = pathnameOf(request.url);
171
+ const matched = matchSocketRoute(pathname, runtime.sockets);
172
+ if (!matched) {
173
+ socket.close(SOCKET_CLOSE_POLICY, "not_found");
174
+ return;
175
+ }
176
+
177
+ const auth = await resolveSocketUser(options, matched.socket.def, request);
178
+ if (!auth.ok) {
179
+ socket.close(SOCKET_CLOSE_POLICY, "unauthorized");
180
+ return;
181
+ }
182
+
183
+ const clients = routeClients(options, getRuntime);
184
+ const session = await bindSocketSession({
185
+ projectId: options.projectId,
186
+ name: matched.socket.name,
187
+ socket: {
188
+ send: (data) => socket.send(data),
189
+ close: (code, reason) => socket.close(code, reason),
190
+ get readyState() {
191
+ return socket.readyState;
192
+ },
193
+ },
194
+ timeoutMs: matched.socket.def.timeoutMs,
195
+ createContext: ({ send, close, room }) => ({
196
+ db: runtime.db,
197
+ email: clients.email,
198
+ llm: clients.llm,
199
+ agent: clients.agent,
200
+ storage: clients.storage,
201
+ env: clients.env,
202
+ params: matched.params,
203
+ query: queryRecord(request.query),
204
+ headers: request.headers,
205
+ user: auth.user,
206
+ push: clients.push,
207
+ jobs: clients.jobs,
208
+ sockets: clients.sockets,
209
+ send,
210
+ close,
211
+ room,
212
+ }),
213
+ onConnect: matched.socket.def.onConnect,
214
+ onMessage: matched.socket.def.onMessage,
215
+ onClose: matched.socket.def.onClose,
216
+ });
217
+ if (!session.ok) {
218
+ if (socket.readyState === socket.OPEN) {
219
+ socket.close(session.code, session.reason);
220
+ }
221
+ return;
222
+ }
223
+
224
+ let alive = true;
225
+ const pingTimer = setInterval(() => {
226
+ if (socket.readyState !== socket.OPEN) {
227
+ clearInterval(pingTimer);
228
+ return;
229
+ }
230
+ if (!alive) {
231
+ socket.close(SOCKET_CLOSE_POLICY, "idle");
232
+ return;
233
+ }
234
+ alive = false;
235
+ try {
236
+ socket.ping();
237
+ } catch {
238
+ socket.close(SOCKET_CLOSE_POLICY, "idle");
239
+ }
240
+ }, SOCKET_IDLE_PING_MS);
241
+ pingTimer.unref?.();
242
+
243
+ socket.on("pong", () => {
244
+ alive = true;
245
+ });
246
+ socket.on("message", (data, isBinary) => {
247
+ if (isBinary) {
248
+ session.onBinary();
249
+ return;
250
+ }
251
+ const text = typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8");
252
+ session.onText(text);
253
+ });
254
+ socket.on("close", () => {
255
+ clearInterval(pingTimer);
256
+ session.onClose();
257
+ });
258
+ }
259
+
260
+ async function serveLocalStorage(
261
+ request: FastifyRequest,
262
+ reply: FastifyReply,
263
+ options: DevServerOptions,
264
+ getRuntime: () => DevRuntime | null,
265
+ pathname: string,
266
+ ): Promise<void> {
267
+ const runtime = getRuntime();
268
+ if (!runtime) {
269
+ await reply.code(503).send({ error: "not_ready" });
270
+ return;
271
+ }
272
+ const encodedKey = pathname.slice("/storage/objects/".length);
273
+ let key: string;
274
+ try {
275
+ key = decodeURIComponent(encodedKey);
276
+ parseLogicalKey(key);
277
+ } catch (error) {
278
+ const mapped = codedErrorBody(error);
279
+ if (mapped) {
280
+ await reply.code(mapped.status).send(mapped.body);
281
+ return;
282
+ }
283
+ await reply.code(400).send({ error: "invalid_key" });
284
+ return;
285
+ }
286
+
287
+ const resolved = await resolveLocalObject({
288
+ query: (sql, values) => runtime.pool.query(sql, values),
289
+ objectsDir: options.storageDir,
290
+ key,
291
+ });
292
+ if (!resolved) {
293
+ await reply.code(404).send({ error: "not_found" });
294
+ return;
295
+ }
296
+
297
+ const user = await resolveLocalUser(
298
+ { projectId: options.projectId, jwtSecret: options.jwtSecret },
299
+ request.headers.authorization,
300
+ );
301
+ const signed = verifyStorageServeToken(
302
+ options.jwtSecret,
303
+ options.projectId,
304
+ key,
305
+ request.query as { exp?: unknown; sig?: unknown },
306
+ );
307
+ if (!resolved.row.public && !user && !signed) {
308
+ await reply.code(404).send({ error: "not_found" });
309
+ return;
310
+ }
311
+
312
+ await reply.type(resolved.row.content_type).send(resolved.body);
313
+ }
314
+
60
315
  /**
61
316
  * A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
62
317
  * and no static SPA serving (local Vite does that). Fastify stays `logger: false`;
63
- * project/auth/docs requests are recorded on the process-local inspect page after reply.
318
+ * project/auth/docs requests are recorded on the process-local dashboard after reply.
64
319
  * Routes, validation, and OpenAPI all come from `@robodev-ai/runtime`.
65
320
  */
66
321
  export async function startDevServer(
@@ -74,6 +329,10 @@ export async function startDevServer(
74
329
  credentials: true,
75
330
  methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
76
331
  });
332
+ await app.register(websocket);
333
+ app.get("/sockets/*", { websocket: true }, (socket, request) => {
334
+ void handleLocalSocket(socket, request, options, getRuntime);
335
+ });
77
336
 
78
337
  app.addContentTypeParser("application/json", { parseAs: "string" }, (_request, body, done) => {
79
338
  if (!body) {
@@ -103,13 +362,16 @@ export async function startDevServer(
103
362
  );
104
363
 
105
364
  // `rawBody` handlers and multipart parsing need the untouched bytes.
106
- app.addHook("preParsing", async (_request, _reply, payload) => {
365
+ app.addHook("preParsing", async (request, _reply, payload) => {
366
+ if (String(request.headers.upgrade ?? "").toLowerCase() === "websocket") {
367
+ return payload;
368
+ }
107
369
  const chunks: Buffer[] = [];
108
370
  for await (const chunk of payload) {
109
371
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
110
372
  }
111
373
  const raw = Buffer.concat(chunks);
112
- (_request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
374
+ (request as FastifyRequest & { rawBody?: Buffer }).rawBody = raw;
113
375
  return Readable.from(raw);
114
376
  });
115
377
 
@@ -120,8 +382,18 @@ export async function startDevServer(
120
382
  const method = request.method.toLowerCase();
121
383
 
122
384
  if (isInspectPath(pathname)) {
123
- if (pathname === "/_robodev/logs.json") {
124
- return reply.send(options.inspect.list());
385
+ if (pathname === "/_robodev/logs") {
386
+ return reply.redirect("/_robodev");
387
+ }
388
+ if (pathname === "/_robodev/state.json" || pathname === "/_robodev/logs.json") {
389
+ return reply.send(
390
+ await buildDashboardState({
391
+ inspect: options.inspect,
392
+ runtime: getRuntime(),
393
+ googleClientId: options.googleClientId,
394
+ googleClientSecret: options.googleClientSecret,
395
+ }),
396
+ );
125
397
  }
126
398
  return reply.type("text/html; charset=utf-8").send(options.inspect.html());
127
399
  }
@@ -132,15 +404,20 @@ export async function startDevServer(
132
404
  } finally {
133
405
  const status = reply.statusCode;
134
406
  const durationMs = Date.now() - started;
135
- options.inspect.record({
136
- kind: "request",
137
- method: request.method,
138
- path: pathname,
139
- status,
140
- durationMs,
141
- });
142
- // Print here (not via `log`) so request lines stay off the Inspect Logs list.
143
- console.log(`${request.method} ${pathname} ${status} ${durationMs}ms`);
407
+ if (!isInspectPath(pathname)) {
408
+ options.inspect.record({
409
+ kind: "request",
410
+ method: request.method,
411
+ path: pathname,
412
+ status,
413
+ durationMs,
414
+ });
415
+ // Print here (not via `log`) so request lines stay off compile/reload logs.
416
+ console.log(`${request.method} ${pathname} ${status} ${durationMs}ms`);
417
+ if (shouldCountRequestHistogram(pathname, getRuntime(), request.method)) {
418
+ options.inspect.incrementRequestHistogram(status);
419
+ }
420
+ }
144
421
  }
145
422
  return reply;
146
423
 
@@ -214,6 +491,11 @@ export async function startDevServer(
214
491
  return;
215
492
  }
216
493
 
494
+ if (request.method.toUpperCase() === "GET" && isReservedStoragePath(pathname)) {
495
+ await serveLocalStorage(request, reply, options, getRuntime, pathname);
496
+ return;
497
+ }
498
+
217
499
  if (!matchProjectRoute(pathname, method, runtime.routes)) {
218
500
  await reply.code(404).send({
219
501
  error: "not_found",
@@ -238,13 +520,8 @@ export async function startDevServer(
238
520
  { projectId: options.projectId, jwtSecret: options.jwtSecret },
239
521
  authorization,
240
522
  ),
241
- clients: () =>
242
- devRouteClients({
243
- env: options.env,
244
- log: options.log,
245
- enqueue: options.enqueue,
246
- inspect: options.inspect,
247
- }),
523
+ clients: () => routeClients(options, getRuntime),
524
+ mapError: (error) => codedErrorBody(error),
248
525
  });
249
526
 
250
527
  if (outcome.kind === "error") {
package/src/dev.ts CHANGED
@@ -9,9 +9,13 @@ import {
9
9
  assertJobCrons,
10
10
  buildPlan,
11
11
  compileApiTree,
12
+ closeProjectSockets,
12
13
  createLocalJobsEngine,
13
14
  ensureAuthSchema,
14
15
  ensureJobsSchema,
16
+ ensureStorageSchema,
17
+ setProjectSocketNames,
18
+ SOCKET_CLOSE_RESTART,
15
19
  isEsbuildFailure,
16
20
  loadModules,
17
21
  mapEsbuildErrors,
@@ -154,6 +158,8 @@ export async function main(args: string[]): Promise<void> {
154
158
  const postgres: DevPostgres = parsed.databaseUrl
155
159
  ? externalDevPostgres(parsed.databaseUrl)
156
160
  : await startDevPostgres({ dataDir: join(devDir, "pg"), port: parsed.dbPort, quiet: true });
161
+ const storageDir = join(devDir, "storage");
162
+ await mkdir(storageDir, { recursive: true });
157
163
 
158
164
  let current: DevRuntime | null = null;
159
165
  let lastSchemaSource = "";
@@ -172,7 +178,30 @@ export async function main(args: string[]): Promise<void> {
172
178
  getGeneration: () => current,
173
179
  log,
174
180
  clients: () =>
175
- devRouteClients({ env, log, inspect, enqueue: (input) => engine.enqueue(input) }),
181
+ devRouteClients({
182
+ env,
183
+ log,
184
+ inspect,
185
+ enqueue: (input) => engine.enqueue(input),
186
+ query: (sql, values) => {
187
+ if (!current) throw new Error("dev runtime not ready");
188
+ return current.pool.query(sql, values);
189
+ },
190
+ objectsDir: storageDir,
191
+ publicBaseUrl: `http://localhost:${parsed.port}`,
192
+ projectId: DEV_PROJECT_ID,
193
+ jwtSecret: DEV_JWT_SECRET,
194
+ }),
195
+ onJobFinished: (event) => {
196
+ inspect.record({
197
+ kind: "job_run",
198
+ name: event.name,
199
+ status: event.outcome,
200
+ payload: event.payload,
201
+ error: event.error ?? undefined,
202
+ });
203
+ inspect.incrementJobHistogram(event.outcome);
204
+ },
176
205
  });
177
206
 
178
207
  async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
@@ -222,15 +251,22 @@ export async function main(args: string[]): Promise<void> {
222
251
  await pool.query("SELECT 1");
223
252
  await ensureAuthSchema(pool);
224
253
  await ensureJobsSchema(pool);
254
+ await ensureStorageSchema(pool);
225
255
 
256
+ closeProjectSockets(DEV_PROJECT_ID, SOCKET_CLOSE_RESTART, "deployed");
226
257
  const previous = current;
227
258
  current = {
228
259
  db,
229
260
  pool,
230
261
  routes: loaded.routes,
231
262
  jobs: loaded.jobs,
263
+ sockets: loaded.sockets,
232
264
  openapi: buildDevOpenApi(loaded.database.name, loaded.routes),
233
265
  };
266
+ setProjectSocketNames(
267
+ DEV_PROJECT_ID,
268
+ loaded.sockets.map((socket) => socket.name),
269
+ );
234
270
  await engine.reconcile(loaded.jobs);
235
271
 
236
272
  for (const route of loaded.routes) {
@@ -243,10 +279,8 @@ export async function main(args: string[]): Promise<void> {
243
279
  log(` job ${job.name}`);
244
280
  }
245
281
  }
246
- if (loaded.sockets.length > 0) {
247
- log(
248
- ` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
249
- );
282
+ for (const socket of loaded.sockets) {
283
+ log(` WS ${socket.path}`);
250
284
  }
251
285
  if (previous) {
252
286
  await previous.pool.end().catch(() => undefined);
@@ -275,6 +309,7 @@ export async function main(args: string[]): Promise<void> {
275
309
  brokerBaseUrl: starbaseUrl(),
276
310
  googleClientId: env.GOOGLE_CLIENT_ID,
277
311
  googleClientSecret: env.GOOGLE_CLIENT_SECRET,
312
+ storageDir,
278
313
  },
279
314
  () => current,
280
315
  );
@@ -295,9 +330,10 @@ export async function main(args: string[]): Promise<void> {
295
330
  `Postgres ${postgres.host}:${postgres.port}/${postgres.database} (--database-url)`,
296
331
  );
297
332
  }
333
+ console.log(`Storage ${relative(project.root, storageDir) || storageDir}`);
298
334
  console.log(`API ${apiUrl}`);
299
335
  console.log(`Swagger ${apiUrl}/_robodev/docs`);
300
- console.log(`Inspect ${apiUrl}/_robodev/logs`);
336
+ console.log(`Dashboard ${apiUrl}/_robodev`);
301
337
  console.log(
302
338
  `Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
303
339
  );
@@ -352,6 +388,7 @@ export async function main(args: string[]): Promise<void> {
352
388
  async function shutdown(code: number): Promise<void> {
353
389
  if (closing) return;
354
390
  closing = true;
391
+ closeProjectSockets(DEV_PROJECT_ID);
355
392
  await engine.stop();
356
393
  for (const watcher of watchers) watcher.close();
357
394
  fe?.kill("SIGTERM");
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.34.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.34.0",
14
+ "robodev": "^0.36.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.11.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.34.0",
14
+ "robodev": "^0.36.0",
15
15
  "typescript": "^5.9.2"
16
16
  }
17
17
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.34.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }
@@ -12,7 +12,7 @@
12
12
  "@robodev-ai/sdk": "^0.11.0"
13
13
  },
14
14
  "devDependencies": {
15
- "robodev": "^0.34.0",
15
+ "robodev": "^0.36.0",
16
16
  "rulesync": "^8.18.0",
17
17
  "typescript": "^5.9.2"
18
18
  }