@cfbender/cesium 0.3.5
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/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- package/src/tools/wait.ts +192 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// API route handler for interactive artifact submissions and state queries.
|
|
2
|
+
//
|
|
3
|
+
// Routes:
|
|
4
|
+
// POST /api/sessions/:projectSlug/:filename/answers/:questionId
|
|
5
|
+
// GET /api/sessions/:projectSlug/:filename/state
|
|
6
|
+
//
|
|
7
|
+
// Wire into startServer via handle.addHandler() before the static file fallback.
|
|
8
|
+
|
|
9
|
+
import { join, resolve, relative } from "node:path";
|
|
10
|
+
import { submitAnswer, getState } from "../storage/mutate.ts";
|
|
11
|
+
import type { AnswerValue } from "../render/validate.ts";
|
|
12
|
+
|
|
13
|
+
export interface ApiHandlerOptions {
|
|
14
|
+
stateDir: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Artifact filename regex: <iso-utc>__<slug>__<6char>.html
|
|
18
|
+
// Permissive form: no path separators + ends with .html
|
|
19
|
+
const FILENAME_RE = /^[^/\\]+\.html$/;
|
|
20
|
+
const DANGEROUS_RE = /[/\\]|\.\./;
|
|
21
|
+
|
|
22
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
23
|
+
return new Response(JSON.stringify(body), {
|
|
24
|
+
status,
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
27
|
+
"Cache-Control": "no-store",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createApiHandler(
|
|
33
|
+
options: ApiHandlerOptions,
|
|
34
|
+
): (req: Request) => Promise<Response | undefined> {
|
|
35
|
+
const { stateDir } = options;
|
|
36
|
+
|
|
37
|
+
return async (req: Request): Promise<Response | undefined> => {
|
|
38
|
+
const url = new URL(req.url);
|
|
39
|
+
const { pathname } = url;
|
|
40
|
+
|
|
41
|
+
// Only handle /api/ routes
|
|
42
|
+
if (!pathname.startsWith("/api/")) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Route matching ────────────────────────────────────────────────────
|
|
47
|
+
// POST /api/sessions/:projectSlug/:filename/answers/:questionId
|
|
48
|
+
const answerMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/answers\/([^/]+)$/.exec(pathname);
|
|
49
|
+
// GET /api/sessions/:projectSlug/:filename/state
|
|
50
|
+
const stateMatch = /^\/api\/sessions\/([^/]+)\/([^/]+)\/state$/.exec(pathname);
|
|
51
|
+
|
|
52
|
+
if (answerMatch === null && stateMatch === null) {
|
|
53
|
+
// Unrecognized /api/ path
|
|
54
|
+
return jsonResponse({ ok: false, error: "not found" }, 404);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const match = (answerMatch ?? stateMatch)!;
|
|
58
|
+
const projectSlug = match[1]!;
|
|
59
|
+
const filename = match[2]!;
|
|
60
|
+
|
|
61
|
+
// ─── Input validation ──────────────────────────────────────────────────
|
|
62
|
+
if (DANGEROUS_RE.test(projectSlug) || DANGEROUS_RE.test(filename)) {
|
|
63
|
+
return jsonResponse({ ok: false, error: "invalid path component" }, 400);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!FILENAME_RE.test(filename)) {
|
|
67
|
+
return jsonResponse({ ok: false, error: "filename must end with .html" }, 400);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── Path traversal defense ────────────────────────────────────────────
|
|
71
|
+
const artifactsDir = join(stateDir, "projects", projectSlug, "artifacts");
|
|
72
|
+
const artifactPath = join(artifactsDir, filename);
|
|
73
|
+
|
|
74
|
+
const resolvedArtifactsDir = resolve(artifactsDir);
|
|
75
|
+
const resolvedArtifact = resolve(artifactPath);
|
|
76
|
+
const rel = relative(resolvedArtifactsDir, resolvedArtifact);
|
|
77
|
+
if (rel.startsWith("..") || rel.includes("/")) {
|
|
78
|
+
return jsonResponse({ ok: false, error: "invalid path" }, 400);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Route dispatch ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
if (answerMatch !== null) {
|
|
84
|
+
// POST /api/sessions/:projectSlug/:filename/answers/:questionId
|
|
85
|
+
if (req.method !== "POST") {
|
|
86
|
+
return jsonResponse({ ok: false, error: "method not allowed" }, 404);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const questionId = answerMatch[3]!;
|
|
90
|
+
|
|
91
|
+
// Parse body
|
|
92
|
+
let body: unknown;
|
|
93
|
+
try {
|
|
94
|
+
body = await req.json();
|
|
95
|
+
} catch {
|
|
96
|
+
return jsonResponse({ ok: false, error: "invalid JSON body" }, 400);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
body === null ||
|
|
101
|
+
typeof body !== "object" ||
|
|
102
|
+
Array.isArray(body) ||
|
|
103
|
+
!("value" in (body as Record<string, unknown>))
|
|
104
|
+
) {
|
|
105
|
+
return jsonResponse({ ok: false, error: 'body must contain a "value" field' }, 400);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const value = (body as Record<string, unknown>)["value"] as AnswerValue;
|
|
109
|
+
|
|
110
|
+
const outcome = await submitAnswer({ artifactPath: resolvedArtifact, questionId, value });
|
|
111
|
+
|
|
112
|
+
if (outcome.ok) {
|
|
113
|
+
return jsonResponse(
|
|
114
|
+
{
|
|
115
|
+
ok: true,
|
|
116
|
+
status: outcome.status,
|
|
117
|
+
remaining: outcome.remaining,
|
|
118
|
+
replacementHtml: outcome.replacementHtml,
|
|
119
|
+
},
|
|
120
|
+
200,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
switch (outcome.reason) {
|
|
125
|
+
case "not-found":
|
|
126
|
+
case "not-interactive":
|
|
127
|
+
case "unknown-question":
|
|
128
|
+
return jsonResponse({ ok: false, reason: outcome.reason }, 404);
|
|
129
|
+
case "session-ended":
|
|
130
|
+
return jsonResponse({ ok: false, status: outcome.status }, 410);
|
|
131
|
+
case "expired":
|
|
132
|
+
return jsonResponse({ ok: false, status: "expired" }, 410);
|
|
133
|
+
case "invalid-value":
|
|
134
|
+
return jsonResponse({ ok: false, message: outcome.message }, 422);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fallback (should not reach)
|
|
138
|
+
return jsonResponse({ ok: false, error: "internal error" }, 500);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (stateMatch !== null) {
|
|
142
|
+
// GET /api/sessions/:projectSlug/:filename/state
|
|
143
|
+
if (req.method !== "GET") {
|
|
144
|
+
return jsonResponse({ ok: false, error: "method not allowed" }, 404);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const outcome = await getState(resolvedArtifact);
|
|
148
|
+
|
|
149
|
+
if (!outcome.ok) {
|
|
150
|
+
return jsonResponse({ ok: false, reason: outcome.reason }, 404);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return jsonResponse(
|
|
154
|
+
{
|
|
155
|
+
status: outcome.status,
|
|
156
|
+
answers: outcome.answers,
|
|
157
|
+
remaining: outcome.remaining,
|
|
158
|
+
},
|
|
159
|
+
200,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Should not reach here
|
|
164
|
+
return jsonResponse({ ok: false, error: "not found" }, 404);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Bun HTTP server bound to 127.0.0.1 (default), serving the cesium state directory.
|
|
2
|
+
|
|
3
|
+
import { resolve, extname } from "node:path";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
export interface ServerHandle {
|
|
7
|
+
port: number;
|
|
8
|
+
url: string; // "http://127.0.0.1:<port>"
|
|
9
|
+
stop(): Promise<void>;
|
|
10
|
+
onRequest(handler: () => void): void; // for idle-tracking; lifecycle attaches a callback
|
|
11
|
+
/** Register a pre-static handler. Returns a Response to short-circuit, or undefined to fall through. */
|
|
12
|
+
addHandler(handler: (req: Request) => Promise<Response | undefined>): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface StartServerArgs {
|
|
16
|
+
stateDir: string; // absolute, served as the root
|
|
17
|
+
port: number; // exact port to bind; lifecycle handles conflict scanning
|
|
18
|
+
hostname?: string; // default "127.0.0.1"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIME_TYPES: Record<string, string> = {
|
|
22
|
+
".html": "text/html; charset=utf-8",
|
|
23
|
+
".htm": "text/html; charset=utf-8",
|
|
24
|
+
".json": "application/json; charset=utf-8",
|
|
25
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
26
|
+
".css": "text/css; charset=utf-8",
|
|
27
|
+
".js": "application/javascript; charset=utf-8",
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".txt": "text/plain; charset=utf-8",
|
|
33
|
+
".md": "text/plain; charset=utf-8",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const NOT_FOUND_HTML = `<!doctype html>
|
|
37
|
+
<html lang="en">
|
|
38
|
+
<head><meta charset="utf-8"><title>404</title>
|
|
39
|
+
<style>
|
|
40
|
+
body{font-family:system-ui,sans-serif;background:#faf9f5;color:#141413;
|
|
41
|
+
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
42
|
+
.box{text-align:center}h1{font-size:2rem;margin:0 0 .5rem}p{color:#87867f;margin:0}
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body><div class="box"><h1>404</h1><p>not found</p></div></body>
|
|
46
|
+
</html>`;
|
|
47
|
+
|
|
48
|
+
const FORBIDDEN_HTML = `<!doctype html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head><meta charset="utf-8"><title>403</title>
|
|
51
|
+
<style>
|
|
52
|
+
body{font-family:system-ui,sans-serif;background:#faf9f5;color:#141413;
|
|
53
|
+
display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
54
|
+
.box{text-align:center}h1{font-size:2rem;margin:0 0 .5rem}p{color:#87867f;margin:0}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body><div class="box"><h1>403</h1><p>forbidden</p></div></body>
|
|
58
|
+
</html>`;
|
|
59
|
+
|
|
60
|
+
function mimeFor(filePath: string): string {
|
|
61
|
+
const ext = extname(filePath).toLowerCase();
|
|
62
|
+
return MIME_TYPES[ext] ?? "application/octet-stream";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function startServer(args: StartServerArgs): Promise<ServerHandle> {
|
|
66
|
+
const { stateDir, port, hostname = "127.0.0.1" } = args;
|
|
67
|
+
const stateDirResolved = resolve(stateDir);
|
|
68
|
+
const requestHandlers: Array<() => void> = [];
|
|
69
|
+
const preHandlers: Array<(req: Request) => Promise<Response | undefined>> = [];
|
|
70
|
+
|
|
71
|
+
const server = Bun.serve({
|
|
72
|
+
hostname,
|
|
73
|
+
port,
|
|
74
|
+
async fetch(req) {
|
|
75
|
+
// Notify idle tracker
|
|
76
|
+
for (const h of requestHandlers) {
|
|
77
|
+
h();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Run pre-static handlers (e.g. API routes) before serving static files
|
|
81
|
+
for (const handler of preHandlers) {
|
|
82
|
+
const result = await handler(req);
|
|
83
|
+
if (result !== undefined) {
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (req.method !== "GET") {
|
|
89
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const url = new URL(req.url);
|
|
93
|
+
// Decode and normalize the request path
|
|
94
|
+
let reqPath: string;
|
|
95
|
+
try {
|
|
96
|
+
reqPath = decodeURIComponent(url.pathname);
|
|
97
|
+
} catch {
|
|
98
|
+
return new Response(NOT_FOUND_HTML, {
|
|
99
|
+
status: 404,
|
|
100
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve the absolute path under stateDir
|
|
105
|
+
// Use resolve with stateDir as root to prevent traversal
|
|
106
|
+
const joined = resolve(stateDirResolved, "." + reqPath);
|
|
107
|
+
|
|
108
|
+
// Path traversal defense: resolved path must be rooted at stateDir
|
|
109
|
+
if (!joined.startsWith(stateDirResolved + "/") && joined !== stateDirResolved) {
|
|
110
|
+
return new Response(FORBIDDEN_HTML, {
|
|
111
|
+
status: 403,
|
|
112
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Determine final file path: if directory, try index.html
|
|
117
|
+
let filePath = joined;
|
|
118
|
+
const trailingSlash = reqPath.endsWith("/");
|
|
119
|
+
|
|
120
|
+
// Check if it's a directory by trying index.html
|
|
121
|
+
const indexPath = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
122
|
+
|
|
123
|
+
// Try as-is first, then as directory index
|
|
124
|
+
let fileToServe = filePath;
|
|
125
|
+
let isDir = false;
|
|
126
|
+
|
|
127
|
+
// If path has no extension or trailing slash, it might be a directory
|
|
128
|
+
if (trailingSlash || extname(filePath) === "") {
|
|
129
|
+
isDir = true;
|
|
130
|
+
fileToServe = filePath.endsWith("/") ? filePath + "index.html" : filePath + "/index.html";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const bunFile = Bun.file(fileToServe);
|
|
134
|
+
let exists = await bunFile.exists();
|
|
135
|
+
|
|
136
|
+
if (!exists && !isDir) {
|
|
137
|
+
// Try as directory index (e.g. /sub → /sub/index.html)
|
|
138
|
+
fileToServe = indexPath;
|
|
139
|
+
const bunFileIdx = Bun.file(fileToServe);
|
|
140
|
+
exists = await bunFileIdx.exists();
|
|
141
|
+
if (!exists) {
|
|
142
|
+
return new Response(NOT_FOUND_HTML, {
|
|
143
|
+
status: 404,
|
|
144
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
} else if (!exists) {
|
|
148
|
+
return new Response(NOT_FOUND_HTML, {
|
|
149
|
+
status: 404,
|
|
150
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contentType = mimeFor(fileToServe);
|
|
155
|
+
const finalFile = Bun.file(fileToServe);
|
|
156
|
+
const size = finalFile.size;
|
|
157
|
+
|
|
158
|
+
// Large files (>= 1MB): stream; small files: read fully
|
|
159
|
+
const ONE_MB = 1024 * 1024;
|
|
160
|
+
if (size >= ONE_MB) {
|
|
161
|
+
return new Response(finalFile.stream(), {
|
|
162
|
+
status: 200,
|
|
163
|
+
headers: { "Content-Type": contentType },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const buf = await readFile(fileToServe);
|
|
168
|
+
return new Response(buf, {
|
|
169
|
+
status: 200,
|
|
170
|
+
headers: { "Content-Type": contentType },
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const actualPort = server.port;
|
|
176
|
+
if (actualPort === undefined) {
|
|
177
|
+
await server.stop();
|
|
178
|
+
throw new Error("cesium: server.port is undefined after Bun.serve (unexpected)");
|
|
179
|
+
}
|
|
180
|
+
const serverUrl = `http://${hostname}:${actualPort}`;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
port: actualPort,
|
|
184
|
+
url: serverUrl,
|
|
185
|
+
stop: async () => {
|
|
186
|
+
await server.stop();
|
|
187
|
+
},
|
|
188
|
+
onRequest: (handler: () => void) => {
|
|
189
|
+
requestHandlers.push(handler);
|
|
190
|
+
},
|
|
191
|
+
addHandler: (handler: (req: Request) => Promise<Response | undefined>) => {
|
|
192
|
+
preHandlers.push(handler);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Lazy server start, idle shutdown, and PID file management.
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { startServer, type ServerHandle } from "./http.ts";
|
|
7
|
+
import { acquireLock } from "../storage/lock.ts";
|
|
8
|
+
import { createApiHandler } from "./api.ts";
|
|
9
|
+
|
|
10
|
+
export interface LifecycleConfig {
|
|
11
|
+
stateDir: string;
|
|
12
|
+
port: number; // start port for scan
|
|
13
|
+
portMax: number; // upper bound (inclusive)
|
|
14
|
+
idleTimeoutMs: number;
|
|
15
|
+
hostname?: string; // default "127.0.0.1"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RunningInfo {
|
|
19
|
+
port: number;
|
|
20
|
+
url: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
startedAt: string; // ISO UTC
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PidFileContent {
|
|
26
|
+
pid: number;
|
|
27
|
+
port: number;
|
|
28
|
+
hostname: string;
|
|
29
|
+
startedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Module-level singleton ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let currentHandle: ServerHandle | null = null;
|
|
35
|
+
let currentInfo: RunningInfo | null = null;
|
|
36
|
+
let idleInterval: ReturnType<typeof setInterval> | null = null;
|
|
37
|
+
let lastRequestAt: number = Date.now();
|
|
38
|
+
let exitHandler: (() => void) | null = null;
|
|
39
|
+
let sigTermHandler: (() => void) | null = null;
|
|
40
|
+
let sigIntHandler: (() => void) | null = null;
|
|
41
|
+
let currentPidFilePath: string | null = null;
|
|
42
|
+
|
|
43
|
+
// ─── PID file helpers ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export function readPidFile(path: string): PidFileContent | null {
|
|
46
|
+
try {
|
|
47
|
+
const raw = readFileSync(path, "utf8");
|
|
48
|
+
const parsed: unknown = JSON.parse(raw);
|
|
49
|
+
if (
|
|
50
|
+
parsed !== null &&
|
|
51
|
+
typeof parsed === "object" &&
|
|
52
|
+
!Array.isArray(parsed) &&
|
|
53
|
+
"pid" in parsed &&
|
|
54
|
+
"port" in parsed &&
|
|
55
|
+
"hostname" in parsed &&
|
|
56
|
+
"startedAt" in parsed &&
|
|
57
|
+
typeof (parsed as Record<string, unknown>)["pid"] === "number" &&
|
|
58
|
+
typeof (parsed as Record<string, unknown>)["port"] === "number" &&
|
|
59
|
+
typeof (parsed as Record<string, unknown>)["hostname"] === "string" &&
|
|
60
|
+
typeof (parsed as Record<string, unknown>)["startedAt"] === "string"
|
|
61
|
+
) {
|
|
62
|
+
const p = parsed as Record<string, unknown>;
|
|
63
|
+
return {
|
|
64
|
+
pid: p["pid"] as number,
|
|
65
|
+
port: p["port"] as number,
|
|
66
|
+
hostname: p["hostname"] as string,
|
|
67
|
+
startedAt: p["startedAt"] as string,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function writePidFile(path: string, content: PidFileContent): Promise<void> {
|
|
77
|
+
await writeFile(path, JSON.stringify(content, null, 2), "utf8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isAlive(pid: number): boolean {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const e = err as NodeJS.ErrnoException;
|
|
86
|
+
if (e.code === "ESRCH") return false;
|
|
87
|
+
// EPERM → process exists but owned by different user
|
|
88
|
+
if (e.code === "EPERM") return true;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Idle timer management ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function clearIdleTimer(): void {
|
|
96
|
+
if (idleInterval !== null) {
|
|
97
|
+
clearInterval(idleInterval);
|
|
98
|
+
idleInterval = null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function startIdleTimer(idleTimeoutMs: number): void {
|
|
103
|
+
clearIdleTimer();
|
|
104
|
+
// 0 (or negative) means "never time out" — used by `cesium serve` in the
|
|
105
|
+
// foreground, where the user expects the server to live until they Ctrl-C.
|
|
106
|
+
if (idleTimeoutMs <= 0) return;
|
|
107
|
+
// Check every 10% of the timeout (but at least every 5s, at most every 60s)
|
|
108
|
+
const checkMs = Math.max(5_000, Math.min(60_000, Math.floor(idleTimeoutMs / 10)));
|
|
109
|
+
const interval = setInterval(() => {
|
|
110
|
+
if (Date.now() - lastRequestAt > idleTimeoutMs) {
|
|
111
|
+
void stopRunning(currentPidFilePath ? currentPidFilePath.replace(/\/.server\.pid$/, "") : "");
|
|
112
|
+
}
|
|
113
|
+
}, checkMs);
|
|
114
|
+
// Unref so the interval doesn't keep the process alive
|
|
115
|
+
interval.unref();
|
|
116
|
+
idleInterval = interval;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Signal / exit handler management ────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function removeSignalHandlers(): void {
|
|
122
|
+
if (sigTermHandler !== null) {
|
|
123
|
+
process.removeListener("SIGTERM", sigTermHandler);
|
|
124
|
+
sigTermHandler = null;
|
|
125
|
+
}
|
|
126
|
+
if (sigIntHandler !== null) {
|
|
127
|
+
process.removeListener("SIGINT", sigIntHandler);
|
|
128
|
+
sigIntHandler = null;
|
|
129
|
+
}
|
|
130
|
+
if (exitHandler !== null) {
|
|
131
|
+
process.removeListener("exit", exitHandler);
|
|
132
|
+
exitHandler = null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function installSignalHandlers(pidFilePath: string): void {
|
|
137
|
+
removeSignalHandlers();
|
|
138
|
+
|
|
139
|
+
sigTermHandler = () => {
|
|
140
|
+
void stopRunning(pidFilePath.replace(/\/.server\.pid$/, "")).finally(() => {
|
|
141
|
+
process.exit(0);
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
sigIntHandler = () => {
|
|
146
|
+
void stopRunning(pidFilePath.replace(/\/.server\.pid$/, "")).finally(() => {
|
|
147
|
+
process.exit(0);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Synchronous exit handler — last resort PID file cleanup
|
|
152
|
+
exitHandler = () => {
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(pidFilePath);
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore ENOENT and any other error
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
process.on("SIGTERM", sigTermHandler);
|
|
161
|
+
process.on("SIGINT", sigIntHandler);
|
|
162
|
+
process.on("exit", exitHandler);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Core lifecycle ───────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
export async function stopRunning(stateDir: string): Promise<void> {
|
|
168
|
+
clearIdleTimer();
|
|
169
|
+
removeSignalHandlers();
|
|
170
|
+
|
|
171
|
+
const handle = currentHandle;
|
|
172
|
+
currentHandle = null;
|
|
173
|
+
currentInfo = null;
|
|
174
|
+
const pidPath = currentPidFilePath;
|
|
175
|
+
currentPidFilePath = null;
|
|
176
|
+
lastRequestAt = Date.now();
|
|
177
|
+
|
|
178
|
+
if (handle !== null) {
|
|
179
|
+
try {
|
|
180
|
+
await handle.stop();
|
|
181
|
+
} catch {
|
|
182
|
+
// best-effort
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (pidPath !== null) {
|
|
187
|
+
try {
|
|
188
|
+
await unlink(pidPath);
|
|
189
|
+
} catch {
|
|
190
|
+
// ENOENT is fine
|
|
191
|
+
}
|
|
192
|
+
} else if (stateDir) {
|
|
193
|
+
try {
|
|
194
|
+
await unlink(join(stateDir, ".server.pid"));
|
|
195
|
+
} catch {
|
|
196
|
+
// ENOENT is fine
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
|
|
202
|
+
const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1" } = cfg;
|
|
203
|
+
const pidFilePath = join(stateDir, ".server.pid");
|
|
204
|
+
const lockPath = join(stateDir, ".server-start.lock");
|
|
205
|
+
|
|
206
|
+
// Fast path: already running in this process
|
|
207
|
+
if (currentHandle !== null && currentInfo !== null) {
|
|
208
|
+
return currentInfo;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lock = await acquireLock({ lockPath, timeoutMs: 10_000, staleMs: 30_000 });
|
|
212
|
+
try {
|
|
213
|
+
// Re-check after acquiring lock (another concurrent call may have started it)
|
|
214
|
+
if (currentHandle !== null && currentInfo !== null) {
|
|
215
|
+
return currentInfo;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Read existing PID file
|
|
219
|
+
const existing = readPidFile(pidFilePath);
|
|
220
|
+
if (existing !== null) {
|
|
221
|
+
if (existing.pid === process.pid) {
|
|
222
|
+
// Our own PID but no in-process handle — stale from a previous test reset or crash
|
|
223
|
+
// Fall through to start new
|
|
224
|
+
} else if (isAlive(existing.pid)) {
|
|
225
|
+
// Another process owns this server
|
|
226
|
+
const info: RunningInfo = {
|
|
227
|
+
port: existing.port,
|
|
228
|
+
url: `http://${existing.hostname}:${existing.port}`,
|
|
229
|
+
pid: existing.pid,
|
|
230
|
+
startedAt: existing.startedAt,
|
|
231
|
+
};
|
|
232
|
+
return info;
|
|
233
|
+
}
|
|
234
|
+
// Stale — delete and start fresh
|
|
235
|
+
try {
|
|
236
|
+
await unlink(pidFilePath);
|
|
237
|
+
} catch {
|
|
238
|
+
// ENOENT is fine
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Scan for a free port using a recursive helper (avoids await-in-loop lint rule)
|
|
243
|
+
async function tryBindPort(p: number): Promise<ServerHandle> {
|
|
244
|
+
if (p > portMax) {
|
|
245
|
+
throw new Error(`cesium: no free port found in range ${port}–${portMax}`);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
return await startServer({ stateDir, port: p, hostname });
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const e = err as NodeJS.ErrnoException;
|
|
251
|
+
if (e.code === "EADDRINUSE" || e.code === "EACCES") {
|
|
252
|
+
return tryBindPort(p + 1);
|
|
253
|
+
}
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const handle = await tryBindPort(port);
|
|
259
|
+
const boundPort = handle.port;
|
|
260
|
+
|
|
261
|
+
// Wire API handler before static file fallback
|
|
262
|
+
handle.addHandler(createApiHandler({ stateDir }));
|
|
263
|
+
|
|
264
|
+
const startedAt = new Date().toISOString();
|
|
265
|
+
|
|
266
|
+
// Write PID file
|
|
267
|
+
await writePidFile(pidFilePath, {
|
|
268
|
+
pid: process.pid,
|
|
269
|
+
port: boundPort,
|
|
270
|
+
hostname,
|
|
271
|
+
startedAt,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
currentHandle = handle;
|
|
275
|
+
currentPidFilePath = pidFilePath;
|
|
276
|
+
lastRequestAt = Date.now();
|
|
277
|
+
|
|
278
|
+
const info: RunningInfo = {
|
|
279
|
+
port: boundPort,
|
|
280
|
+
url: `http://${hostname}:${boundPort}`,
|
|
281
|
+
pid: process.pid,
|
|
282
|
+
startedAt,
|
|
283
|
+
};
|
|
284
|
+
currentInfo = info;
|
|
285
|
+
|
|
286
|
+
// Attach idle tracking
|
|
287
|
+
handle.onRequest(() => {
|
|
288
|
+
lastRequestAt = Date.now();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Install signal handlers
|
|
292
|
+
installSignalHandlers(pidFilePath);
|
|
293
|
+
|
|
294
|
+
// Start idle timer
|
|
295
|
+
startIdleTimer(idleTimeoutMs);
|
|
296
|
+
|
|
297
|
+
return info;
|
|
298
|
+
} finally {
|
|
299
|
+
await lock.release();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Test reset hook ──────────────────────────────────────────────────────────
|
|
304
|
+
// This function is intended for test use only. It clears module-level singleton
|
|
305
|
+
// state, stops any running server, and removes signal/exit listeners.
|
|
306
|
+
|
|
307
|
+
export async function resetForTests(): Promise<void> {
|
|
308
|
+
clearIdleTimer();
|
|
309
|
+
removeSignalHandlers();
|
|
310
|
+
|
|
311
|
+
if (currentHandle !== null) {
|
|
312
|
+
try {
|
|
313
|
+
await currentHandle.stop();
|
|
314
|
+
} catch {
|
|
315
|
+
// best-effort
|
|
316
|
+
}
|
|
317
|
+
currentHandle = null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (currentPidFilePath !== null) {
|
|
321
|
+
try {
|
|
322
|
+
await unlink(currentPidFilePath);
|
|
323
|
+
} catch {
|
|
324
|
+
// ENOENT is fine
|
|
325
|
+
}
|
|
326
|
+
currentPidFilePath = null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
currentInfo = null;
|
|
330
|
+
lastRequestAt = Date.now();
|
|
331
|
+
}
|