aee3c194af
The unscoped package lives on the Gitea npm registry but its deps span two
registries (`@punktfunk/host` on Gitea, `effect`/`undici` on npmjs). Installing
by name with `--registry <gitea>` sets the default registry for the whole tree,
so `effect` 404s (Gitea doesn't proxy npm), and an unscoped name can't be
scope-mapped. Fix: bundle the backend into ONE self-contained `dist/index.js`
(+ `dist/cli.js`) via `bun build --target=bun` with `@punktfunk/host` + `effect`
inlined — mirroring the scripting runner. The published package now has zero
runtime dependencies and installs with a single `--registry` flag.
- build → `bun build src/{index,cli}.ts --target=bun --outdir dist --external undici`
- @punktfunk/host + effect → devDependencies (bundled, not resolved at install)
- resolveUiDir(): walk to the SPA dir so staticDir works in the bundled
(dist/index.js) and unbundled (dist/server/index.js) layouts
- version 0.1.0 → 0.1.1
Verified: the bundle loads + scans/detects/previews with NO node_modules present.
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
// The standalone fallback UI server (design §9/§10.2, D1): for host-only installs without the console,
|
|
// or as schedule insurance if the console surface is unavailable. Serves the SAME SPA + router, but on
|
|
// a fixed port with its own password/session auth instead of the console proxy. Fail-closed: a
|
|
// non-loopback bind REQUIRES a password (a UI that can rewrite launch templates is host-user code).
|
|
|
|
import * as nodePath from "node:path";
|
|
import type { Config } from "../config.js";
|
|
import type { Engine } from "../engine/index.js";
|
|
import { log } from "../log.js";
|
|
import { resolveUiDir } from "../paths.js";
|
|
import { verifyPassword } from "./password.js";
|
|
import { makeRouter } from "./router.js";
|
|
|
|
export interface StandaloneHandle {
|
|
url: string;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
const COOKIE = "pf_rm_session";
|
|
|
|
const loginPage = (error?: string): Response =>
|
|
new Response(
|
|
`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
|
|
<title>ROM Manager</title>
|
|
<style>body{font:15px system-ui;display:grid;place-items:center;height:100vh;margin:0;background:#0b0b0f;color:#e5e7eb}
|
|
form{display:grid;gap:.75rem;width:260px}input{padding:.5rem;border-radius:.5rem;border:1px solid #333;background:#111;color:#eee}
|
|
button{padding:.5rem;border-radius:.5rem;border:0;background:#6d28d9;color:#fff;cursor:pointer}.e{color:#f87171;font-size:13px}</style>
|
|
<form method=post action="./login"><h1>ROM Manager</h1>${error ? `<div class=e>${error}</div>` : ""}
|
|
<input type=password name=password placeholder="Password" autofocus><button>Sign in</button></form>`,
|
|
{
|
|
status: error ? 401 : 200,
|
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
},
|
|
);
|
|
|
|
/** Resolve a request path to a file inside `root`, or null on traversal (mirrors servePluginUi). */
|
|
const staticFile = (root: string, pathname: string): string | null => {
|
|
let rel: string;
|
|
try {
|
|
rel = decodeURIComponent(pathname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (rel.endsWith("/")) rel += "index.html";
|
|
if (!rel.startsWith("/")) rel = `/${rel}`;
|
|
const abs = nodePath.resolve(root, `.${rel}`);
|
|
const rootAbs = nodePath.resolve(root);
|
|
if (abs !== rootAbs && !abs.startsWith(rootAbs + nodePath.sep)) return null;
|
|
return abs;
|
|
};
|
|
|
|
const cookieHas = (req: Request, name: string, value: string): boolean => {
|
|
const raw = req.headers.get("cookie") ?? "";
|
|
return raw.split(/;\s*/).some((c) => c === `${name}=${value}`);
|
|
};
|
|
|
|
/** Serve the standalone UI. Throws (fail-closed) if a non-loopback bind lacks a password. */
|
|
export const serveStandaloneUi = (
|
|
engine: Engine,
|
|
config: Config,
|
|
): StandaloneHandle => {
|
|
const { port, bind, passwordHash } = config.ui;
|
|
const isLoopback = LOOPBACK.has(bind);
|
|
if (!isLoopback && !passwordHash) {
|
|
throw new Error(
|
|
`standalone UI refuses to bind ${bind} without a password (set ui.passwordHash via \`punktfunk-plugin-rom-manager set-password\`, or bind 127.0.0.1)`,
|
|
);
|
|
}
|
|
const authRequired = Boolean(passwordHash);
|
|
// A per-boot session token — any client presenting it in the cookie is authed until the plugin restarts.
|
|
const sessionToken = crypto.randomUUID().replace(/-/g, "");
|
|
const root = resolveUiDir(import.meta.url);
|
|
const router = makeRouter(engine);
|
|
|
|
const authed = (req: Request) =>
|
|
!authRequired || cookieHas(req, COOKIE, sessionToken);
|
|
|
|
const server = Bun.serve({
|
|
hostname: bind,
|
|
port,
|
|
async fetch(req) {
|
|
const { pathname } = new URL(req.url);
|
|
if (pathname === "/__health") return Response.json({ ok: true });
|
|
|
|
if (authRequired && pathname === "/login" && req.method === "POST") {
|
|
const form = await req.formData().catch(() => null);
|
|
const password = form?.get("password");
|
|
if (
|
|
typeof password === "string" &&
|
|
passwordHash &&
|
|
verifyPassword(password, passwordHash)
|
|
) {
|
|
return new Response(null, {
|
|
status: 303,
|
|
headers: {
|
|
location: "./",
|
|
"set-cookie": `${COOKIE}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
|
|
},
|
|
});
|
|
}
|
|
return loginPage("Incorrect password");
|
|
}
|
|
if (pathname === "/logout" && req.method === "POST") {
|
|
return new Response(null, {
|
|
status: 303,
|
|
headers: {
|
|
location: "./",
|
|
"set-cookie": `${COOKIE}=; Max-Age=0; Path=/`,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!authed(req)) {
|
|
if (pathname.startsWith("/api/"))
|
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
return loginPage();
|
|
}
|
|
|
|
// 1) plugin-local API
|
|
const api = await router(req);
|
|
if (api) return api;
|
|
// 2) static asset
|
|
const file = staticFile(root, pathname);
|
|
if (file) {
|
|
const bf = Bun.file(file);
|
|
if (await bf.exists()) return new Response(bf);
|
|
}
|
|
// 3) SPA fallback
|
|
if (
|
|
req.method === "GET" &&
|
|
(req.headers.get("accept") ?? "").includes("text/html")
|
|
) {
|
|
const index = Bun.file(nodePath.join(root, "index.html"));
|
|
if (await index.exists()) return new Response(index);
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
},
|
|
});
|
|
|
|
const url = `http://${isLoopback ? "127.0.0.1" : bind}:${server.port}`;
|
|
log(
|
|
`standalone UI on ${url}${authRequired ? " (password-protected)" : " (loopback, no password)"}`,
|
|
);
|
|
return {
|
|
url,
|
|
async close() {
|
|
server.stop(true);
|
|
},
|
|
};
|
|
};
|