← Files WebMCP KitARCHIVED FILE

skills/implement/interactive/server.ts

23.2 KB · Oct 3, 2026 · 06:32 UTC

↓ Download file

// Explorer relay server. Bun, zero dependencies.
//
// A normal start creates a new logical run. `--resume` is the only way to retain the
// identity and event order of a run whose server died without a clean shutdown.
import {
  closeSync,
  constants,
  fsyncSync,
  linkSync,
  lstatSync,
  mkdirSync,
  openSync,
  readFileSync,
  readdirSync,
  realpathSync,
  renameSync,
  unlinkSync,
  watch,
  writeSync,
} from "node:fs";
import { join } from "node:path";
import type { ServerWebSocket } from "bun";
import {
  AppendLimitError,
  MAX_JOURNAL_BYTES,
  MAX_REQUEST_BYTES,
  MAX_JOURNAL_LINE_BYTES,
  PROTOCOL_VERSION,
  SERVICE,
  appendDurable,
  parseRecordedEnvelope,
  parseRunFile,
  readRegularText,
  validateRequest,
  type RecordedEnvelope,
  type RunFileV1,
} from "./protocol";

const ACTIONABLE = new Set(["comment", "feedback", "submit", "approve", "cancel"]);
const MAX_RUNTIME_BYTES = 16 * 1024;

function testDuration(name: string, fallback: number): number {
  const value = process.env[name];
  return value && /^[1-9][0-9]{1,5}$/.test(value) ? Number(value) : fallback;
}

const SERVER_LOCK_LEASE_MS = testDuration("WEBMCP_TEST_SERVER_LOCK_LEASE_MS", 60 * 1_000);
const SERVER_LOCK_RENEW_MS = Math.min(
  testDuration("WEBMCP_TEST_SERVER_LOCK_RENEW_MS", 10 * 1_000),
  Math.max(10, Math.floor(SERVER_LOCK_LEASE_MS / 2)),
);
const args = process.argv.slice(2);
const resume = args.includes("--resume");
const positional = args.filter((arg) => !arg.startsWith("--"));
const unknownFlags = args.filter((arg) => arg.startsWith("--") && arg !== "--resume");
if (unknownFlags.length > 0 || positional.length > 1) {
  console.error("usage: bun server.ts [workspace] [--resume]");
  process.exit(2);
}

let workspace: string;
try {
  workspace = realpathSync(positional[0] ?? process.cwd());
  if (!lstatSync(workspace).isDirectory()) throw new Error("not a directory");
} catch (error) {
  console.error(`refusing to start: invalid workspace (${(error as Error).message})`);
  process.exit(1);
  throw error;
}

const stateDir = join(workspace, ".webmcp");
const runFile = join(stateDir, ".run.json");
const lockFile = join(stateDir, ".server.lock");
const portFile = join(stateDir, ".port");
const lastPortFile = join(stateDir, ".port.last");
const gitignoreFile = join(stateDir, ".gitignore");
const feedbackFile = join(stateDir, "_feedback.ndjson");

function stateDirIsReal(): boolean {
  try {
    return lstatSync(stateDir).isDirectory() && !lstatSync(stateDir).isSymbolicLink();
  } catch {
    return false;
  }
}

try {
  if (lstatSync(stateDir).isSymbolicLink()) {
    console.error(`refusing to start: ${stateDir} is a symlink`);
    process.exit(1);
  }
} catch {
  // Absent is expected on the first run.
}
mkdirSync(stateDir, { recursive: true });
if (!stateDirIsReal()) {
  console.error(`refusing to start: ${stateDir} is not a real directory`);
  process.exit(1);
}
try {
  if (lstatSync(runFile).isSymbolicLink()) {
    console.error("refusing to start: .webmcp/.run.json is a symlink");
    process.exit(1);
  }
} catch {
  // Absent is expected.
}

function writeFileExclusive(path: string, text: string): void {
  let fd: number | undefined;
  try {
    fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o644);
    const buffer = Buffer.from(text, "utf8");
    const written = writeSync(fd, buffer, 0, buffer.length);
    if (written !== buffer.length) throw new Error(`short write (${written}/${buffer.length} bytes)`);
    fsyncSync(fd);
  } finally {
    if (fd !== undefined) closeSync(fd);
  }
}

