From 287f8c899ec843cbadcab0f4777997ef6aba0ae0 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:06:05 -0300 Subject: [PATCH] feat(web): discover a running deco serve on localhost Discovers a `deco serve` already running on localhost (loopback only, with probe timeouts). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../deco-serve-discovery.test.ts | 233 ++++++++++++++++++ .../sections-editor/deco-serve-discovery.ts | 168 +++++++++++++ knip.jsonc | 3 + 3 files changed, 404 insertions(+) create mode 100644 apps/web/src/components/sections-editor/deco-serve-discovery.test.ts create mode 100644 apps/web/src/components/sections-editor/deco-serve-discovery.ts diff --git a/apps/web/src/components/sections-editor/deco-serve-discovery.test.ts b/apps/web/src/components/sections-editor/deco-serve-discovery.test.ts new file mode 100644 index 0000000000..3dd6191791 --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-discovery.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import { ContentProtocolError, ErrorCode } from "@decocms/blocks/protocol"; +import { DEFAULT_SERVE_ENDPOINT } from "./deco-serve-connection"; +import { type DiscoveryState, startDiscovery } from "./deco-serve-discovery"; + +const OTHER = "http://localhost:4547/rpc"; + +/** Timers that run only when the test says, and a tab that can hide. */ +function harness() { + let now = 0; + let nextId = 1; + const timers = new Map void }>(); + const listeners = new Set<() => void>(); + const doc = { + visibilityState: "visible" as DocumentVisibilityState, + addEventListener: (_: "visibilitychange", fn: () => void) => + void listeners.add(fn), + removeEventListener: (_: "visibilitychange", fn: () => void) => + void listeners.delete(fn), + }; + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + return { + doc, + setTimer: (fn: () => void, ms: number) => { + const id = nextId++; + timers.set(id, { at: now + ms, fn }); + return id; + }, + clearTimer: (id: unknown) => void timers.delete(id as number), + /** Moves time forward, firing every due timer, and lets probes settle. */ + async advance(ms: number) { + const until = now + ms; + await flush(); + for (;;) { + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= until) + .sort((a, b) => a[1].at - b[1].at)[0]; + if (!due) break; + timers.delete(due[0]); + now = due[1].at; + due[1].fn(); + await flush(); + } + now = until; + await flush(); + }, + pendingTimers: () => timers.size, + async setVisibility(state: DocumentVisibilityState) { + doc.visibilityState = state; + for (const listener of listeners) listener(); + await flush(); + }, + listenerCount: () => listeners.size, + }; +} + +function run( + h: ReturnType, + candidates: string[], + probe: (endpoint: string) => Promise, +) { + const found: string[] = []; + const states: DiscoveryState[] = []; + const stop = startDiscovery({ + candidates, + probe, + onFound: (endpoint) => found.push(endpoint), + onChange: (state) => states.push(state), + doc: h.doc, + setTimer: h.setTimer, + clearTimer: h.clearTimer, + }); + return { found, states, stop, last: () => states.at(-1)! }; +} + +const refused = () => Promise.reject(new TypeError("Failed to fetch")); + +describe("startDiscovery", () => { + test("connects as soon as the default port answers", async () => { + const h = harness(); + let up = false; + const probed: string[] = []; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], (endpoint) => { + probed.push(endpoint); + return up ? Promise.resolve({}) : refused(); + }); + await h.advance(0); + expect(d.found).toEqual([]); + expect(d.last()).toMatchObject({ + status: "searching", + firstRoundDone: true, + problem: null, + }); + + up = true; + await h.advance(1_000); + expect(d.found).toEqual([DEFAULT_SERVE_ENDPOINT]); + expect(d.last().status).toBe("found"); + // Nothing left running. + expect(h.pendingTimers()).toBe(0); + expect(h.listenerCount()).toBe(0); + d.stop(); + }); + + test("probes the remembered server first, one at a time", async () => { + const h = harness(); + const probed: string[] = []; + let inFlight = 0; + let maxInFlight = 0; + const d = run(h, [OTHER, DEFAULT_SERVE_ENDPOINT], async (endpoint) => { + probed.push(endpoint); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + inFlight -= 1; + if (endpoint === DEFAULT_SERVE_ENDPOINT) return {}; + throw new TypeError("Failed to fetch"); + }); + await h.advance(0); + expect(probed).toEqual([OTHER, DEFAULT_SERVE_ENDPOINT]); + expect(maxInFlight).toBe(1); + expect(d.found).toEqual([DEFAULT_SERVE_ENDPOINT]); + }); + + test("backs off up to 15s between rounds", async () => { + const h = harness(); + let probes = 0; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], () => { + probes += 1; + return refused(); + }); + await h.advance(0); + expect(probes).toBe(1); + // 1s, 2s, 4s, 8s, then 15s each. + await h.advance(1_000); + expect(probes).toBe(2); + await h.advance(2_000); + expect(probes).toBe(3); + await h.advance(4_000 + 8_000); + expect(probes).toBe(5); + await h.advance(14_999); + expect(probes).toBe(5); + await h.advance(1); + expect(probes).toBe(6); + d.stop(); + }); + + test("stops polling while the tab is hidden and resumes when it shows", async () => { + const h = harness(); + let probes = 0; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], () => { + probes += 1; + return refused(); + }); + await h.advance(0); + expect(probes).toBe(1); + + await h.setVisibility("hidden"); + expect(d.last().status).toBe("paused"); + expect(h.pendingTimers()).toBe(0); + await h.advance(60_000); + expect(probes).toBe(1); + + await h.setVisibility("visible"); + expect(probes).toBe(2); + expect(d.last().status).toBe("searching"); + d.stop(); + }); + + test("does not start probing in a hidden tab", async () => { + const h = harness(); + h.doc.visibilityState = "hidden"; + let probes = 0; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], () => { + probes += 1; + return refused(); + }); + await h.advance(30_000); + expect(probes).toBe(0); + expect(d.last().status).toBe("paused"); + d.stop(); + }); + + test("reports a server that answers but can't be used, and keeps looking", async () => { + const h = harness(); + let probes = 0; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], () => { + probes += 1; + return Promise.reject( + new ContentProtocolError( + ErrorCode.Unauthorized, + "missing or invalid bearer token", + ), + ); + }); + await h.advance(0); + expect(d.last().problem).toEqual({ + reason: "outdated", + endpoint: DEFAULT_SERVE_ENDPOINT, + }); + expect(d.found).toEqual([]); + await h.advance(1_000); + expect(probes).toBe(2); + d.stop(); + }); + + test("stop() ends the search", async () => { + const h = harness(); + let probes = 0; + const d = run(h, [DEFAULT_SERVE_ENDPOINT], () => { + probes += 1; + return refused(); + }); + await h.advance(0); + d.stop(); + await h.advance(60_000); + expect(probes).toBe(1); + expect(h.listenerCount()).toBe(0); + }); + + test("with no candidate (all disconnected), probes nothing", async () => { + const h = harness(); + let probes = 0; + const d = run(h, [], () => { + probes += 1; + return refused(); + }); + await h.advance(30_000); + expect(probes).toBe(0); + expect(d.last().status).toBe("stopped"); + d.stop(); + }); +}); diff --git a/apps/web/src/components/sections-editor/deco-serve-discovery.ts b/apps/web/src/components/sections-editor/deco-serve-discovery.ts new file mode 100644 index 0000000000..f72df61aca --- /dev/null +++ b/apps/web/src/components/sections-editor/deco-serve-discovery.ts @@ -0,0 +1,168 @@ +/** + * `/site-editor`'s search for a running `deco serve`: probes the candidate + * endpoints (the last one used, then the default port) one at a time, backing + * off from 1s to 15s while none answers, and pausing while the tab is hidden. + * One request at most is in flight, each with a timeout, so it never blocks + * the page or floods the console. + * + * Plain logic with its timers, document and probe injected, so it is tested + * without React or a network. + */ + +import { + classifyServeProbeError, + probeRetryDelay, + type ServeProblem, +} from "./deco-serve-connection"; + +/** Retries back off up to this, so a closed port is rarely re-asked. */ +const DISCOVERY_MAX_DELAY_MS = 15_000; +/** A probe that takes longer than this counts as no answer. */ +export const DISCOVERY_PROBE_TIMEOUT_MS = 3_000; + +export interface DiscoveryState { + /** `paused`: the tab is hidden; nothing is probed until it shows again. */ + status: "searching" | "paused" | "found" | "stopped"; + /** Whether every candidate was probed at least once. */ + firstRoundDone: boolean; + /** A candidate that answered, but can't be used (out of date, …). */ + problem: (ServeProblem & { endpoint: string }) | null; +} + +interface VisibilityTarget { + visibilityState: DocumentVisibilityState; + addEventListener(type: "visibilitychange", listener: () => void): void; + removeEventListener(type: "visibilitychange", listener: () => void): void; +} + +export interface DiscoveryOptions { + candidates: readonly string[]; + /** Resolves when `endpoint` is a usable `deco serve`; throws otherwise. */ + probe: (endpoint: string, signal: AbortSignal) => Promise; + onFound: (endpoint: string) => void; + onChange: (state: DiscoveryState) => void; + doc?: VisibilityTarget; + setTimer?: (fn: () => void, ms: number) => unknown; + clearTimer?: (handle: unknown) => void; + timeoutMs?: number; +} + +/** Starts the search; the returned function stops it. */ +export function startDiscovery(options: DiscoveryOptions): () => void { + const { + candidates, + probe, + onFound, + onChange, + doc = document, + setTimer = (fn, ms) => setTimeout(fn, ms), + clearTimer = (handle) => clearTimeout(handle as number), + timeoutMs = DISCOVERY_PROBE_TIMEOUT_MS, + } = options; + + let state: DiscoveryState = { + status: "searching", + firstRoundDone: false, + problem: null, + }; + let stopped = false; + let running = false; + let failures = 0; + let timer: unknown = null; + let controller: AbortController | null = null; + + const hidden = () => doc.visibilityState === "hidden"; + + const update = (patch: Partial) => { + state = { ...state, ...patch }; + onChange(state); + }; + + const probeOnce = async (endpoint: string): Promise => { + controller = new AbortController(); + const abort = controller; + const timeout = setTimer(() => abort.abort(), timeoutMs); + try { + await probe(endpoint, abort.signal); + return true; + } catch (error) { + if (stopped) return false; + const problem = classifyServeProbeError(error); + if (problem.reason !== "not-answering") { + update({ problem: { ...problem, endpoint } }); + } else if (state.problem?.endpoint === endpoint) { + update({ problem: null }); + } + return false; + } finally { + clearTimer(timeout); + controller = null; + } + }; + + const round = async () => { + timer = null; + if (stopped || running) return; + if (candidates.length === 0) { + update({ status: "stopped", firstRoundDone: true }); + return; + } + if (hidden()) { + update({ status: "paused" }); + return; + } + running = true; + if (state.status !== "searching") update({ status: "searching" }); + for (const endpoint of candidates) { + if (stopped) break; + if (await probeOnce(endpoint)) { + running = false; + if (stopped) return; + stopped = true; + doc.removeEventListener("visibilitychange", onVisibility); + update({ status: "found", firstRoundDone: true }); + onFound(endpoint); + return; + } + } + running = false; + if (stopped) return; + failures += 1; + if (!state.firstRoundDone) update({ firstRoundDone: true }); + if (hidden()) { + update({ status: "paused" }); + return; + } + timer = setTimer( + () => void round(), + probeRetryDelay(failures, DISCOVERY_MAX_DELAY_MS), + ); + }; + + function onVisibility() { + if (stopped) return; + if (hidden()) { + if (timer !== null) { + clearTimer(timer); + timer = null; + } + if (!running) update({ status: "paused" }); + return; + } + // Back in view: look again at once, from the shortest delay. + if (timer !== null) clearTimer(timer); + failures = 0; + void round(); + } + + doc.addEventListener("visibilitychange", onVisibility); + onChange(state); + void round(); + + return () => { + stopped = true; + if (timer !== null) clearTimer(timer); + controller?.abort(); + doc.removeEventListener("visibilitychange", onVisibility); + }; +} diff --git a/knip.jsonc b/knip.jsonc index 01099d0048..cb9820bcdb 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -89,6 +89,9 @@ "apps/web/src/hooks/use-new-blocks-editor.ts": ["exports"], "apps/web/src/components/sections-editor/deco-serve-notices.tsx": [ "exports" + ], + "apps/web/src/components/sections-editor/deco-serve-discovery.ts": [ + "exports" ] } }