@cfbender/cesium 0.5.0 → 0.5.2

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.
@@ -2,7 +2,10 @@
2
2
 
3
3
  import { join } from "node:path";
4
4
  import { readFileSync, unlinkSync } from "node:fs";
5
- import { unlink, writeFile } from "node:fs/promises";
5
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname } from "node:path";
8
+ import { spawn } from "node:child_process";
6
9
  import { startServer, type ServerHandle } from "./http.ts";
7
10
  import { acquireLock } from "../storage/lock.ts";
8
11
  import { createApiHandler } from "./api.ts";
@@ -202,8 +205,19 @@ export async function stopRunning(stateDir: string): Promise<void> {
202
205
  }
203
206
  }
204
207
 
205
- export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
206
- const { stateDir, port, portMax, idleTimeoutMs, hostname = "127.0.0.1", theme = defaultTheme() } = cfg;
208
+ // ─── In-process (foreground) server start ────────────────────────────────────
209
+ // Used by `cesium serve` CLI. Runs Bun.serve() in-process; killing the process
210
+ // IS stopping the server, which is the user's intent for a foreground invocation.
211
+
212
+ export async function runServerForeground(cfg: LifecycleConfig): Promise<RunningInfo> {
213
+ const {
214
+ stateDir,
215
+ port,
216
+ portMax,
217
+ idleTimeoutMs,
218
+ hostname = "127.0.0.1",
219
+ theme = defaultTheme(),
220
+ } = cfg;
207
221
  const pidFilePath = join(stateDir, ".server.pid");
208
222
  const lockPath = join(stateDir, ".server-start.lock");
209
223
 
@@ -310,6 +324,177 @@ export async function ensureRunning(cfg: LifecycleConfig): Promise<RunningInfo>
310
324
  }
311
325
  }
312
326
 
