refactor: compose browser application runtimes

This commit is contained in:
vorotamoroz
2026-07-29 09:11:57 +00:00
parent b22c1bd777
commit 4723771ee0
65 changed files with 3919 additions and 2771 deletions
+103
View File
@@ -0,0 +1,103 @@
import { assertEquals } from "@std/assert";
import { extname, relative, resolve } from "@std/path";
import type { Page } from "playwright";
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function resolveStaticPath(root: string, pathname: string): string | undefined {
const requested = decodeURIComponent(pathname).replace(/^\/+/, "") || "index.html";
const candidate = resolve(root, requested);
const relativePath = relative(root, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
return undefined;
}
return candidate;
}
export async function startStaticServer(root: string): Promise<{
readonly baseUrl: string;
close(): Promise<void>;
}> {
let reportListening!: (port: number) => void;
const listening = new Promise<number>((resolvePromise) => {
reportListening = resolvePromise;
});
const server = Deno.serve(
{
hostname: "127.0.0.1",
onListen: ({ port }) => reportListening(port),
port: 0,
},
async (request) => {
const path = resolveStaticPath(root, new URL(request.url).pathname);
if (path === undefined) {
return new Response("Not found", { status: 404 });
}
try {
const stat = await Deno.stat(path);
const filePath = stat.isDirectory ? resolve(path, "index.html") : path;
const file = await Deno.open(filePath, { read: true });
return new Response(file.readable, {
headers: {
"content-type": contentType(filePath),
},
});
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return new Response("Not found", { status: 404 });
}
throw error;
}
}
);
const port = await listening;
return {
baseUrl: `http://127.0.0.1:${port}/`,
close: async () => {
await server.shutdown();
},
};
}
export function observePageFailures(page: Page): () => void {
const failures: string[] = [];
page.on("pageerror", (error) => {
failures.push(`pageerror: ${error.stack ?? error.message}`);
});
page.on("console", (message) => {
if (message.type() === "error") {
failures.push(`console.error: ${message.text()}`);
}
});
return () => assertEquals(failures, [], failures.join("\n"));
}
export async function waitFor(
predicate: () => Promise<boolean>,
message: string,
timeoutMilliseconds = 5_000
): Promise<void> {
const deadline = Date.now() + timeoutMilliseconds;
while (!(await predicate())) {
if (Date.now() >= deadline) {
throw new Error(message);
}
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
}
}
+172
View File
@@ -0,0 +1,172 @@
import { resolve } from "@std/path";
const repositoryRoot = resolve(import.meta.dirname!, "../../..");
const cliDirectory = resolve(repositoryRoot, "src/apps/cli");
const cliDist = resolve(cliDirectory, "dist/index.cjs");
export interface CliResult {
readonly code: number;
readonly combined: string;
readonly stderr: string;
readonly stdout: string;
}
export class TemporaryDirectory implements AsyncDisposable {
private constructor(readonly path: string) {}
static async create(prefix: string): Promise<TemporaryDirectory> {
return new TemporaryDirectory(await Deno.makeTempDir({ prefix: `${prefix}.` }));
}
resolve(...parts: string[]): string {
return resolve(this.path, ...parts);
}
async [Symbol.asyncDispose](): Promise<void> {
await Deno.remove(this.path, { recursive: true }).catch(() => {});
}
}
async function collectStream(stream: ReadableStream<Uint8Array>, append: (text: string) => void): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
append(decoder.decode());
return;
}
if (value !== undefined) {
append(decoder.decode(value, { stream: true }));
}
}
} finally {
reader.releaseLock();
}
}
export async function runCli(...args: string[]): Promise<CliResult> {
const output = await new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).output();
const decoder = new TextDecoder();
const stdout = decoder.decode(output.stdout);
const stderr = decoder.decode(output.stderr);
return {
code: output.code,
combined: stdout + stderr,
stderr,
stdout,
};
}
export async function initSettingsFile(path: string): Promise<void> {
const result = await runCli("init-settings", "--force", path);
if (result.code !== 0) {
throw new Error(`CLI settings initialisation failed with code ${result.code}\n${result.combined}`);
}
}
export function sanitiseCatStdout(raw: string): string {
return raw
.split("\n")
.filter((line) => line !== "[CLIWatchAdapter] File watching is not enabled in CLI version")
.join("\n");
}
export class BackgroundCliProcess {
#stdout = "";
#stderr = "";
readonly #stdoutDone: Promise<void>;
readonly #stderrDone: Promise<void>;
constructor(readonly child: Deno.ChildProcess) {
this.#stdoutDone = collectStream(child.stdout, (text) => {
this.#stdout += text;
});
this.#stderrDone = collectStream(child.stderr, (text) => {
this.#stderr += text;
});
}
get combined(): string {
return this.#stdout + this.#stderr;
}
async stop(): Promise<number> {
try {
this.child.kill("SIGTERM");
} catch {
// The process has already exited.
}
const status = await this.child.status;
await Promise.all([this.#stdoutDone, this.#stderrDone]);
return status.code;
}
}
export function startCliInBackground(...args: string[]): BackgroundCliProcess {
return new BackgroundCliProcess(
new Deno.Command("node", {
args: [cliDist, ...args],
cwd: cliDirectory,
stdin: "null",
stdout: "piped",
stderr: "piped",
}).spawn()
);
}
type WaitForPortOptions = {
timeoutMs?: number;
intervalMs?: number;
connectTimeoutMs?: number;
};
async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const connection = await Promise.race([
Deno.connect({ hostname, port }),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs} ms`)), timeoutMs);
}),
]);
connection.close();
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
}
}
export async function waitForPort(
hostname: string,
port: number,
options: WaitForPortOptions = {}
): Promise<void> {
const timeoutMs = options.timeoutMs ?? 15_000;
const intervalMs = options.intervalMs ?? 250;
const connectTimeoutMs = options.connectTimeoutMs ?? 1_000;
const started = Date.now();
let lastError: unknown;
while (Date.now() - started < timeoutMs) {
try {
await connectWithTimeout(hostname, port, connectTimeoutMs);
return;
} catch (error) {
lastError = error;
await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
}
}
throw new Error(
`Port ${hostname}:${port} did not become ready within ${timeoutMs} ms` +
(lastError === undefined ? "" : ` (last error: ${String(lastError)})`)
);
}