@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.
Files changed (44) hide show
  1. package/ARCHITECTURE.md +304 -0
  2. package/CHANGELOG.md +335 -0
  3. package/LICENSE +21 -0
  4. package/README.md +479 -0
  5. package/agents/cesium.md +39 -0
  6. package/assets/styleguide.html +857 -0
  7. package/package.json +61 -0
  8. package/src/cli/commands/ls.ts +186 -0
  9. package/src/cli/commands/open.ts +208 -0
  10. package/src/cli/commands/prune.ts +348 -0
  11. package/src/cli/commands/restart.ts +38 -0
  12. package/src/cli/commands/serve.ts +214 -0
  13. package/src/cli/commands/stop.ts +130 -0
  14. package/src/cli/commands/theme.ts +333 -0
  15. package/src/cli/index.ts +78 -0
  16. package/src/config.ts +94 -0
  17. package/src/index.ts +35 -0
  18. package/src/prompt/system-fragment.md +97 -0
  19. package/src/render/client-js.ts +316 -0
  20. package/src/render/controls.ts +302 -0
  21. package/src/render/critique.ts +360 -0
  22. package/src/render/extract.ts +83 -0
  23. package/src/render/scrub.ts +141 -0
  24. package/src/render/theme.ts +712 -0
  25. package/src/render/validate.ts +524 -0
  26. package/src/render/wrap.ts +165 -0
  27. package/src/server/api.ts +166 -0
  28. package/src/server/http.ts +195 -0
  29. package/src/server/lifecycle.ts +331 -0
  30. package/src/server/stop.ts +124 -0
  31. package/src/storage/index-cache.ts +71 -0
  32. package/src/storage/index-gen.ts +447 -0
  33. package/src/storage/lock.ts +108 -0
  34. package/src/storage/mutate.ts +396 -0
  35. package/src/storage/paths.ts +159 -0
  36. package/src/storage/project-summaries.ts +19 -0
  37. package/src/storage/theme-write.ts +19 -0
  38. package/src/storage/write.ts +75 -0
  39. package/src/tools/ask.ts +353 -0
  40. package/src/tools/critique.ts +66 -0
  41. package/src/tools/publish.ts +404 -0
  42. package/src/tools/stop.ts +53 -0
  43. package/src/tools/styleguide.ts +23 -0
  44. 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
+ }