327
+ // ─── Detached (lazy) server start ────────────────────────────────────────────
328
+ // Used by plugin callers (publish, ask). Spawns `cesium serve` as a detached
329
+ // subprocess so the subprocess PID is what ends up in the PID file. Sending a
330
+ // signal to that PID kills only the server child, never the plugin host.
331
+
332
+ // Locate CLI entry relative to this file: src/server/lifecycle.ts → src/cli/index.ts
333
+ const HERE = dirname(fileURLToPath(import.meta.url));
334
+ const CLI_ENTRY = join(HERE, "..", "cli", "index.ts");
335
+
336
+ // Readiness poll backoff schedule (ms between attempts)
337
+ const POLL_SCHEDULE = [50, 100, 200, 500, 1000, 1000, 1000, 1000, 1000, 1000];
338
+
339
+ async function sleep(ms: number): Promise<void> {
340
+ return new Promise((resolve) => setTimeout(resolve, ms));
341
+ }
342
+
343
+ async function httpProbe(url: string): Promise<boolean> {
344
+ try {
345
+ const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
346
+ // Any HTTP response (even 404) means the server is up
347
+ return res.status < 600;
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+
353
+ export async function ensureServerRunning(cfg: LifecycleConfig): Promise<RunningInfo> {
354
+ const { stateDir, port, idleTimeoutMs } = cfg;
355
+ const pidFilePath = join(stateDir, ".server.pid");
356
+ // Use a separate lock from runServerForeground's ".server-start.lock" to avoid
357
+ // deadlock: the child process runs runServerForeground which acquires that lock,
358
+ // so the parent must not hold it while waiting for the child.
359
+ const spawnLockPath = join(stateDir, ".server-spawn.lock");
360
+
361
+ // Fast path: read existing PID file and probe liveness
362
+ const existing = readPidFile(pidFilePath);
363
+ if (existing !== null && isAlive(existing.pid)) {
364
+ const probeUrl = `http://${existing.hostname}:${existing.port}/`;
365
+ const alive = await httpProbe(probeUrl);
366
+ if (alive) {
367
+ return {
368
+ port: existing.port,
369
+ url: `http://${existing.hostname}:${existing.port}`,
370
+ pid: existing.pid,
371
+ startedAt: existing.startedAt,
372
+ };
373
+ }
374
+ // Process alive but not responding — fall through to spawn fresh
375
+ }
376
+
377
+ // Ensure state dir exists before trying to acquire lock or write files
378
+ await mkdir(stateDir, { recursive: true });
379
+
380
+ // Use a spawn-only lock to prevent concurrent spawns. Release it immediately
381
+ // after spawning so the child can acquire its own (.server-start.lock) lock.
382
+ const spawnLock = await acquireLock({
383
+ lockPath: spawnLockPath,
384
+ timeoutMs: 15_000,
385
+ staleMs: 30_000,
386
+ });
387
+ try {
388
+ // Re-check after acquiring lock
389
+ const existingAfterLock = readPidFile(pidFilePath);
390
+ if (existingAfterLock !== null && isAlive(existingAfterLock.pid)) {
391
+ const probeUrl = `http://${existingAfterLock.hostname}:${existingAfterLock.port}/`;
392
+ const alive = await httpProbe(probeUrl);
393
+ if (alive) {
394
+ return {
395
+ port: existingAfterLock.port,
396
+ url: `http://${existingAfterLock.hostname}:${existingAfterLock.port}`,
397
+ pid: existingAfterLock.pid,
398
+ startedAt: existingAfterLock.startedAt,
399
+ };
400
+ }
401
+ }
402
+
403
+ // Clean up stale PID file if present
404
+ try {
405
+ await unlink(pidFilePath);
406
+ } catch {
407
+ // ENOENT is fine
408
+ }
409
+
410
+ // Build spawn args — rely on env vars for config; CLI flags as defense in depth.
411
+ // portMax is not a serve flag; the child will scan ports starting from `port`.
412
+ // Port 0 means "auto-assign" — the CLI flag rejects 0, so rely on CESIUM_PORT=0 env var.
413
+ const spawnArgs: string[] = ["run", CLI_ENTRY, "serve", "--state-dir", stateDir];
414
+ if (port > 0) {
415
+ spawnArgs.push("--port", String(port));
416
+ }
417
+ // Pass idle timeout so the detached child self-terminates on inactivity.
418
+ // Serve command defaults to 0 (never) for foreground use; we override for daemon mode.
419
+ if (idleTimeoutMs > 0) {
420
+ spawnArgs.push("--idle-timeout", String(idleTimeoutMs));
421
+ }
422
+
423
+ const child = spawn("bun", spawnArgs, {
424
+ detached: true,
425
+ stdio: "ignore",
426
+ env: {
427
+ ...process.env,
428
+ CESIUM_STATE_DIR: stateDir,
429
+ CESIUM_PORT: String(port),
430
+ },
431
+ });
432
+
433
+ // Unref so the parent can exit without waiting for the child
434
+ child.unref();
435
+
436
+ if (child.pid === undefined) {
437
+ throw new Error("cesium: failed to spawn server subprocess (no PID assigned)");
438
+ }
439
+ } finally {
440
+ // Release spawn lock immediately — the child needs to acquire its own lock
441
+ // (.server-start.lock via runServerForeground). Holding the spawn lock any
442
+ // longer would deadlock the child.
443
+ await spawnLock.release();
444
+ }
445
+
446
+ // Wait for the child to write its PID file and respond to HTTP.
447
+ // This polling happens OUTSIDE the spawn lock so the child can run freely.
448
+ const deadline = Date.now() + 10_000;
449
+ let lastError = "timeout";
450
+ let scheduleIdx = 0;
451
+
452
+ while (Date.now() < deadline) {
453
+ const waitMs = POLL_SCHEDULE[scheduleIdx] ?? 1000;
454
+ scheduleIdx = Math.min(scheduleIdx + 1, POLL_SCHEDULE.length - 1);
455
+ await sleep(waitMs);
456
+
457
+ const pidContent = readPidFile(pidFilePath);
458
+ if (pidContent !== null && isAlive(pidContent.pid)) {
459
+ const probeUrl = `http://${pidContent.hostname}:${pidContent.port}/`;
460
+ const alive = await httpProbe(probeUrl);
461
+ if (alive) {
462
+ return {
463
+ port: pidContent.port,
464
+ url: `http://${pidContent.hostname}:${pidContent.port}`,
465
+ pid: pidContent.pid,
466
+ startedAt: pidContent.startedAt,
467
+ };
468
+ }
469
+ lastError = `pid ${pidContent.pid} alive but not yet responding on port ${pidContent.port}`;
470
+ } else if (pidContent !== null) {
471
+ lastError = `pid ${pidContent.pid} in PID file is not alive`;
472
+ } else {
473
+ lastError = "PID file not yet written";
474
+ }
475
+ }
476
+
477
+ // Timeout — try to clean up the spawned process
478
+ const staleContent = readPidFile(pidFilePath);
479
+ if (staleContent !== null && isAlive(staleContent.pid)) {
480
+ try {
481
+ process.kill(staleContent.pid, "SIGTERM");
482
+ } catch {
483
+ // best-effort
484
+ }
485
+ }
486
+
487
+ throw new Error(
488
+ `cesium: timed out waiting for server to start in ${stateDir} (last: ${lastError})`,
489
+ );
490
+ }
491
+
492
+ // ─── Backward-compat alias ────────────────────────────────────────────────────
493
+ // Internal callers have been updated to use runServerForeground or ensureServerRunning.
494
+ // Keep ensureRunning exported for any external consumers that haven't migrated.
495
+
496
+ export { runServerForeground as ensureRunning };
497
+
313
498
  // ─── Test reset hook ──────────────────────────────────────────────────────────
314
499
  // This function is intended for test use only. It clears module-level singleton
315
500
  // state, stops any running server, and removes signal/exit listeners.
@@ -236,9 +236,8 @@ function indexJs(): string {
236
236
  function renderEntryCard(entry: IndexEntry): string {
237
237
  const isSuperseded = entry.supersededBy !== null ? "1" : "0";
238
238
  const kindPill = `<span class="pill">${esc(entry.kind)}</span>`;
239
- const inputModeBadge = entry.inputMode !== undefined
240
- ? ` <span class="tag">${esc(entry.inputMode)}</span>`
241
- : "";
239
+ const inputModeBadge =
240
+ entry.inputMode !== undefined ? ` <span class="tag">${esc(entry.inputMode)}</span>` : "";
242
241
  const dateStr = `<span class="card-date">${esc(formatDate(entry.createdAt))}</span>`;
243
242
  const supersededBadge =
244
243
  entry.supersedes !== null
package/src/tools/ask.ts CHANGED
@@ -20,7 +20,7 @@ import { withLock } from "../storage/lock.ts";
20
20
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
21
21
  import { buildProjectSummaries } from "../storage/project-summaries.ts";
22
22
  import {
23
- ensureRunning as defaultEnsureRunning,
23
+ ensureServerRunning as defaultEnsureServerRunning,
24
24
  type RunningInfo,
25
25
  type LifecycleConfig,
26
26
  } from "../server/lifecycle.ts";
@@ -58,7 +58,7 @@ export function createAskTool(
58
58
  const resolveConfig = overrides?.loadConfig ?? loadConfig;
59
59
  const now = overrides?.now ?? (() => new Date());
60
60
  const genId = overrides?.nanoid ?? defaultNanoid;
61
- const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
61
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
62
62
 
63
63
  return tool({
64
64
  description: TOOL_DESCRIPTION,
@@ -11,6 +11,7 @@ import { extractTextContent } from "../render/extract.ts";
11
11
  import { themeFromPreset, mergeTheme } from "../render/theme.ts";
12
12
  import { validatePublishInput, htmlBodyWarnings, PUBLISH_KINDS } from "../render/validate.ts";
13
13
  import { renderBlocks } from "../render/blocks/render.ts";
14
+ import { resolveHighlightTheme } from "../render/blocks/highlight.ts";
14
15
  import { wrapDocument, type ArtifactMeta } from "../render/wrap.ts";
15
16
  import { deriveProjectIdentity, artifactFilename, pathsFor } from "../storage/paths.ts";
16
17
  import { atomicWrite, patchEmbeddedMetadata } from "../storage/write.ts";
@@ -27,7 +28,7 @@ import { withLock } from "../storage/lock.ts";
27
28
  import { renderProjectIndex, renderGlobalIndex } from "../storage/index-gen.ts";
28
29
  import { buildProjectSummaries } from "../storage/project-summaries.ts";
29
30
  import {
30
- ensureRunning as defaultEnsureRunning,
31
+ ensureServerRunning as defaultEnsureServerRunning,
31
32
  type RunningInfo,
32
33
  type LifecycleConfig,
33
34
  } from "../server/lifecycle.ts";
@@ -106,7 +107,7 @@ export function createPublishTool(
106
107
  const resolveConfig = overrides?.loadConfig ?? loadConfig;
107
108
  const now = overrides?.now ?? (() => new Date());
108
109
  const genId = overrides?.nanoid ?? defaultNanoid;
109
- const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureRunning;
110
+ const runEnsureRunning = overrides?.ensureRunning ?? defaultEnsureServerRunning;
110
111
 
111
112
  return tool({
112
113
  description: TOOL_DESCRIPTION,
@@ -116,9 +117,7 @@ export function createPublishTool(
116
117
  html: tool.schema
117
118
  .string()
118
119
  .optional()
119
- .describe(
120
- "Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks.",
121
- ),
120
+ .describe("Body HTML — escape valve / legacy mode. Provide exactly one of html or blocks."),
122
121
  blocks: tool.schema
123
122
  .array(tool.schema.any())
124
123
  .optional()
@@ -193,7 +192,8 @@ export function createPublishTool(
193
192
 
194
193
  if (input.blocks !== undefined) {
195
194
  // Blocks path: render structured blocks → trusted HTML
196
- bodyHtml = renderBlocks(input.blocks);
195
+ const highlightTheme = resolveHighlightTheme(config.themePreset);
196
+ bodyHtml = await renderBlocks(input.blocks, { highlightTheme });
197
197
  } else {
198
198
  // HTML path: scrub agent-supplied HTML
199
199
  const scrubbed = scrub(input.html);
@@ -8,7 +8,7 @@ import type { RenderCtx, SectionCounter } from "../render/blocks/render.ts";
8
8
 
9
9
  function makeCtx(): RenderCtx {
10
10
  const counter: SectionCounter = { value: 1 };
11
- return { sectionCounter: counter, depth: 0, path: "blocks[0]" };
11
+ return { sectionCounter: counter, depth: 0, path: "blocks[0]", highlightTheme: "claret-dark" };
12
12
  }
13
13
 
14
14
  /** Escape a string for safe insertion inside a markdown fenced code block. */
@@ -18,7 +18,7 @@ function escapeForCodeFence(s: string): string {
18
18
  }
19
19
 
20
20
  /** Generate the full markdown reference from the catalog. Deterministic — same catalog → same output. */
21
- export function generateStyleguideMarkdown(): string {
21
+ export async function generateStyleguideMarkdown(): Promise<string> {
22
22
  const lines: string[] = [];
23
23
 
24
24
  lines.push("# Cesium publishing reference");
@@ -40,8 +40,26 @@ export function generateStyleguideMarkdown(): string {
40
40
  lines.push("## Block reference");
41
41
  lines.push("");
42
42
 
43
- for (const blockType of blockTypes) {
43
+ // Pre-render all examples in parallel (order preserved via index)
44
+ const renderedExamples = await Promise.all(
45
+ blockTypes.map(async (blockType) => {
46
+ const entry = blockCatalog[blockType];
47
+ if (entry.renderedExample !== undefined) {
48
+ return entry.renderedExample;
49
+ }
50
+ try {
51
+ return await renderBlock(entry.example, makeCtx());
52
+ } catch {
53
+ return "";
54
+ }
55
+ }),
56
+ );
57
+
58
+ for (let i = 0; i < blockTypes.length; i++) {
59
+ const blockType = blockTypes[i];
60
+ if (blockType === undefined) continue;
44
61
  const entry = blockCatalog[blockType];
62
+ const rendered = renderedExamples[i] ?? "";
45
63
 
46
64
  lines.push(`### \`${entry.type}\``);
47
65
  lines.push("");
@@ -62,15 +80,6 @@ export function generateStyleguideMarkdown(): string {
62
80
  lines.push("```");
63
81
  lines.push("");
64
82
 
65
- // Rendered HTML
66
- const rendered = entry.renderedExample ?? (() => {
67
- try {
68
- return renderBlock(entry.example, makeCtx());
69
- } catch {
70
- return "";
71
- }
72
- })();
73
-
74
83
  if (rendered !== "") {
75
84
  lines.push("Renders to:");
76
85
  lines.push("");
@@ -92,21 +101,17 @@ export function generateStyleguideMarkdown(): string {
92
101
  "- Inline: `**bold**`, `*italic*`, `` `code` ``, `[text](href)` (relative or anchor only).",
93
102
  );
94
103
  lines.push(
95
- "- HTML safelist: `<kbd>`, `<span class=\"pill\">`, `<span class=\"tag\">`. Anything else is escaped.",
104
+ '- HTML safelist: `<kbd>`, `<span class="pill">`, `<span class="tag">`. Anything else is escaped.',
96
105
  );
97
106
  lines.push("");
98
107
  lines.push("## When to reach for raw_html / diagram");
99
108
  lines.push("");
100
- lines.push(
101
- "- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.",
102
- );
109
+ lines.push("- `diagram` — inline SVG visualizations or bespoke composed HTML diagrams.");
103
110
  lines.push(
104
111
  "- `raw_html` — anything genuinely creative that doesn't fit a known block type." +
105
112
  " Include a `purpose` string describing what you're building.",
106
113
  );
107
- lines.push(
108
- "- Critique flags raw_html overuse (>2 blocks or >30% of body characters).",
109
- );
114
+ lines.push("- Critique flags raw_html overuse (>2 blocks or >30% of body characters).");
110
115
 
111
116
  return lines.join("\n");
112
117
  }
@@ -117,7 +122,7 @@ export function createStyleguideTool(_ctx: PluginInput): ReturnType<typeof tool>
117
122
  "Returns the cesium HTML design system reference page (CSS classes with example usage). Call this once at the start of writing a complex artifact to internalize the available components.",
118
123
  args: {},
119
124
  async execute(_args, _context) {
120
- return generateStyleguideMarkdown();
125
+ return await generateStyleguideMarkdown();
121
126
  },
122
127
  });
123
128
  }