From d2afa3f1538891f2a85b312216ce88ecb8ff934c Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Fri, 25 Sep 2026 23:27:29 +0000 Subject: [PATCH] The dev server says which commit it serves (/__dev-sha), and a helper finds the shared one A shared dev server follows main, so what it serves changes under whoever checks against it; a check that cannot name the commit it measured is not evidence. /__dev-sha answers "" (or "+dirty"), read at request time. internal/dev-server.mjs: url(), devSha(), waitForSha(sha) (resolves once the served commit contains it) and a CLI, for headless checks against the shared server on 127.0.0.1:5190 (DOXA_DEV_URL overrides). Its header says when NOT to use it: an unlanded change, and release/embed checks, which stay on the built tree. (cherry picked from commit e43310e4791cfb906018799fb5303946329e3365) --- internal/dev-server.mjs | 64 ++++++++++++++++++++++++++ internal/plugin-gallery/dev-plugin.mjs | 13 ++++++ 2 files changed, 77 insertions(+) create mode 100644 internal/dev-server.mjs diff --git a/internal/dev-server.mjs b/internal/dev-server.mjs new file mode 100644 index 000000000..2a4a3b0d7 --- /dev/null +++ b/internal/dev-server.mjs @@ -0,0 +1,64 @@ +/** + * dev-server.mjs — the SHARED DEV SERVER every agent can check against (headless: curl-style fetches). + * + * One `bun run dev` on this machine follows origin/main (fast-forward, ~30 s) from a checkout that is + * nobody's worktree, so it shows what a developer running the dev server on main sees. It answers + * /__dev-sha with the commit it is serving. + * + * USE IT FOR: "does main work under the dev server", dev-parity checks, and waiting for a landing + * to appear. DO NOT use it for an UNLANDED change (it does not have it): start a short-lived dev + * server in your own worktree, or BUNDLE= bun x vite build. Release/embed checks (SRI, manifest, + * iframe/script cells) stay on the BUILT tree. + * + * import { url, devSha, waitForSha } from './dev-server.mjs' + * await waitForSha('ce42977c') // resolves once the server serves that commit (or a descendant) + * const r = await fetch(url('/assets/boundaries/world-adm0.geojson')) + * + * DOXA_DEV_URL overrides the address (default http://127.0.0.1:5190). + * CLI: node internal/dev-server.mjs [--wait ] [--timeout ] prints the served sha. + */ +import { spawnSync } from 'node:child_process' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const DEV_URL = (process.env.DOXA_DEV_URL || 'http://127.0.0.1:5190').replace(/\/+$/, '') +export const url = (path = '/') => DEV_URL + (path.startsWith('/') ? path : '/' + path) + +/** The commit the dev server is serving ("" or "+dirty"), or '' when it does not answer. */ +export async function devSha() { + try { + const r = await fetch(url('/__dev-sha'), { signal: AbortSignal.timeout(5000) }) + return r.ok ? (await r.text()).trim() : '' + } catch { return '' } +} + +/** Is `sha` the served commit or one of its ancestors? (Needs `sha` in this clone's object store.) */ +function contains(served, sha) { + const head = served.replace(/\+dirty$/, '') + if (!head) return false + if (head.startsWith(sha) || sha.startsWith(head)) return true + const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + return spawnSync('git', ['merge-base', '--is-ancestor', sha, head], { cwd: ROOT }).status === 0 +} + +/** Resolve with the served sha once it contains `sha`; reject after `timeoutMs`. */ +export async function waitForSha(sha, { timeoutMs = 180000, everyMs = 5000 } = {}) { + const until = Date.now() + timeoutMs + let last = '' + while (Date.now() < until) { + last = await devSha() + if (last && contains(last, sha)) return last + await new Promise((r) => setTimeout(r, everyMs)) + } + throw new Error(`the shared dev server (${DEV_URL}) did not serve ${sha} within ${Math.round(timeoutMs / 1000)} s (serving ${last || 'nothing'})`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const argv = process.argv.slice(2) + const i = argv.indexOf('--wait'), t = argv.indexOf('--timeout') + try { + const got = i >= 0 ? await waitForSha(argv[i + 1], { timeoutMs: (t >= 0 ? Number(argv[t + 1]) : 180) * 1000 }) : await devSha() + if (!got) { console.log(`DEV-SERVER: ${DEV_URL} does not answer /__dev-sha`); process.exit(75) } + console.log(`DEV-SERVER: ${DEV_URL} serves ${got}`) + } catch (e) { console.log(`DEV-SERVER: ${e.message}`); process.exit(1) } +} diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 261ceeed7..6557a8438 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -11,6 +11,7 @@ * Verify: bun run smoke:gallery (internal/plugin-gallery-smoke.mjs — every element must boot) */ import { resolve, extname } from 'node:path' +import { spawnSync } from 'node:child_process' import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs' import { REPO_ROOT, discoverSpaRegistry } from './discover.mjs' import { readBuildConfig, renderGallery } from '../generate-gallery.mjs' @@ -281,6 +282,18 @@ export function autoSpaDevPlugin(token = '') { } // Machine-readable, the same discovery the page renders: the registry (kinds + items, // read by the smoke tools) and the manifest-shaped model (what the page fetches live). + // /__dev-sha — WHICH COMMIT THIS DEV SERVER IS SERVING, read at request time (a shared dev server + // follows main, so its answer changes under it). "" or "+dirty" when the checkout has + // local edits. Every check aimed at a dev server prints this first, as gates print tree identity. + if (path === '/__dev-sha') { + const git = (...a) => spawnSync('git', a, { cwd: REPO_ROOT, encoding: 'utf8' }).stdout.trim() + const sha = git('rev-parse', 'HEAD') + const dirty = git('status', '--porcelain', '--untracked-files=no', '--', 'library', 'plugin-designs', 'internal', 'vite.config.js', 'package.json') ? '+dirty' : '' + res.statusCode = sha ? 200 : 500 + res.setHeader('Content-Type', 'text/plain; charset=utf-8') + res.setHeader('Cache-Control', 'no-store') + return res.end((sha || 'unknown') + dirty + '\n') + } if (path === '/__plugin-gallery/registry.json' || path === '/__plugin-gallery/manifest.json') { let reg try { reg = discoverSpaRegistry() } catch (e) { return json(res, { error: String(e) }, 500) }