diff --git a/.github/extensions/replica-prototype/agent-commands/index.mjs b/.github/extensions/replica-prototype/agent-commands/index.mjs index e4f1990f..a8ed7284 100644 --- a/.github/extensions/replica-prototype/agent-commands/index.mjs +++ b/.github/extensions/replica-prototype/agent-commands/index.mjs @@ -1,13 +1,11 @@ import { randomUUID } from "node:crypto" - +import { readHubObject } from "../read-hub-object/index.mjs" // Allow the 8 KiB Controls query plus the underlying prototype route. export const MAX_RELOAD_ADDRESS_LENGTH = 16_384 - export function agentCommands() { let pending = null const queue = [] const history = [] - function stage(command, name) { command.trace.push({ stage: name, elapsedMs: Date.now() - command.queuedAt }) } @@ -20,16 +18,16 @@ export function agentCommands() { stages: command.trace.map((entry) => ({ ...entry })), } } - function startNext() { - if (pending || !queue.length) return + if (pending) return const command = queue.shift() + if (!command) return const timer = setTimeout(() => { pending = null command.outcome = "timeout" command.reject( new Error( - `The live canvas did not respond. Request ${command.id}; last stage: ${command.trace.at(-1).stage}. Keep its panel open and try again.`, + `The live canvas did not respond. Request ${command.id}; last stage: ${command.trace.at(-1)?.stage}. Keep its panel open and try again.`, ), ) startNext() @@ -37,7 +35,6 @@ export function agentCommands() { command.timer = timer pending = command } - return { invoke(type, input = {}, timeoutMs = 5_000) { return new Promise((resolve, reject) => { @@ -70,17 +67,19 @@ export function agentCommands() { stage(pending, "delivered") return { id: pending.id, type: pending.type, input: pending.input } }, - progress({ id, stage: name }, rendererId) { + progress(input, rendererId) { + const { id, stage: name } = readHubObject(input) ?? {} if (!pending || pending.id !== id || pending.rendererId !== rendererId) throw new Error("No matching canvas action is pending for this renderer.") - const stages = ["handler started", "status collected"] - if (!stages.includes(name)) throw new Error("Invalid canvas action stage.") + if (name !== "handler started" && name !== "status collected") + throw new Error("Invalid canvas action stage.") if (!pending.trace.some((entry) => entry.stage === name)) stage(pending, name) }, diagnostics() { return { requests: history.map(diagnostic) } }, - complete(result, rendererId) { + complete(input, rendererId) { + const result = readHubObject(input) if (!pending || result?.id !== pending.id || typeof result.ok !== "boolean") { throw new Error("No matching canvas action is pending.") } diff --git a/.github/extensions/replica-prototype/agent-commands/index.mts b/.github/extensions/replica-prototype/agent-commands/index.mts new file mode 100644 index 00000000..bc2524c1 --- /dev/null +++ b/.github/extensions/replica-prototype/agent-commands/index.mts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto" +import { readHubObject } from "../read-hub-object/index.mjs" + +// Allow the 8 KiB Controls query plus the underlying prototype route. +export const MAX_RELOAD_ADDRESS_LENGTH = 16_384 + +type ActionStage = + "queued" | "delivered" | "handler started" | "status collected" | "reply received" +type Command = { + id: string + type: string + input: unknown + timeoutMs: number + resolve: (value: unknown) => void + reject: (error: Error) => void + queuedAt: number + trace: { stage: ActionStage; elapsedMs: number }[] + timer?: ReturnType + sent?: boolean + rendererId?: string + outcome?: "timeout" | "completed" | "failed" | "cancelled" +} +export type AgentCommands = ReturnType + +export function agentCommands() { + let pending: Command | null = null + const queue: Command[] = [] + const history: Command[] = [] + + function stage(command: Command, name: ActionStage) { + command.trace.push({ stage: name, elapsedMs: Date.now() - command.queuedAt }) + } + function diagnostic(command: Command) { + return { + id: command.id, + type: command.type, + rendererId: command.rendererId ?? null, + outcome: command.outcome ?? "pending", + stages: command.trace.map((entry) => ({ ...entry })), + } + } + + function startNext() { + if (pending) return + const command = queue.shift() + if (!command) return + const timer = setTimeout(() => { + pending = null + command.outcome = "timeout" + command.reject( + new Error( + `The live canvas did not respond. Request ${command.id}; last stage: ${command.trace.at(-1)?.stage}. Keep its panel open and try again.`, + ), + ) + startNext() + }, command.timeoutMs) + command.timer = timer + pending = command + } + + return { + invoke(type: string, input: unknown = {}, timeoutMs = 5_000) { + return new Promise((resolve, reject) => { + const command: Command = { + id: randomUUID(), + type, + input, + timeoutMs, + resolve, + reject, + queuedAt: Date.now(), + trace: [], + } + stage(command, "queued") + history.push(command) + if (history.length > 20) history.shift() + queue.push(command) + startNext() + }) + }, + next(rendererId?: unknown) { + if ( + rendererId !== undefined && + (typeof rendererId !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(rendererId)) + ) + throw new Error("Invalid canvas renderer ID.") + if (!pending || pending.sent) return null + pending.sent = true + pending.rendererId = rendererId + stage(pending, "delivered") + return { id: pending.id, type: pending.type, input: pending.input } + }, + progress(input: unknown, rendererId?: unknown) { + const { id, stage: name } = readHubObject(input) ?? {} + if (!pending || pending.id !== id || pending.rendererId !== rendererId) + throw new Error("No matching canvas action is pending for this renderer.") + if (name !== "handler started" && name !== "status collected") + throw new Error("Invalid canvas action stage.") + if (!pending.trace.some((entry) => entry.stage === name)) stage(pending, name) + }, + diagnostics() { + return { requests: history.map(diagnostic) } + }, + complete(input: unknown, rendererId?: unknown) { + const result = readHubObject(input) + if (!pending || result?.id !== pending.id || typeof result.ok !== "boolean") { + throw new Error("No matching canvas action is pending.") + } + if (pending.rendererId !== rendererId) + throw new Error("This canvas action belongs to another renderer.") + const { resolve, reject, timer } = pending + stage(pending, "reply received") + pending.outcome = result.ok ? "completed" : "failed" + clearTimeout(timer) + pending = null + if (result.ok) resolve(result.value) + else + reject(new Error(typeof result.error === "string" ? result.error : "Canvas action failed.")) + startNext() + }, + close() { + const error = new Error("The canvas was closed or changed during this action.") + if (pending) { + pending.outcome = "cancelled" + clearTimeout(pending.timer) + pending.reject(error) + pending = null + } + for (const command of queue) { + command.outcome = "cancelled" + command.reject(error) + } + queue.length = 0 + }, + } +} diff --git a/.github/extensions/replica-prototype/check-prototype-server/index.mts b/.github/extensions/replica-prototype/check-prototype-server/index.mts new file mode 100644 index 00000000..bd6eb787 --- /dev/null +++ b/.github/extensions/replica-prototype/check-prototype-server/index.mts @@ -0,0 +1,18 @@ +export type PrototypeServerStatus = Awaited> + +export async function checkPrototypeServer(prototypeUrl: string, request = globalThis.fetch) { + try { + const response = await request(prototypeUrl, { + redirect: "manual", + signal: AbortSignal.timeout(2000), + }) + await response.body?.cancel() + // HTTP errors can be useful dev-server diagnostics; only a failed connection needs setup. + return { available: true } + } catch (error) { + return { + available: false, + message: `Prototype server unavailable. Start it again or choose another server. ${error instanceof Error ? error.message : String(error)}`, + } + } +} diff --git a/.github/extensions/replica-prototype/dev-server/index.mjs b/.github/extensions/replica-prototype/dev-server/index.mjs index b5c8c69e..b8920ef2 100644 --- a/.github/extensions/replica-prototype/dev-server/index.mjs +++ b/.github/extensions/replica-prototype/dev-server/index.mjs @@ -5,12 +5,12 @@ import os from "node:os" import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { stripVTControlCharacters } from "node:util" - import { discoverPrototypeServers } from "../discover-prototype-servers/index.mjs" import { createPrivateStore } from "../private-store/index.mjs" import { runCommand } from "../run-command/index.mjs" import { createStartupStatus, publicStartupError, StartupError } from "../startup-error/index.mjs" - +import { readErrorCode } from "../read-error-code/index.mjs" +import { readHubObject } from "../read-hub-object/index.mjs" const supportedCommand = /^(?:vite(?=\s|$)(?!\s+(?:build|preview)\b)|next\s+dev\b|astro\s+dev\b|webpack\s+serve\b|webpack-dev-server\b|vue-cli-service\s+serve\b|react-scripts\s+start\b)/ const lockfiles = { @@ -19,7 +19,6 @@ const lockfiles = { yarn: ["yarn.lock"], bun: ["bun.lock", "bun.lockb"], } - async function readOptional(file) { try { const info = await lstat(file) @@ -27,11 +26,10 @@ async function readOptional(file) { throw new StartupError(`Cannot read linked, non-file, or oversized ${path.basename(file)}.`) return await readFile(file) } catch (error) { - if (error.code === "ENOENT") return null + if (readErrorCode(error) === "ENOENT") return null throw error } } - export async function readDevScripts(directory) { const content = await readOptional(path.join(directory, "package.json")) if (!content) @@ -41,7 +39,7 @@ export async function readDevScripts(directory) { } let manifest try { - manifest = JSON.parse(content.toString("utf8")) + manifest = readHubObject(JSON.parse(content.toString("utf8"))) } catch { throw new StartupError("Cannot read dev scripts: package.json is not valid JSON.") } @@ -55,9 +53,13 @@ export async function readDevScripts(directory) { ) const managers = present.filter(({ files }) => files.some((file) => file !== null)) const declared = manifest.packageManager - if (declared != null && !/^(npm|pnpm|yarn|bun)@\d[^ \t\r\n]*$/.test(declared)) + if ( + declared != null && + (typeof declared !== "string" || !/^(npm|pnpm|yarn|bun)@\d[^ \t\r\n]*$/.test(declared)) + ) throw new StartupError("Unsupported packageManager. Start your server manually.") - const manager = declared?.split("@")[0] || managers[0]?.manager || "npm" + const manager = + (typeof declared === "string" && declared.split("@")[0]) || managers[0]?.manager || "npm" if (managers.some((entry) => entry.manager !== manager)) throw new StartupError("Conflicting package managers. Start your server manually.") const revision = createHash("sha256").update(content) @@ -66,17 +68,19 @@ export async function readDevScripts(directory) { for (const file of entry.files) revision.update(file || "missing") } const fingerprint = revision.digest("hex") + const manifestScripts = readHubObject(manifest.scripts) const scripts = ["dev", "start", "serve"].flatMap((name) => { - const command = manifest.scripts?.[name] + const command = manifestScripts?.[name] if ( typeof command !== "string" || !supportedCommand.test(command.trim()) || /[;&|`$<>\r\n]/.test(command) ) return [] - const hooks = [`pre${name}`, `post${name}`] - .filter((hook) => typeof manifest.scripts?.[hook] === "string") - .map((hook) => `${hook}: ${manifest.scripts[hook]}`) + const hooks = [`pre${name}`, `post${name}`].flatMap((hook) => { + const script = manifestScripts?.[hook] + return typeof script === "string" ? [`${hook}: ${script}`] : [] + }) return [ { id: `${fingerprint}:${name}`, @@ -96,7 +100,6 @@ export async function readDevScripts(directory) { : "No supported dev, start, or serve script in this directory. Start your server manually.", } } - export function createDevServerManager({ discover = discoverPrototypeServers, command = runCommand, @@ -108,39 +111,38 @@ export function createDevServerManager({ const entries = new Map() const children = new Set() let closed = false - function stop(entry) { if (entry.stopping) return entry.stopping const child = entry.child entry.child = null if (!child?.pid) { - children.delete(child) + if (child) children.delete(child) return Promise.resolve() } + const pid = child.pid function signal(value) { try { - process.kill(-child.pid, value) + process.kill(-pid, value) } catch (error) { - if (error.code !== "ESRCH") throw error + if (readErrorCode(error) !== "ESRCH") throw error } } - async function terminateChild() { + async function terminateChild(child) { const exited = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() - : new Promise((resolve) => child.once("exit", resolve)) + : new Promise((resolve) => child.once("exit", () => resolve())) signal("SIGTERM") await delay(500) signal("SIGKILL") await exited children.delete(child) } - entry.stopping = terminateChild().finally(() => { + entry.stopping = terminateChild(child).finally(() => { entry.stopping = null }) return entry.stopping } - async function start(entry, id) { await entry.stopping const plan = await readDevScripts(entry.directory) @@ -148,7 +150,7 @@ export function createDevServerManager({ if (!script) throw new StartupError("Dev scripts changed or are unavailable. Scan again before starting.") const found = await discover(entry.directory) - if (found.servers.length === 1) return found.servers[0].url + if (found.servers.length === 1 && found.servers[0]) return found.servers[0].url if (found.servers.length > 1) throw new StartupError("Servers are already running. Scan again and choose one to connect.") await stop(entry) @@ -161,7 +163,7 @@ export function createDevServerManager({ if (!version.ok) throw new StartupError(`${script.manager} is unavailable. Install it yourself, then retry.`) const latest = await discover(entry.directory) - if (latest.servers.length === 1) return latest.servers[0].url + if (latest.servers.length === 1 && latest.servers[0]) return latest.servers[0].url if (latest.servers.length > 1) throw new StartupError("Servers are already running. Scan again and choose one to connect.") // Revalidate after the preflight: the click authorizes only the displayed manifest. @@ -200,10 +202,13 @@ export function createDevServerManager({ }) } }) - const deadline = Date.now() + timeout - while (Date.now() < deadline) { + function checkStartup() { if (failure) throw failure if (entry.released || closed) throw new StartupError("The prototype panel was closed.") + } + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + checkStartup() const processes = await command("ps", ["-axo", "pid=,pgid="], entry.directory) if (!processes.ok) throw new StartupError("Cannot identify the started server: ps is unavailable.") @@ -214,14 +219,13 @@ export function createDevServerManager({ }), ) const result = await discover(entry.directory) - if (failure) throw failure - if (entry.released || closed) throw new StartupError("The prototype panel was closed.") + checkStartup() const servers = result.servers.filter((server) => owned.has(server.pid)) if (servers.length > 1) throw new StartupError( "The script started multiple servers and was stopped. Start it manually, then scan and choose one.", ) - if (servers.length === 1) { + if (servers.length === 1 && servers[0]) { entry.message = "Dev server ready." return servers[0].url } @@ -231,7 +235,6 @@ export function createDevServerManager({ "The dev server did not serve an HTML page within 30 seconds. Check the output below, then retry or start it manually.", ) } - function retire(entry) { if (entry.retiring) return entry.retiring entry.released = true @@ -245,7 +248,6 @@ export function createDevServerManager({ }) return entry.retiring } - return { terminate() { // Process exit cannot await teardown, including an in-flight graceful stop. @@ -254,7 +256,7 @@ export function createDevServerManager({ try { process.kill(-child.pid, "SIGKILL") } catch (error) { - if (error.code !== "ESRCH") throw error + if (readErrorCode(error) !== "ESRCH") throw error } } children.clear() @@ -262,14 +264,14 @@ export function createDevServerManager({ async acquire(directory) { directory = await realpath(directory) if (closed) throw new Error("The dev server manager is closed.") - let entry = entries.get(directory) - while (entry?.retiring) { - await entry.retiring + let candidate = entries.get(directory) + while (candidate?.retiring) { + await candidate.retiring if (closed) throw new Error("The dev server manager is closed.") - entry = entries.get(directory) + candidate = entries.get(directory) } - if (!entry) { - entry = { + if (!candidate) { + candidate = { directory, users: 0, child: null, @@ -278,8 +280,9 @@ export function createDevServerManager({ message: "", released: false, } - entries.set(directory, entry) + entries.set(directory, candidate) } + const entry = candidate entry.users += 1 let released = false return { diff --git a/.github/extensions/replica-prototype/dev-server/index.mts b/.github/extensions/replica-prototype/dev-server/index.mts new file mode 100644 index 00000000..8b5e80b2 --- /dev/null +++ b/.github/extensions/replica-prototype/dev-server/index.mts @@ -0,0 +1,369 @@ +import { spawn } from "node:child_process" +import type { ChildProcess } from "node:child_process" +import { createHash } from "node:crypto" +import { lstat, readFile, realpath } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { stripVTControlCharacters } from "node:util" + +import { discoverPrototypeServers } from "../discover-prototype-servers/index.mjs" +import { createPrivateStore } from "../private-store/index.mjs" +import { runCommand } from "../run-command/index.mjs" +import { createStartupStatus, publicStartupError, StartupError } from "../startup-error/index.mjs" +import { readErrorCode } from "../read-error-code/index.mjs" +import { readHubObject } from "../read-hub-object/index.mjs" + +type DevServerEntry = { + directory: string + users: number + child: ChildProcess | null + pending: Promise | null + stopping?: Promise | null + retiring?: Promise + startingId?: string | null + output: string + message: string + released: boolean +} +export type DevServerManager = ReturnType +export type DevServer = Awaited> +export type DevScripts = Awaited> + +const supportedCommand = + /^(?:vite(?=\s|$)(?!\s+(?:build|preview)\b)|next\s+dev\b|astro\s+dev\b|webpack\s+serve\b|webpack-dev-server\b|vue-cli-service\s+serve\b|react-scripts\s+start\b)/ +const lockfiles = { + npm: ["package-lock.json", "npm-shrinkwrap.json"], + pnpm: ["pnpm-lock.yaml"], + yarn: ["yarn.lock"], + bun: ["bun.lock", "bun.lockb"], +} + +async function readOptional(file: string) { + try { + const info = await lstat(file) + if (!info.isFile() || info.isSymbolicLink() || info.size > 2_000_000) + throw new StartupError(`Cannot read linked, non-file, or oversized ${path.basename(file)}.`) + return await readFile(file) + } catch (error) { + if (readErrorCode(error) === "ENOENT") return null + throw error + } +} + +export async function readDevScripts(directory: string) { + const content = await readOptional(path.join(directory, "package.json")) + if (!content) + return { + scripts: [], + message: "No package.json in this directory. Start your server manually.", + } + let manifest + try { + manifest = readHubObject(JSON.parse(content.toString("utf8"))) + } catch { + throw new StartupError("Cannot read dev scripts: package.json is not valid JSON.") + } + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) + throw new StartupError("Cannot read dev scripts: package.json must contain an object.") + const present = await Promise.all( + Object.entries(lockfiles).map(async ([manager, names]) => ({ + manager, + files: await Promise.all(names.map((name) => readOptional(path.join(directory, name)))), + })), + ) + const managers = present.filter(({ files }) => files.some((file) => file !== null)) + const declared = manifest.packageManager + if ( + declared != null && + (typeof declared !== "string" || !/^(npm|pnpm|yarn|bun)@\d[^ \t\r\n]*$/.test(declared)) + ) + throw new StartupError("Unsupported packageManager. Start your server manually.") + const manager = + (typeof declared === "string" && declared.split("@")[0]) || managers[0]?.manager || "npm" + if (managers.some((entry) => entry.manager !== manager)) + throw new StartupError("Conflicting package managers. Start your server manually.") + const revision = createHash("sha256").update(content) + for (const entry of present) { + revision.update(entry.manager) + for (const file of entry.files) revision.update(file || "missing") + } + const fingerprint = revision.digest("hex") + const manifestScripts = readHubObject(manifest.scripts) + const scripts = ["dev", "start", "serve"].flatMap((name) => { + const command = manifestScripts?.[name] + if ( + typeof command !== "string" || + !supportedCommand.test(command.trim()) || + /[;&|`$<>\r\n]/.test(command) + ) + return [] + const hooks = [`pre${name}`, `post${name}`].flatMap((hook) => { + const script = manifestScripts?.[hook] + return typeof script === "string" ? [`${hook}: ${script}`] : [] + }) + return [ + { + id: `${fingerprint}:${name}`, + name, + command, + invocation: `${manager} run ${name}`, + manager, + hooks, + directory, + }, + ] + }) + return { + scripts, + message: scripts.length + ? "Starting a script executes repository code, including any pre/post hooks." + : "No supported dev, start, or serve script in this directory. Start your server manually.", + } +} + +export function createDevServerManager({ + discover = discoverPrototypeServers, + command = runCommand, + launch = spawn, + timeout = 30_000, + pollInterval = 250, + store = createPrivateStore(path.join(os.homedir(), ".config", "replica", "dev-servers")), +} = {}) { + const entries = new Map() + const children = new Set() + let closed = false + + function stop(entry: DevServerEntry): Promise { + if (entry.stopping) return entry.stopping + const child = entry.child + entry.child = null + if (!child?.pid) { + if (child) children.delete(child) + return Promise.resolve() + } + const pid = child.pid + function signal(value: NodeJS.Signals) { + try { + process.kill(-pid, value) + } catch (error) { + if (readErrorCode(error) !== "ESRCH") throw error + } + } + async function terminateChild(child: ChildProcess) { + const exited = + child.exitCode !== null || child.signalCode !== null + ? Promise.resolve() + : new Promise((resolve) => child.once("exit", () => resolve())) + signal("SIGTERM") + await delay(500) + signal("SIGKILL") + await exited + children.delete(child) + } + entry.stopping = terminateChild(child).finally(() => { + entry.stopping = null + }) + return entry.stopping + } + + async function start(entry: DevServerEntry, id: string): Promise { + await entry.stopping + const plan = await readDevScripts(entry.directory) + const script = plan.scripts.find((candidate) => candidate.id === id) + if (!script) + throw new StartupError("Dev scripts changed or are unavailable. Scan again before starting.") + const found = await discover(entry.directory) + if (found.servers.length === 1 && found.servers[0]) return found.servers[0].url + if (found.servers.length > 1) + throw new StartupError("Servers are already running. Scan again and choose one to connect.") + await stop(entry) + if (process.platform === "win32") + throw new StartupError( + "Starting dev servers is supported on macOS and Linux. Start it manually.", + ) + const env = { ...process.env, NO_COLOR: "1", COREPACK_ENABLE_NETWORK: "0" } + const version = await command(script.manager, ["--version"], entry.directory, { env }) + if (!version.ok) + throw new StartupError(`${script.manager} is unavailable. Install it yourself, then retry.`) + const latest = await discover(entry.directory) + if (latest.servers.length === 1 && latest.servers[0]) return latest.servers[0].url + if (latest.servers.length > 1) + throw new StartupError("Servers are already running. Scan again and choose one to connect.") + // Revalidate after the preflight: the click authorizes only the displayed manifest. + if (!(await readDevScripts(entry.directory)).scripts.some((candidate) => candidate.id === id)) + throw new StartupError("Dev scripts changed. Scan again before starting.") + if (entry.released || closed) throw new StartupError("The prototype panel was closed.") + entry.output = "" + entry.message = `Starting ${script.invocation}...` + const child = launch(script.manager, ["run", script.name], { + cwd: entry.directory, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + env, + }) + entry.child = child + children.add(child) + let failure: StartupError | undefined + function append(chunk: Buffer) { + entry.output = stripVTControlCharacters(entry.output + chunk.toString("utf8")).slice(-8000) + } + child.stdout?.on("data", append) + child.stderr?.on("data", append) + child.on("error", () => { + failure = new StartupError( + `Could not launch ${script.invocation}. Check your package manager.`, + ) + }) + child.on("exit", (code, signal) => { + failure = new StartupError( + `${script.invocation} exited (${signal || `code ${code}`}). Check the output below.`, + ) + if (entry.child === child) { + entry.message = failure.message + stop(entry).catch((error) => { + entry.message = publicStartupError(error, "Could not clean up the exited dev server.") + }) + } + }) + function checkStartup() { + if (failure) throw failure + if (entry.released || closed) throw new StartupError("The prototype panel was closed.") + } + const deadline = Date.now() + timeout + while (Date.now() < deadline) { + checkStartup() + const processes = await command("ps", ["-axo", "pid=,pgid="], entry.directory) + if (!processes.ok) + throw new StartupError("Cannot identify the started server: ps is unavailable.") + const owned = new Set( + processes.stdout.split("\n").flatMap((line) => { + const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line) + return match && Number(match[2]) === child.pid ? [Number(match[1])] : [] + }), + ) + const result = await discover(entry.directory) + checkStartup() + const servers = result.servers.filter((server) => owned.has(server.pid)) + if (servers.length > 1) + throw new StartupError( + "The script started multiple servers and was stopped. Start it manually, then scan and choose one.", + ) + if (servers.length === 1 && servers[0]) { + entry.message = "Dev server ready." + return servers[0].url + } + await delay(pollInterval) + } + throw new StartupError( + "The dev server did not serve an HTML page within 30 seconds. Check the output below, then retry or start it manually.", + ) + } + + function retire(entry: DevServerEntry): Promise { + if (entry.retiring) return entry.retiring + entry.released = true + entry.retiring = (async () => { + await entry.pending?.catch(() => {}) + await store.lock(createHash("sha256").update(entry.directory).digest("hex"), () => + stop(entry), + ) + })().finally(() => { + if (entries.get(entry.directory) === entry) entries.delete(entry.directory) + }) + return entry.retiring + } + + return { + terminate() { + // Process exit cannot await teardown, including an in-flight graceful stop. + for (const child of children) { + if (!child.pid) continue + try { + process.kill(-child.pid, "SIGKILL") + } catch (error) { + if (readErrorCode(error) !== "ESRCH") throw error + } + } + children.clear() + }, + async acquire(directory: string) { + directory = await realpath(directory) + if (closed) throw new Error("The dev server manager is closed.") + let candidate = entries.get(directory) + while (candidate?.retiring) { + await candidate.retiring + if (closed) throw new Error("The dev server manager is closed.") + candidate = entries.get(directory) + } + if (!candidate) { + candidate = { + directory, + users: 0, + child: null, + pending: null, + output: "", + message: "", + released: false, + } + entries.set(directory, candidate) + } + const entry = candidate + entry.users += 1 + let released = false + return { + describe: () => readDevScripts(directory), + status: () => + createStartupStatus({ + starting: Boolean(entry.pending), + message: entry.message, + output: entry.output, + }), + async start(id: string) { + if (released) throw new StartupError("The prototype panel was closed.") + if (entry.pending) { + if (entry.startingId !== id) + throw new StartupError( + "Another panel is starting a different script. Wait, then scan again.", + ) + return entry.pending + } + entry.startingId = id + entry.message = "Checking dev server startup..." + entry.output = "" + const previousChild = entry.child + entry.pending = store + .lock(createHash("sha256").update(directory).digest("hex"), async () => { + try { + return await start(entry, id) + } catch (error) { + if (entry.child !== previousChild || entry.stopping) await stop(entry) + throw error + } + }) + .catch((error) => { + entry.message = publicStartupError(error) + throw error + }) + .finally(() => { + entry.pending = null + entry.startingId = null + }) + return entry.pending + }, + async release() { + if (released) return + released = true + entry.users -= 1 + if (entry.users) return + await retire(entry) + }, + } + }, + async close() { + closed = true + await Promise.all([...entries.values()].map(retire)) + entries.clear() + }, + } +} diff --git a/.github/extensions/replica-prototype/discover-prototype-servers/index.mjs b/.github/extensions/replica-prototype/discover-prototype-servers/index.mjs index aefd5465..5c996de0 100644 --- a/.github/extensions/replica-prototype/discover-prototype-servers/index.mjs +++ b/.github/extensions/replica-prototype/discover-prototype-servers/index.mjs @@ -1,16 +1,15 @@ import { realpath } from "node:fs/promises" import path from "node:path" - import { runCommand } from "../run-command/index.mjs" - export async function discoverPrototypeServers(repositoryPath, options = {}) { const command = options.runCommand || runCommand const request = options.fetch || globalThis.fetch const root = await realpath(repositoryPath) const listeners = await command("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn"], root) if (!hasLsofOutput(listeners)) - throw new Error(`Could not inspect running servers: ${listeners.error}`) - + throw new Error( + `Could not inspect running servers: ${!listeners.ok ? listeners.error : "Invalid lsof output."}`, + ) const processes = new Map() let current for (const line of listeners.stdout.split("\n")) { @@ -28,21 +27,23 @@ export async function discoverPrototypeServers(repositoryPath, options = {}) { if (pid === process.pid || /^(copilot|github)$/.test(processInfo.command)) processes.delete(pid) } if (!processes.size) return { servers: [], warnings: [] } - const directories = await command( "lsof", ["-a", "-p", [...processes.keys()].join(","), "-d", "cwd", "-Fn"], root, ) if (!hasLsofOutput(directories)) - throw new Error(`Could not inspect server working directories: ${directories.error}`) + throw new Error( + `Could not inspect server working directories: ${!directories.ok ? directories.error : "Invalid lsof output."}`, + ) let pid for (const line of directories.stdout.split("\n")) { if (/^p\d+$/.test(line)) pid = Number(line.slice(1)) - else if (line.startsWith("n") && processes.has(pid)) - processes.get(pid).directory = line.slice(1) + else if (line.startsWith("n") && pid !== undefined) { + const processInfo = processes.get(pid) + if (processInfo) processInfo.directory = line.slice(1) + } } - const candidates = new Map() for (const processInfo of processes.values()) { if (!processInfo.directory) continue @@ -51,7 +52,7 @@ export async function discoverPrototypeServers(repositoryPath, options = {}) { continue for (const address of processInfo.addresses) { const match = /^(\*|0\.0\.0\.0|127(?:\.\d+){3}|\[::\]|\[::1\]):(\d+)$/.exec(address) - if (!match) continue + if (!match?.[1]) continue const host = match[1] === "[::1]" || match[1] === "[::]" ? "[::1]" @@ -94,8 +95,10 @@ export async function discoverPrototypeServers(repositoryPath, options = {}) { warnings.sort() return { servers, warnings } } - function hasLsofOutput(result) { // lsof uses exit 1 for no matches, including processes that exited between reads. - return result.ok || (result.exitCode === 1 && result.stderr === "") + return ( + typeof result.stdout === "string" && + (result.ok || (result.exitCode === 1 && result.stderr === "")) + ) } diff --git a/.github/extensions/replica-prototype/discover-prototype-servers/index.mts b/.github/extensions/replica-prototype/discover-prototype-servers/index.mts new file mode 100644 index 00000000..0bf5b560 --- /dev/null +++ b/.github/extensions/replica-prototype/discover-prototype-servers/index.mts @@ -0,0 +1,118 @@ +import { realpath } from "node:fs/promises" +import path from "node:path" + +import { runCommand } from "../run-command/index.mjs" +import type { CommandResult } from "../run-command/index.mjs" + +export type PrototypeServer = { url: string; pid: number; command: string; directory: string } +export type PrototypeServers = { servers: PrototypeServer[]; warnings: string[] } +type ProcessInfo = { pid: number; command: string; addresses: string[]; directory?: string } + +export async function discoverPrototypeServers( + repositoryPath: string, + options: { runCommand?: typeof runCommand; fetch?: typeof globalThis.fetch } = {}, +): Promise { + const command = options.runCommand || runCommand + const request = options.fetch || globalThis.fetch + const root = await realpath(repositoryPath) + const listeners = await command("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcn"], root) + if (!hasLsofOutput(listeners)) + throw new Error( + `Could not inspect running servers: ${!listeners.ok ? listeners.error : "Invalid lsof output."}`, + ) + + const processes = new Map() + let current: ProcessInfo | undefined + for (const line of listeners.stdout.split("\n")) { + if (/^p\d+$/.test(line)) { + current = { pid: Number(line.slice(1)), command: "", addresses: [] } + processes.set(current.pid, current) + } else if (current && line.startsWith("c")) { + current.command = line.slice(1) + } else if (current && line.startsWith("n")) { + current.addresses.push(line.slice(1)) + } + } + // Copilot providers serve their own HTML panels, not prototype applications. + for (const [pid, processInfo] of processes) { + if (pid === process.pid || /^(copilot|github)$/.test(processInfo.command)) processes.delete(pid) + } + if (!processes.size) return { servers: [], warnings: [] } + + const directories = await command( + "lsof", + ["-a", "-p", [...processes.keys()].join(","), "-d", "cwd", "-Fn"], + root, + ) + if (!hasLsofOutput(directories)) + throw new Error( + `Could not inspect server working directories: ${!directories.ok ? directories.error : "Invalid lsof output."}`, + ) + let pid + for (const line of directories.stdout.split("\n")) { + if (/^p\d+$/.test(line)) pid = Number(line.slice(1)) + else if (line.startsWith("n") && pid !== undefined) { + const processInfo = processes.get(pid) + if (processInfo) processInfo.directory = line.slice(1) + } + } + + const candidates = new Map() + for (const processInfo of processes.values()) { + if (!processInfo.directory) continue + const relative = path.relative(root, processInfo.directory) + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + continue + for (const address of processInfo.addresses) { + const match = /^(\*|0\.0\.0\.0|127(?:\.\d+){3}|\[::\]|\[::1\]):(\d+)$/.exec(address) + if (!match?.[1]) continue + const host = + match[1] === "[::1]" || match[1] === "[::]" + ? "[::1]" + : match[1].startsWith("127.") + ? match[1] + : "127.0.0.1" + const url = `http://${host}:${match[2]}/` + candidates.set(url, { + url, + pid: processInfo.pid, + command: processInfo.command, + directory: processInfo.directory, + }) + } + } + const servers: PrototypeServer[] = [] + const warnings: string[] = [] + await Promise.all( + [...candidates.values()].map(async (candidate) => { + try { + const response = await request(candidate.url, { + signal: AbortSignal.timeout(2000), + redirect: "manual", + headers: { Accept: "text/html" }, + }) + await response.body?.cancel() + if (!response.ok || !/\btext\/html\b/i.test(response.headers.get("content-type") || "")) { + warnings.push(`${candidate.url} did not return an HTML page (HTTP ${response.status}).`) + return + } + servers.push(candidate) + } catch (error) { + warnings.push( + `${candidate.url} could not be checked: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }), + ) + servers.sort((a, b) => a.url.localeCompare(b.url)) + warnings.sort() + return { servers, warnings } +} + +function hasLsofOutput(result: CommandResult): result is CommandResult & { stdout: string } { + // lsof uses exit 1 for no matches, including processes that exited between reads. + return ( + typeof result.stdout === "string" && + (result.ok || (result.exitCode === 1 && result.stderr === "")) + ) +} diff --git a/.github/extensions/replica-prototype/improvement-notes/index.d.mts b/.github/extensions/replica-prototype/improvement-notes/index.d.mts new file mode 100644 index 00000000..bc37e935 --- /dev/null +++ b/.github/extensions/replica-prototype/improvement-notes/index.d.mts @@ -0,0 +1,20 @@ +import type { CanvasContext } from "../create-canvas-context/index.mjs" +import type { HubClient } from "../create-hub-client/index.mjs" +import type { PublishSession } from "../request-publish/index.mjs" + +export type ImprovementNotes = { + capture(): Promise + load(): Promise + save(input: Record): Promise + sync(input: Record): Promise + fix(input: Record): Promise + batch(input: Record): Promise + results(input: Record): Promise +} + +export function createImprovementNotes( + getContext: () => CanvasContext, + session: PublishSession, + getHub: () => Promise, + options?: { directory?: string }, +): ImprovementNotes diff --git a/.github/extensions/replica-prototype/open-browser/index.mjs b/.github/extensions/replica-prototype/open-browser/index.mjs index 4162c1a3..80eadde7 100644 --- a/.github/extensions/replica-prototype/open-browser/index.mjs +++ b/.github/extensions/replica-prototype/open-browser/index.mjs @@ -1,5 +1,4 @@ import { runCommand } from "../run-command/index.mjs" - export async function openBrowser(url, { platform = process.platform, run = runCommand } = {}) { const target = new URL(url) if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) { diff --git a/.github/extensions/replica-prototype/open-browser/index.mts b/.github/extensions/replica-prototype/open-browser/index.mts new file mode 100644 index 00000000..745a70d3 --- /dev/null +++ b/.github/extensions/replica-prototype/open-browser/index.mts @@ -0,0 +1,17 @@ +import { runCommand } from "../run-command/index.mjs" + +export async function openBrowser( + url: string, + { + platform = process.platform, + run = runCommand, + }: { platform?: NodeJS.Platform; run?: typeof runCommand } = {}, +) { + const target = new URL(url) + if (!["http:", "https:"].includes(target.protocol) || target.username || target.password) { + throw new Error("Browser approval requires an HTTP or HTTPS URL without credentials.") + } + if (platform === "darwin") return run("open", [target.href]) + if (platform === "win32") return run("rundll32", ["url.dll,FileProtocolHandler", target.href]) + return run("xdg-open", [target.href]) +} diff --git a/.github/extensions/replica-prototype/prototype-connection/index.mjs b/.github/extensions/replica-prototype/prototype-connection/index.mjs index 5dbddda6..863c7950 100644 --- a/.github/extensions/replica-prototype/prototype-connection/index.mjs +++ b/.github/extensions/replica-prototype/prototype-connection/index.mjs @@ -1,7 +1,7 @@ import { createHash, randomBytes } from "node:crypto" import { mkdir, readFile, rename, writeFile } from "node:fs/promises" import path from "node:path" - +import { readErrorCode } from "../read-error-code/index.mjs" export function prototypeConnection(sessionWorkspacePath, repositoryPath) { if (!sessionWorkspacePath || !repositoryPath) { throw new Error("Connecting a prototype requires a session workspace and working directory.") @@ -14,7 +14,7 @@ export function prototypeConnection(sessionWorkspacePath, repositoryPath) { try { return JSON.parse(await readFile(file, "utf8")) } catch (error) { - if (error.code === "ENOENT") return null + if (readErrorCode(error) === "ENOENT") return null throw error } }, diff --git a/.github/extensions/replica-prototype/prototype-connection/index.mts b/.github/extensions/replica-prototype/prototype-connection/index.mts new file mode 100644 index 00000000..7b7c84f7 --- /dev/null +++ b/.github/extensions/replica-prototype/prototype-connection/index.mts @@ -0,0 +1,31 @@ +import { createHash, randomBytes } from "node:crypto" +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" +import path from "node:path" +import { readErrorCode } from "../read-error-code/index.mjs" + +export type PrototypeConnection = ReturnType + +export function prototypeConnection(sessionWorkspacePath: string, repositoryPath: string) { + if (!sessionWorkspacePath || !repositoryPath) { + throw new Error("Connecting a prototype requires a session workspace and working directory.") + } + const key = createHash("sha256").update(path.resolve(repositoryPath)).digest("hex").slice(0, 24) + const directory = path.join(sessionWorkspacePath, "replica-prototype") + const file = path.join(directory, `connection-${key}.json`) + return { + async read(): Promise { + try { + return JSON.parse(await readFile(file, "utf8")) + } catch (error) { + if (readErrorCode(error) === "ENOENT") return null + throw error + } + }, + async save(input: unknown) { + await mkdir(directory, { recursive: true }) + const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp` + await writeFile(temporary, JSON.stringify(input), { mode: 0o600 }) + await rename(temporary, file) + }, + } +} diff --git a/.github/extensions/replica-prototype/read-prototype-start-page/index.mjs b/.github/extensions/replica-prototype/read-prototype-start-page/index.mjs index aefd8f12..6e212e0a 100644 --- a/.github/extensions/replica-prototype/read-prototype-start-page/index.mjs +++ b/.github/extensions/replica-prototype/read-prototype-start-page/index.mjs @@ -1,9 +1,9 @@ import { constants } from "node:fs" import { open } from "node:fs/promises" import path from "node:path" - +import { readErrorCode } from "../read-error-code/index.mjs" +import { readHubObject } from "../read-hub-object/index.mjs" const MAXIMUM_FILE_BYTES = 16_384 - export async function readPrototypeStartPage(repositoryPath) { let file try { @@ -20,7 +20,7 @@ export async function readPrototypeStartPage(repositoryPath) { const content = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)) return { localStartPage: parsePrototypeStartPage(content) } } catch (error) { - if (error?.code === "ENOENT") return { localStartPage: null } + if (readErrorCode(error) === "ENOENT") return { localStartPage: null } return { localStartPage: null, localStartPageError: `Could not read .prototype: ${error instanceof Error ? error.message : String(error)}`, @@ -29,9 +29,8 @@ export async function readPrototypeStartPage(repositoryPath) { await file?.close() } } - export function parsePrototypeStartPage(content) { - const data = JSON.parse(content) + const data = readHubObject(JSON.parse(content)) const route = data?.startPage if ( typeof route !== "string" || @@ -45,7 +44,7 @@ export function parsePrototypeStartPage(content) { }) ) throw new Error("startPage must be a local route, including an optional query or fragment.") - const pathname = decodeURIComponent(route.split(/[?#]/)[0]) + const pathname = decodeURIComponent(route.split(/[?#]/)[0] ?? "") if ( pathname.startsWith("//") || pathname.includes("\\") || diff --git a/.github/extensions/replica-prototype/read-prototype-start-page/index.mts b/.github/extensions/replica-prototype/read-prototype-start-page/index.mts new file mode 100644 index 00000000..1f4c91af --- /dev/null +++ b/.github/extensions/replica-prototype/read-prototype-start-page/index.mts @@ -0,0 +1,63 @@ +import { constants } from "node:fs" +import { open } from "node:fs/promises" +import path from "node:path" +import { readErrorCode } from "../read-error-code/index.mjs" +import { readHubObject } from "../read-hub-object/index.mjs" + +const MAXIMUM_FILE_BYTES = 16_384 + +export async function readPrototypeStartPage(repositoryPath: string) { + let file + try { + file = await open( + path.join(repositoryPath, ".prototype"), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ) + const stat = await file.stat() + if (!stat.isFile() || stat.size > MAXIMUM_FILE_BYTES) + throw new Error(".prototype must be a regular JSON file of at most 16 KiB.") + const buffer = Buffer.alloc(MAXIMUM_FILE_BYTES + 1) + const { bytesRead } = await file.read(buffer) + if (bytesRead > MAXIMUM_FILE_BYTES) throw new Error(".prototype exceeds 16 KiB.") + const content = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)) + return { localStartPage: parsePrototypeStartPage(content) } + } catch (error) { + if (readErrorCode(error) === "ENOENT") return { localStartPage: null } + return { + localStartPage: null, + localStartPageError: `Could not read .prototype: ${error instanceof Error ? error.message : String(error)}`, + } + } finally { + await file?.close() + } +} + +export function parsePrototypeStartPage(content: string) { + const data = readHubObject(JSON.parse(content)) + const route = data?.startPage + if ( + typeof route !== "string" || + Buffer.byteLength(route) > 2048 || + !route.startsWith("/") || + route.startsWith("//") || + route.includes("\\") || + Array.from(route).some((character) => { + const code = character.charCodeAt(0) + return code <= 32 || code >= 127 + }) + ) + throw new Error("startPage must be a local route, including an optional query or fragment.") + const pathname = decodeURIComponent(route.split(/[?#]/)[0] ?? "") + if ( + pathname.startsWith("//") || + pathname.includes("\\") || + Array.from(pathname).some((character) => { + const code = character.charCodeAt(0) + return code < 32 || code === 127 + }) || + pathname.split("/").some((segment) => segment === "." || segment === "..") || + /^\/__replica(?:\/|$)/.test(pathname) + ) + throw new Error("startPage contains an unsafe or reserved path.") + return route +} diff --git a/.github/extensions/replica-prototype/render-canvas-html/index.mjs b/.github/extensions/replica-prototype/render-canvas-html/index.mjs index e1b3e2c9..575a8c81 100644 --- a/.github/extensions/replica-prototype/render-canvas-html/index.mjs +++ b/.github/extensions/replica-prototype/render-canvas-html/index.mjs @@ -7,7 +7,6 @@ export function renderCanvasHtml(context, token, setupUrl) { pickAndPolish: true, ...(context.improvementThreadsSample ? { improvementThreadsSample: true } : {}), }).replaceAll("<", "\\u003c") - return ` @@ -49,7 +48,6 @@ export function renderCanvasHtml(context, token, setupUrl) { ` } - function escapeHtml(value) { return String(value) .replaceAll("&", "&") diff --git a/.github/extensions/replica-prototype/render-canvas-html/index.mts b/.github/extensions/replica-prototype/render-canvas-html/index.mts new file mode 100644 index 00000000..0f051e0b --- /dev/null +++ b/.github/extensions/replica-prototype/render-canvas-html/index.mts @@ -0,0 +1,68 @@ +import type { CanvasContext } from "../create-canvas-context/index.mjs" + +export function renderCanvasHtml( + context: Pick & { + previewUrl?: string + }, + token: string, + setupUrl?: string, +) { + const bootstrap = JSON.stringify({ + prototypeUrl: context.normalizedUrl, + previewUrl: context.previewUrl, + name: context.name, + token, + pickAndPolish: true, + ...(context.improvementThreadsSample ? { improvementThreadsSample: true } : {}), + }).replaceAll("<", "\\u003c") + + return ` + + + + + ${escapeHtml(context.name)} · Replica + + + +
+ + + ${ + setupUrl + ? ` + ` + : "" + } + +` +} + +function escapeHtml(value: unknown) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.mjs b/.github/extensions/replica-prototype/start-canvas-server/index.mjs index 74421171..bcb73a52 100644 --- a/.github/extensions/replica-prototype/start-canvas-server/index.mjs +++ b/.github/extensions/replica-prototype/start-canvas-server/index.mjs @@ -1,7 +1,6 @@ import { createHash, randomBytes } from "node:crypto" import { readFile } from "node:fs/promises" import { createServer } from "node:http" - import { MAX_RELOAD_ADDRESS_LENGTH } from "../agent-commands/index.mjs" import { projectControlsStatus } from "../controls-status/index.mjs" import { currentCanvasContext } from "../create-canvas-context/index.mjs" @@ -26,9 +25,10 @@ import { createShareLinks } from "../share-links/index.mjs" import { startPreviewProxy } from "../start-preview-proxy/index.mjs" import { captureReviewRevision } from "../capture-review-revision/index.mjs" import { createLocalReviewComments } from "../create-local-review-comments/index.mjs" -import { uuidInputPattern } from "../identity-patterns/index.mjs" import { createReviewerProfile } from "../create-reviewer-profile/index.mjs" - +import { readHubObject } from "../read-hub-object/index.mjs" +import { readHubError } from "../read-hub-error/index.mjs" +import { uuidInputPattern } from "../identity-patterns/index.mjs" const ASSET_PATHS = new Map([ [ "/avatar-placeholder.svg", @@ -69,7 +69,6 @@ const ASSET_PATHS = new Map([ ], ), ]) - export async function startCanvasServer(initialContext, session, options = {}) { let context = initialContext let preview @@ -93,10 +92,14 @@ export async function startCanvasServer(initialContext, session, options = {}) { hubOrigin: hub.origin, repository: status.repository, branch: status.branch, - viewerLogin: status.prototype?.viewerLogin || status.viewerLogin, + viewerLogin: status.state === "connected" ? status.prototype.viewerLogin : status.viewerLogin, connectionId, } - if (!scope.viewerLogin) throw new Error("Hub did not identify the connected reviewer.") + if (typeof scope.viewerLogin !== "string" || !scope.viewerLogin) + throw new Error("Hub did not identify the connected reviewer.") + if (typeof scope.branch !== "string" || !scope.branch) + throw new Error("Open a named branch before using local comments.") + const identifiedScope = { ...scope, viewerLogin: scope.viewerLogin, branch: scope.branch } const identity = await readCanvasRepository(context) const hubHostname = new URL(hub.origin).hostname const local = @@ -107,13 +110,13 @@ export async function startCanvasServer(initialContext, session, options = {}) { hubHostname.endsWith(".localhost")) const profile = local ? { - author: { login: scope.viewerLogin, name: null, avatarUrl: null }, + author: { login: identifiedScope.viewerLogin, name: null, avatarUrl: null }, error: null, } - : await reviewerProfile.read(scope) + : await reviewerProfile.read(identifiedScope) if ((await hub.connectionKey(status.repository)) !== connectionId) throw httpError(409, "The Hub connection changed. Refresh before using local comments.") - return { ...scope, author: profile.author, profileError: profile.error } + return { ...identifiedScope, author: profile.author, profileError: profile.error } } async function updateMetadata({ name, description, startPage }, expectedTarget) { const hub = await hubClient() @@ -186,14 +189,13 @@ export async function startCanvasServer(initialContext, session, options = {}) { return { state: "registered", alreadyRegistered: true, - buildNumber: status.prototype.build?.number ?? 0, + buildNumber: readHubObject(status.prototype.build)?.number ?? 0, } } if (!status.prototype.canRegister) { throw httpError(403, "This branch is not eligible for registration in Hub.") } - - const branchName = status.branch.split("/").at(-1).replaceAll(/[-_]+/g, " ").trim() + const branchName = status.branch.split("/").at(-1)?.replaceAll(/[-_]+/g, " ").trim() const canvasName = context.target.branch === publishContext.target.branch && publishContext.name !== new URL(publishContext.normalizedUrl).hostname @@ -209,7 +211,7 @@ export async function startCanvasServer(initialContext, session, options = {}) { ) return { state: "registered", - buildNumber: status.prototype.build?.number ?? 0, + buildNumber: readHubObject(status.prototype.build)?.number ?? 0, } } async function hubStatus() { @@ -225,7 +227,7 @@ export async function startCanvasServer(initialContext, session, options = {}) { } if ( hub && - status.repositoryAuthorized && + (status.state === "connected" || status.state === "undiscovered") && status.branch && status.registrationReason !== "branch_not_reviewable" ) { @@ -242,26 +244,39 @@ export async function startCanvasServer(initialContext, session, options = {}) { if (scope.connectionId !== connectionId) throw httpError(409, "The Hub connection changed while preparing local comments.") if (scope.profileError) status = { ...status, localCommentsError: scope.profileError } - if (status.prototype?.build) { + const prototype = status.state === "connected" ? status.prototype : undefined + const build = readHubObject(prototype?.build) + if (prototype?.build) { try { + if ( + !build || + typeof prototype.repositoryId !== "number" || + typeof prototype.id !== "number" || + typeof build.number !== "number" || + typeof build.commitSha !== "string" || + typeof build.publishedAt !== "string" + ) + throw new Error("Hub returned an invalid Build for local comments.") await localComments.sync( scope, { - ...status.prototype.build, - repositoryId: status.prototype.repositoryId, - prototypeId: status.prototype.id, + ...build, + number: build.number, + commitSha: build.commitSha, + publishedAt: build.publishedAt, + repositoryId: prototype.repositoryId, + prototypeId: prototype.id, }, hub, ) } catch (error) { status = { ...status, - localCommentsError: `Local comments not synced: ${error.message}`, + localCommentsError: `Local comments not synced: ${readHubError(error).message}`, } } } - const published = - snapshot.clean && status.prototype?.build?.commitSha === snapshot.commitSha + const published = snapshot.clean && build?.commitSha === snapshot.commitSha status = { ...status, reviewRevision: { @@ -272,10 +287,10 @@ export async function startCanvasServer(initialContext, session, options = {}) { createHash("sha256").update(snapshot.treeSha).digest("hex").slice(0, 12), 16, ) + 1, - prototypeId: status.prototype?.id ?? null, + prototypeId: prototype?.id ?? null, repository: status.repository, branch: status.branch, - buildNumber: published ? status.prototype.build.number : null, + buildNumber: published ? build?.number : null, hubOrigin: hub.origin, connectionId, }, @@ -283,7 +298,7 @@ export async function startCanvasServer(initialContext, session, options = {}) { } catch (error) { status = { ...status, - reviewError: `Local comments unavailable: ${error.message}`, + reviewError: `Local comments unavailable: ${readHubError(error).message}`, } } } @@ -304,7 +319,10 @@ export async function startCanvasServer(initialContext, session, options = {}) { const shareLinks = createShareLinks(hubClient, options.openBrowser || openBrowser) const token = randomBytes(24).toString("base64url") const documentToken = randomBytes(32).toString("base64url") - const server = createServer(async (request, response) => { + const server = createServer((request, response) => { + void serve(request, response) + }) + async function serve(request, response) { try { if (request.headers.host !== `127.0.0.1:${request.socket.localPort}`) { throw httpError(403, "Invalid canvas host.") @@ -332,24 +350,28 @@ export async function startCanvasServer(initialContext, session, options = {}) { localComments, localScope, ) - } catch (error) { + } catch (caught) { + const error = readHubError(caught) sendJson( response, - error?.statusCode || (error?.code === "stale_comment_target" ? 409 : 500), + typeof error.statusCode === "number" + ? error.statusCode + : error.code === "stale_comment_target" + ? 409 + : 500, { error: error instanceof Error ? error.message : String(error), ...(request.url?.startsWith("/api/hub/share-links") ? { code: error.code, deliveryPhase: error.deliveryPhase, - authorization: error.authorization, + authorization: readHubObject(error)?.authorization, } : {}), }, ) } - }) - + } await new Promise((resolve, reject) => { server.once("error", reject) server.listen(0, "127.0.0.1", () => { @@ -357,13 +379,11 @@ export async function startCanvasServer(initialContext, session, options = {}) { resolve() }) }) - const address = server.address() if (!address || typeof address === "string") { await closeServer(server) throw new Error("Replica prototype server did not receive a loopback port.") } - const url = `http://127.0.0.1:${address.port}/?document=${documentToken}` function connectPreview(current) { return startPreviewProxy({ @@ -421,7 +441,6 @@ export async function startCanvasServer(initialContext, session, options = {}) { }, } } - async function routeRequest( request, response, @@ -446,7 +465,6 @@ async function routeRequest( localScope, ) { const requestUrl = new URL(request.url || "/", "http://127.0.0.1") - if (request.method === "GET" && requestUrl.pathname === "/") { if ( requestUrl.searchParams.getAll("document").length !== 1 || @@ -458,14 +476,14 @@ async function routeRequest( return } if (request.method === "GET" && ASSET_PATHS.has(requestUrl.pathname)) { - await sendAsset(response, ASSET_PATHS.get(requestUrl.pathname)) + const asset = ASSET_PATHS.get(requestUrl.pathname) + if (asset) await sendAsset(response, asset) return } if (request.method === "GET" && requestUrl.pathname === "/api/state") { sendJson(response, 200, await getPrototypeState(await currentCanvasContext(context))) return } - requireToken(request, token) if (requestUrl.pathname === "/api/improvement-notes/capture" && request.method === "POST") { sendJson(response, 200, await improvementNotes.capture()) @@ -491,7 +509,7 @@ async function routeRequest( /^\/api\/hub\/share-links\/(context|list|create|delete|open|controls-preference|authorize)$/.exec( requestUrl.pathname, ) - if (sharingRoute) { + if (sharingRoute?.[1]) { if (request.method !== "POST") throw httpError(405, "Sharing action not supported.") sendJson( response, @@ -592,7 +610,7 @@ async function routeRequest( const status = await hubStatus() sendJson(response, 200, { ...status, - message: `Connection activity could not be verified: ${error.message}`, + message: `Connection activity could not be verified: ${readHubError(error).message}`, }) return } @@ -602,210 +620,246 @@ async function routeRequest( } if (request.method === "POST" && requestUrl.pathname === "/api/hub/origin") { const { origin } = await readJson(request) + if (typeof origin !== "string") throw httpError(422, "Enter a valid Hub URL.") sendJson(response, 200, await selectHubOrigin(origin)) return } - const hub = requestUrl.pathname.startsWith("/api/hub/") ? await hubClient() : null - if (request.method === "GET" && requestUrl.pathname === "/api/hub/status") { sendJson(response, 200, await hubStatus()) return } + const hub = requestUrl.pathname.startsWith("/api/hub/") ? await hubClient() : null if (requestUrl.pathname.startsWith("/api/hub/") && !hub) { throw httpError(409, "Enter a Hub URL before connecting.") } - if ( - request.method === "POST" && - ["/api/hub/pair", "/api/hub/connect"].includes(requestUrl.pathname) - ) { - const pairing = await hub.pair() - let browserError - if (requestUrl.pathname === "/api/hub/connect") { - try { - const result = await launchBrowser(pairing.verificationUrl) - if (!result.ok) browserError = result.error - } catch (error) { - browserError = error instanceof Error ? error.message : String(error) + if (hub) { + if ( + request.method === "POST" && + ["/api/hub/pair", "/api/hub/connect"].includes(requestUrl.pathname) + ) { + const pairing = await hub.pair() + let browserError + if (requestUrl.pathname === "/api/hub/connect") { + try { + const result = await launchBrowser(pairing.verificationUrl) + if (!result.ok) browserError = result.error + } catch (error) { + browserError = error instanceof Error ? error.message : String(error) + } } + sendJson(response, 201, { + ...pairing, + ...(browserError ? { browserError } : {}), + }) + return } - sendJson(response, 201, { - ...pairing, - ...(browserError ? { browserError } : {}), - }) - return - } - if (request.method === "POST" && requestUrl.pathname === "/api/hub/poll") { - sendJson(response, 200, await hub.poll()) - return - } - if (request.method === "POST" && requestUrl.pathname === "/api/hub/disconnect") { - await hub.disconnect() - sendJson(response, 200, { state: "disconnected" }) - return - } - if (request.method === "PATCH" && requestUrl.pathname === "/api/hub/metadata") { - sendJson(response, 200, await updateMetadata(await readJson(request))) - return - } - if (requestUrl.pathname === "/api/hub/comments" && request.method === "GET") { - const status = await hub.status() - if (!status.repositoryAuthorized) throw httpError(403, "Connect to Hub before commenting.") - const scope = await localScope(hub, status) - const collection = status.prototype - ? await hub.request(await reviewPath(hub)) - : { comments: [], hasMore: false, nextCursor: null } - const revision = requestUrl.searchParams.get("local_revision_id") - const drafts = (await localComments.list(scope)).filter( - (comment) => !revision || comment.draftRevisionId === Number(revision), - ) - sendJson(response, 200, { - ...collection, - comments: [...collection.comments, ...drafts], - }) - return - } - if (requestUrl.pathname === "/api/hub/mention-candidates" && request.method === "GET") { - const base = await reviewPath(hub) - const expected = `/api/canvas/repositories/${requestUrl.searchParams.get("repositoryId")}/prototypes/${requestUrl.searchParams.get("prototypeId")}/review_comments` - if (base !== expected) - throw httpError(409, "The Hub prototype changed. Refresh before mentioning someone.") - sendJson( - response, - 200, - await hub.request(base.replace(/\/review_comments$/, "/mention_candidates")), - ) - return - } - if (requestUrl.pathname === "/api/hub/comments" && request.method === "POST") { - const body = await readJson(request, 32_768) - const expected = body.review_revision - const status = await hub.status() - if ( - !expected || - expected.hubOrigin !== hub.origin || - typeof expected.connectionId !== "string" || - !expected.connectionId || - !status.repositoryAuthorized || - (status.prototype?.id ?? null) !== expected.prototypeId || - status.repository !== expected.repository || - status.branch !== expected.branch || - expected.connectionId !== (await hub.connectionKey(status.repository)) - ) - throw httpError( - 409, - "The Hub review target changed. Select a target again before commenting.", - ) - const attributes = { - body: body.review_comment?.body, - anchor: body.review_comment?.anchor, + if (request.method === "POST" && requestUrl.pathname === "/api/hub/poll") { + sendJson(response, 200, await hub.poll()) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/hub/disconnect") { + await hub.disconnect() + sendJson(response, 200, { state: "disconnected" }) + return } - if (expected.id === null) { - if (!Number.isSafeInteger(expected.buildNumber) || expected.buildNumber < 1) - throw httpError(400, "Select a valid published Build before commenting.") - attributes.build_number = expected.buildNumber - } else { - if (!Number.isSafeInteger(expected.id) || expected.id < 1) - throw httpError(400, "Select a valid local revision before commenting.") + if (request.method === "PATCH" && requestUrl.pathname === "/api/hub/metadata") { + sendJson(response, 200, await updateMetadata(await readJson(request))) + return + } + if (requestUrl.pathname === "/api/hub/comments" && request.method === "GET") { + const status = await hub.status() + if (status.state !== "connected" && status.state !== "undiscovered") + throw httpError(403, "Connect to Hub before commenting.") const scope = await localScope(hub, status) - if (scope.connectionId !== expected.connectionId) - throw httpError(409, "The Hub connection changed. Select a target again.") - const comment = await localComments.create(scope, expected, attributes) - sendJson(response, 201, { comments: [comment] }) + const cursor = requestUrl.searchParams.get("cursor") + const page = + status.state === "connected" + ? await hub.request( + `${await reviewPath(hub)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, + ) + : { comments: [], hasMore: false, nextCursor: null } + if (!Array.isArray(page.comments)) throw httpError(502, "Hub returned invalid comments.") + const remoteComments = page.comments + const revision = requestUrl.searchParams.get("local_revision_id") + const drafts = cursor + ? [] + : (await localComments.list(scope)).filter( + (comment) => !revision || comment.draftRevisionId === Number(revision), + ) + sendJson(response, 200, { + ...page, + comments: [...remoteComments, ...drafts], + }) return } - const base = `/api/canvas/repositories/${status.prototype.repositoryId}/prototypes/${status.prototype.id}/review_comments` - sendJson( - response, - 201, - await hub.request(base, { - method: "POST", - interactive: true, - expectedTarget: status, - connectionScope: { identity: expected.connectionId }, - body: { review_comment: attributes }, - }), - ) - return - } - if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { - const socket = await hub.subscribe( - () => { - if (!response.writableEnded) response.write("event: comments\ndata: {}\n\n") - }, - (error) => { - if (response.writableEnded) return - response.write(`event: error\ndata: ${JSON.stringify(error.message)}\n\n`) - response.end() - }, - ) - response.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }) - response.write(": connected\n\n") - streams.add(response) - const heartbeat = setInterval(() => { - if (!response.writableEnded) response.write(": keepalive\n\n") - }, 25_000) - response.on("close", () => { - streams.delete(response) - clearInterval(heartbeat) - socket.close() - }) - return - } - const commentRoute = - /^\/api\/hub\/comments\/(\d+)(?:\/(replies|resolution|read|reactions)(?:\/(%2B1|\+1|-1|laugh|hooray|confused|heart|rocket|eyes))?)?$/i.exec( - requestUrl.pathname, - ) - if (commentRoute) { - const [, id, operation, content] = commentRoute - const method = request.method - const valid = !operation - ? ["PATCH", "DELETE"].includes(method) - : operation === "replies" - ? method === "POST" && !content - : operation === "resolution" || operation === "read" - ? ["POST", "DELETE"].includes(method) && !content - : (method === "POST" && !content) || (method === "DELETE" && !!content) - if (!valid) throw httpError(405, "Review action not supported.") - const body = - (!operation && method === "PATCH") || - operation === "replies" || - (operation === "read" && method === "POST") || - operation === "reactions" - ? await readJson(request) - : undefined - const status = await hub.status() - if (!status.repositoryAuthorized) throw httpError(403, "Connect to Hub before commenting.") - const scope = await localScope(hub, status) - if ((await localComments.list(scope)).some((comment) => comment.id === Number(id))) { - if (operation === "read" && method === "POST") { - sendJson(response, 200, {}) + if (requestUrl.pathname === "/api/hub/mention-candidates" && request.method === "GET") { + const base = await reviewPath(hub) + const expected = `/api/canvas/repositories/${requestUrl.searchParams.get("repositoryId")}/prototypes/${requestUrl.searchParams.get("prototypeId")}/review_comments` + if (base !== expected) + throw httpError(409, "The Hub prototype changed. Refresh before mentioning someone.") + sendJson( + response, + 200, + await hub.request(base.replace(/\/review_comments$/, "/mention_candidates")), + ) + return + } + if (requestUrl.pathname === "/api/hub/comments" && request.method === "POST") { + const body = await readJson(request, 32_768) + const expected = readHubObject(body.review_revision) + const status = await hub.status() + if ( + !expected || + expected.hubOrigin !== hub.origin || + typeof expected.connectionId !== "string" || + !expected.connectionId || + (status.state !== "connected" && status.state !== "undiscovered") || + (status.state === "connected" ? status.prototype.id : null) !== expected.prototypeId || + status.repository !== expected.repository || + status.branch !== expected.branch || + expected.connectionId !== (await hub.connectionKey(status.repository)) + ) + throw httpError( + 409, + "The Hub review target changed. Select a target again before commenting.", + ) + const commentInput = readHubObject(body.review_comment) + const attributes = { + body: commentInput?.body, + anchor: commentInput?.anchor, + } + if (expected.id === null) { + if ( + typeof expected.buildNumber !== "number" || + !Number.isSafeInteger(expected.buildNumber) || + expected.buildNumber < 1 + ) + throw httpError(400, "Select a valid published Build before commenting.") + attributes.build_number = expected.buildNumber + } else { + if ( + typeof expected.id !== "number" || + !Number.isSafeInteger(expected.id) || + expected.id < 1 + ) + throw httpError(400, "Select a valid local revision before commenting.") + const scope = await localScope(hub, status) + if (scope.connectionId !== expected.connectionId) + throw httpError(409, "The Hub connection changed. Select a target again.") + if ( + typeof expected.treeSha !== "string" || + (expected.prototypeId !== null && typeof expected.prototypeId !== "number") + ) + throw httpError(400, "Select a valid local revision before commenting.") + const comment = await localComments.create( + scope, + { + ...expected, + id: expected.id, + treeSha: expected.treeSha, + prototypeId: expected.prototypeId, + }, + attributes, + ) + sendJson(response, 201, { comments: [comment] }) + return + } + if (status.state !== "connected") + throw httpError(409, "Connect to a discovered Hub prototype.") + const base = prototypeReviewPath(status.prototype) + sendJson( + response, + 201, + await hub.request(base, { + method: "POST", + interactive: true, + expectedTarget: status, + connectionScope: { identity: expected.connectionId }, + body: { review_comment: attributes }, + }), + ) + return + } + if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { + const socket = await hub.subscribe( + () => { + if (!response.writableEnded) response.write("event: comments\ndata: {}\n\n") + }, + (error) => { + if (response.writableEnded) return + response.write(`event: error\ndata: ${JSON.stringify(error.message)}\n\n`) + response.end() + }, + ) + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + response.write(": connected\n\n") + streams.add(response) + const heartbeat = setInterval(() => { + if (!response.writableEnded) response.write(": keepalive\n\n") + }, 25_000) + response.on("close", () => { + streams.delete(response) + clearInterval(heartbeat) + socket.close() + }) + return + } + const commentRoute = + /^\/api\/hub\/comments\/(\d+)(?:\/(replies|resolution|read|reactions)(?:\/(%2B1|\+1|-1|laugh|hooray|confused|heart|rocket|eyes))?)?$/i.exec( + requestUrl.pathname, + ) + if (commentRoute) { + const [, id, operation, content] = commentRoute + const method = request.method || "" + const valid = !operation + ? ["PATCH", "DELETE"].includes(method) + : operation === "replies" + ? method === "POST" && !content + : operation === "resolution" || operation === "read" + ? ["POST", "DELETE"].includes(method) && !content + : (method === "POST" && !content) || (method === "DELETE" && !!content) + if (!valid) throw httpError(405, "Review action not supported.") + const body = + (!operation && method === "PATCH") || + operation === "replies" || + (operation === "read" && method === "POST") || + operation === "reactions" + ? await readJson(request) + : undefined + const status = await hub.status() + if (status.state !== "connected" && status.state !== "undiscovered") + throw httpError(403, "Connect to Hub before commenting.") + const scope = await localScope(hub, status) + if ((await localComments.list(scope)).some((comment) => comment.id === Number(id))) { + if (operation === "read" && method === "POST") { + sendJson(response, 200, {}) + return + } + if (operation) throw httpError(409, "Publish this comment before starting a conversation.") + const comment = + method === "PATCH" + ? await localComments.update(scope, id, readHubObject(body?.review_comment)?.body) + : await localComments.remove(scope, id) + sendJson(response, 200, { comments: [comment] }) return } - if (operation) throw httpError(409, "Publish this comment before starting a conversation.") - const comment = - method === "PATCH" - ? await localComments.update(scope, id, body?.review_comment?.body) - : await localComments.remove(scope, id) - sendJson(response, 200, { comments: [comment] }) + const suffix = operation ? `/${operation}${content ? `/${content}` : ""}` : "" + sendJson( + response, + 200, + await hub.request(`${await reviewPath(hub)}/${id}${suffix}`, { + method, + body, + interactive: operation !== "read" || method === "DELETE", + }), + ) return } - const suffix = operation ? `/${operation}${content ? `/${content}` : ""}` : "" - sendJson( - response, - 200, - await hub.request(`${await reviewPath(hub)}/${id}${suffix}`, { - method, - body, - interactive: operation !== "read" || method === "DELETE", - }), - ) - return } - if (request.method === "POST" && requestUrl.pathname === "/api/refresh") { sendJson(response, 200, await refreshPrototypeStatus(await currentCanvasContext(context))) return @@ -814,17 +868,18 @@ async function routeRequest( const input = await readJson(request, 16_384, { allowEmpty: true }) const publishContext = await currentCanvasContext(context) const preparation = await preparePublish(publishContext, publishPreferences) - if (input.target && !samePublishTarget(input.target, preparation.target)) + if (input.target && !samePublishTarget(readPublishTarget(input.target), preparation.target)) throw httpError(409, "The checkout changed while preparing to publish. Try again.") if ( input.workingTree !== undefined && - !["clean", "changed", "unavailable"].includes(input.workingTree) + (typeof input.workingTree !== "string" || + !["clean", "changed", "unavailable"].includes(input.workingTree)) ) throw httpError(422, "Invalid working tree state.") if (preparation.workingTree === "unavailable" && input.mode !== "committed-only") throw httpError( 409, - `Could not check local changes. Retry the check or choose Publish committed version only. ${preparation.workingTreeError}`, + `Could not check local changes. Retry the check or choose Publish committed version only. ${"workingTreeError" in preparation ? preparation.workingTreeError : undefined}`, ) if (input.workingTree === "clean" && preparation.workingTree === "changed") throw httpError(409, "Uncommitted changes appeared while preparing to publish. Try again.") @@ -837,12 +892,13 @@ async function routeRequest( : null) if (!mode) throw httpError(409, "Uncommitted changes exist. Choose how to publish before continuing.") - if (!["commit-changes", "committed-only"].includes(mode)) + if (mode !== "commit-changes" && mode !== "committed-only") throw httpError(422, "Choose a supported publishing mode.") if (input.remember !== undefined && typeof input.remember !== "boolean") throw httpError(422, "Invalid publishing preference.") if (input.remember === true && mode !== "commit-changes") throw httpError(422, "Only Commit changes & publish can be remembered.") + const remember = input.remember sendJson( response, 202, @@ -852,8 +908,8 @@ async function routeRequest( mode, expectedTarget: preparation.target, savePreference: - input.remember !== undefined - ? (target) => publishPreferences.write(target, input.remember) + remember !== undefined + ? (target) => publishPreferences.write(target, remember) : undefined, }), ) @@ -873,22 +929,45 @@ async function routeRequest( await currentCanvasContext(context), publishPreferences, ) - if (!input.target || !samePublishTarget(input.target, preparation.target)) + if (!input.target || !samePublishTarget(readPublishTarget(input.target), preparation.target)) throw httpError(409, "The checkout changed. Refresh Settings and try again.") + if (typeof input.remember !== "boolean") throw new Error("Invalid publishing preference.") await publishPreferences.write(preparation.target, input.remember) sendJson(response, 200, { remember: input.remember }) return } - sendJson(response, 404, { error: "Not found." }) } - +function readPublishTarget(input) { + const value = readHubObject(input) + if (!value) return null + if ( + (value.repository !== undefined && + value.repository !== null && + typeof value.repository !== "string") || + (value.branch !== undefined && typeof value.branch !== "string") || + (value.commitSha !== undefined && typeof value.commitSha !== "string") || + (value.originFingerprint !== undefined && + value.originFingerprint !== null && + typeof value.originFingerprint !== "string") || + (value.pushFingerprint !== undefined && + value.pushFingerprint !== null && + typeof value.pushFingerprint !== "string") + ) + throw httpError(422, "Invalid publication target.") + return { + repository: value.repository, + branch: value.branch, + commitSha: value.commitSha, + originFingerprint: value.originFingerprint, + pushFingerprint: value.pushFingerprint, + } +} function requireToken(request, token) { if (request.headers["x-replica-canvas-token"] !== token) { throw httpError(403, "Invalid canvas request token.") } } - function sendHtml(response, html) { response.writeHead(200, { "Cache-Control": "no-store", @@ -909,7 +988,6 @@ function sendHtml(response, html) { }) response.end(html) } - async function sendAsset(response, asset) { const body = await readFile(asset.fileUrl) response.writeHead(200, { @@ -919,12 +997,10 @@ async function sendAsset(response, asset) { }) response.end(body) } - function sendJson(response, statusCode, body) { if (response.headersSent) { return } - response.writeHead(statusCode, { "Cache-Control": "no-store", "Content-Type": "application/json; charset=utf-8", @@ -932,13 +1008,9 @@ function sendJson(response, statusCode, body) { }) response.end(`${JSON.stringify(body)}\n`) } - function httpError(statusCode, message) { - const error = new Error(message) - error.statusCode = statusCode - return error + return Object.assign(new Error(message), { statusCode }) } - function hasKeys(value, keys) { return ( value !== null && @@ -948,11 +1020,9 @@ function hasKeys(value, keys) { keys.every((key) => Object.hasOwn(value, key)) ) } - function boundedString(value, limit) { return typeof value === "string" && value.length <= limit } - function validRoute(route) { return ( hasKeys(route, ["pathname", "search", "hash"]) && @@ -961,7 +1031,6 @@ function validRoute(route) { boundedString(route.hash, 1024) ) } - function validAgentResult(result) { if (!boundedString(result.id, 36) || !uuidInputPattern.test(result.id)) return false if (result.ok === false) @@ -999,7 +1068,7 @@ function validAgentResult(result) { ]) && boundedString(value.address, 2048) && boundedString(value.addressDraft, 2048) && - ["light", "dark"].includes(value.appearance) && + (value.appearance === "light" || value.appearance === "dark") && typeof value.canGoBack === "boolean" && typeof value.canGoForward === "boolean" && Number.isSafeInteger(value.historyIndex) && @@ -1007,31 +1076,42 @@ function validAgentResult(result) { (value.route === null || validRoute(value.route)) ) } - async function reviewPath(hub) { const status = await hub.status() if (status.state !== "connected") throw httpError(409, "Connect to a discovered Hub prototype.") - return `/api/canvas/repositories/${status.prototype.repositoryId}/prototypes/${status.prototype.id}/review_comments` + return prototypeReviewPath(status.prototype) +} +function prototypeReviewPath(prototype) { + if ( + typeof prototype.repositoryId !== "number" || + !Number.isSafeInteger(prototype.repositoryId) || + prototype.repositoryId < 1 || + typeof prototype.id !== "number" || + !Number.isSafeInteger(prototype.id) || + prototype.id < 1 + ) + throw httpError(502, "Hub returned an invalid prototype identity.") + return `/api/canvas/repositories/${prototype.repositoryId}/prototypes/${prototype.id}/review_comments` } - async function readJson(request, limit = 16_384, { allowEmpty = false } = {}) { const chunks = [] let bytes = 0 - for await (const chunk of request) { + const incoming = request + for await (const chunk of incoming) { + if (!Buffer.isBuffer(chunk)) throw httpError(400, "Invalid canvas request body.") bytes += chunk.length if (bytes > limit) throw httpError(413, "Canvas request is too large.") chunks.push(chunk) } if (bytes === 0 && allowEmpty) return {} try { - const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed + const parsed = readHubObject(JSON.parse(Buffer.concat(chunks).toString("utf8"))) + if (parsed && !Array.isArray(parsed)) return parsed } catch { // Report malformed JSON as an invalid request. } throw httpError(400, "Invalid canvas JSON.") } - function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())) diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.mts b/.github/extensions/replica-prototype/start-canvas-server/index.mts new file mode 100644 index 00000000..3e363640 --- /dev/null +++ b/.github/extensions/replica-prototype/start-canvas-server/index.mts @@ -0,0 +1,1207 @@ +import { createHash, randomBytes } from "node:crypto" +import { readFile } from "node:fs/promises" +import { createServer } from "node:http" +import type { IncomingMessage, Server, ServerResponse } from "node:http" + +import { MAX_RELOAD_ADDRESS_LENGTH } from "../agent-commands/index.mjs" +import { projectControlsStatus } from "../controls-status/index.mjs" +import { currentCanvasContext } from "../create-canvas-context/index.mjs" +import { getPrototypeState } from "../get-prototype-state/index.mjs" +import { createHubConnection } from "../create-hub-connection/index.mjs" +import { InvalidHubOrigin } from "../invalid-hub-origin/index.mjs" +import { openBrowser } from "../open-browser/index.mjs" +import { readCanvasRepository } from "../read-canvas-repository/index.mjs" +import { readPrototypeStartPage } from "../read-prototype-start-page/index.mjs" +import { refreshPrototypeStatus } from "../refresh-prototype-status/index.mjs" +import { renderCanvasHtml } from "../render-canvas-html/index.mjs" +import { requestPublish } from "../request-publish/index.mjs" +import { preparePublish } from "../prepare-publish/index.mjs" +import { createPublishPreferences } from "../publish-preferences/index.mjs" +import { samePublishTarget } from "../publish-target/index.mjs" +import { requestCommentingFix } from "../request-commenting-fix/index.mjs" +import { requestControlsDiagnosis } from "../request-controls-diagnosis/index.mjs" +import { reviewAction } from "../review-action/index.mjs" +import { createCommentStore } from "../comment-store/index.mjs" +import { createImprovementNotes } from "../improvement-notes/index.mjs" +import { createShareLinks } from "../share-links/index.mjs" +import { startPreviewProxy } from "../start-preview-proxy/index.mjs" +import { captureReviewRevision } from "../capture-review-revision/index.mjs" +import { createLocalReviewComments } from "../create-local-review-comments/index.mjs" +import { createReviewerProfile } from "../create-reviewer-profile/index.mjs" +import { readHubObject } from "../read-hub-object/index.mjs" +import { readHubError } from "../read-hub-error/index.mjs" +import type { CanvasContext } from "../create-canvas-context/index.mjs" +import type { HubClient, HubStatus } from "../create-hub-client/index.mjs" +import type { HubConnection, HubConnectionOptions } from "../create-hub-connection/index.mjs" +import type { PublishSession } from "../request-publish/index.mjs" +import type { PublishTarget } from "../publish-target/index.mjs" +import type { PublishPreferences } from "../publish-preferences/index.mjs" +import type { + LocalReviewComments, + LocalReviewScope, +} from "../create-local-review-comments/index.mjs" +import type { ShareBrowser, ShareLinks } from "../share-links/index.mjs" +import type { PreviewProxy } from "../start-preview-proxy/index.mjs" +import type { AgentCommands } from "../agent-commands/index.mjs" +import type { PrototypeServerStatus } from "../check-prototype-server/index.mjs" +import type { ImprovementNotes } from "../improvement-notes/index.mjs" +import { uuidInputPattern } from "../identity-patterns/index.mjs" + +export type CanvasServerOptions = { + publishPreferencesDirectory?: string + localCommentsDirectory?: string + improvementNotesDirectory?: string + hubOptions?: HubConnectionOptions + openBrowser?: ShareBrowser + agentCommands?: AgentCommands + setupUrl?: string + controlsDiagnosisDirectory?: string + commentingDirectory?: string + prototypeStatus?: () => Promise + instanceId?: string +} +export type CanvasServer = Awaited> +type Asset = { contentType: string; fileUrl: URL } +type Metadata = { name?: unknown; description?: unknown; startPage?: unknown } +type UpdateMetadata = ( + input: Metadata, + target?: Pick, +) => Promise> +type RegisterForPublish = (context: CanvasContext) => Promise> +type AuthorizedStatus = Extract +type LocalScope = (hub: HubClient, status: AuthorizedStatus) => Promise +type ExtendedStatus = HubStatus & { + localCommentsError?: string + reviewError?: string + reviewRevision?: Record +} +type HubStatusReader = () => Promise< + ExtendedStatus | { state: "unconfigured"; repository: string | null; message: string } +> + +const ASSET_PATHS = new Map([ + [ + "/avatar-placeholder.svg", + { + contentType: "image/svg+xml", + fileUrl: new URL("../assets/avatar-placeholder.svg", import.meta.url), + }, + ], + [ + "/assets/renderer.css", + { + contentType: "text/css; charset=utf-8", + fileUrl: new URL("../assets/renderer.css", import.meta.url), + }, + ], + ...[ + "renderer.js", + "renderer-vendor.js", + "renderer-rolldown-runtime.js", + "renderer-improvement-notes-prototype.js", + "renderer-improvement-notes-vendor.js", + "renderer-prototype-canvas-thread-sample.js", + "renderer-prototype-canvas-improvement-threads.js", + ].map((name): [string, Asset] => [ + `/assets/${name}`, + { + contentType: "text/javascript; charset=utf-8", + fileUrl: new URL(`../assets/${name}`, import.meta.url), + }, + ]), + ...["Hubot-Sans.woff2", "MonaSansMonoVF-wght.woff2", "MonaSansVF-wdth-wght-opsz.woff2"].map( + (name): [string, Asset] => [ + `/assets/${name}`, + { + contentType: "font/woff2", + fileUrl: new URL(`../assets/${name}`, import.meta.url), + }, + ], + ), +]) + +export async function startCanvasServer( + initialContext: CanvasContext, + session: PublishSession, + options: CanvasServerOptions = {}, +) { + let context = initialContext + let preview: PreviewProxy + const publishPreferences = createPublishPreferences(options.publishPreferencesDirectory) + const comments = createCommentStore(initialContext.sessionWorkspacePath) + const source = await readFile(new URL("../assets/canvas-bridge.js", import.meta.url), "utf8") + const hubConnection = createHubConnection(() => context, options.hubOptions) + const { hubClient, refreshDiscovery, selectHubOrigin, discoveries } = hubConnection + const improvementNotes = createImprovementNotes(() => context, session, hubClient, { + directory: options.improvementNotesDirectory, + }) + const localComments = createLocalReviewComments(initialContext, { + directory: options.localCommentsDirectory, + }) + const reviewerProfile = createReviewerProfile(initialContext) + async function localScope(hub: HubClient, status: AuthorizedStatus) { + const connectionId = await hub.connectionKey(status.repository) + if (!connectionId || connectionId !== status.reviewConnectionId) + throw httpError(409, "The Hub connection changed. Refresh before using local comments.") + const scope = { + hubOrigin: hub.origin, + repository: status.repository, + branch: status.branch, + viewerLogin: status.state === "connected" ? status.prototype.viewerLogin : status.viewerLogin, + connectionId, + } + if (typeof scope.viewerLogin !== "string" || !scope.viewerLogin) + throw new Error("Hub did not identify the connected reviewer.") + if (typeof scope.branch !== "string" || !scope.branch) + throw new Error("Open a named branch before using local comments.") + const identifiedScope = { ...scope, viewerLogin: scope.viewerLogin, branch: scope.branch } + const identity = await readCanvasRepository(context) + const hubHostname = new URL(hub.origin).hostname + const local = + identity.local && + identity.repository === scope.repository && + (hubHostname === "localhost" || + hubHostname === "127.0.0.1" || + hubHostname.endsWith(".localhost")) + const profile = local + ? { + author: { login: identifiedScope.viewerLogin, name: null, avatarUrl: null }, + error: null, + } + : await reviewerProfile.read(identifiedScope) + if ((await hub.connectionKey(status.repository)) !== connectionId) + throw httpError(409, "The Hub connection changed. Refresh before using local comments.") + return { ...identifiedScope, author: profile.author, profileError: profile.error } + } + async function updateMetadata( + { name, description, startPage }: Metadata, + expectedTarget?: Pick, + ) { + const hub = await hubClient() + if (!hub) throw httpError(409, "Enter a Hub URL before connecting.") + const { branch, repository } = await hub.checkout() + if ( + expectedTarget && + (branch !== expectedTarget.branch || repository !== expectedTarget.repository) + ) { + throw httpError(409, "The checkout changed while preparing to publish. Try again.") + } + let start_page = startPage + if (typeof startPage === "string" && /^https?:\/\//i.test(startPage.trim())) { + // URL parsing removes dot segments before Hub can validate the original path. + const rawPath = startPage + .trim() + .replace(/^https?:\/\/[^/?#]*/i, "") + .replace(/[?#].*$/s, "") + if ( + rawPath + .replace(/%2e/gi, ".") + .split("/") + .some((segment) => segment === "." || segment === "..") + ) + throw httpError(422, "Start page must not contain dot path segments.") + let url + try { + url = new URL(startPage.trim()) + } catch { + throw httpError(422, "Start page must be a valid prototype URL or route.") + } + if (url.origin === new URL(context.normalizedUrl).origin) { + if ( + url.username || + url.password || + startPage.includes("\\") || + Array.from(startPage).some( + (character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127, + ) + ) + throw httpError(422, "Start page must not contain credentials or control characters.") + start_page = `${url.pathname}${url.search}${url.hash}` + } + } + return hub.request("/api/canvas/prototype", { + method: "PATCH", + interactive: true, + body: { branch, name, description, start_page }, + }) + } + async function registerForPublish(publishContext: CanvasContext) { + let status + try { + status = await hubStatus() + } catch (error) { + if (!(error instanceof InvalidHubOrigin)) throw error + return { state: "unavailable", message: error.message } + } + if (status.state === "undiscovered" || status.state === "connected") { + if ( + status.repository !== publishContext.target.repository || + status.branch !== publishContext.target.branch + ) { + throw httpError(409, "The checkout changed while preparing to publish. Try again.") + } + } + if (status.state === "undiscovered") return { ...status, buildNumber: 0 } + if (status.state !== "connected") return status + if (status.prototype.registered) { + return { + state: "registered", + alreadyRegistered: true, + buildNumber: readHubObject(status.prototype.build)?.number ?? 0, + } + } + if (!status.prototype.canRegister) { + throw httpError(403, "This branch is not eligible for registration in Hub.") + } + + const branchName = status.branch.split("/").at(-1)?.replaceAll(/[-_]+/g, " ").trim() + const canvasName = + context.target.branch === publishContext.target.branch && + publishContext.name !== new URL(publishContext.normalizedUrl).hostname + ? publishContext.name + : null + const name = status.prototype.name || canvasName || branchName || status.branch + await updateMetadata( + { + name, + description: status.prototype.description ?? undefined, + }, + { branch: status.branch, repository: status.repository }, + ) + return { + state: "registered", + buildNumber: readHubObject(status.prototype.build)?.number ?? 0, + } + } + async function hubStatus() { + const hub = await hubClient() + const { repository } = await readCanvasRepository(context) + const discovery = discoveries.get(repository) + let status: + ExtendedStatus | { state: "unconfigured"; repository: string | null; message: string } = hub + ? await hub.status() + : { + state: "unconfigured", + repository, + message: "Enter a Hub URL to connect.", + } + if ( + hub && + (status.state === "connected" || status.state === "undiscovered") && + status.branch && + status.registrationReason !== "branch_not_reviewable" + ) { + try { + const connectionId = await hub.connectionKey(status.repository) + if (!connectionId) throw httpError(401, "Connect to Hub before commenting.") + const snapshot = await captureReviewRevision(context) + if (snapshot.commitSha !== status.commitSha) + throw httpError( + 409, + "The checkout changed while preparing local comments. Refresh again.", + ) + const scope = await localScope(hub, status) + if (scope.connectionId !== connectionId) + throw httpError(409, "The Hub connection changed while preparing local comments.") + if (scope.profileError) status = { ...status, localCommentsError: scope.profileError } + const prototype = status.state === "connected" ? status.prototype : undefined + const build = readHubObject(prototype?.build) + if (prototype?.build) { + try { + if ( + !build || + typeof prototype.repositoryId !== "number" || + typeof prototype.id !== "number" || + typeof build.number !== "number" || + typeof build.commitSha !== "string" || + typeof build.publishedAt !== "string" + ) + throw new Error("Hub returned an invalid Build for local comments.") + await localComments.sync( + scope, + { + ...build, + number: build.number, + commitSha: build.commitSha, + publishedAt: build.publishedAt, + repositoryId: prototype.repositoryId, + prototypeId: prototype.id, + }, + hub, + ) + } catch (error) { + status = { + ...status, + localCommentsError: `Local comments not synced: ${readHubError(error).message}`, + } + } + } + const published = snapshot.clean && build?.commitSha === snapshot.commitSha + status = { + ...status, + reviewRevision: { + ...snapshot, + id: published + ? null + : Number.parseInt( + createHash("sha256").update(snapshot.treeSha).digest("hex").slice(0, 12), + 16, + ) + 1, + prototypeId: prototype?.id ?? null, + repository: status.repository, + branch: status.branch, + buildNumber: published ? build?.number : null, + hubOrigin: hub.origin, + connectionId, + }, + } + } catch (error) { + status = { + ...status, + reviewError: `Local comments unavailable: ${readHubError(error).message}`, + } + } + } + const ownsStartPage = + status.state === "connected" && + (status.prototype?.startPageSet ?? status.prototype?.startPage !== undefined) + return { + ...status, + ...(ownsStartPage ? {} : await readPrototypeStartPage(context.repositoryPath)), + hubOrigin: hub?.origin, + discovery: { + ...discovery, + mismatch: Boolean(hub && discovery?.state === "valid" && discovery.origin !== hub.origin), + }, + } + } + const streams = new Set() + const shareLinks = createShareLinks(hubClient, options.openBrowser || openBrowser) + const token = randomBytes(24).toString("base64url") + const documentToken = randomBytes(32).toString("base64url") + const server = createServer((request, response) => { + void serve(request, response) + }) + async function serve(request: IncomingMessage, response: ServerResponse) { + try { + if (request.headers.host !== `127.0.0.1:${request.socket.localPort}`) { + throw httpError(403, "Invalid canvas host.") + } + await routeRequest( + request, + response, + { ...context, previewUrl: preview.url }, + session, + token, + documentToken, + hubClient, + updateMetadata, + registerForPublish, + selectHubOrigin, + streams, + options, + hubStatus, + refreshDiscovery, + options.openBrowser || openBrowser, + options.agentCommands, + shareLinks, + publishPreferences, + improvementNotes, + localComments, + localScope, + ) + } catch (caught) { + const error = readHubError(caught) + sendJson( + response, + typeof error.statusCode === "number" + ? error.statusCode + : error.code === "stale_comment_target" + ? 409 + : 500, + { + error: error instanceof Error ? error.message : String(error), + ...(request.url?.startsWith("/api/hub/share-links") + ? { + code: error.code, + deliveryPhase: error.deliveryPhase, + authorization: readHubObject(error)?.authorization, + } + : {}), + }, + ) + } + } + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve() + }) + }) + + const address = server.address() + if (!address || typeof address === "string") { + await closeServer(server) + throw new Error("Replica prototype server did not receive a loopback port.") + } + + const url = `http://127.0.0.1:${address.port}/?document=${documentToken}` + function connectPreview(current: CanvasContext) { + return startPreviewProxy({ + prototypeUrl: current.normalizedUrl, + parentOrigin: new URL(url).origin, + source, + }) + } + let previewUpdate = Promise.resolve() + try { + preview = await connectPreview(context) + } catch (error) { + await closeServer(server) + throw error + } + return { + url, + setContext(nextContext: CanvasContext) { + // Each caller receives its own failure; a failed transition must not block later retries. + previewUpdate = previewUpdate + .catch(() => {}) + .then(async () => { + const upstream = new URL(nextContext.normalizedUrl) + if (upstream.username || upstream.password) + throw new Error("Replica preview requires an unauthenticated loopback prototype URL.") + let previous + if (upstream.origin !== new URL(context.normalizedUrl).origin) { + const nextPreview = await connectPreview(nextContext) + previous = preview + preview = nextPreview + } + options.agentCommands?.close() + context = nextContext + hubConnection.invalidate() + await previous?.close() + }) + return previewUpdate + }, + getHubStatus: hubStatus, + reviewAction: async (action: string, input: unknown) => + reviewAction(await hubClient(), action, input, comments), + updateMetadata, + improvementNotes, + close: async () => { + options.agentCommands?.close() + for (const stream of streams) stream.end() + try { + await previewUpdate + } finally { + try { + await preview.close() + } finally { + await closeServer(server) + } + } + }, + } +} + +async function routeRequest( + request: IncomingMessage, + response: ServerResponse, + context: CanvasContext & { previewUrl: string }, + session: PublishSession, + token: string, + documentToken: string, + hubClient: HubConnection["hubClient"], + updateMetadata: UpdateMetadata, + registerForPublish: RegisterForPublish, + selectHubOrigin: HubConnection["selectHubOrigin"], + streams: Set, + options: CanvasServerOptions, + hubStatus: HubStatusReader, + refreshDiscovery: HubConnection["refreshDiscovery"], + launchBrowser: ShareBrowser, + agentCommands: AgentCommands | undefined, + shareLinks: ShareLinks, + publishPreferences: PublishPreferences, + improvementNotes: ImprovementNotes, + localComments: LocalReviewComments, + localScope: LocalScope, +) { + const requestUrl = new URL(request.url || "/", "http://127.0.0.1") + + if (request.method === "GET" && requestUrl.pathname === "/") { + if ( + requestUrl.searchParams.getAll("document").length !== 1 || + requestUrl.searchParams.get("document") !== documentToken + ) { + throw httpError(403, "Invalid canvas document token.") + } + sendHtml(response, renderCanvasHtml(context, token, options.setupUrl)) + return + } + if (request.method === "GET" && ASSET_PATHS.has(requestUrl.pathname)) { + const asset = ASSET_PATHS.get(requestUrl.pathname) + if (asset) await sendAsset(response, asset) + return + } + if (request.method === "GET" && requestUrl.pathname === "/api/state") { + sendJson(response, 200, await getPrototypeState(await currentCanvasContext(context))) + return + } + + requireToken(request, token) + if (requestUrl.pathname === "/api/improvement-notes/capture" && request.method === "POST") { + sendJson(response, 200, await improvementNotes.capture()) + return + } + if (requestUrl.pathname === "/api/improvement-notes" && request.method === "GET") { + sendJson(response, 200, await improvementNotes.load()) + return + } + if (requestUrl.pathname === "/api/improvement-notes" && request.method === "POST") { + sendJson(response, 200, await improvementNotes.save(await readJson(request, 1_550_000))) + return + } + if ( + request.method === "POST" && + ["/api/improvement-notes/sync", "/api/improvement-notes/fix"].includes(requestUrl.pathname) + ) { + const action = requestUrl.pathname.endsWith("/fix") ? "fix" : "sync" + sendJson(response, 200, await improvementNotes[action](await readJson(request))) + return + } + const sharingRoute = + /^\/api\/hub\/share-links\/(context|list|create|delete|open|controls-preference|authorize)$/.exec( + requestUrl.pathname, + ) + if (sharingRoute?.[1]) { + if (request.method !== "POST") throw httpError(405, "Sharing action not supported.") + sendJson( + response, + sharingRoute[1] === "create" ? 201 : 200, + await shareLinks(sharingRoute[1], await readJson(request)), + ) + return + } + if (requestUrl.pathname === "/api/controls/diagnose" && request.method === "POST") { + sendJson( + response, + 200, + await requestControlsDiagnosis( + await currentCanvasContext(context), + session, + await readJson(request, 16_384), + { directory: options.controlsDiagnosisDirectory }, + ), + ) + return + } + if (agentCommands && request.method === "GET" && requestUrl.pathname === "/api/agent/next") { + const rendererUrl = request.headers["x-replica-prototype-url"] + sendJson(response, 200, { + prototypeUrl: context.normalizedUrl, + command: + rendererUrl && rendererUrl !== context.normalizedUrl + ? null + : agentCommands.next(request.headers["x-replica-renderer-id"]), + }) + return + } + if (agentCommands && request.method === "POST" && requestUrl.pathname === "/api/agent/progress") { + const progress = await readJson(request, 1024) + agentCommands.progress(progress, request.headers["x-replica-renderer-id"]) + sendJson(response, 200, { received: true }) + return + } + if (agentCommands && request.method === "POST" && requestUrl.pathname === "/api/agent/result") { + const result = await readJson(request, 1_048_576) + if (!validAgentResult(result)) throw httpError(422, "Invalid agent result payload.") + agentCommands.complete(result, request.headers["x-replica-renderer-id"]) + sendJson(response, 200, { received: true }) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/prototype/open") { + const { url: value } = await readJson(request) + if (typeof value !== "string" || value.length > 2048) { + throw httpError(422, "Enter a valid prototype URL.") + } + let target + try { + target = new URL(value) + } catch { + throw httpError(422, "Enter a valid prototype URL.") + } + if ( + !["http:", "https:"].includes(target.protocol) || + target.origin !== new URL(context.normalizedUrl).origin || + target.username || + target.password + ) { + throw httpError(422, "The address must stay on this prototype's origin.") + } + const opened = await launchBrowser(target.href) + if (!opened.ok) throw httpError(502, opened.error || "Could not open the browser.") + sendJson(response, 200, { opened: true }) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/commenting/fix") { + sendJson( + response, + 200, + await requestCommentingFix(await currentCanvasContext(context), session, { + directory: options.commentingDirectory, + }), + ) + return + } + if ( + request.method === "GET" && + requestUrl.pathname === "/api/prototype/status" && + options.prototypeStatus + ) { + sendJson(response, 200, await options.prototypeStatus()) + return + } + if ( + request.method === "POST" && + ["/api/hub/open", "/api/hub/activity", "/api/hub/discovery"].includes(requestUrl.pathname) + ) { + await refreshDiscovery(requestUrl.pathname === "/api/hub/discovery") + const hub = await hubClient() + if (hub && requestUrl.pathname === "/api/hub/activity") { + try { + await hub.activity() + } catch (error) { + const status = await hubStatus() + sendJson(response, 200, { + ...status, + message: `Connection activity could not be verified: ${readHubError(error).message}`, + }) + return + } + } + sendJson(response, 200, await hubStatus()) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/hub/origin") { + const { origin } = await readJson(request) + if (typeof origin !== "string") throw httpError(422, "Enter a valid Hub URL.") + sendJson(response, 200, await selectHubOrigin(origin)) + return + } + if (request.method === "GET" && requestUrl.pathname === "/api/hub/status") { + sendJson(response, 200, await hubStatus()) + return + } + const hub = requestUrl.pathname.startsWith("/api/hub/") ? await hubClient() : null + if (requestUrl.pathname.startsWith("/api/hub/") && !hub) { + throw httpError(409, "Enter a Hub URL before connecting.") + } + if (hub) { + if ( + request.method === "POST" && + ["/api/hub/pair", "/api/hub/connect"].includes(requestUrl.pathname) + ) { + const pairing = await hub.pair() + let browserError + if (requestUrl.pathname === "/api/hub/connect") { + try { + const result = await launchBrowser(pairing.verificationUrl) + if (!result.ok) browserError = result.error + } catch (error) { + browserError = error instanceof Error ? error.message : String(error) + } + } + sendJson(response, 201, { + ...pairing, + ...(browserError ? { browserError } : {}), + }) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/hub/poll") { + sendJson(response, 200, await hub.poll()) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/hub/disconnect") { + await hub.disconnect() + sendJson(response, 200, { state: "disconnected" }) + return + } + if (request.method === "PATCH" && requestUrl.pathname === "/api/hub/metadata") { + sendJson(response, 200, await updateMetadata(await readJson(request))) + return + } + if (requestUrl.pathname === "/api/hub/comments" && request.method === "GET") { + const status = await hub.status() + if (status.state !== "connected" && status.state !== "undiscovered") + throw httpError(403, "Connect to Hub before commenting.") + const scope = await localScope(hub, status) + const cursor = requestUrl.searchParams.get("cursor") + const page = + status.state === "connected" + ? await hub.request( + `${await reviewPath(hub)}${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, + ) + : { comments: [], hasMore: false, nextCursor: null } + if (!Array.isArray(page.comments)) throw httpError(502, "Hub returned invalid comments.") + const remoteComments: unknown[] = page.comments + const revision = requestUrl.searchParams.get("local_revision_id") + const drafts = cursor + ? [] + : (await localComments.list(scope)).filter( + (comment) => !revision || comment.draftRevisionId === Number(revision), + ) + sendJson(response, 200, { + ...page, + comments: [...remoteComments, ...drafts], + }) + return + } + if (requestUrl.pathname === "/api/hub/mention-candidates" && request.method === "GET") { + const base = await reviewPath(hub) + const expected = `/api/canvas/repositories/${requestUrl.searchParams.get("repositoryId")}/prototypes/${requestUrl.searchParams.get("prototypeId")}/review_comments` + if (base !== expected) + throw httpError(409, "The Hub prototype changed. Refresh before mentioning someone.") + sendJson( + response, + 200, + await hub.request(base.replace(/\/review_comments$/, "/mention_candidates")), + ) + return + } + if (requestUrl.pathname === "/api/hub/comments" && request.method === "POST") { + const body = await readJson(request, 32_768) + const expected = readHubObject(body.review_revision) + const status = await hub.status() + if ( + !expected || + expected.hubOrigin !== hub.origin || + typeof expected.connectionId !== "string" || + !expected.connectionId || + (status.state !== "connected" && status.state !== "undiscovered") || + (status.state === "connected" ? status.prototype.id : null) !== expected.prototypeId || + status.repository !== expected.repository || + status.branch !== expected.branch || + expected.connectionId !== (await hub.connectionKey(status.repository)) + ) + throw httpError( + 409, + "The Hub review target changed. Select a target again before commenting.", + ) + const commentInput = readHubObject(body.review_comment) + const attributes: { body: unknown; anchor: unknown; build_number?: number } = { + body: commentInput?.body, + anchor: commentInput?.anchor, + } + if (expected.id === null) { + if ( + typeof expected.buildNumber !== "number" || + !Number.isSafeInteger(expected.buildNumber) || + expected.buildNumber < 1 + ) + throw httpError(400, "Select a valid published Build before commenting.") + attributes.build_number = expected.buildNumber + } else { + if ( + typeof expected.id !== "number" || + !Number.isSafeInteger(expected.id) || + expected.id < 1 + ) + throw httpError(400, "Select a valid local revision before commenting.") + const scope = await localScope(hub, status) + if (scope.connectionId !== expected.connectionId) + throw httpError(409, "The Hub connection changed. Select a target again.") + if ( + typeof expected.treeSha !== "string" || + (expected.prototypeId !== null && typeof expected.prototypeId !== "number") + ) + throw httpError(400, "Select a valid local revision before commenting.") + const comment = await localComments.create( + scope, + { + ...expected, + id: expected.id, + treeSha: expected.treeSha, + prototypeId: expected.prototypeId, + }, + attributes, + ) + sendJson(response, 201, { comments: [comment] }) + return + } + if (status.state !== "connected") + throw httpError(409, "Connect to a discovered Hub prototype.") + const base = prototypeReviewPath(status.prototype) + sendJson( + response, + 201, + await hub.request(base, { + method: "POST", + interactive: true, + expectedTarget: status, + connectionScope: { identity: expected.connectionId }, + body: { review_comment: attributes }, + }), + ) + return + } + if (requestUrl.pathname === "/api/hub/events" && request.method === "GET") { + const socket = await hub.subscribe( + () => { + if (!response.writableEnded) response.write("event: comments\ndata: {}\n\n") + }, + (error) => { + if (response.writableEnded) return + response.write(`event: error\ndata: ${JSON.stringify(error.message)}\n\n`) + response.end() + }, + ) + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + response.write(": connected\n\n") + streams.add(response) + const heartbeat = setInterval(() => { + if (!response.writableEnded) response.write(": keepalive\n\n") + }, 25_000) + response.on("close", () => { + streams.delete(response) + clearInterval(heartbeat) + socket.close() + }) + return + } + const commentRoute = + /^\/api\/hub\/comments\/(\d+)(?:\/(replies|resolution|read|reactions)(?:\/(%2B1|\+1|-1|laugh|hooray|confused|heart|rocket|eyes))?)?$/i.exec( + requestUrl.pathname, + ) + if (commentRoute) { + const [, id, operation, content] = commentRoute + const method = request.method || "" + const valid = !operation + ? ["PATCH", "DELETE"].includes(method) + : operation === "replies" + ? method === "POST" && !content + : operation === "resolution" || operation === "read" + ? ["POST", "DELETE"].includes(method) && !content + : (method === "POST" && !content) || (method === "DELETE" && !!content) + if (!valid) throw httpError(405, "Review action not supported.") + const body = + (!operation && method === "PATCH") || + operation === "replies" || + (operation === "read" && method === "POST") || + operation === "reactions" + ? await readJson(request) + : undefined + const status = await hub.status() + if (status.state !== "connected" && status.state !== "undiscovered") + throw httpError(403, "Connect to Hub before commenting.") + const scope = await localScope(hub, status) + if ((await localComments.list(scope)).some((comment) => comment.id === Number(id))) { + if (operation === "read" && method === "POST") { + sendJson(response, 200, {}) + return + } + if (operation) throw httpError(409, "Publish this comment before starting a conversation.") + const comment = + method === "PATCH" + ? await localComments.update(scope, id, readHubObject(body?.review_comment)?.body) + : await localComments.remove(scope, id) + sendJson(response, 200, { comments: [comment] }) + return + } + const suffix = operation ? `/${operation}${content ? `/${content}` : ""}` : "" + sendJson( + response, + 200, + await hub.request(`${await reviewPath(hub)}/${id}${suffix}`, { + method, + body, + interactive: operation !== "read" || method === "DELETE", + }), + ) + return + } + } + + if (request.method === "POST" && requestUrl.pathname === "/api/refresh") { + sendJson(response, 200, await refreshPrototypeStatus(await currentCanvasContext(context))) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/publish") { + const input = await readJson(request, 16_384, { allowEmpty: true }) + const publishContext = await currentCanvasContext(context) + const preparation = await preparePublish(publishContext, publishPreferences) + if (input.target && !samePublishTarget(readPublishTarget(input.target), preparation.target)) + throw httpError(409, "The checkout changed while preparing to publish. Try again.") + if ( + input.workingTree !== undefined && + (typeof input.workingTree !== "string" || + !["clean", "changed", "unavailable"].includes(input.workingTree)) + ) + throw httpError(422, "Invalid working tree state.") + if (preparation.workingTree === "unavailable" && input.mode !== "committed-only") + throw httpError( + 409, + `Could not check local changes. Retry the check or choose Publish committed version only. ${"workingTreeError" in preparation ? preparation.workingTreeError : undefined}`, + ) + if (input.workingTree === "clean" && preparation.workingTree === "changed") + throw httpError(409, "Uncommitted changes appeared while preparing to publish. Try again.") + const mode = + input.mode ?? + (preparation.workingTree === "changed" && preparation.remember + ? "commit-changes" + : preparation.workingTree === "clean" + ? "committed-only" + : null) + if (!mode) + throw httpError(409, "Uncommitted changes exist. Choose how to publish before continuing.") + if (mode !== "commit-changes" && mode !== "committed-only") + throw httpError(422, "Choose a supported publishing mode.") + if (input.remember !== undefined && typeof input.remember !== "boolean") + throw httpError(422, "Invalid publishing preference.") + if (input.remember === true && mode !== "commit-changes") + throw httpError(422, "Only Commit changes & publish can be remembered.") + const remember = input.remember + sendJson( + response, + 202, + await requestPublish(publishContext, session, { + prepareRegistration: () => registerForPublish(publishContext), + instanceId: options.instanceId, + mode, + expectedTarget: preparation.target, + savePreference: + remember !== undefined + ? (target) => publishPreferences.write(target, remember) + : undefined, + }), + ) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/publish/prepare") { + sendJson( + response, + 200, + await preparePublish(await currentCanvasContext(context), publishPreferences), + ) + return + } + if (request.method === "POST" && requestUrl.pathname === "/api/publish/preference") { + const input = await readJson(request) + const preparation = await preparePublish( + await currentCanvasContext(context), + publishPreferences, + ) + if (!input.target || !samePublishTarget(readPublishTarget(input.target), preparation.target)) + throw httpError(409, "The checkout changed. Refresh Settings and try again.") + if (typeof input.remember !== "boolean") throw new Error("Invalid publishing preference.") + await publishPreferences.write(preparation.target, input.remember) + sendJson(response, 200, { remember: input.remember }) + return + } + + sendJson(response, 404, { error: "Not found." }) +} + +function readPublishTarget(input: unknown): Partial | null { + const value = readHubObject(input) + if (!value) return null + if ( + (value.repository !== undefined && + value.repository !== null && + typeof value.repository !== "string") || + (value.branch !== undefined && typeof value.branch !== "string") || + (value.commitSha !== undefined && typeof value.commitSha !== "string") || + (value.originFingerprint !== undefined && + value.originFingerprint !== null && + typeof value.originFingerprint !== "string") || + (value.pushFingerprint !== undefined && + value.pushFingerprint !== null && + typeof value.pushFingerprint !== "string") + ) + throw httpError(422, "Invalid publication target.") + return { + repository: value.repository, + branch: value.branch, + commitSha: value.commitSha, + originFingerprint: value.originFingerprint, + pushFingerprint: value.pushFingerprint, + } +} + +function requireToken(request: IncomingMessage, token: string) { + if (request.headers["x-replica-canvas-token"] !== token) { + throw httpError(403, "Invalid canvas request token.") + } +} + +function sendHtml(response: ServerResponse, html: string) { + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": [ + "default-src 'none'", + "style-src 'self' 'unsafe-inline'", + "script-src 'self' 'unsafe-inline'", + "connect-src 'self'", + "img-src 'self' data: https://avatars.githubusercontent.com", + "frame-src http: https:", + "font-src 'self'", + "base-uri 'none'", + "form-action 'none'", + ].join("; "), + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }) + response.end(html) +} + +async function sendAsset(response: ServerResponse, asset: Asset) { + const body = await readFile(asset.fileUrl) + response.writeHead(200, { + "Cache-Control": "no-cache", + "Content-Type": asset.contentType, + "X-Content-Type-Options": "nosniff", + }) + response.end(body) +} + +function sendJson(response: ServerResponse, statusCode: number, body: unknown) { + if (response.headersSent) { + return + } + + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + }) + response.end(`${JSON.stringify(body)}\n`) +} + +function httpError(statusCode: number, message: string) { + return Object.assign(new Error(message), { statusCode }) +} + +function hasKeys(value: unknown, keys: string[]): value is Record { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) + ) +} + +function boundedString(value: unknown, limit: number): value is string { + return typeof value === "string" && value.length <= limit +} + +function validRoute(route: unknown) { + return ( + hasKeys(route, ["pathname", "search", "hash"]) && + boundedString(route.pathname, 2048) && + boundedString(route.search, 2048) && + boundedString(route.hash, 1024) + ) +} + +function validAgentResult(result: Record) { + if (!boundedString(result.id, 36) || !uuidInputPattern.test(result.id)) return false + if (result.ok === false) + return ( + hasKeys(result, ["id", "ok", "error"]) && + boundedString(result.error, 2048) && + result.error.length > 0 + ) + if (result.ok !== true || !hasKeys(result, ["id", "ok", "value"])) return false + const value = result.value + if ( + hasKeys(value, ["status", "url", "error", "controls"]) || + hasKeys(value, ["status", "url", "error", "controls", "runtime"]) + ) { + try { + projectControlsStatus(value) + return true + } catch { + return false + } + } + if (hasKeys(value, ["clicked", "route"])) return value.clicked === true && validRoute(value.route) + if (hasKeys(value, ["reloaded", "address"])) + return value.reloaded === true && boundedString(value.address, MAX_RELOAD_ADDRESS_LENGTH) + return ( + hasKeys(value, [ + "address", + "addressDraft", + "appearance", + "canGoBack", + "canGoForward", + "historyIndex", + "historyLength", + "route", + ]) && + boundedString(value.address, 2048) && + boundedString(value.addressDraft, 2048) && + (value.appearance === "light" || value.appearance === "dark") && + typeof value.canGoBack === "boolean" && + typeof value.canGoForward === "boolean" && + Number.isSafeInteger(value.historyIndex) && + Number.isSafeInteger(value.historyLength) && + (value.route === null || validRoute(value.route)) + ) +} + +async function reviewPath(hub: HubClient) { + const status = await hub.status() + if (status.state !== "connected") throw httpError(409, "Connect to a discovered Hub prototype.") + return prototypeReviewPath(status.prototype) +} + +function prototypeReviewPath(prototype: Record) { + if ( + typeof prototype.repositoryId !== "number" || + !Number.isSafeInteger(prototype.repositoryId) || + prototype.repositoryId < 1 || + typeof prototype.id !== "number" || + !Number.isSafeInteger(prototype.id) || + prototype.id < 1 + ) + throw httpError(502, "Hub returned an invalid prototype identity.") + return `/api/canvas/repositories/${prototype.repositoryId}/prototypes/${prototype.id}/review_comments` +} + +async function readJson( + request: IncomingMessage, + limit = 16_384, + { allowEmpty = false } = {}, +): Promise> { + const chunks: Buffer[] = [] + let bytes = 0 + const incoming: AsyncIterable = request + for await (const chunk of incoming) { + if (!Buffer.isBuffer(chunk)) throw httpError(400, "Invalid canvas request body.") + bytes += chunk.length + if (bytes > limit) throw httpError(413, "Canvas request is too large.") + chunks.push(chunk) + } + if (bytes === 0 && allowEmpty) return {} + try { + const parsed = readHubObject(JSON.parse(Buffer.concat(chunks).toString("utf8"))) + if (parsed && !Array.isArray(parsed)) return parsed + } catch { + // Report malformed JSON as an invalid request. + } + throw httpError(400, "Invalid canvas JSON.") +} + +function closeServer(server: Server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} diff --git a/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs b/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs index 83522b33..4eb93eff 100644 --- a/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs +++ b/.github/extensions/replica-prototype/start-canvas-server/index.test.mjs @@ -12,6 +12,7 @@ import { withContext } from "../test-support/with-context/index.mjs" import { readState } from "../read-state/index.mjs" import { writeState } from "../write-state/index.mjs" import { createPublishPreferences } from "../publish-preferences/index.mjs" +import { agentCommands } from "../agent-commands/index.mjs" function publishData(prompt) { assert.match(prompt, /\/replica-publish-prototype\b/) @@ -21,6 +22,61 @@ function publishData(prompt) { } describe("startCanvasServer()", () => { + it("rejects malformed HTTP action results without consuming the pending command", async () => { + await withContext(async (context) => { + const commands = agentCommands() + const canvas = await startCanvasServer( + context, + { + send() { + throw new Error("Action results must not send chat requests.") + }, + }, + { agentCommands: commands }, + ) + try { + const html = await (await fetch(canvas.url)).text() + const { token } = JSON.parse(html.match(/window\.__REPLICA_CANVAS__ = ([^;]+);/)[1]) + const headers = { "X-Replica-Canvas-Token": token, "X-Replica-Renderer-Id": "renderer-1" } + const pending = commands.invoke("reload") + pending.catch(() => {}) + const next = await (await fetch(new URL("/api/agent/next", canvas.url), { headers })).json() + const id = next.command.id + for (const body of ["null", "[]", "{broken"]) { + const response = await fetch(new URL("/api/agent/result", canvas.url), { + method: "POST", + headers, + body, + }) + assert.equal(response.status, 400) + } + for (const value of [ + { id, ok: true, value: { reloaded: true, address: 123 } }, + { id, ok: false, error: "" }, + { id, ok: false, error: "Failed", extra: true }, + ]) { + const response = await fetch(new URL("/api/agent/result", canvas.url), { + method: "POST", + headers, + body: JSON.stringify(value), + }) + assert.equal(response.status, 422) + } + assert.equal(commands.diagnostics().requests[0].outcome, "pending") + const value = { reloaded: true, address: context.normalizedUrl } + const accepted = await fetch(new URL("/api/agent/result", canvas.url), { + method: "POST", + headers, + body: JSON.stringify({ id, ok: true, value }), + }) + assert.equal(accepted.status, 200) + assert.deepEqual(await pending, value) + } finally { + await canvas.close() + } + }) + }) + it("protects saved notes with the canvas token and restores them after reopening", async () => { await withContext(async (context) => { const options = { diff --git a/.github/extensions/replica-prototype/start-missing-prototype-server/index.mjs b/.github/extensions/replica-prototype/start-missing-prototype-server/index.mjs index a15afb5f..03bb66e0 100644 --- a/.github/extensions/replica-prototype/start-missing-prototype-server/index.mjs +++ b/.github/extensions/replica-prototype/start-missing-prototype-server/index.mjs @@ -1,21 +1,17 @@ import { randomBytes } from "node:crypto" import { createServer } from "node:http" - -import { renderMissingPrototypeHtml } from "../render-missing-prototype-html/index.mjs" import { DESCRIPTION_ERROR, publicStartupError, publicStartupStatus, StartupError, } from "../startup-error/index.mjs" - const SCAN_ERROR = "Could not scan for running servers. Check that lsof is installed and can inspect this checkout, then scan again." const CONNECT_ERROR = "Could not connect to that server. Make sure it is running, then scan again or choose another server." const DISCONNECTED_MESSAGE = "Prototype server unavailable. Start it again or choose another server." - export async function startMissingPrototypeServer({ discover, connect, @@ -27,7 +23,10 @@ export async function startMissingPrototypeServer({ let scan let prototypeUrl = null let message = "" - const server = createServer(async (request, response) => { + const server = createServer((request, response) => { + void serve(request, response) + }) + async function serve(request, response) { if (request.headers.host !== `127.0.0.1:${request.socket.localPort}`) { send(response, 403, "Invalid canvas host.", "text/plain; charset=utf-8") return @@ -78,7 +77,12 @@ export async function startMissingPrototypeServer({ } if (request.method === "POST" && requestUrl.pathname === "/start" && devServer) { try { - const url = await devServer.start(requestUrl.searchParams.get("script")) + const script = requestUrl.searchParams.get("script") + if (script === null) + throw new StartupError( + "Dev scripts changed or are unavailable. Scan again before starting.", + ) + const url = await devServer.start(script) try { await connect(url) } catch { @@ -133,11 +137,7 @@ export async function startMissingPrototypeServer({ response, 200, JSON.stringify({ - servers: result.servers.map(({ url, command, pid }) => ({ - url, - command, - pid, - })), + servers: result.servers.map(({ url, command, pid }) => ({ url, command, pid })), warnings: result.warnings.map(publicWarning), ...(devServer ? { @@ -147,12 +147,7 @@ export async function startMissingPrototypeServer({ })), } : startupUnavailableMessage - ? { - development: { - scripts: [], - message: startupUnavailableMessage, - }, - } + ? { development: { scripts: [], message: startupUnavailableMessage } } : {}), }), "application/json", @@ -163,8 +158,7 @@ export async function startMissingPrototypeServer({ return } send(response, 404, "Not found.", "text/plain; charset=utf-8") - }) - + } await new Promise((resolve, reject) => { server.once("error", reject) server.listen(0, "127.0.0.1", () => { @@ -172,13 +166,11 @@ export async function startMissingPrototypeServer({ resolve() }) }) - const address = server.address() if (!address || typeof address === "string") { await closeServer(server) throw new Error("Replica prototype server did not receive a loopback port.") } - return { url: `http://127.0.0.1:${address.port}/?document=${documentToken}`, connect(url) { @@ -192,7 +184,6 @@ export async function startMissingPrototypeServer({ close: () => closeServer(server), } } - function publicWarning(warning) { // Discovery warnings can contain fetch errors; keep only the loopback URL and HTTP status. const match = @@ -205,7 +196,237 @@ function publicWarning(warning) { if (match[2]) return `${match[1]} did not return an HTML page (HTTP ${match[2]}).` return `${match[1]} could not be checked. Make sure it is running, then scan again.` } - +function renderMissingPrototypeHtml(token) { + return ` + + + + + Replica prototype + + + +
+