function readRun(): RunFileV1 | null {
  const text = readRegularText(runFile, MAX_RUNTIME_BYTES);
  if (text === null) return null;
  try {
    const parsed = parseRunFile(JSON.parse(text));
    return parsed?.workspace === workspace ? parsed : null;
  } catch {
    return null;
  }
}

function explorerUrl(run: RunFileV1): string {
  const query = new URLSearchParams({ capability: run.capability, run_id: run.run_id });
  return `http://localhost:${run.port}/?${query}`;
}

function scopedUrl(run: RunFileV1, pathname: string): string {
  const query = new URLSearchParams({ capability: run.capability, run_id: run.run_id });
  return `http://127.0.0.1:${run.port}${pathname}?${query}`;
}

async function isLive(run: RunFileV1): Promise<boolean> {
  try {
    const response = await fetch(scopedUrl(run, "/healthz"), {
      signal: AbortSignal.timeout(500),
    });
    if (!response.ok) return false;
    const body = (await response.json()) as Record<string, unknown>;
    return (
      body.service === SERVICE &&
      body.version === PROTOCOL_VERSION &&
      body.run_id === run.run_id &&
      body.workspace === workspace &&
      Object.keys(body).sort().join(",") === "run_id,service,version,workspace"
    );
  } catch {
    return false;
  }
}

const initialRun = readRun();
if (initialRun && (await isLive(initialRun))) {
  console.log(`webmcp-explorer already live on ${explorerUrl(initialRun)}  state: ${stateDir}`);
  process.exit(0);
}
if (resume && !initialRun) {
  console.error("refusing to resume: .webmcp/.run.json is absent or invalid for this workspace");
  process.exit(1);
}

const lockNonce = crypto.randomUUID();
let ownLockText = JSON.stringify({
  pid: process.pid,
  nonce: lockNonce,
  lease_expires_at_ms: Date.now() + SERVER_LOCK_LEASE_MS,
});
let ownLock = false;

type LockOwner = { pid: number; nonce: string; lease_expires_at_ms: number };

function lockOwnerAt(path: string): LockOwner | null {
  const text = readRegularText(path, 4096);
  if (text === null) return null;
  try {
    const value = JSON.parse(text) as Record<string, unknown>;
    const keys = Object.keys(value).sort().join(",");
    if (
      (keys !== "lease_expires_at_ms,nonce,pid" &&
        keys !== "created_at_ms,nonce,pid" &&
        keys !== "nonce,pid") ||
      !Number.isSafeInteger(value.pid) ||
      (value.pid as number) < 1 ||
      typeof value.nonce !== "string" ||
      value.nonce.length > 128 ||
      (keys === "lease_expires_at_ms,nonce,pid" &&
        (!Number.isSafeInteger(value.lease_expires_at_ms) ||
          (value.lease_expires_at_ms as number) < 0)) ||
      (keys === "created_at_ms,nonce,pid" &&
        (!Number.isSafeInteger(value.created_at_ms) || (value.created_at_ms as number) < 0))
    ) {
      return null;
    }
    return {
      pid: value.pid as number,
      nonce: value.nonce,
      // Pre-lease development locks are recoverable migration input, never immortal.
      lease_expires_at_ms:
        keys === "lease_expires_at_ms,nonce,pid" ? (value.lease_expires_at_ms as number) : 0,
    };
  } catch {
    return null;
  }
}

function sameLockOwner(left: LockOwner | null, right: LockOwner): boolean {
  return (
    left?.pid === right.pid &&
    left.nonce === right.nonce &&
    left.lease_expires_at_ms === right.lease_expires_at_ms
  );
}

function processExists(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (error) {
    return (error as NodeJS.ErrnoException).code !== "ESRCH";
  }
}

