mirror of
https://github.com/aphrody-labs/aphrody-m3-next.git
synced 2026-10-13 19:55:43 +00:00
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
#!/usr/bin/env bun
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
// Build, serve and check the Next 16 examples through the yolo CLI.
|
|
//
|
|
// bun scripts/yolo-example.ts <build|serve|start|export|bench|check> [--example app|tauri] [--json]
|
|
//
|
|
// yolo's `web` verb runs `bun run build|dev` in `$YOLO_HOME/m3/packages/app-ui`. This script points YOLO_HOME at a directory
|
|
// whose `m3/packages/app-ui` is the example (a symlink under the temp dir; nothing is written into this repository) and runs
|
|
// `bun $YOLO_ROOT/packages/engine/yolo/src/index.ts web build|dev` (YOLO_ROOT: this Aphrody checkout by default). The example
|
|
// scripts run `yolo m3 next`, so Next, Turbopack workers included, runs on Bun; no Node is involved.
|
|
//
|
|
// build yolo web build (cold: `.next` removed first)
|
|
// serve yolo web dev (dev server, curl every route)
|
|
// start yolo web build, then `bun run start` (production server, curl every route)
|
|
// export yolo tauri build (the tauri example: static export in out/)
|
|
// bench cold and warm yolo web build, three runs each
|
|
// check build, serve, start, export in turn
|
|
/* oxlint-disable eslint/no-await-in-loop -- polling and benchmark runs are sequential on purpose */
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readlinkSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
|
|
const PKG = resolve(import.meta.dir, "..");
|
|
export const YOLO_ROOT = process.env["YOLO_ROOT"] ?? resolve(PKG, "../../..");
|
|
export const YOLO_CLI = join(YOLO_ROOT, "packages/engine/yolo/src/index.ts");
|
|
|
|
export type Example = "app" | "tauri";
|
|
export const exampleDir = (name: Example): string => join(PKG, "examples", name);
|
|
|
|
/** Routes that must answer, with the status each returns. */
|
|
export const ROUTES: Record<Example, [string, number][]> = {
|
|
app: [
|
|
["/", 200],
|
|
["/components", 200],
|
|
["/theme", 200],
|
|
["/api/health", 200],
|
|
["/missing", 404],
|
|
],
|
|
tauri: [
|
|
["/", 200],
|
|
["/about/", 200],
|
|
],
|
|
};
|
|
|
|
/** A YOLO_HOME whose `m3/packages/app-ui` (the `yolo web` app directory) is the example; created under the temp dir, repaired when it points elsewhere. */
|
|
export function yoloHome(
|
|
example: Example,
|
|
root: string = join(tmpdir(), `m3-next-yolo-${process.getuid?.() ?? 0}`),
|
|
): string {
|
|
const home = join(root, example);
|
|
const web = join(home, "m3", "packages", "app-ui");
|
|
mkdirSync(join(home, "m3", "packages"), { recursive: true });
|
|
const target = exampleDir(example);
|
|
try {
|
|
if (lstatSync(web).isSymbolicLink() && readlinkSync(web) === target) return home;
|
|
unlinkSync(web);
|
|
} catch {
|
|
// Absent: create it below.
|
|
}
|
|
symlinkSync(target, web);
|
|
return home;
|
|
}
|
|
|
|
/** The yolo invocation: `bun <cli> <args>` with YOLO_HOME at the example. */
|
|
export function yoloCommand(
|
|
example: Example,
|
|
...args: string[]
|
|
): { cmd: string[]; env: Record<string, string | undefined> } {
|
|
return {
|
|
cmd: ["bun", YOLO_CLI, ...args],
|
|
env: { ...process.env, YOLO_HOME: yoloHome(example), NEXT_TELEMETRY_DISABLED: "1" },
|
|
};
|
|
}
|
|
|
|
export interface Timed {
|
|
exitCode: number;
|
|
ms: number;
|
|
output: string;
|
|
}
|
|
|
|
/** Run a command to completion, capturing stdout and stderr. */
|
|
async function run(
|
|
cmd: string[],
|
|
env: Record<string, string | undefined>,
|
|
cwd?: string,
|
|
): Promise<Timed> {
|
|
const start = performance.now();
|
|
const proc = Bun.spawn(cmd, { windowsHide: true, env, cwd, stdout: "pipe", stderr: "pipe" });
|
|
const [out, err, exitCode] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
return { exitCode, ms: Math.round(performance.now() - start), output: out + err };
|
|
}
|
|
|
|
export async function yoloBuild(
|
|
example: Example,
|
|
verb: "web" | "tauri" = "web",
|
|
cold = true,
|
|
): Promise<Timed> {
|
|
const dir = exampleDir(example);
|
|
if (cold)
|
|
for (const out of [".next", "out"]) rmSync(join(dir, out), { recursive: true, force: true });
|
|
const { cmd, env } = yoloCommand(example, verb, "build");
|
|
return run(cmd, env);
|
|
}
|
|
|
|
/** Wait for the URL to answer, up to `timeoutMs`. */
|
|
async function waitFor(url: string, timeoutMs: number): Promise<boolean> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
if ((await fetch(url, { redirect: "manual" })).status > 0) return true;
|
|
} catch {
|
|
// Not up yet.
|
|
}
|
|
await Bun.sleep(250);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export interface RouteResult {
|
|
route: string;
|
|
expected: number;
|
|
status: number;
|
|
ok: boolean;
|
|
}
|
|
|
|
async function probe(base: string, routes: [string, number][]): Promise<RouteResult[]> {
|
|
const out: RouteResult[] = [];
|
|
for (const [route, expected] of routes) {
|
|
const status =
|
|
(await fetch(`${base}${route}`, { redirect: "manual" }).catch(() => undefined))?.status ?? 0;
|
|
out.push({ route, expected, status, ok: status === expected });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Run a server in its own process group and make sure it is gone afterwards. */
|
|
async function withServer<T>(
|
|
cmd: string[],
|
|
env: Record<string, string | undefined>,
|
|
cwd: string | undefined,
|
|
url: string,
|
|
body: () => Promise<T>,
|
|
): Promise<T> {
|
|
// A leftover server on the port would answer for ours: refuse to start.
|
|
if (
|
|
await fetch(url, { redirect: "manual" }).then(
|
|
() => true,
|
|
() => false,
|
|
)
|
|
)
|
|
throw new Error(`${url} is already served`);
|
|
const proc = Bun.spawn(cmd, {
|
|
windowsHide: true,
|
|
env,
|
|
cwd,
|
|
stdout: "ignore",
|
|
stderr: "ignore",
|
|
detached: true,
|
|
});
|
|
try {
|
|
if (!(await waitFor(url, 90_000))) throw new Error(`${url} did not come up`);
|
|
return await body();
|
|
} finally {
|
|
try {
|
|
process.kill(-proc.pid, "SIGTERM");
|
|
} catch {
|
|
proc.kill();
|
|
}
|
|
await Promise.race([proc.exited, Bun.sleep(5000)]);
|
|
try {
|
|
process.kill(-proc.pid, "SIGKILL");
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
}
|
|
|
|
const PORT_DEV = "3217";
|
|
const PORT_START = "3218";
|
|
|
|
/** `yolo web dev` serves the example; curl each route. */
|
|
export async function yoloServe(example: Example = "app"): Promise<RouteResult[]> {
|
|
const { cmd, env } = yoloCommand(example, "web", "dev");
|
|
const base = `http://localhost:${PORT_DEV}`;
|
|
return withServer(cmd, { ...env, PORT: PORT_DEV }, undefined, `${base}/`, () =>
|
|
probe(base, ROUTES[example]),
|
|
);
|
|
}
|
|
|
|
/** `yolo web build` then the production server (`bun run start`); curl each route. */
|
|
export async function yoloStart(
|
|
example: Example = "app",
|
|
): Promise<{ build: Timed; routes: RouteResult[] }> {
|
|
const build = await yoloBuild(example);
|
|
if (build.exitCode !== 0) return { build, routes: [] };
|
|
const base = `http://localhost:${PORT_START}`;
|
|
const { env } = yoloCommand(example, "web", "build");
|
|
const routes = await withServer(
|
|
["bun", "run", "start"],
|
|
{ ...env, PORT: PORT_START },
|
|
exampleDir(example),
|
|
`${base}/`,
|
|
() => probe(base, ROUTES[example]),
|
|
);
|
|
return { build, routes };
|
|
}
|
|
|
|
/** Median of the runs. */
|
|
const median = (values: number[]): number =>
|
|
// oxlint-disable-next-line unicorn/no-array-sort -- sorts a copy; ES2022 has no toSorted
|
|
[...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
|
|
|
|
/** Cold (`.next` removed) and warm (`.next` kept) `yolo web build` times, `runs` each. */
|
|
export async function yoloBench(
|
|
example: Example = "app",
|
|
runs = 3,
|
|
): Promise<{ cold: number[]; warm: number[]; coldMedian: number; warmMedian: number }> {
|
|
const cold: number[] = [];
|
|
const warm: number[] = [];
|
|
for (let i = 0; i < runs; i += 1) {
|
|
const result = await yoloBuild(example, "web", true);
|
|
if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
|
|
cold.push(result.ms);
|
|
}
|
|
for (let i = 0; i < runs; i += 1) {
|
|
const result = await yoloBuild(example, "web", false);
|
|
if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
|
|
warm.push(result.ms);
|
|
}
|
|
return { cold, warm, coldMedian: median(cold), warmMedian: median(warm) };
|
|
}
|
|
|
|
function report(label: string, results: RouteResult[]): boolean {
|
|
for (const r of results)
|
|
console.log(
|
|
` ${r.ok ? "ok " : "FAIL"} ${r.route.padEnd(14)} ${r.status} (expected ${r.expected})`,
|
|
);
|
|
const ok = results.length > 0 && results.every((r) => r.ok);
|
|
console.log(`${ok ? "pass" : "FAIL"} ${label}`);
|
|
return ok;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const args = process.argv.slice(2);
|
|
const [task = "check"] = args;
|
|
const flag = (name: string): string | undefined => {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? args[i + 1] : undefined;
|
|
};
|
|
const example = (flag("--example") ?? "app") as Example;
|
|
const json = args.includes("--json");
|
|
if (!existsSync(YOLO_CLI)) {
|
|
console.error(`yolo CLI not found at ${YOLO_CLI}; set YOLO_ROOT`);
|
|
process.exit(2);
|
|
}
|
|
let ok = true;
|
|
const doBuild = async (name: Example, verb: "web" | "tauri") => {
|
|
const result = await yoloBuild(name, verb);
|
|
console.log(
|
|
`${result.exitCode === 0 ? "pass" : "FAIL"} yolo ${verb} build (${name}) ${result.ms} ms`,
|
|
);
|
|
if (result.exitCode !== 0) console.log(result.output.slice(-1500));
|
|
return result.exitCode === 0;
|
|
};
|
|
const doExport = async () => {
|
|
if (!(await doBuild("tauri", "tauri"))) return false;
|
|
const out = join(exampleDir("tauri"), "out");
|
|
const html = existsSync(join(out, "index.html"))
|
|
? readFileSync(join(out, "index.html"), "utf8")
|
|
: "";
|
|
const good =
|
|
html.includes('id="m3-scheme"') &&
|
|
html.includes("document.cookie.match") &&
|
|
existsSync(join(out, "about", "index.html"));
|
|
console.log(
|
|
`${good ? "pass" : "FAIL"} static export: out/index.html, out/about/index.html, inline scheme and pre-paint script`,
|
|
);
|
|
return good;
|
|
};
|
|
switch (task) {
|
|
case "build":
|
|
ok = await doBuild(example, "web");
|
|
break;
|
|
case "serve":
|
|
ok = report(`yolo web dev (${example})`, await yoloServe(example));
|
|
break;
|
|
case "start": {
|
|
const { build, routes } = await yoloStart(example);
|
|
console.log(`yolo web build (${example}) ${build.ms} ms, exit ${build.exitCode}`);
|
|
ok = report(`next start (${example})`, routes);
|
|
break;
|
|
}
|
|
case "export":
|
|
ok = await doExport();
|
|
break;
|
|
case "bench": {
|
|
const bench = await yoloBench(example);
|
|
console.log(
|
|
json
|
|
? JSON.stringify(bench)
|
|
: `cold ${bench.cold.join(", ")} ms (median ${bench.coldMedian}); warm ${bench.warm.join(", ")} ms (median ${bench.warmMedian})`,
|
|
);
|
|
break;
|
|
}
|
|
case "check":
|
|
ok = (await doBuild("app", "web")) && report("yolo web dev (app)", await yoloServe("app"));
|
|
if (ok) {
|
|
const { routes } = await yoloStart("app");
|
|
ok = report("next start (app)", routes);
|
|
}
|
|
ok = (await doExport()) && ok;
|
|
break;
|
|
default:
|
|
console.error(`unknown task "${task}"`);
|
|
process.exit(2);
|
|
}
|
|
process.exit(ok ? 0 : 1);
|
|
}
|