From 30b1f36e1b0e483fa3d3a2b423b784b75a46dc59 Mon Sep 17 00:00:00 2001
From: Michael Mainguy
Date: Sat, 19 Sep 2026 19:09:13 -0500
Subject: [PATCH] Ship a standalone build and an onvif-dashboard start command
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- next.config.ts builds standalone output with image optimization off,
and scripts/bundle-standalone.mjs puts the static files beside the
server, then drops what tracing swept in but the app never runs (the
MediaMTX binary and sharp's platform binaries, 79 MB). The build is
27 MB and npm pack is 4.9 MB.
- The CLI gained start, which runs that server from any directory with
the remembered data folder and stored key in its environment, and
admin, so the messages in the UI can name a command that exists.
- Messages that said "npm run …" now say "onvif-dashboard …".
Co-Authored-By: Claude Opus 5 (1M context)
---
.vrek/log.ndjson | 6 ++++
cli/onvif-dashboard.mjs | 43 ++++++++++++++++++++++++++-
cli/onvif-dashboard.test.ts | 5 ++++
next.config.ts | 7 +++++
package.json | 2 +-
scripts/bundle-standalone.mjs | 46 +++++++++++++++++++++++++++++
src/app/auth-pages.test.tsx | 4 +--
src/app/login/page.tsx | 2 +-
src/app/setup/page.tsx | 2 +-
src/lib/mediamtx-supervisor.test.ts | 2 +-
src/lib/mediamtx-supervisor.ts | 2 +-
src/lib/setup-code.test.ts | 2 +-
src/lib/setup-code.ts | 2 +-
13 files changed, 115 insertions(+), 10 deletions(-)
create mode 100644 scripts/bundle-standalone.mjs
diff --git a/.vrek/log.ndjson b/.vrek/log.ndjson
index ae84ae3..37d50fc 100644
--- a/.vrek/log.ndjson
+++ b/.vrek/log.ndjson
@@ -1083,3 +1083,9 @@
{"id":"evt-5rfrprcqxrt0","type":"node.status_changed","subject":"iss-kz5c88a","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T00:00:01.419Z","parents":["evt-eqrerenwyntb"],"hash":"90695a74ba466023c8958bbc69904a2662e1dc9e0d76bca4eb07846891987a17"}
{"id":"evt-nwrj4gzcx3kr","type":"node.created","subject":"mea-t76mxwf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 3 of 5 issues done","body":"Counted from vrek on 2026-09-19: package identity, the paths module and the first-run experience are closed with evidence. Left: iss-ej4ahga standalone build and start command, iss-9zcawn7 publish and install on a clean machine.","status":"recorded","owner":null,"attrs":{"value":3,"applies_at":"2026-09-19"}},"at":"2026-09-20T00:00:03.766Z","parents":["evt-5rfrprcqxrt0"],"hash":"cc01c984fca3860f31f63b81ea476c93a524742e2731b49ab30f14ef7c31ca5a"}
{"id":"evt-nnmw5y70jh2v","type":"edge.added","subject":"mea-t76mxwf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-t76mxwf","to":"gol-k56hc69"},"at":"2026-09-20T00:00:03.767Z","parents":["evt-nwrj4gzcx3kr"],"hash":"ced3f88a47aab1cf0e574138436e828205b800cd02c7ea30fe6a9b21f497d5f9"}
+{"id":"evt-hhvg5v4mr4fe","type":"node.created","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The build produces a standalone server with its static files, pruned of what the app never runs, and onvif-dashboard start runs it from any directory","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-20T00:08:53.630Z","parents":["evt-nnmw5y70jh2v"],"hash":"b259000d6ac7367bd7f22e69abd2889955d14fddf9d35e78438be96425de6e9b"}
+{"id":"evt-se7t2mq52hda","type":"edge.added","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-0gx8s86","to":"iss-ej4ahga"},"at":"2026-09-20T00:08:53.633Z","parents":["evt-hhvg5v4mr4fe"],"hash":"36da77eea43d6980bbd08665f6c10700ab5117832efb9ba8fce2540b8a777fb7"}
+{"id":"evt-qxcfkcrdwhsr","type":"verification.recorded","subject":"ver-0gx8s86","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"next.config.ts sets output: \"standalone\" and images.unoptimized; scripts/bundle-standalone.mjs copies .next/static and public beside the server and drops the traced-but-unused MediaMTX binary and sharp's platform binaries (79 MB), leaving .next/standalone at 27 MB and npm pack at 4.9 MB. cli/onvif-dashboard.mjs gained start (PORT/HOSTNAME, signals forwarded, quiet first run when nothing is set up) and admin. Checked for real: started from /tmp with its own data folder, /login answered HTTP 307 to setup, the console printed the one-time setup code, and it reported MediaMTX missing. Messages that said \"npm run …\" now name the installed command. Full suite 937 tests pass, 99.7% lines; tsc and eslint clean."},"at":"2026-09-20T00:08:53.634Z","parents":["evt-se7t2mq52hda"],"hash":"60c1fe2b8f8358174aad5b495257ba9908ac5ba4186e5f8345a58b638e36f7ce"}
+{"id":"evt-svss3f90kf71","type":"node.status_changed","subject":"iss-ej4ahga","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-20T00:08:56.449Z","parents":["evt-qxcfkcrdwhsr"],"hash":"86783b1bae9e8acbdeed1191d27e175226cba155011fad66fd9a4befa53edc9d"}
+{"id":"evt-s93xtm5nv20z","type":"node.created","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measurement","title":"npm packaging: 4 of 5 issues done","body":"Counted from vrek on 2026-09-19: package identity, the paths module, the first-run experience and the standalone build with a start command are closed with evidence. Left: iss-9zcawn7, publishing and installing on a machine that has never held this repo, which the user has to do since publishing needs their npm account.","status":"recorded","owner":null,"attrs":{"value":4,"applies_at":"2026-09-19"}},"at":"2026-09-20T00:08:58.895Z","parents":["evt-svss3f90kf71"],"hash":"b7a4892d809e426538d084c9da3c2af3d0e91b6f21cd064b1d2b772f2fcffa52"}
+{"id":"evt-w855xpb3jfwm","type":"edge.added","subject":"mea-mtq4xsm","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"measures","from":"mea-mtq4xsm","to":"gol-k56hc69"},"at":"2026-09-20T00:08:58.896Z","parents":["evt-s93xtm5nv20z"],"hash":"c5fa46275ff117df73f142897cbfb085a3fefd06e939b5809d64447ea56486d1"}
diff --git a/cli/onvif-dashboard.mjs b/cli/onvif-dashboard.mjs
index 4d0f5b1..e2cddc1 100755
--- a/cli/onvif-dashboard.mjs
+++ b/cli/onvif-dashboard.mjs
@@ -1,6 +1,8 @@
#!/usr/bin/env node
// The command people get from npm (vrek iss-kz5c88a). Node runs the TypeScript in src/lib
// directly (type stripping, Node 24+), so there is nothing to build for the CLI itself.
+import { spawn } from "node:child_process";
+import { access } from "node:fs/promises";
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";
import path from "node:path";
@@ -11,7 +13,9 @@ import { adminFilePath, readAdminFile } from "../src/lib/admin-file.ts";
const HELP = `onvif-dashboard — watch and manage ONVIF cameras on your own network
Usage:
+ onvif-dashboard start run the dashboard (PORT, HOSTNAME)
onvif-dashboard setup choose where data lives and prepare this machine
+ onvif-dashboard admin create or replace the admin login (--force to replace)
onvif-dashboard install-video download MediaMTX, needed for live video and recording
onvif-dashboard help this message
@@ -60,6 +64,41 @@ async function setup() {
console.log("\nReady. Start it with: onvif-dashboard start");
}
+/** Runs the built server, with the remembered folder and stored key in its environment. */
+async function start() {
+ await applyEnvironment();
+ if (!process.env.CAMERA_CREDENTIALS_KEY) {
+ // Never started before: take the defaults quietly rather than refuse to run.
+ await firstRun();
+ await applyEnvironment();
+ }
+
+ const server = path.join(import.meta.dirname, "..", ".next", "standalone", "server.js");
+ if (!(await access(server).then(() => true, () => false))) {
+ console.error("The built server is missing. From a source checkout, run: npm run build");
+ process.exit(1);
+ }
+
+ const child = spawn(process.execPath, [server], {
+ cwd: path.dirname(server),
+ stdio: "inherit",
+ env: { ...process.env, PORT: process.env.PORT ?? "3000", HOSTNAME: process.env.HOSTNAME ?? "0.0.0.0" },
+ });
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal));
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
+}
+
+/** Creates the admin login, prompting for it; the script it runs has the interactive bits. */
+async function admin(args) {
+ await applyEnvironment();
+ const script = path.join(import.meta.dirname, "..", "scripts", "create-admin.mts");
+ const child = spawn(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", script, ...args], {
+ stdio: "inherit",
+ env: process.env,
+ });
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
+}
+
async function installVideo() {
const result = await installMediamtx();
console.log(
@@ -73,7 +112,9 @@ const { positionals } = parseArgs({ allowPositionals: true, strict: false });
const command = positionals[0] ?? "help";
try {
- if (command === "setup") await setup();
+ if (command === "start") await start();
+ else if (command === "setup") await setup();
+ else if (command === "admin") await admin(process.argv.slice(3));
else if (command === "install-video") await installVideo();
else if (command === "help" || command === "--help" || command === "-h") console.log(HELP);
else {
diff --git a/cli/onvif-dashboard.test.ts b/cli/onvif-dashboard.test.ts
index c645da6..929673f 100644
--- a/cli/onvif-dashboard.test.ts
+++ b/cli/onvif-dashboard.test.ts
@@ -57,6 +57,11 @@ describe("onvif-dashboard", () => {
});
});
+ it("offers the admin command the messages point people at", async () => {
+ const { stdout } = await cli(["admin", "--help"]);
+ expect(stdout).toContain("--force");
+ });
+
it("running it again changes nothing", async () => {
await cli(["setup"]);
const secrets = path.join(dir, ".data", "secrets.env");
diff --git a/next.config.ts b/next.config.ts
index b7cc26f..fc2e41b 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -26,6 +26,13 @@ const project = process.cwd();
const turbopackRoot = linkedPackagesRoot(project, ["onvif"]);
const nextConfig: NextConfig = {
+ // What gets published: a minimal server plus only the files it needs, so installing the
+ // package doesn't drag in Next and React (vrek iss-ej4ahga).
+ output: "standalone",
+ // Runtime data and the downloaded MediaMTX binary are not part of the app.
+ outputFileTracingExcludes: { "/*": ["bin/**/*", ".data/**/*", "node_modules/@img/**/*", "node_modules/sharp/**/*"] },
+ // next/image is unused, and leaving it on pulls sharp's platform binaries (tens of MB).
+ images: { unoptimized: true },
// onvif uses Node's dgram/os modules; load it with native require instead of bundling.
serverExternalPackages: ["onvif"],
...(turbopackRoot && { turbopack: { root: turbopackRoot } }),
diff --git a/package.json b/package.json
index 7f90e58..bb6d4da 100644
--- a/package.json
+++ b/package.json
@@ -38,7 +38,7 @@
],
"scripts": {
"dev": "next dev",
- "build": "next build",
+ "build": "next build && node scripts/bundle-standalone.mjs",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
diff --git a/scripts/bundle-standalone.mjs b/scripts/bundle-standalone.mjs
new file mode 100644
index 0000000..2f323ff
--- /dev/null
+++ b/scripts/bundle-standalone.mjs
@@ -0,0 +1,46 @@
+// After next build, the standalone server needs the static files beside it; Next leaves
+// that copy to us (Next docs, config/output.md). Also drops in the CLI's own modules, so
+// the published package can run from anywhere (vrek iss-ej4ahga).
+import { access, cp, mkdir, readdir, rm, stat } from "node:fs/promises";
+import path from "node:path";
+
+const root = process.cwd();
+const standalone = path.join(root, ".next", "standalone");
+
+const exists = (file) => access(file).then(() => true, () => false);
+
+/** Bytes under a path, so the build can say what it dropped. */
+async function size(target) {
+ const info = await stat(target);
+ if (!info.isDirectory()) return info.size;
+ let total = 0;
+ for (const entry of await readdir(target)) total += await size(path.join(target, entry));
+ return total;
+}
+
+if (!(await exists(standalone))) {
+ console.error("No .next/standalone: is output: 'standalone' still set in next.config.ts?");
+ process.exit(1);
+}
+
+await mkdir(path.join(standalone, ".next"), { recursive: true });
+await cp(path.join(root, ".next", "static"), path.join(standalone, ".next", "static"), { recursive: true });
+if (await exists(path.join(root, "public"))) {
+ await cp(path.join(root, "public"), path.join(standalone, "public"), { recursive: true });
+}
+// Next's tracing sweeps in things the app never runs: the MediaMTX binary that happens to
+// sit in bin/, and sharp's platform binaries, which are only for next/image (unused here).
+// outputFileTracingExcludes didn't keep them out, so they go now, before publishing.
+const prune = ["bin", "node_modules/@img", "node_modules/sharp"];
+let freed = 0;
+for (const name of prune) {
+ const target = path.join(standalone, name);
+ if (!(await exists(target))) continue;
+ freed += await size(target);
+ await rm(target, { recursive: true, force: true });
+}
+
+console.log(
+ `Bundled the standalone server with its static files` +
+ (freed ? `, and dropped ${(freed / 1024 / 1024).toFixed(0)} MB it doesn't need.` : "."),
+);
diff --git a/src/app/auth-pages.test.tsx b/src/app/auth-pages.test.tsx
index 169e7ce..e3b62ba 100644
--- a/src/app/auth-pages.test.tsx
+++ b/src/app/auth-pages.test.tsx
@@ -42,7 +42,7 @@ describe("/login", () => {
const html = renderToStaticMarkup(await loginPage("/?refresh=500"));
expect(html).toContain("Sign in");
expect(html).toContain('name="next" value="/?refresh=500"');
- expect(html).toContain("npm run admin:create -- --force");
+ expect(html).toContain("onvif-dashboard admin --force");
});
it("drops an off-site destination", async () => {
@@ -69,7 +69,7 @@ describe("/setup", () => {
expect(ensureSetupCode).toHaveBeenCalled();
expect(html).toContain("Secure the camera dashboard");
expect(html).toContain("printed in the server's console");
- expect(html).toContain("npm run admin:create");
+ expect(html).toContain("onvif-dashboard admin");
expect(html).toContain('name="code"');
});
diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx
index 0b87eba..b26f682 100644
--- a/src/app/login/page.tsx
+++ b/src/app/login/page.tsx
@@ -21,7 +21,7 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
- Forgot the password? On the server, run npm run admin:create -- --force.
+ Forgot the password? On the server, run onvif-dashboard admin --force.
);
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx
index 42c3a63..4b15f0d 100644
--- a/src/app/setup/page.tsx
+++ b/src/app/setup/page.tsx
@@ -22,7 +22,7 @@ export default async function SetupPage() {
The setup code is printed in the server's console, so only someone
with access to the server can claim this login. Prefer the command line? Run{" "}
- npm run admin:create on the server instead.
+ onvif-dashboard admin on the server instead.
diff --git a/src/lib/mediamtx-supervisor.test.ts b/src/lib/mediamtx-supervisor.test.ts
index 06c149a..650d5f9 100644
--- a/src/lib/mediamtx-supervisor.test.ts
+++ b/src/lib/mediamtx-supervisor.test.ts
@@ -109,7 +109,7 @@ describe("MediamtxSupervisor", () => {
children[0].emit("error", Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }));
expect(sup.status()).toEqual({
state: "missing",
- detail: "MediaMTX is not installed. Run npm run video:install.",
+ detail: "MediaMTX is not installed. Run: onvif-dashboard install-video",
restarts: 0,
});
expect(timers).toHaveLength(0);
diff --git a/src/lib/mediamtx-supervisor.ts b/src/lib/mediamtx-supervisor.ts
index 0b2df44..d288897 100644
--- a/src/lib/mediamtx-supervisor.ts
+++ b/src/lib/mediamtx-supervisor.ts
@@ -103,7 +103,7 @@ export class MediamtxSupervisor {
this.child = null;
if (err.code === "ENOENT") {
// Not installed: retrying won't help until someone runs the installer.
- this.set("missing", "MediaMTX is not installed. Run npm run video:install.");
+ this.set("missing", "MediaMTX is not installed. Run: onvif-dashboard install-video");
this.log(`[video] ${this.current.detail}`);
return;
}
diff --git a/src/lib/setup-code.test.ts b/src/lib/setup-code.test.ts
index 6055471..3ea9d8c 100644
--- a/src/lib/setup-code.test.ts
+++ b/src/lib/setup-code.test.ts
@@ -22,7 +22,7 @@ describe("setup code", () => {
const message = log.mock.calls[0][0];
expect(message).toContain("NOT secured");
expect(message).toContain("open /setup");
- expect(message).toContain("npm run admin:create");
+ expect(message).toContain("onvif-dashboard admin");
expect(printedCode()).toBe(`${code.slice(0, 4)}-${code.slice(4)}`);
});
diff --git a/src/lib/setup-code.ts b/src/lib/setup-code.ts
index f22c7d7..7b1e837 100644
--- a/src/lib/setup-code.ts
+++ b/src/lib/setup-code.ts
@@ -44,7 +44,7 @@ function announce(code: string, log: (message: string) => void) {
"",
` ${pretty(code)}`,
"",
- " Or create the admin without the browser: npm run admin:create",
+ " Or create the admin without the browser: onvif-dashboard admin",
rule,
"",
].join("\n"),