mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-30 16:33:01 +00:00
refactor: compose browser application runtimes
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)})`)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user