async function acquireLock(): Promise<RunFileV1 | null> {
  for (let attempt = 0; attempt < 40; attempt++) {
    const stage = `${lockFile}.claim.${process.pid}.${crypto.randomUUID()}`;
    try {
      writeFileExclusive(stage, ownLockText);
      linkSync(stage, lockFile);
      ownLock = true;
      return null;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
    } finally {
      try {
        unlinkSync(stage);
      } catch {
        // Linked, never created, or already gone.
      }
    }
    const owner = lockOwnerAt(lockFile);
    if (!owner) {
      try {
        if (lstatSync(lockFile).isFile() && !lstatSync(lockFile).isSymbolicLink()) {
          throw new Error(".webmcp/.server.lock has invalid contents");
        }
        throw new Error(".webmcp/.server.lock is not a valid regular lock file");
      } catch (error) {
        if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
        throw error;
      }
    }
    const now = Date.now();
    const expired =
      owner.lease_expires_at_ms <= now ||
      owner.lease_expires_at_ms > now + 2 * SERVER_LOCK_LEASE_MS;
    if (processExists(owner.pid) && !expired) {
      const candidate = readRun();
      if (candidate && (await isLive(candidate))) return candidate;
      await Bun.sleep(50);
      continue;
    }
    await staleLockTestBarrier();
    const stale = `${lockFile}.stale.${crypto.randomUUID()}`;
    try {
      renameSync(lockFile, stale);
    } catch {
      await Bun.sleep(10);
      continue;
    }
    // `rename` has no compare-and-swap form. Another starter can replace the lock after
    // our read but before this rename, so verify the quarantined inode before deleting it.
    // On a mismatch, put that fresh owner's inode back only if the canonical name is still
    // free, then abort. Its owner independently revalidates the canonical claim below.
    if (!sameLockOwner(lockOwnerAt(stale), owner)) {
      try {
        linkSync(stale, lockFile);
        unlinkSync(stale);
      } catch {
        // A third claimant already owns the canonical name. Leave the mismatched tombstone
        // intact rather than deleting a lock we did not observe.
      }
      throw new Error("the server lock changed during stale-owner takeover");
    }
    unlinkSync(stale);
  }
  throw new Error("another Explorer server is starting in this workspace");
}

// Deterministic regression-test barrier for the stale-observe/takeover interleaving. It is
// inert unless a test process explicitly supplies a private temporary directory.
async function staleLockTestBarrier(): Promise<void> {
  const directory = process.env.WEBMCP_TEST_STALE_LOCK_BARRIER;
  if (!directory) return;
  const observed = join(directory, `observed-${process.pid}`);
  const release = join(directory, `release-${process.pid}`);
  try {
    writeFileExclusive(observed, "observed");
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
  }
  const deadline = Date.now() + 5_000;
  while (readRegularText(release, 32) === null) {
    if (Date.now() >= deadline) throw new Error("stale-lock test barrier timed out");
    await Bun.sleep(5);
  }
}

let competingRun: RunFileV1 | null = null;
try {
  competingRun = await acquireLock();
} catch (error) {
  console.error(`refusing to start: ${(error as Error).message}`);
  process.exit(1);
}
if (competingRun) {
  console.log(`webmcp-explorer already live on ${explorerUrl(competingRun)}  state: ${stateDir}`);
  process.exit(0);
}
if (readRegularText(lockFile, 4096) !== ownLockText) {
  console.error("refusing to start: lost the Explorer server lock before startup");
  process.exit(1);
}

const identity: RunFileV1 = resume
  ? (initialRun as RunFileV1)
  : {
      version: PROTOCOL_VERSION,
      run_id: crypto.randomUUID(),
      capability: crypto.randomUUID(),
      workspace,
      port: 1,
      pid: process.pid,
      started_at: new Date().toISOString(),
    };

function portNumberIn(path: string): number | null {
  const text = readRegularText(path, 16)?.trim();
  if (!text || !/^[0-9]{1,5}$/.test(text)) return null;
  const port = Number(text);
  return port >= 1 && port <= 65535 ? port : null;
}

function maxRecordedOrder(runId: string): number {
  const text = readRegularText(feedbackFile);
  if (text === null) return 0;
  let max = 0;
  for (const line of text.split("\n")) {
    if (!line.trim()) continue;
    try {
      const envelope = parseRecordedEnvelope(JSON.parse(line));
      if (envelope?.run_id === runId && envelope.order > max) max = envelope.order;
    } catch {
      // Legacy records, fragments, and malformed foreign input never allocate order.
    }
  }
  return max;
}