Open your prototype

+

Checking this checkout for running servers...

+
+ + +
+ + + +
    +

    +
    + + +` +} function send(response, status, body, contentType, nonce) { response.writeHead(status, { "Content-Type": contentType, @@ -216,7 +437,6 @@ function send(response, status, body, contentType, nonce) { }) response.end(body) } - function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())) diff --git a/.github/extensions/replica-prototype/start-missing-prototype-server/index.mts b/.github/extensions/replica-prototype/start-missing-prototype-server/index.mts new file mode 100644 index 00000000..0040d3da --- /dev/null +++ b/.github/extensions/replica-prototype/start-missing-prototype-server/index.mts @@ -0,0 +1,471 @@ +import { randomBytes } from "node:crypto" +import { createServer } from "node:http" +import type { IncomingMessage, Server, ServerResponse } from "node:http" +import type { DevServer } from "../dev-server/index.mjs" +import type { PrototypeServers } from "../discover-prototype-servers/index.mjs" + +import { + DESCRIPTION_ERROR, + publicStartupError, + publicStartupStatus, + StartupError, +} from "../startup-error/index.mjs" + +const SCAN_ERROR = + "Could not scan for running servers. Check that lsof is installed and can inspect this checkout, then scan again." +const CONNECT_ERROR = + "Could not connect to that server. Make sure it is running, then scan again or choose another server." +const DISCONNECTED_MESSAGE = + "Prototype server unavailable. Start it again or choose another server." + +export type MissingPrototypeServer = Awaited> +export type MissingPrototypeServerOptions = { + discover: () => Promise + connect: (url: string) => Promise + devServer?: DevServer + startupUnavailableMessage?: string +} + +export async function startMissingPrototypeServer({ + discover, + connect, + devServer, + startupUnavailableMessage, +}: MissingPrototypeServerOptions) { + const token = randomBytes(24).toString("base64url") + const documentToken = randomBytes(32).toString("base64url") + let scan: Promise | undefined + let prototypeUrl: string | null = null + let message = "" + const server = createServer((request, response) => { + void serve(request, response) + }) + async function serve(request: IncomingMessage, response: ServerResponse) { + if (request.headers.host !== `127.0.0.1:${request.socket.localPort}`) { + send(response, 403, "Invalid canvas host.", "text/plain; charset=utf-8") + return + } + let requestUrl + try { + requestUrl = new URL(request.url || "/", "http://127.0.0.1") + } catch { + send(response, 400, "Invalid canvas request URL.", "text/plain; charset=utf-8") + return + } + if (request.method === "GET" && requestUrl.pathname === "/") { + if ( + requestUrl.searchParams.getAll("document").length !== 1 || + requestUrl.searchParams.get("document") !== documentToken + ) { + send(response, 403, "Invalid canvas document token.", "text/plain; charset=utf-8") + return + } + if (prototypeUrl) { + response.writeHead(302, { + Location: prototypeUrl, + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }) + response.end() + return + } + send(response, 200, renderMissingPrototypeHtml(token), "text/html; charset=utf-8", token) + return + } + if (request.headers["x-replica-canvas-token"] !== token) { + send(response, 403, "Invalid canvas token.", "text/plain; charset=utf-8") + return + } + if (request.method === "GET" && requestUrl.pathname === "/status") { + send( + response, + 200, + JSON.stringify({ + prototypeUrl, + message, + ...(devServer ? { startup: publicStartupStatus(devServer.status()) } : {}), + }), + "application/json", + ) + return + } + if (request.method === "POST" && requestUrl.pathname === "/start" && devServer) { + try { + const script = requestUrl.searchParams.get("script") + if (script === null) + throw new StartupError( + "Dev scripts changed or are unavailable. Scan again before starting.", + ) + const url = await devServer.start(script) + try { + await connect(url) + } catch { + throw new StartupError(CONNECT_ERROR) + } + send(response, 200, JSON.stringify({ prototypeUrl }), "application/json") + } catch (error) { + send( + response, + 500, + JSON.stringify({ + error: publicStartupError(error), + }), + "application/json", + ) + } + return + } + if ( + (request.method === "GET" && requestUrl.pathname === "/servers") || + (request.method === "POST" && requestUrl.pathname === "/connect") + ) { + let failureMessage = SCAN_ERROR + try { + scan ||= Promise.resolve() + .then(discover) + .finally(() => { + scan = undefined + }) + const result = await scan + if (request.method === "POST") { + const selected = result.servers.find( + (candidate) => candidate.url === requestUrl.searchParams.get("url"), + ) + if (!selected) { + send( + response, + 409, + JSON.stringify({ + error: "This server is no longer available in this checkout. Scan again.", + }), + "application/json", + ) + return + } + failureMessage = CONNECT_ERROR + await connect(selected.url) + send(response, 200, JSON.stringify({ prototypeUrl }), "application/json") + return + } + send( + response, + 200, + JSON.stringify({ + servers: result.servers.map(({ url, command, pid }) => ({ url, command, pid })), + warnings: result.warnings.map(publicWarning), + ...(devServer + ? { + development: await devServer.describe().catch((error) => ({ + scripts: [], + message: publicStartupError(error, DESCRIPTION_ERROR), + })), + } + : startupUnavailableMessage + ? { development: { scripts: [], message: startupUnavailableMessage } } + : {}), + }), + "application/json", + ) + } catch { + send(response, 500, JSON.stringify({ error: failureMessage }), "application/json") + } + return + } + send(response, 404, "Not found.", "text/plain; charset=utf-8") + } + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve() + }) + }) + + const address = server.address() + if (!address || typeof address === "string") { + await closeServer(server) + throw new Error("Replica prototype server did not receive a loopback port.") + } + + return { + url: `http://127.0.0.1:${address.port}/?document=${documentToken}`, + connect(url: string) { + prototypeUrl = url + message = "" + }, + disconnect() { + prototypeUrl = null + message = DISCONNECTED_MESSAGE + }, + close: () => closeServer(server), + } +} + +function publicWarning(warning: unknown) { + // Discovery warnings can contain fetch errors; keep only the loopback URL and HTTP status. + const match = + typeof warning === "string" && + /^(http:\/\/(?:127(?:\.\d+){3}|\[::1\]):\d+\/) (?:did not return an HTML page \(HTTP (\d{3})\)\.|could not be checked:[\s\S]*)$/.exec( + warning, + ) + if (!match) + return "A server could not be checked. Make sure it is running and serves HTML, then scan again." + if (match[2]) return `${match[1]} did not return an HTML page (HTTP ${match[2]}).` + return `${match[1]} could not be checked. Make sure it is running, then scan again.` +} + +function renderMissingPrototypeHtml(token: string) { + return ` + + + + + Replica prototype + + + +
    +

    Open your prototype

    +

    Checking this checkout for running servers...

    +
    + + +
    + + + +
      +

      +
      + + +` +} + +function send( + response: ServerResponse, + status: number, + body: string, + contentType: string, + nonce?: string, +) { + response.writeHead(status, { + "Content-Type": contentType, + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": `default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce || ""}'; connect-src 'self'; base-uri 'none'; form-action 'none'`, + }) + response.end(body) +} + +function closeServer(server: Server) { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) +} diff --git a/.github/extensions/replica-prototype/start-preview-proxy/index.mjs b/.github/extensions/replica-prototype/start-preview-proxy/index.mjs index 120858a3..ce897eac 100644 --- a/.github/extensions/replica-prototype/start-preview-proxy/index.mjs +++ b/.github/extensions/replica-prototype/start-preview-proxy/index.mjs @@ -3,7 +3,7 @@ import { createServer, request as httpRequest } from "node:http" import { request as httpsRequest } from "node:https" import { pipeline } from "node:stream/promises" import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib" - +import { readHubError } from "../read-hub-error/index.mjs" const loaderPath = "/__replica/canvas/loader.js" const runtimePath = "/__replica/canvas/runtime.js" const hopHeaders = [ @@ -17,7 +17,6 @@ const hopHeaders = [ "upgrade", ] const maxHtmlBytes = 8 * 1024 * 1024 - /** Gives each local prototype preview its own unprivileged origin. */ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) { const upstream = new URL(prototypeUrl) @@ -36,17 +35,16 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) addresses.some(({ address }) => address !== "127.0.0.1" && address !== "::1") ) throw new Error("The prototype host must resolve only to loopback.") - const sockets = new Set() const requests = new Set() - let origin let closing const server = createServer((request, response) => { - void forward(request, response).catch((error) => { + void forward(request, response).catch((caught) => { + const error = readHubError(caught) if (response.destroyed) return if (response.headersSent) response.destroy(error) else { - response.writeHead(error.statusCode || 502, { + response.writeHead(typeof error.statusCode === "number" ? error.statusCode : 502, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", @@ -102,9 +100,10 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) socket.pipe(upstreamSocket).pipe(socket) }) target.end() - } catch (error) { + } catch (caught) { + const error = readHubError(caught) socket.end( - `HTTP/1.1 ${error.statusCode || 502} Preview unavailable\r\nConnection: close\r\n\r\n`, + `HTTP/1.1 ${typeof error.statusCode === "number" ? error.statusCode : 502} Preview unavailable\r\nConnection: close\r\n\r\n`, ) } }) @@ -115,8 +114,14 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) resolve() }) }) - origin = `http://127.0.0.1:${server.address().port}` - + const address = server.address() + if (!address || typeof address === "string") { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + throw new Error("Replica preview server did not receive a loopback port.") + } + const origin = `http://127.0.0.1:${address.port}` function trackSocket(socket) { sockets.add(socket) socket.on("error", () => socket.destroy()) @@ -128,7 +133,7 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) !request.url?.startsWith("/") || request.url.startsWith("//") || request.url.includes("\\") || - ["CONNECT", "TRACE"].includes(request.method) || + (request.method !== undefined && ["CONNECT", "TRACE"].includes(request.method)) || request.headers["sec-fetch-site"] === "cross-site" || (request.headers.origin && request.headers.origin !== origin) ) @@ -162,7 +167,7 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) headers.connection = "Upgrade" headers.upgrade = "websocket" } - const target = (upstream.protocol === "https:" ? httpsRequest : httpRequest)({ + const targetOptions = { protocol: upstream.protocol, hostname, port: upstream.port, @@ -170,11 +175,17 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) path: request.url, headers, autoSelectFamily: true, - lookup: (_host, options, callback) => - options.all - ? callback(null, addresses) - : callback(null, addresses[0].address, addresses[0].family), - }) + lookup: (_host, options, callback) => { + const first = addresses[0] + if (!first) { + callback(new Error("The prototype host must resolve only to loopback."), "", 4) + return + } + if (options.all) callback(null, addresses) + else callback(null, first.address, first.family) + }, + } + const target = (upstream.protocol === "https:" ? httpsRequest : httpRequest)(targetOptions) requests.add(target) target.once("close", () => requests.delete(target)) target.setTimeout(30_000, () => target.destroy(new Error("The prototype server timed out."))) @@ -196,7 +207,7 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) } async function forward(request, response) { validateRequest(request) - const url = new URL(request.url, origin) + const url = new URL(request.url || "/", origin) if ([loaderPath, runtimePath].includes(url.pathname)) { if ( request.method !== "GET" || @@ -229,17 +240,19 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) request.pipe(target) const result = await received try { - const headers = responseHeaders(result.headers, new URL(request.url, upstream.origin)) + const headers = responseHeaders(result.headers, new URL(request.url || "/", upstream.origin)) const html = /^text\/html(?:;|$)/i.test(headers["content-type"] || "") if (html && request.method !== "HEAD" && result.statusCode === 200) { if ( - /charset=/i.test(headers["content-type"]) && - !/charset=(?:"?utf-8"?|"?us-ascii"?)(?:;|$)/i.test(headers["content-type"]) + /charset=/i.test(headers["content-type"] || "") && + !/charset=(?:"?utf-8"?|"?us-ascii"?)(?:;|$)/i.test(headers["content-type"] || "") ) throw new Error("Preview HTML must use UTF-8.") let size = 0 const chunks = [] - for await (const chunk of result) { + const incoming = result + for await (const chunk of incoming) { + if (!Buffer.isBuffer(chunk)) throw new Error("The prototype returned invalid HTML bytes.") size += chunk.length if (size > maxHtmlBytes) throw new Error("Preview HTML exceeds the 8 MiB limit.") chunks.push(chunk) @@ -248,7 +261,7 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) const compression = headers["content-encoding"] const decompress = { gzip: gunzipSync, br: brotliDecompressSync, deflate: inflateSync } if (compression && compression !== "identity") { - if (!Object.hasOwn(decompress, compression)) + if (compression !== "gzip" && compression !== "br" && compression !== "deflate") throw new Error("Unsupported preview HTML compression.") body = decompress[compression](body, { maxOutputLength: maxHtmlBytes }) } @@ -265,10 +278,12 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) delete headers[header] headers["cache-control"] = "no-store" // CSP and framing policies remain authoritative, even if they block the bridge. - response.writeHead(result.statusCode, headers) + response.writeHead(200, headers) response.end(text) } else { target.setTimeout(0) + if (result.statusCode === undefined) + throw new Error("The prototype returned no HTTP status.") response.writeHead(result.statusCode, headers) await pipeline(result, response) } @@ -295,7 +310,6 @@ export async function startPreviewProxy({ prototypeUrl, parentOrigin, source }) }, } } - function withoutHopHeaders(original) { const headers = { ...original } for (const name of [ @@ -305,7 +319,6 @@ function withoutHopHeaders(original) { delete headers[name] return headers } - function requestError(message) { return Object.assign(new Error(message), { statusCode: 403 }) } diff --git a/.github/extensions/replica-prototype/start-preview-proxy/index.mts b/.github/extensions/replica-prototype/start-preview-proxy/index.mts new file mode 100644 index 00000000..0a7469f3 --- /dev/null +++ b/.github/extensions/replica-prototype/start-preview-proxy/index.mts @@ -0,0 +1,345 @@ +import { lookup } from "node:dns/promises" +import { createServer, request as httpRequest } from "node:http" +import type { + IncomingMessage, + IncomingHttpHeaders, + ServerResponse, + ClientRequest, + RequestOptions, +} from "node:http" +import { request as httpsRequest } from "node:https" +import type { Duplex } from "node:stream" +import { pipeline } from "node:stream/promises" +import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib" +import { readHubError } from "../read-hub-error/index.mjs" + +export type PreviewProxy = Awaited> +export type PreviewProxyOptions = { prototypeUrl: string; parentOrigin: string; source: string } + +const loaderPath = "/__replica/canvas/loader.js" +const runtimePath = "/__replica/canvas/runtime.js" +const hopHeaders = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +] +const maxHtmlBytes = 8 * 1024 * 1024 + +/** Gives each local prototype preview its own unprivileged origin. */ +export async function startPreviewProxy({ + prototypeUrl, + parentOrigin, + source, +}: PreviewProxyOptions) { + const upstream = new URL(prototypeUrl) + if ( + !["http:", "https:"].includes(upstream.protocol) || + !["localhost", "127.0.0.1", "[::1]"].includes(upstream.hostname) || + upstream.username || + upstream.password || + upstream.origin === parentOrigin + ) + throw new Error("Replica preview requires an unauthenticated loopback prototype URL.") + const hostname = upstream.hostname.replace(/^\[|\]$/g, "") + const addresses = await lookup(hostname, { all: true }) + if ( + !addresses.length || + addresses.some(({ address }) => address !== "127.0.0.1" && address !== "::1") + ) + throw new Error("The prototype host must resolve only to loopback.") + + const sockets = new Set() + const requests = new Set() + let closing: Promise | undefined + const server = createServer((request, response) => { + void forward(request, response).catch((caught: unknown) => { + const error = readHubError(caught) + if (response.destroyed) return + if (response.headersSent) response.destroy(error) + else { + response.writeHead(typeof error.statusCode === "number" ? error.statusCode : 502, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + response.end(`Replica preview: ${error.message}`) + } + }) + }) + server.on("connection", trackSocket) + server.on("upgrade", (request, socket, head) => { + try { + validateRequest(request) + if ( + request.headers.origin !== origin || + request.headers.upgrade?.toLowerCase() !== "websocket" + ) + throw requestError("Invalid preview WebSocket origin.") + const target = upstreamRequest(request, true) + target.on("error", () => socket.destroy()) + socket.once("close", () => target.destroy()) + target.on("response", (response) => { + response.resume() + socket.end( + `HTTP/1.1 ${response.statusCode} WebSocket unavailable\r\nConnection: close\r\n\r\n`, + ) + }) + target.on("upgrade", (response, upstreamSocket, upstreamHead) => { + target.setTimeout(0) + trackSocket(upstreamSocket) + let headers + try { + headers = responseHeaders(response.headers) + } catch { + upstreamSocket.destroy() + socket.end("HTTP/1.1 502 Preview unavailable\r\nConnection: close\r\n\r\n") + return + } + headers.connection = "Upgrade" + headers.upgrade = "websocket" + socket.write( + `HTTP/1.1 101 Switching Protocols\r\n${Object.entries(headers) + .flatMap(([name, value]) => + (Array.isArray(value) ? value : [value]).map((entry) => `${name}: ${entry}\r\n`), + ) + .join("")}\r\n`, + ) + if (upstreamHead.length) socket.write(upstreamHead) + if (head.length) upstreamSocket.write(head) + socket.on("error", () => upstreamSocket.destroy()) + upstreamSocket.on("error", () => socket.destroy()) + socket.once("close", () => upstreamSocket.destroy()) + upstreamSocket.once("close", () => socket.destroy()) + socket.pipe(upstreamSocket).pipe(socket) + }) + target.end() + } catch (caught) { + const error = readHubError(caught) + socket.end( + `HTTP/1.1 ${typeof error.statusCode === "number" ? error.statusCode : 502} Preview unavailable\r\nConnection: close\r\n\r\n`, + ) + } + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === "string") { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + throw new Error("Replica preview server did not receive a loopback port.") + } + const origin = `http://127.0.0.1:${address.port}` + + function trackSocket(socket: Duplex) { + sockets.add(socket) + socket.on("error", () => socket.destroy()) + socket.once("close", () => sockets.delete(socket)) + } + function validateRequest(request: IncomingMessage) { + if ( + request.headers.host !== new URL(origin).host || + !request.url?.startsWith("/") || + request.url.startsWith("//") || + request.url.includes("\\") || + (request.method !== undefined && ["CONNECT", "TRACE"].includes(request.method)) || + request.headers["sec-fetch-site"] === "cross-site" || + (request.headers.origin && request.headers.origin !== origin) + ) + throw requestError("Invalid preview request origin or target.") + if (request.headers.authorization || request.headers["proxy-authorization"]) + throw requestError("Authenticated prototype previews are not supported.") + } + function upstreamRequest(request: IncomingMessage, websocket = false) { + const headers = withoutHopHeaders(request.headers) + // Port isolation does not isolate localhost cookies or canvas capabilities. + for (const name of Object.keys(headers)) { + if ( + name === "cookie" || + name.startsWith("x-replica-") || + name.startsWith("sec-fetch-") || + name.startsWith("x-forwarded-") || + name === "forwarded" + ) + delete headers[name] + } + headers.host = upstream.host + headers["accept-encoding"] = "identity" + if (headers.origin) headers.origin = upstream.origin + if (headers.referer) { + const referer = new URL(headers.referer) + if (referer.origin === origin) + headers.referer = `${upstream.origin}${referer.pathname}${referer.search}` + else delete headers.referer + } + if (websocket) { + headers.connection = "Upgrade" + headers.upgrade = "websocket" + } + const targetOptions: RequestOptions & { autoSelectFamily: boolean } = { + protocol: upstream.protocol, + hostname, + port: upstream.port, + method: request.method, + path: request.url, + headers, + autoSelectFamily: true, + lookup: (_host, options, callback) => { + const first = addresses[0] + if (!first) { + callback(new Error("The prototype host must resolve only to loopback."), "", 4) + return + } + if (options.all) callback(null, addresses) + else callback(null, first.address, first.family) + }, + } + const target = (upstream.protocol === "https:" ? httpsRequest : httpRequest)(targetOptions) + requests.add(target) + target.once("close", () => requests.delete(target)) + target.setTimeout(30_000, () => target.destroy(new Error("The prototype server timed out."))) + return target + } + function responseHeaders(original: IncomingHttpHeaders, requestUrl = upstream) { + const headers = withoutHopHeaders(original) + if (headers["set-cookie"] || headers["www-authenticate"]) + throw new Error("Authenticated prototype previews are not supported.") + if (headers.location) { + const destination = new URL(headers.location, requestUrl) + if (destination.origin !== upstream.origin || destination.username || destination.password) + throw new Error("The prototype redirected outside its selected origin.") + headers.location = `${origin}${destination.pathname}${destination.search}${destination.hash}` + } + if (headers["access-control-allow-origin"] === upstream.origin) + headers["access-control-allow-origin"] = origin + return headers + } + async function forward(request: IncomingMessage, response: ServerResponse) { + validateRequest(request) + const url = new URL(request.url || "/", origin) + if ([loaderPath, runtimePath].includes(url.pathname)) { + if ( + request.method !== "GET" || + (url.pathname === runtimePath && url.searchParams.get("parent") !== parentOrigin) + ) + throw requestError("Invalid preview bridge request.") + response.writeHead(200, { + "Content-Type": "text/javascript; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }) + response.end( + url.pathname === runtimePath + ? source + : `import { installBridge } from ${JSON.stringify(`${runtimePath}?parent=${encodeURIComponent(parentOrigin)}`)};\ninstallBridge(${JSON.stringify(parentOrigin)});\n`, + ) + return + } + const target = upstreamRequest(request) + response.once("close", () => target.destroy()) + request.once("aborted", () => target.destroy()) + let upstreamError: Error | undefined + const received = new Promise((resolve, reject) => { + target.once("response", resolve) + target.once("error", (error) => { + upstreamError = error + reject(error) + }) + }) + request.pipe(target) + const result = await received + try { + const headers = responseHeaders(result.headers, new URL(request.url || "/", upstream.origin)) + const html = /^text\/html(?:;|$)/i.test(headers["content-type"] || "") + if (html && request.method !== "HEAD" && result.statusCode === 200) { + if ( + /charset=/i.test(headers["content-type"] || "") && + !/charset=(?:"?utf-8"?|"?us-ascii"?)(?:;|$)/i.test(headers["content-type"] || "") + ) + throw new Error("Preview HTML must use UTF-8.") + let size = 0 + const chunks: Buffer[] = [] + const incoming: AsyncIterable = result + for await (const chunk of incoming) { + if (!Buffer.isBuffer(chunk)) throw new Error("The prototype returned invalid HTML bytes.") + size += chunk.length + if (size > maxHtmlBytes) throw new Error("Preview HTML exceeds the 8 MiB limit.") + chunks.push(chunk) + } + let body = Buffer.concat(chunks) + const compression = headers["content-encoding"] + const decompress = { gzip: gunzipSync, br: brotliDecompressSync, deflate: inflateSync } + if (compression && compression !== "identity") { + if (compression !== "gzip" && compression !== "br" && compression !== "deflate") + throw new Error("Unsupported preview HTML compression.") + body = decompress[compression](body, { maxOutputLength: maxHtmlBytes }) + } + let text = new TextDecoder("utf-8", { fatal: true }).decode(body) + // Module scripts run once per URL, including an existing adapter loader. + text += `` + for (const header of [ + "content-encoding", + "content-length", + "etag", + "content-md5", + "digest", + ]) + delete headers[header] + headers["cache-control"] = "no-store" + // CSP and framing policies remain authoritative, even if they block the bridge. + response.writeHead(200, headers) + response.end(text) + } else { + target.setTimeout(0) + if (result.statusCode === undefined) + throw new Error("The prototype returned no HTTP status.") + response.writeHead(result.statusCode, headers) + await pipeline(result, response) + } + } catch (error) { + // A closed response reports "aborted"; retain the upstream failure reason. + throw upstreamError || error + } finally { + if (!result.readableEnded) result.destroy() + } + } + const url = new URL(upstream) + url.protocol = "http:" + url.host = new URL(origin).host + return { + url: url.href, + close() { + if (closing) return closing + for (const request of requests) request.destroy() + for (const socket of sockets) socket.destroy() + closing = new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + return closing + }, + } +} + +function withoutHopHeaders(original: IncomingHttpHeaders): IncomingHttpHeaders { + const headers = { ...original } + for (const name of [ + ...hopHeaders, + ...(original.connection || "").split(",").map((name) => name.trim().toLowerCase()), + ]) + delete headers[name] + return headers +} + +function requestError(message: string) { + return Object.assign(new Error(message), { statusCode: 403 }) +} diff --git a/.github/extensions/replica-prototype/startup-error/index.mjs b/.github/extensions/replica-prototype/startup-error/index.mjs index 43a2b328..a5ca38dd 100644 --- a/.github/extensions/replica-prototype/startup-error/index.mjs +++ b/.github/extensions/replica-prototype/startup-error/index.mjs @@ -2,25 +2,25 @@ export const STARTUP_ERROR = "Could not start the dev server. Scan again, then retry or start it manually." export const DESCRIPTION_ERROR = "Could not read dev scripts. Check package.json and start your server manually." - export class StartupError extends Error {} - export function publicStartupError(error, fallback = STARTUP_ERROR) { return error instanceof StartupError ? error.message : fallback } - const publicStatuses = new WeakSet() - export function createStartupStatus(status) { // Only the manager's filtered messages may pass through the HTTP status projection. publicStatuses.add(status) return status } - export function publicStartupStatus(status) { return { starting: Boolean(status.starting), - message: publicStatuses.has(status) ? status.message : status.message ? STARTUP_ERROR : "", + message: + publicStatuses.has(status) && typeof status.message === "string" + ? status.message + : status.message + ? STARTUP_ERROR + : "", // Script output is intentionally visible, independently of exception filtering. output: typeof status.output === "string" ? status.output : "", } diff --git a/.github/extensions/replica-prototype/startup-error/index.mts b/.github/extensions/replica-prototype/startup-error/index.mts new file mode 100644 index 00000000..d8de4d8f --- /dev/null +++ b/.github/extensions/replica-prototype/startup-error/index.mts @@ -0,0 +1,38 @@ +export const STARTUP_ERROR = + "Could not start the dev server. Scan again, then retry or start it manually." +export const DESCRIPTION_ERROR = + "Could not read dev scripts. Check package.json and start your server manually." + +export class StartupError extends Error {} + +export function publicStartupError(error: unknown, fallback = STARTUP_ERROR) { + return error instanceof StartupError ? error.message : fallback +} + +export type StartupStatus = { starting: boolean; message: string; output: string } + +const publicStatuses = new WeakSet() + +export function createStartupStatus(status: StartupStatus) { + // Only the manager's filtered messages may pass through the HTTP status projection. + publicStatuses.add(status) + return status +} + +export function publicStartupStatus(status: { + starting?: unknown + message?: unknown + output?: unknown +}) { + return { + starting: Boolean(status.starting), + message: + publicStatuses.has(status) && typeof status.message === "string" + ? status.message + : status.message + ? STARTUP_ERROR + : "", + // Script output is intentionally visible, independently of exception filtering. + output: typeof status.output === "string" ? status.output : "", + } +} diff --git a/hub/docs/prototype-canvas.md b/hub/docs/prototype-canvas.md index 19f71dc7..4ba140ab 100644 --- a/hub/docs/prototype-canvas.md +++ b/hub/docs/prototype-canvas.md @@ -536,7 +536,7 @@ Plugin releases, the Controls definition format and the Canvas interface are ver Canvas frontend source follows Hub's normal layers under `hub/app/javascript/`. `application/prototype-canvas/` composes the interface; `application/start-prototype-canvas/` starts it, and `application/prototype-preview-bridge/` owns the injected preview entry point. Workflow panels belong in `features/`, reusable fields and dialogs in `components/`, and stateful browser behavior in `hooks/`. Shared contracts, locale copy, protocols and URL helpers live in `utils/`; the Controls engine and definition schema live in `utils/prototype-controls/`. Extension registration and server operations remain in `.github/extensions/replica-prototype/`. -The canvas frontend already uses TypeScript. Extension Node modules are migrating separately. Remote-branch checks, command execution, private storage, state-file reads and writes, repository identity and context, Hub clients and connection selection, origin validation and discovery, the learnings hook, publication requests and results, publication preferences and preparation, working-tree checks, prototype state and repository status, review and sharing workflows, local comment capture and storage, reviewer profiles, Controls diagnostics and validation, and chat diagnosis requests now have authored `index.mts` files. Servers, browser launch, preview connection and SDK registration still use authored JavaScript. +The canvas frontend already uses TypeScript. Extension Node modules are migrating separately. Remote-branch checks, command execution, private storage, state-file reads and writes, repository identity and context, Hub clients and connection selection, origin validation and discovery, the learnings hook, publication requests and results, publication preferences and preparation, working-tree checks, prototype state and repository status, review and sharing workflows, local comment capture and storage, reviewer profiles, Controls diagnostics and validation, and chat diagnosis requests now have authored `index.mts` files. Local HTTP servers, the preview proxy, prototype connection storage, development-server discovery and lifecycle, start-page reading, HTML rendering, browser launch, startup errors, and agent action delivery are also authored TypeScript. SDK registration, asset checking, and test helpers still use authored JavaScript. Pick & polish lives in `features/prototype-canvas-improvement-notes/`, with its selection and persistence hooks and presentation tests. The application loads it on demand; its generated feature and server-rendering chunks must stay in the server's public asset list. Anchor validation for the Node extension is built from `utils/review-anchor/validate-anchor.ts`, which is covered by Hub's shared type check. The build minifies the generated validator; its TypeScript source remains unminified. @@ -544,6 +544,8 @@ Edit migrated `.mts` sources, not their generated `.mjs` files. The extension's Publication workflows use `read-publication-state` to check the saved fields they consume while preserving unrelated fields and legacy requests without a target or mode. Invalid publication fields produce an explicit error instead of being replaced with idle state. Shared target, request, mode, outcome, session, and status types live with their owning modules; Hub and SDK responses remain untrusted until their consumed fields are checked. Review, local comment, sharing and Controls diagnostic types also live with their owning modules. Comment snapshots, reply receipts, local drafts and diagnosis request timestamps are checked when read from private storage. Malformed records fail explicitly rather than appearing empty or allowing an uncertain write to replay. The bundled Controls validator remains generated JavaScript from Hub's canonical validator; `validate-controls/controls-validator.d.ts` declares only the validation function and error fields consumed by the extension, outside the rebuilt assets directory. +Runtime modules export their server, proxy, connection, discovery, startup-status, development-server lease, and action-queue contracts from their owning modules. Connection storage returns unknown JSON for registration to check before reuse. HTTP handlers check request objects and action-result shapes before delivery; malformed results do not complete the pending action. The loopback host, origin, token and size checks, exact prototype URL identity, action cancellation, server ownership and explicit startup choice remain unchanged. + The Hub client exports its connection, request, status and recall contracts from its owning module. Requests return records with unknown field values; a response being valid JSON does not establish a prototype, review or sharing contract. Consuming workflows must check the fields they use. Stored credentials, saved origins, discovery responses and learnings are checked at their respective read boundaries. No Hub bearer credential reaches the renderer. Hub builds renderer files, Brand fonts and browser runtime into the plugin's `assets/`; commit them so installed users need no Node packages. Build configuration lives in `hub/vite.prototype-canvas.config.mjs` and `hub/script/`. Generated assets are excluded from formatting checks; do not edit them manually. There is no Vite/npm distribution or release preparation.