robodev 0.32.0 → 0.34.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 +2 -2
- package/src/dev-clients.ts +34 -3
- package/src/dev-inspect.test.ts +123 -0
- package/src/dev-inspect.ts +230 -0
- package/src/dev-server.ts +139 -78
- package/src/dev.ts +26 -13
- package/templates/auth-chat/package.json +1 -1
- package/templates/backend/package.json +1 -1
- package/templates/empty/package.json +1 -1
- package/templates/marketplace/package.json +1 -1
- package/templates/space/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "robodev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "CLI for Robodev Starbase — create, auth, link, deploy, and run a project locally with robodev dev",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"embedded-postgres": "17.10.0-beta.17",
|
|
26
26
|
"fastify": "^5.12.1",
|
|
27
27
|
"tsx": "^4.20.5",
|
|
28
|
-
"@robodev-ai/runtime": "0.
|
|
28
|
+
"@robodev-ai/runtime": "0.3.0",
|
|
29
29
|
"@robodev-ai/sdk": "0.11.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
package/src/dev-clients.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RouteClients } from "@robodev-ai/runtime";
|
|
2
2
|
import type { EmailClient, JobEnqueueInput, LlmClient } from "@robodev-ai/sdk";
|
|
3
|
+
import type { DevInspectStore } from "./dev-inspect.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The `ctx.*` clients `robodev dev` can offer offline. `email` prints to the terminal;
|
|
@@ -14,13 +15,21 @@ function unavailable(feature: string): never {
|
|
|
14
15
|
);
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
function errorMessage(error: unknown): string {
|
|
19
|
+
return error instanceof Error ? error.message : String(error);
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/** Prints mail to stdout so local signup and reset flows are followable. */
|
|
18
|
-
export function consoleEmailClient(
|
|
23
|
+
export function consoleEmailClient(
|
|
24
|
+
log: (line: string) => void,
|
|
25
|
+
inspect: DevInspectStore,
|
|
26
|
+
): EmailClient {
|
|
19
27
|
return {
|
|
20
28
|
async send(input) {
|
|
21
29
|
const to = Array.isArray(input.to) ? input.to.join(", ") : input.to;
|
|
22
30
|
log(`email → ${to}: ${input.subject}`);
|
|
23
31
|
if (input.text) log(` ${input.text}`);
|
|
32
|
+
inspect.record({ kind: "email", to, subject: input.subject, text: input.text });
|
|
24
33
|
return { id: `local_${Date.now().toString(36)}`, status: "sent" };
|
|
25
34
|
},
|
|
26
35
|
};
|
|
@@ -37,9 +46,10 @@ export function devRouteClients(input: {
|
|
|
37
46
|
env: Record<string, string>;
|
|
38
47
|
log: (line: string) => void;
|
|
39
48
|
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
49
|
+
inspect: DevInspectStore;
|
|
40
50
|
}): RouteClients {
|
|
41
51
|
return {
|
|
42
|
-
email: consoleEmailClient(input.log),
|
|
52
|
+
email: consoleEmailClient(input.log, input.inspect),
|
|
43
53
|
llm: offlineLlmClient(),
|
|
44
54
|
agent: {
|
|
45
55
|
createSession: () => unavailable("agent"),
|
|
@@ -56,7 +66,28 @@ export function devRouteClients(input: {
|
|
|
56
66
|
list: () => unavailable("storage"),
|
|
57
67
|
},
|
|
58
68
|
push: { send: () => unavailable("push") },
|
|
59
|
-
jobs: {
|
|
69
|
+
jobs: {
|
|
70
|
+
enqueue: async (job) => {
|
|
71
|
+
try {
|
|
72
|
+
const result = await input.enqueue(job);
|
|
73
|
+
input.inspect.record({
|
|
74
|
+
kind: "job",
|
|
75
|
+
name: job.name,
|
|
76
|
+
payload: job.payload,
|
|
77
|
+
error: "",
|
|
78
|
+
});
|
|
79
|
+
return result;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
input.inspect.record({
|
|
82
|
+
kind: "job",
|
|
83
|
+
name: job.name,
|
|
84
|
+
payload: job.payload,
|
|
85
|
+
error: errorMessage(error),
|
|
86
|
+
});
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
},
|
|
60
91
|
sockets: {
|
|
61
92
|
send: () => unavailable("sockets"),
|
|
62
93
|
broadcast: () => unavailable("sockets"),
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
createDevInspect,
|
|
5
|
+
inspectHtml,
|
|
6
|
+
isInspectPath,
|
|
7
|
+
type DevInspectAuthCodeEvent,
|
|
8
|
+
type DevInspectEmailEvent,
|
|
9
|
+
type DevInspectJobEvent,
|
|
10
|
+
type DevInspectLogEvent,
|
|
11
|
+
type DevInspectRequestEvent,
|
|
12
|
+
} from "./dev-inspect.js";
|
|
13
|
+
|
|
14
|
+
test("isInspectPath excludes only inspect self-routes", () => {
|
|
15
|
+
assert.equal(isInspectPath("/_robodev"), true);
|
|
16
|
+
assert.equal(isInspectPath("/_robodev/logs"), true);
|
|
17
|
+
assert.equal(isInspectPath("/_robodev/logs.json"), true);
|
|
18
|
+
assert.equal(isInspectPath("/_robodev/docs"), false);
|
|
19
|
+
assert.equal(isInspectPath("/_robodev/ready"), false);
|
|
20
|
+
assert.equal(isInspectPath("/_robodev/scalar"), false);
|
|
21
|
+
assert.equal(isInspectPath("/openapi.json"), false);
|
|
22
|
+
assert.equal(isInspectPath("/api/planets"), false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("ring buffer drops oldest events past max", () => {
|
|
26
|
+
const inspect = createDevInspect({ max: 3 });
|
|
27
|
+
inspect.record({ kind: "log", message: "a" });
|
|
28
|
+
inspect.record({ kind: "log", message: "b" });
|
|
29
|
+
inspect.record({ kind: "log", message: "c" });
|
|
30
|
+
inspect.record({ kind: "log", message: "d" });
|
|
31
|
+
|
|
32
|
+
const list = inspect.list();
|
|
33
|
+
assert.equal(list.length, 3);
|
|
34
|
+
assert.deepEqual(
|
|
35
|
+
list.map((event) => (event.kind === "log" ? event.message : "")),
|
|
36
|
+
["b", "c", "d"],
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("list is newest last and does not leak the internal buffer", () => {
|
|
41
|
+
const inspect = createDevInspect({ max: 500 });
|
|
42
|
+
inspect.record({ kind: "log", message: "first" });
|
|
43
|
+
inspect.record({ kind: "log", message: "second" });
|
|
44
|
+
|
|
45
|
+
const snapshot = inspect.list();
|
|
46
|
+
snapshot.pop();
|
|
47
|
+
assert.equal(inspect.list().length, 2);
|
|
48
|
+
assert.equal((inspect.list()[1] as DevInspectLogEvent).message, "second");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("record assigns id, at, and the expected fields for each kind", () => {
|
|
52
|
+
const inspect = createDevInspect();
|
|
53
|
+
const before = Date.now();
|
|
54
|
+
|
|
55
|
+
const request = inspect.record({
|
|
56
|
+
kind: "request",
|
|
57
|
+
method: "GET",
|
|
58
|
+
path: "/api/planets",
|
|
59
|
+
status: 200,
|
|
60
|
+
durationMs: 12,
|
|
61
|
+
}) as DevInspectRequestEvent;
|
|
62
|
+
const email = inspect.record({
|
|
63
|
+
kind: "email",
|
|
64
|
+
to: "a@b.test",
|
|
65
|
+
subject: "Hello",
|
|
66
|
+
text: "Body",
|
|
67
|
+
}) as DevInspectEmailEvent;
|
|
68
|
+
const auth = inspect.record({
|
|
69
|
+
kind: "auth_code",
|
|
70
|
+
type: "magic",
|
|
71
|
+
to: "a@b.test",
|
|
72
|
+
code: "123456",
|
|
73
|
+
}) as DevInspectAuthCodeEvent;
|
|
74
|
+
const job = inspect.record({
|
|
75
|
+
kind: "job",
|
|
76
|
+
name: "hourly",
|
|
77
|
+
payload: { n: 1 },
|
|
78
|
+
error: "",
|
|
79
|
+
}) as DevInspectJobEvent;
|
|
80
|
+
const log = inspect.record({ kind: "log", message: "Change detected, reloading…" });
|
|
81
|
+
|
|
82
|
+
const after = Date.now();
|
|
83
|
+
for (const event of [request, email, auth, job, log]) {
|
|
84
|
+
assert.ok(event.id);
|
|
85
|
+
assert.ok(event.at >= before && event.at <= after);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
assert.equal(request.kind, "request");
|
|
89
|
+
assert.equal(request.method, "GET");
|
|
90
|
+
assert.equal(request.path, "/api/planets");
|
|
91
|
+
assert.equal(request.status, 200);
|
|
92
|
+
assert.equal(request.durationMs, 12);
|
|
93
|
+
|
|
94
|
+
assert.equal(email.kind, "email");
|
|
95
|
+
assert.equal(email.to, "a@b.test");
|
|
96
|
+
assert.equal(email.subject, "Hello");
|
|
97
|
+
assert.equal(email.text, "Body");
|
|
98
|
+
|
|
99
|
+
assert.equal(auth.kind, "auth_code");
|
|
100
|
+
assert.equal(auth.type, "magic");
|
|
101
|
+
assert.equal(auth.to, "a@b.test");
|
|
102
|
+
assert.equal(auth.code, "123456");
|
|
103
|
+
|
|
104
|
+
assert.equal(job.kind, "job");
|
|
105
|
+
assert.equal(job.name, "hourly");
|
|
106
|
+
assert.deepEqual(job.payload, { n: 1 });
|
|
107
|
+
assert.equal(job.error, "");
|
|
108
|
+
|
|
109
|
+
assert.equal(log.kind, "log");
|
|
110
|
+
assert.equal((log as DevInspectLogEvent).message, "Change detected, reloading…");
|
|
111
|
+
|
|
112
|
+
assert.equal(inspect.list().length, 5);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("inspect HTML has four lists and polls logs.json", () => {
|
|
116
|
+
const html = inspectHtml();
|
|
117
|
+
assert.match(html, /<h2>Requests<\/h2>/);
|
|
118
|
+
assert.match(html, /<h2>Emails<\/h2>/);
|
|
119
|
+
assert.match(html, /<h2>Jobs<\/h2>/);
|
|
120
|
+
assert.match(html, /<h2>Logs<\/h2>/);
|
|
121
|
+
assert.match(html, /\/_robodev\/logs\.json/);
|
|
122
|
+
assert.match(html, /setInterval\(poll, 1000\)/);
|
|
123
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const DEV_INSPECT_MAX = 500;
|
|
2
|
+
|
|
3
|
+
const INSPECT_PATHS = new Set(["/_robodev", "/_robodev/logs", "/_robodev/logs.json"]);
|
|
4
|
+
|
|
5
|
+
export type DevInspectKind = "request" | "email" | "auth_code" | "job" | "log";
|
|
6
|
+
|
|
7
|
+
type EventBase = {
|
|
8
|
+
id: string;
|
|
9
|
+
at: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type DevInspectRequestEvent = EventBase & {
|
|
13
|
+
kind: "request";
|
|
14
|
+
method: string;
|
|
15
|
+
path: string;
|
|
16
|
+
status: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type DevInspectEmailEvent = EventBase & {
|
|
21
|
+
kind: "email";
|
|
22
|
+
to: string;
|
|
23
|
+
subject: string;
|
|
24
|
+
text?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type DevInspectAuthCodeEvent = EventBase & {
|
|
28
|
+
kind: "auth_code";
|
|
29
|
+
type: string;
|
|
30
|
+
to: string;
|
|
31
|
+
code: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type DevInspectJobEvent = EventBase & {
|
|
35
|
+
kind: "job";
|
|
36
|
+
name: string;
|
|
37
|
+
payload: unknown;
|
|
38
|
+
error: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DevInspectLogEvent = EventBase & {
|
|
42
|
+
kind: "log";
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type DevInspectEvent =
|
|
47
|
+
| DevInspectRequestEvent
|
|
48
|
+
| DevInspectEmailEvent
|
|
49
|
+
| DevInspectAuthCodeEvent
|
|
50
|
+
| DevInspectJobEvent
|
|
51
|
+
| DevInspectLogEvent;
|
|
52
|
+
|
|
53
|
+
export type DevInspectRecordInput =
|
|
54
|
+
| Omit<DevInspectRequestEvent, "id" | "at">
|
|
55
|
+
| Omit<DevInspectEmailEvent, "id" | "at">
|
|
56
|
+
| Omit<DevInspectAuthCodeEvent, "id" | "at">
|
|
57
|
+
| Omit<DevInspectJobEvent, "id" | "at">
|
|
58
|
+
| Omit<DevInspectLogEvent, "id" | "at">;
|
|
59
|
+
|
|
60
|
+
export type DevInspectStore = {
|
|
61
|
+
record: (input: DevInspectRecordInput) => DevInspectEvent;
|
|
62
|
+
list: () => DevInspectEvent[];
|
|
63
|
+
html: () => string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Inspect HTML/JSON self-routes — do not print or record these as requests. */
|
|
67
|
+
export function isInspectPath(pathname: string): boolean {
|
|
68
|
+
return INSPECT_PATHS.has(pathname);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createDevInspect(options?: { max?: number }): DevInspectStore {
|
|
72
|
+
const max = options?.max ?? DEV_INSPECT_MAX;
|
|
73
|
+
const events: DevInspectEvent[] = [];
|
|
74
|
+
let nextId = 1;
|
|
75
|
+
|
|
76
|
+
function record(input: DevInspectRecordInput): DevInspectEvent {
|
|
77
|
+
const event = { ...input, id: String(nextId++), at: Date.now() } as DevInspectEvent;
|
|
78
|
+
events.push(event);
|
|
79
|
+
if (events.length > max) {
|
|
80
|
+
events.splice(0, events.length - max);
|
|
81
|
+
}
|
|
82
|
+
return event;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function list(): DevInspectEvent[] {
|
|
86
|
+
return events.slice();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { record, list, html: inspectHtml };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Inline inspect page — same pattern as runtime `swaggerHtml` (string template, no FE build). */
|
|
93
|
+
export function inspectHtml(): string {
|
|
94
|
+
return `<!doctype html>
|
|
95
|
+
<html>
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8" />
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
99
|
+
<title>Robodev Inspect</title>
|
|
100
|
+
<style>
|
|
101
|
+
:root { color-scheme: light dark; }
|
|
102
|
+
body {
|
|
103
|
+
margin: 0 auto;
|
|
104
|
+
max-width: 960px;
|
|
105
|
+
padding: 24px 20px 48px;
|
|
106
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
107
|
+
line-height: 1.45;
|
|
108
|
+
}
|
|
109
|
+
h1 { font-size: 1.25rem; font-weight: 650; margin: 0 0 4px; }
|
|
110
|
+
.lead { color: #666; margin: 0 0 24px; font-size: 0.9rem; }
|
|
111
|
+
h2 {
|
|
112
|
+
font-size: 0.75rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
letter-spacing: 0.06em;
|
|
115
|
+
text-transform: uppercase;
|
|
116
|
+
margin: 28px 0 8px;
|
|
117
|
+
}
|
|
118
|
+
ul { list-style: none; margin: 0; padding: 0; border-top: 1px solid #ddd; }
|
|
119
|
+
li {
|
|
120
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
121
|
+
font-size: 12.5px;
|
|
122
|
+
padding: 8px 0;
|
|
123
|
+
border-bottom: 1px solid #eee;
|
|
124
|
+
white-space: pre-wrap;
|
|
125
|
+
word-break: break-word;
|
|
126
|
+
}
|
|
127
|
+
.empty { color: #888; font-family: inherit; }
|
|
128
|
+
.meta { color: #888; }
|
|
129
|
+
@media (prefers-color-scheme: dark) {
|
|
130
|
+
.lead, .empty, .meta { color: #9aa0a6; }
|
|
131
|
+
ul { border-top-color: #333; }
|
|
132
|
+
li { border-bottom-color: #2a2a2a; }
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
135
|
+
</head>
|
|
136
|
+
<body>
|
|
137
|
+
<h1>Inspect</h1>
|
|
138
|
+
<p class="lead">Local <code>robodev dev</code> events. Newest first. Polls every 1s.</p>
|
|
139
|
+
<h2>Requests</h2>
|
|
140
|
+
<ul id="requests"></ul>
|
|
141
|
+
<h2>Emails</h2>
|
|
142
|
+
<ul id="emails"></ul>
|
|
143
|
+
<h2>Jobs</h2>
|
|
144
|
+
<ul id="jobs"></ul>
|
|
145
|
+
<h2>Logs</h2>
|
|
146
|
+
<ul id="logs"></ul>
|
|
147
|
+
<script>
|
|
148
|
+
function timeLabel(at) {
|
|
149
|
+
return new Date(at).toLocaleTimeString();
|
|
150
|
+
}
|
|
151
|
+
function emptyRow() {
|
|
152
|
+
const li = document.createElement("li");
|
|
153
|
+
li.className = "empty";
|
|
154
|
+
li.textContent = "None yet";
|
|
155
|
+
return li;
|
|
156
|
+
}
|
|
157
|
+
function line(at, text) {
|
|
158
|
+
const li = document.createElement("li");
|
|
159
|
+
const meta = document.createElement("span");
|
|
160
|
+
meta.className = "meta";
|
|
161
|
+
meta.textContent = timeLabel(at) + " ";
|
|
162
|
+
li.appendChild(meta);
|
|
163
|
+
li.appendChild(document.createTextNode(text));
|
|
164
|
+
return li;
|
|
165
|
+
}
|
|
166
|
+
function fill(id, rows) {
|
|
167
|
+
const ul = document.getElementById(id);
|
|
168
|
+
ul.replaceChildren();
|
|
169
|
+
if (rows.length === 0) {
|
|
170
|
+
ul.appendChild(emptyRow());
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const row of rows) ul.appendChild(row);
|
|
174
|
+
}
|
|
175
|
+
function payloadText(payload) {
|
|
176
|
+
if (payload === undefined) return "";
|
|
177
|
+
try {
|
|
178
|
+
return " " + JSON.stringify(payload);
|
|
179
|
+
} catch {
|
|
180
|
+
return " [unserializable payload]";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function render(events) {
|
|
184
|
+
const newest = events.slice().reverse();
|
|
185
|
+
fill(
|
|
186
|
+
"requests",
|
|
187
|
+
newest
|
|
188
|
+
.filter((e) => e.kind === "request")
|
|
189
|
+
.map((e) => line(e.at, e.method + " " + e.path + " " + e.status + " " + e.durationMs + "ms")),
|
|
190
|
+
);
|
|
191
|
+
fill(
|
|
192
|
+
"emails",
|
|
193
|
+
newest
|
|
194
|
+
.filter((e) => e.kind === "email" || e.kind === "auth_code")
|
|
195
|
+
.map((e) => {
|
|
196
|
+
if (e.kind === "auth_code") {
|
|
197
|
+
return line(e.at, e.type + " " + e.to + " " + e.code);
|
|
198
|
+
}
|
|
199
|
+
const extra = e.text ? "\\n" + e.text : "";
|
|
200
|
+
return line(e.at, e.to + " " + e.subject + extra);
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
fill(
|
|
204
|
+
"jobs",
|
|
205
|
+
newest
|
|
206
|
+
.filter((e) => e.kind === "job")
|
|
207
|
+
.map((e) => {
|
|
208
|
+
const err = e.error ? " " + e.error : "";
|
|
209
|
+
return line(e.at, e.name + payloadText(e.payload) + err);
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
fill(
|
|
213
|
+
"logs",
|
|
214
|
+
newest.filter((e) => e.kind === "log").map((e) => line(e.at, e.message)),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
async function poll() {
|
|
218
|
+
try {
|
|
219
|
+
const events = await fetch("/_robodev/logs.json").then((r) => r.json());
|
|
220
|
+
render(events);
|
|
221
|
+
} catch {
|
|
222
|
+
/* keep last render */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
poll();
|
|
226
|
+
setInterval(poll, 1000);
|
|
227
|
+
</script>
|
|
228
|
+
</body>
|
|
229
|
+
</html>`;
|
|
230
|
+
}
|
package/src/dev-server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import type { JobEnqueueInput, RobodevDb } from "@robodev-ai/sdk";
|
|
17
17
|
import type { RuntimeJob } from "@robodev-ai/runtime";
|
|
18
18
|
import { devRouteClients } from "./dev-clients.js";
|
|
19
|
+
import { isInspectPath, type DevInspectStore } from "./dev-inspect.js";
|
|
19
20
|
|
|
20
21
|
/** One compiled generation of the project. Swapped wholesale on reload. */
|
|
21
22
|
export type DevRuntime = {
|
|
@@ -33,6 +34,11 @@ export type DevServerOptions = {
|
|
|
33
34
|
env: Record<string, string>;
|
|
34
35
|
log: (line: string) => void;
|
|
35
36
|
enqueue: (job: JobEnqueueInput) => Promise<{ id: string }>;
|
|
37
|
+
inspect: DevInspectStore;
|
|
38
|
+
listenOrigin: string;
|
|
39
|
+
brokerBaseUrl: string;
|
|
40
|
+
googleClientId?: string;
|
|
41
|
+
googleClientSecret?: string;
|
|
36
42
|
};
|
|
37
43
|
|
|
38
44
|
const SWAGGER_PATHS = new Set(["/_robodev/docs", "/_robodev/swagger", "/docs", "/swagger"]);
|
|
@@ -52,9 +58,10 @@ function rawBodyOf(request: FastifyRequest): Buffer | undefined {
|
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/**
|
|
55
|
-
* A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
|
|
56
|
-
* no static SPA serving (local Vite does that)
|
|
57
|
-
*
|
|
61
|
+
* A single-project HTTP host. Unlike the hosted project host there is no `Host` lookup
|
|
62
|
+
* 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.
|
|
64
|
+
* Routes, validation, and OpenAPI all come from `@robodev-ai/runtime`.
|
|
58
65
|
*/
|
|
59
66
|
export async function startDevServer(
|
|
60
67
|
options: DevServerOptions,
|
|
@@ -112,92 +119,146 @@ export async function startDevServer(
|
|
|
112
119
|
const pathname = pathnameOf(request.url);
|
|
113
120
|
const method = request.method.toLowerCase();
|
|
114
121
|
|
|
115
|
-
if (pathname
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
await runtime.pool.query("SELECT 1");
|
|
120
|
-
return reply.send({ ok: true });
|
|
121
|
-
} catch {
|
|
122
|
-
return reply.code(503).send({ error: "not_ready" });
|
|
122
|
+
if (isInspectPath(pathname)) {
|
|
123
|
+
if (pathname === "/_robodev/logs.json") {
|
|
124
|
+
return reply.send(options.inspect.list());
|
|
123
125
|
}
|
|
126
|
+
return reply.type("text/html; charset=utf-8").send(options.inspect.html());
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
129
|
+
const started = Date.now();
|
|
130
|
+
try {
|
|
131
|
+
await dispatchDevRequest();
|
|
132
|
+
} finally {
|
|
133
|
+
const status = reply.statusCode;
|
|
134
|
+
const durationMs = Date.now() - started;
|
|
135
|
+
options.inspect.record({
|
|
136
|
+
kind: "request",
|
|
137
|
+
method: request.method,
|
|
138
|
+
path: pathname,
|
|
139
|
+
status,
|
|
140
|
+
durationMs,
|
|
138
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`);
|
|
139
144
|
}
|
|
145
|
+
return reply;
|
|
140
146
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
async function dispatchDevRequest(): Promise<void> {
|
|
148
|
+
if (pathname === "/_robodev/ready") {
|
|
149
|
+
const runtime = getRuntime();
|
|
150
|
+
if (!runtime) {
|
|
151
|
+
await reply.code(503).send({ error: "not_ready" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await runtime.pool.query("SELECT 1");
|
|
156
|
+
await reply.send({ ok: true });
|
|
157
|
+
} catch {
|
|
158
|
+
await reply.code(503).send({ error: "not_ready" });
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
144
162
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
db: runtime.pool,
|
|
156
|
-
projectId: options.projectId,
|
|
157
|
-
jwtSecret: options.jwtSecret,
|
|
158
|
-
deliverCode: ({ to, type, code }) => options.log(`${type} code for ${to}: ${code}`),
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
return reply.code(result.status).send(result.body);
|
|
162
|
-
}
|
|
163
|
+
if (SWAGGER_PATHS.has(pathname)) {
|
|
164
|
+
await reply.type("text/html; charset=utf-8").send(swaggerHtml("/openapi.json"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (SCALAR_PATHS.has(pathname)) {
|
|
168
|
+
await reply.type("text/html; charset=utf-8").send(scalarHtml("/openapi.json"));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
163
171
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
const runtime = getRuntime();
|
|
173
|
+
if (!runtime) {
|
|
174
|
+
await reply.code(503).send({
|
|
175
|
+
error: "not_ready",
|
|
176
|
+
message: "The project is still compiling. Retry in a moment.",
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (pathname === "/openapi.json") {
|
|
182
|
+
await reply.send(runtime.openapi);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isLocalAuthPath(pathname)) {
|
|
187
|
+
const result = await handleLocalAuth(
|
|
188
|
+
{
|
|
189
|
+
method: request.method,
|
|
190
|
+
pathname,
|
|
191
|
+
body: request.body,
|
|
192
|
+
query: request.query,
|
|
193
|
+
authorization: request.headers.authorization,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
db: runtime.pool,
|
|
197
|
+
projectId: options.projectId,
|
|
198
|
+
jwtSecret: options.jwtSecret,
|
|
199
|
+
deliverCode: ({ to, type, code }) => {
|
|
200
|
+
options.inspect.record({ kind: "auth_code", type, to, code });
|
|
201
|
+
options.log(`${type} code for ${to}: ${code}`);
|
|
202
|
+
},
|
|
203
|
+
listenOrigin: options.listenOrigin,
|
|
204
|
+
brokerBaseUrl: options.brokerBaseUrl,
|
|
205
|
+
googleClientId: options.googleClientId,
|
|
206
|
+
googleClientSecret: options.googleClientSecret,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
if (result.headers?.Location) {
|
|
210
|
+
await reply.redirect(result.headers.Location);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await reply.code(result.status).send(result.body);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!matchProjectRoute(pathname, method, runtime.routes)) {
|
|
218
|
+
await reply.code(404).send({
|
|
219
|
+
error: "not_found",
|
|
220
|
+
message: `${request.method} ${pathname} is not defined`,
|
|
221
|
+
routes: runtime.routes.map((route) => `${route.method.toUpperCase()} ${route.path}`),
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const outcome = await runRoute({
|
|
227
|
+
db: runtime.db,
|
|
228
|
+
routes: runtime.routes,
|
|
229
|
+
method,
|
|
230
|
+
pathname,
|
|
231
|
+
query: request.query ?? {},
|
|
232
|
+
body: request.body,
|
|
233
|
+
rawBody: rawBodyOf(request),
|
|
234
|
+
headers: request.headers as Record<string, string | string[] | undefined>,
|
|
235
|
+
authorization: request.headers.authorization,
|
|
236
|
+
resolveUser: (authorization) =>
|
|
237
|
+
resolveLocalUser(
|
|
238
|
+
{ projectId: options.projectId, jwtSecret: options.jwtSecret },
|
|
239
|
+
authorization,
|
|
240
|
+
),
|
|
241
|
+
clients: () =>
|
|
242
|
+
devRouteClients({
|
|
243
|
+
env: options.env,
|
|
244
|
+
log: options.log,
|
|
245
|
+
enqueue: options.enqueue,
|
|
246
|
+
inspect: options.inspect,
|
|
247
|
+
}),
|
|
169
248
|
});
|
|
170
|
-
}
|
|
171
249
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
resolveLocalUser(
|
|
184
|
-
{ projectId: options.projectId, jwtSecret: options.jwtSecret },
|
|
185
|
-
authorization,
|
|
186
|
-
),
|
|
187
|
-
clients: () =>
|
|
188
|
-
devRouteClients({ env: options.env, log: options.log, enqueue: options.enqueue }),
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
if (outcome.kind === "error") {
|
|
192
|
-
return reply.code(outcome.status).send(outcome.body);
|
|
193
|
-
}
|
|
194
|
-
for (const [name, value] of Object.entries(outcome.encoded.headers)) {
|
|
195
|
-
reply.header(name, value);
|
|
250
|
+
if (outcome.kind === "error") {
|
|
251
|
+
await reply.code(outcome.status).send(outcome.body);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
for (const [name, value] of Object.entries(outcome.encoded.headers)) {
|
|
255
|
+
reply.header(name, value);
|
|
256
|
+
}
|
|
257
|
+
await reply
|
|
258
|
+
.code(outcome.encoded.status)
|
|
259
|
+
.type(outcome.encoded.contentType)
|
|
260
|
+
.send(outcome.encoded.payload);
|
|
196
261
|
}
|
|
197
|
-
return reply
|
|
198
|
-
.code(outcome.encoded.status)
|
|
199
|
-
.type(outcome.encoded.contentType)
|
|
200
|
-
.send(outcome.encoded.payload);
|
|
201
262
|
});
|
|
202
263
|
|
|
203
264
|
await app.listen({ port: options.port, host: "0.0.0.0" });
|
package/src/dev.ts
CHANGED
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
type DevProject,
|
|
30
30
|
} from "./dev-project.js";
|
|
31
31
|
import { externalDevPostgres, startDevPostgres, type DevPostgres } from "./dev-postgres.js";
|
|
32
|
+
import { starbaseUrl } from "./config.js";
|
|
32
33
|
import { devRouteClients } from "./dev-clients.js";
|
|
34
|
+
import { createDevInspect } from "./dev-inspect.js";
|
|
33
35
|
import { buildDevOpenApi, startDevServer, type DevRuntime } from "./dev-server.js";
|
|
34
36
|
|
|
35
37
|
export type DevArgs = {
|
|
@@ -127,10 +129,10 @@ async function resolveCreateDb(backendDir: string, root: string): Promise<typeof
|
|
|
127
129
|
return fallbackCreateDb;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
function printPlan(plan: SchemaPlan): void {
|
|
132
|
+
function printPlan(plan: SchemaPlan, log: (line: string) => void): void {
|
|
131
133
|
for (const op of plan.operations) {
|
|
132
134
|
const mark = op.destructive ? "!" : " ";
|
|
133
|
-
|
|
135
|
+
log(` ${mark} ${op.type} ${op.table}${op.column ? `.${op.column}` : ""}`);
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
|
|
@@ -157,7 +159,11 @@ export async function main(args: string[]): Promise<void> {
|
|
|
157
159
|
let lastSchemaSource = "";
|
|
158
160
|
let closing = false;
|
|
159
161
|
|
|
160
|
-
const
|
|
162
|
+
const inspect = createDevInspect({ max: 500 });
|
|
163
|
+
const log = (line: string) => {
|
|
164
|
+
console.log(line);
|
|
165
|
+
inspect.record({ kind: "log", message: line });
|
|
166
|
+
};
|
|
161
167
|
const engine = createLocalJobsEngine({
|
|
162
168
|
query: (sql, values) => {
|
|
163
169
|
if (!current) throw new Error("jobs engine not ready");
|
|
@@ -165,7 +171,8 @@ export async function main(args: string[]): Promise<void> {
|
|
|
165
171
|
},
|
|
166
172
|
getGeneration: () => current,
|
|
167
173
|
log,
|
|
168
|
-
clients: () =>
|
|
174
|
+
clients: () =>
|
|
175
|
+
devRouteClients({ env, log, inspect, enqueue: (input) => engine.enqueue(input) }),
|
|
169
176
|
});
|
|
170
177
|
|
|
171
178
|
async function compileAndActivate(input: { syncSchema: boolean }): Promise<void> {
|
|
@@ -199,12 +206,12 @@ export async function main(args: string[]): Promise<void> {
|
|
|
199
206
|
if (input.syncSchema || schemaSource !== lastSchemaSource) {
|
|
200
207
|
const plan = await buildPlan(postgres.connectionString, loaded.database);
|
|
201
208
|
if (plan.destructive && !parsed.force) {
|
|
202
|
-
|
|
203
|
-
printPlan(plan);
|
|
209
|
+
log("Destructive schema changes (skipped, rerun with --force):");
|
|
210
|
+
printPlan(plan, log);
|
|
204
211
|
} else {
|
|
205
212
|
if (plan.operations.length > 0) {
|
|
206
|
-
|
|
207
|
-
printPlan(plan);
|
|
213
|
+
log("Schema changes:");
|
|
214
|
+
printPlan(plan, log);
|
|
208
215
|
}
|
|
209
216
|
await applyPlan(postgres.connectionString, plan);
|
|
210
217
|
lastSchemaSource = schemaSource;
|
|
@@ -227,17 +234,17 @@ export async function main(args: string[]): Promise<void> {
|
|
|
227
234
|
await engine.reconcile(loaded.jobs);
|
|
228
235
|
|
|
229
236
|
for (const route of loaded.routes) {
|
|
230
|
-
|
|
237
|
+
log(` ${route.method.toUpperCase()} ${route.path}`);
|
|
231
238
|
}
|
|
232
239
|
for (const job of loaded.jobs) {
|
|
233
240
|
if (job.def.cron) {
|
|
234
|
-
|
|
241
|
+
log(` job ${job.name} (cron ${job.def.cron})`);
|
|
235
242
|
} else {
|
|
236
|
-
|
|
243
|
+
log(` job ${job.name}`);
|
|
237
244
|
}
|
|
238
245
|
}
|
|
239
246
|
if (loaded.sockets.length > 0) {
|
|
240
|
-
|
|
247
|
+
log(
|
|
241
248
|
` ${loaded.sockets.length} socket(s) compiled but not served — sockets need hosted Starbase.`,
|
|
242
249
|
);
|
|
243
250
|
}
|
|
@@ -262,7 +269,12 @@ export async function main(args: string[]): Promise<void> {
|
|
|
262
269
|
jwtSecret: DEV_JWT_SECRET,
|
|
263
270
|
env,
|
|
264
271
|
log,
|
|
272
|
+
inspect,
|
|
265
273
|
enqueue: (input) => engine.enqueue(input),
|
|
274
|
+
listenOrigin: `http://localhost:${parsed.port}`,
|
|
275
|
+
brokerBaseUrl: starbaseUrl(),
|
|
276
|
+
googleClientId: env.GOOGLE_CLIENT_ID,
|
|
277
|
+
googleClientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
266
278
|
},
|
|
267
279
|
() => current,
|
|
268
280
|
);
|
|
@@ -285,6 +297,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
285
297
|
}
|
|
286
298
|
console.log(`API ${apiUrl}`);
|
|
287
299
|
console.log(`Swagger ${apiUrl}/_robodev/docs`);
|
|
300
|
+
console.log(`Inspect ${apiUrl}/_robodev/logs`);
|
|
288
301
|
console.log(
|
|
289
302
|
`Project ${relative(process.cwd(), project.backendDir) || "."} (${project.layout})`,
|
|
290
303
|
);
|
|
@@ -305,7 +318,7 @@ export async function main(args: string[]): Promise<void> {
|
|
|
305
318
|
}
|
|
306
319
|
running = true;
|
|
307
320
|
try {
|
|
308
|
-
|
|
321
|
+
log("Change detected, reloading…");
|
|
309
322
|
await compileAndActivate({ syncSchema: false });
|
|
310
323
|
} catch (error) {
|
|
311
324
|
console.error(error instanceof Error ? error.message : error);
|