let lastOrder = maxRecordedOrder(identity.run_id);

// Read per request so an Explorer-page edit does not need a server restart.
function page(): string {
  try {
    return readFileSync(join(import.meta.dir, "explorer.html"), "utf8");
  } catch {
    return "<!doctype html><meta charset=utf-8><title>WebMCP Explorer</title><h1>Explorer unavailable</h1>";
  }
}

function stateFiles(): Record<string, string> {
  const files: Record<string, string> = Object.create(null);
  let names: string[] = [];
  try {
    if (stateDirIsReal()) names = readdirSync(stateDir);
  } catch {
    return files;
  }
  for (const name of names) {
    if (name.startsWith(".")) continue;
    const text = readRegularText(join(stateDir, name));
    if (text !== null) files[name] = text;
  }
  return files;
}

const snapshot = () => JSON.stringify({ type: "snapshot", files: stateFiles() });
let publishedFiles = stateFiles();
let debounce: ReturnType<typeof setTimeout> | undefined;
let server: ReturnType<typeof startServer>;
let watcher: ReturnType<typeof watch> | undefined;
let reconciliation: ReturnType<typeof setInterval> | undefined;
let lockRenewal: ReturnType<typeof setInterval> | undefined;

function publishChanges(): void {
  clearTimeout(debounce);
  debounce = undefined;
  const next = stateFiles();
  const names = [...new Set([...Object.keys(publishedFiles), ...Object.keys(next)])].sort();
  for (const name of names) {
    const present = Object.prototype.hasOwnProperty.call(next, name);
    if (present && next[name] === publishedFiles[name]) continue;
    server.publish("page", JSON.stringify({ type: "file", name, text: present ? next[name] : null }));
  }
  publishedFiles = next;
}

function authorized(url: URL): boolean {
  return (
    url.searchParams.get("capability") === identity.capability &&
    url.searchParams.get("run_id") === identity.run_id
  );
}

function sameOrigin(req: Request, port: number): boolean {
  const origin = req.headers.get("origin");
  return !origin || origin === `http://localhost:${port}` || origin === `http://127.0.0.1:${port}`;
}

function sendError(
  ws: ServerWebSocket<{ role: "page" | "claude" }>,
  message: string,
  requestId?: string,
): void {
  ws.send(JSON.stringify({ type: "error", ...(requestId ? { request_id: requestId } : {}), message }));
}

function journalByteLimit(): number {
  const testLimit = process.env.WEBMCP_TEST_JOURNAL_MAX_BYTES;
  if (testLimit && /^[1-9][0-9]{0,7}$/.test(testLimit)) {
    return Math.min(Number(testLimit), MAX_JOURNAL_BYTES);
  }
  return MAX_JOURNAL_BYTES;
}

function startServer(port: number) {
  return Bun.serve<{ role: "page" | "claude" }>({
    port,
    hostname: "127.0.0.1",
    fetch(req, srv) {
      const url = new URL(req.url);
      if (url.pathname === "/healthz") {
        if (!authorized(url)) return new Response("not found", { status: 404 });
        return Response.json(
          { service: SERVICE, version: PROTOCOL_VERSION, run_id: identity.run_id, workspace },
          { headers: { "cache-control": "no-store" } },
        );
      }
      if (url.pathname === "/ws") {
        if (!sameOrigin(req, srv.port)) return new Response("forbidden origin", { status: 403 });
        if (!authorized(url)) return new Response("forbidden", { status: 403 });
        const role = url.searchParams.get("role");
        if (role !== "page" && role !== "claude") return new Response("unknown role", { status: 400 });
        if (srv.upgrade(req, { data: { role } })) return;
        return new Response("upgrade failed", { status: 400 });
      }
      if (url.pathname === "/shutdown") {
        if (!sameOrigin(req, srv.port)) return new Response("forbidden origin", { status: 403 });
        if (!authorized(url)) return new Response("forbidden", { status: 403 });
        if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
        publishChanges();
        setTimeout(cleanExit, 100);
        return new Response("bye");
      }
      return new Response(page(), { headers: { "content-type": "text/html; charset=utf-8" } });
    },
    websocket: {
      open(ws) {
        ws.subscribe(ws.data.role);
        if (ws.data.role === "page") ws.send(snapshot());
        console.log(`ws open: ${ws.data.role}`);
      },
      message(ws, raw) {
        if (ws.data.role !== "page") return;
        const bytes = typeof raw === "string" ? Buffer.byteLength(raw, "utf8") : raw.byteLength;
        if (bytes > MAX_REQUEST_BYTES) {
          sendError(ws, "That request is too large. Keep it under 64 KB.");
          return;
        }
        let parsed: unknown;
        try {
          parsed = JSON.parse(String(raw));
        } catch {
          sendError(ws, "Request must be valid JSON.");
          return;
        }
        const checked = validateRequest(parsed);
        if (checked.ok === false) {
          sendError(ws, checked.message, checked.request_id);
          return;
        }
        if (lastOrder >= Number.MAX_SAFE_INTEGER) {
          sendError(ws, "This run has exhausted its event order.", checked.value.request_id);
          return;
        }
        const envelope: RecordedEnvelope = {
          event_id: crypto.randomUUID(),
          run_id: identity.run_id,
          order: lastOrder + 1,
          type: checked.value.type,
          ts: new Date().toISOString(),
          payload: checked.value.payload,
        };
        const line = JSON.stringify(envelope);
        if (Buffer.byteLength(line, "utf8") > MAX_JOURNAL_LINE_BYTES) {
          sendError(ws, "The event is too large to record.", checked.value.request_id);
          return;
        }
        try {
          if (!stateDirIsReal()) throw new Error("state folder is missing or is a symlink");
          appendDurable(feedbackFile, envelope, journalByteLimit());
        } catch (error) {
          console.log(`warning: could not record ${envelope.type} (${(error as Error).message})`);
          sendError(
            ws,
            error instanceof AppendLimitError
              ? "This run's feedback journal is full. Archive or rotate .webmcp/_feedback.ndjson, then start a new run."
              : "The event could not be recorded.",
            checked.value.request_id,
          );
          return;
        }
        lastOrder = envelope.order;
        ws.send(
          JSON.stringify({
            type: "recorded",
            request_id: checked.value.request_id,
            event_id: envelope.event_id,
            run_id: envelope.run_id,
            order: envelope.order,
          }),
        );
        if (ACTIONABLE.has(envelope.type)) server.publish("claude", line);
        console.log(`event: ${line}`);
      },
      close(ws) {
        console.log(`ws close: ${ws.data.role}`);
      },
    },
  });
}

const preferredPort = resume
  ? identity.port
  : (portNumberIn(portFile) ?? portNumberIn(lastPortFile) ?? 0);
try {
  server = startServer(preferredPort);
} catch {
  server = startServer(0);
}

const liveRun: RunFileV1 = {
  ...identity,
  port: server.port,
  pid: process.pid,
};

function writeAtomic(path: string, text: string): void {
  if (!stateDirIsReal()) throw new Error("state folder is missing or is a symlink");
  try {
    if (lstatSync(path).isSymbolicLink()) throw new Error(`${path} is a symlink`);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
  const temporary = `${path}.${process.pid}.${crypto.randomUUID()}`;
  try {
    writeFileExclusive(temporary, text);
    renameSync(temporary, path);
  } finally {
    try {
      unlinkSync(temporary);
    } catch {
      // Renamed or never created.
    }
  }
}

function ensureRuntimeIgnored(): void {
  const required = [".port*", ".run.json*", ".server.lock*", ".ack.lock*"];
  let existing = readRegularText(gitignoreFile, 64 * 1024) ?? "";
  const lines = new Set(existing.split("\n"));
  const missing = required.filter((line) => !lines.has(line));
  if (missing.length === 0) return;
  if (existing && !existing.endsWith("\n")) existing += "\n";
  writeAtomic(gitignoreFile, `${existing}${missing.join("\n")}\n`);
}

function publishPort(path: string, port: number): void {
  try {
    if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile()) {
      console.log(`warning: ${path} is not a regular file; leaving it untouched`);
      return;
    }
  } catch {
    // Absent is expected.
  }
  writeAtomic(path, String(port));
}

try {
  if (readRegularText(lockFile, 4096) !== ownLockText) {
    throw new Error("lost the Explorer server lock before runtime publication");
  }
  ensureRuntimeIgnored();
  writeAtomic(runFile, `${JSON.stringify(liveRun)}\n`);
  publishPort(portFile, server.port);
  publishPort(lastPortFile, server.port);
} catch (error) {
  server.stop(true);
  console.error(`refusing to start: could not publish runtime state (${(error as Error).message})`);
  releaseLock();
  process.exit(1);
}

function runtimeIsOurs(): boolean {
  const current = readRun();
  return (
    current?.run_id === liveRun.run_id &&
    current.capability === liveRun.capability &&
    current.pid === process.pid
  );
}

function releaseLock(): void {
  if (!ownLock || readRegularText(lockFile, 4096) !== ownLockText) return;
  try {
    unlinkSync(lockFile);
  } catch {
    // Best-effort cleanup. A crash deliberately leaves this claim behind.
  }
  ownLock = false;
}

function renewServerLock(): boolean {
  if (!ownLock || readRegularText(lockFile, 4096) !== ownLockText) return false;
  const renewedText = JSON.stringify({
    pid: process.pid,
    nonce: lockNonce,
    lease_expires_at_ms: Date.now() + SERVER_LOCK_LEASE_MS,
  });
  const stage = `${lockFile}.renew.${process.pid}.${crypto.randomUUID()}`;
  const tombstone = `${lockFile}.renewing.${crypto.randomUUID()}`;
  let quarantinedOurs = false;
  try {
    writeFileExclusive(stage, renewedText);
    renameSync(lockFile, tombstone);
    if (readRegularText(tombstone, 4096) !== ownLockText) {
      try {
        linkSync(tombstone, lockFile);
        unlinkSync(tombstone);
      } catch {
        // Another owner has the canonical name. Preserve the mismatched tombstone.
      }
      return false;
    }
    quarantinedOurs = true;
    try {
      linkSync(stage, lockFile);
    } catch {
      return false;
    }
    ownLockText = renewedText;
    return true;
  } catch {
    return false;
  } finally {
    try {
      unlinkSync(stage);
    } catch {}
    if (quarantinedOurs) {
      try {
        unlinkSync(tombstone);
      } catch {}
    }
  }
}

function clearLiveness(): void {
  if (runtimeIsOurs()) {
    try {
      unlinkSync(runFile);
    } catch {}
  }
  if (portNumberIn(portFile) === liveRun.port) {
    try {
      unlinkSync(portFile);
    } catch {}
  }
  releaseLock();
}

let shuttingDown = false;
function cleanExit(): void {
  if (shuttingDown) return;
  shuttingDown = true;
  if (reconciliation) clearInterval(reconciliation);
  if (lockRenewal) clearInterval(lockRenewal);
  clearTimeout(debounce);
  watcher?.close();
  server.stop(true);
  clearLiveness();
  process.exit(0);
}

for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, cleanExit);

watcher = watch(stateDir, () => {
  clearTimeout(debounce);
  debounce = setTimeout(publishChanges, 120);
});
watcher.on("error", (error) => console.log(`warning: state folder watch stopped (${error.message})`));

// Some platforms coalesce or omit rename events. Re-read by content every 250 ms so an
// atomic replace still reaches the page exactly once.
reconciliation = setInterval(publishChanges, 250);
lockRenewal = setInterval(() => {
  if (renewServerLock()) return;
  console.log("warning: lost the Explorer server lock during lease renewal; shutting down");
  cleanExit();
}, SERVER_LOCK_RENEW_MS);

console.log(`webmcp-explorer on ${explorerUrl(liveRun)}  state: ${stateDir}`);

SHA-256: ad826eb5028f34cd4154b42285836e9f21b4121e07ce45967ba513416cd8ec10