create-volt 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to `create-volt` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.41.0] - 2026-06-30
8
+
9
+ ### Added
10
+ - **PM2 support.** Scaffolds ship `ecosystem.config.cjs` + scripts: `npm run pm2`
11
+ (start under pm2 — fetched via npx if not installed, or uses your global pm2),
12
+ `pm2:restart` (clean reload, no port clash), `pm2:logs`, `pm2:stop`.
13
+ - **`npm run dev` on an already-running app reloads it instead of crashing.** A
14
+ second start detects the in-use port, pings the running instance's new
15
+ `/__volt/reload` route to refresh browsers, prints a note, and exits 0 — no
16
+ more `EADDRINUSE` stack trace.
17
+
18
+ ## [0.40.0] - 2026-06-30
19
+
20
+ ### Added
21
+ - **Themed front page.** `pages/index.md` now takes over `/` (rendered in your
22
+ theme), so a content site's home matches the rest of the site instead of
23
+ showing the demo `views/index.html`. With no `pages/index.md`, `/` stays the
24
+ app's index.html. (Answers: "I chose a theme but the home page was unchanged" —
25
+ the theme styles pages/posts; the home needs to *be* a page.)
26
+
7
27
  ## [0.39.1] - 2026-06-30
8
28
 
9
29
  ### Fixed
@@ -528,6 +548,8 @@ All notable changes to `create-volt` are documented here. The format follows
528
548
  watching and full-page hot reload. Supports `--skip-install` and `--force`,
529
549
  and auto-detects npm / pnpm / yarn / bun for the install step.
530
550
 
551
+ [0.41.0]: https://github.com/MIR-2025/volt/releases/tag/v0.41.0
552
+ [0.40.0]: https://github.com/MIR-2025/volt/releases/tag/v0.40.0
531
553
  [0.39.1]: https://github.com/MIR-2025/volt/releases/tag/v0.39.1
532
554
  [0.39.0]: https://github.com/MIR-2025/volt/releases/tag/v0.39.0
533
555
  [0.38.0]: https://github.com/MIR-2025/volt/releases/tag/v0.38.0
@@ -176,20 +176,29 @@ export async function pagesRouter({ dir }) {
176
176
  const { marked } = await import("marked");
177
177
  ensure(dir);
178
178
  const getTheme = themeResolver(dir);
179
+ // render one markdown file into the theme. `format: html` pages (e.g. from the
180
+ // WYSIWYG editor) are served verbatim; everything else is rendered with marked.
181
+ const renderFile = async (file, fallbackTitle, res) => {
182
+ const { meta, body } = parseFrontMatter(fs.readFileSync(file, "utf8"));
183
+ const content = meta.format === "html" ? body : marked.parse(body);
184
+ const m = { ...meta, title: meta.title || fallbackTitle };
185
+ const { layout } = await getTheme();
186
+ res.type("html").send(injectHot(layout({ title: m.title, head: metaHead(m), content, meta: m })));
187
+ };
179
188
  const r = express.Router();
180
189
  r.get("/_theme.css", async (_req, res) => res.type("css").send((await getTheme()).css));
190
+ // themed home: pages/index.md takes over "/" (the site's front page) when present
191
+ r.get("/", async (_req, res, next) => {
192
+ const file = path.join(dir, "index.md");
193
+ if (!fs.existsSync(file)) return next();
194
+ await renderFile(file, "Home", res);
195
+ });
181
196
  r.get("/:slug", async (req, res, next) => {
182
197
  const slug = req.params.slug;
183
198
  if (!isSafeSlug(slug)) return next(); // safe slug only — no traversal
184
199
  const file = path.join(dir, slug + ".md");
185
200
  if (!fs.existsSync(file)) return next();
186
- const { meta, body } = parseFrontMatter(fs.readFileSync(file, "utf8"));
187
- // `format: html` pages (e.g. from the WYSIWYG editor) are served verbatim to
188
- // preserve complex layouts; everything else is markdown rendered with marked.
189
- const content = meta.format === "html" ? body : marked.parse(body);
190
- const m = { ...meta, title: meta.title || slug };
191
- const { layout } = await getTheme();
192
- res.type("html").send(injectHot(layout({ title: m.title, head: metaHead(m), content, meta: m })));
201
+ await renderFile(file, slug, res);
193
202
  });
194
203
  return r;
195
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-volt",
3
- "version": "0.39.1",
3
+ "version": "0.41.0",
4
4
  "description": "Scaffold a new Volt app — no-build, signals-based UI with Socket.io hot reload.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -1,9 +1,9 @@
1
- // server.js — dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app — which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
25
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
26
26
 
27
- // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
27
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
28
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
29
29
  // itself on a one-off port. Explicit flag wins over PORT in .env.
30
30
  function cliPort() {
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
104
104
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
105
105
 
106
106
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
107
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
107
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
108
108
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
109
109
  const BUILTINS = new Set(Object.keys(LIB_FILE));
110
110
  async function loadAddon(name) {
@@ -128,7 +128,7 @@ function openBrowser(url) {
128
128
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
129
129
  try {
130
130
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
131
- child.on("error", () => {}); // launcher missing — emits async, don't crash
131
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
132
132
  child.unref();
133
133
  return true;
134
134
  } catch {
@@ -163,7 +163,11 @@ async function startApp() {
163
163
  if (fs.existsSync(pub)) app.use(express.static(pub));
164
164
  }
165
165
 
166
- app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
166
+ app.get("/", (_req, res, next) => {
167
+ // a themed home page (pages/index.md) takes over "/" — else the app's index.html
168
+ if (enabled.has("pages") && fs.existsSync(path.join(__dirname, "pages", "index.md"))) return next();
169
+ res.sendFile(path.join(__dirname, "views", "index.html"));
170
+ });
167
171
 
168
172
  // media uploads (POST /api/media, auth-gated; local files served at /media)
169
173
  if (enabled.has("media") && store) {
@@ -171,8 +175,8 @@ async function startApp() {
171
175
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
172
176
  }
173
177
 
174
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
175
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
178
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
179
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
176
180
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
177
181
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
178
182
 
@@ -180,7 +184,14 @@ async function startApp() {
180
184
  const io = new SocketServer(server);
181
185
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
182
186
 
183
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
187
+ // Reload connected browsers on demand used when a second `npm run dev` finds
188
+ // the app already running (see the EADDRINUSE handler below) instead of crashing.
189
+ app.get("/__volt/reload", (_req, res) => {
190
+ io.emit("volt:reload", { file: "__manual__" });
191
+ res.json({ ok: true });
192
+ });
193
+
194
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
184
195
  // are provided so add-ons can gate routes by login.
185
196
  let requireAuth = null;
186
197
  let sessionFromReq = null;
@@ -196,7 +207,7 @@ async function startApp() {
196
207
  if (typeof register === "function") {
197
208
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
198
209
  } else {
199
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
210
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
200
211
  }
201
212
  }
202
213
 
@@ -204,7 +215,7 @@ async function startApp() {
204
215
  const onChange = (file) => {
205
216
  clearTimeout(timer);
206
217
  timer = setTimeout(() => {
207
- console.log(`[volt] change: ${file ?? "?"} → reload`);
218
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
208
219
  io.emit("volt:reload", { file });
209
220
  }, 80);
210
221
  };
@@ -233,7 +244,21 @@ async function startApp() {
233
244
  }
234
245
 
235
246
  const on = [...enabled];
236
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
247
+ // If the port's taken, the app is likely already running reload it (tell the
248
+ // running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
249
+ server.on("error", async (e) => {
250
+ if (e.code === "EADDRINUSE") {
251
+ try {
252
+ await fetch(`http://localhost:${PORT}/__volt/reload`);
253
+ } catch {
254
+ /* old instance without the reload route, or not ours */
255
+ }
256
+ console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
257
+ process.exit(0);
258
+ }
259
+ throw e;
260
+ });
261
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
237
262
  }
238
263
 
239
264
  // Packages an .env's selections need, beyond what package.json already has.
@@ -257,7 +282,7 @@ function neededPackages(env) {
257
282
  function ensureDriverInstalled(driver) {
258
283
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
259
284
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
260
- console.log(`[volt] installing ${pkg} for the connection test…`);
285
+ console.log(`[volt] installing ${pkg} for the connection test…`);
261
286
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
262
287
  }
263
288
 
@@ -380,15 +405,15 @@ function startSetup() {
380
405
  server.closeIdleConnections?.();
381
406
  };
382
407
  if (added.length) {
383
- console.log(`[volt] installing ${added.join(", ")}…`);
408
+ console.log(`[volt] installing ${added.join(", ")}…`);
384
409
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
385
410
  npm.on("error", () => handoff());
386
411
  npm.on("close", () => {
387
- console.log("[volt] saved .env — starting the app…");
412
+ console.log("[volt] saved .env — starting the app…");
388
413
  handoff();
389
414
  });
390
415
  } else {
391
- console.log("[volt] saved .env — starting the app…");
416
+ console.log("[volt] saved .env — starting the app…");
392
417
  handoff();
393
418
  }
394
419
  });
@@ -406,18 +431,18 @@ function startSetup() {
406
431
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
407
432
  server.listen(PORT, "127.0.0.1", () => {
408
433
  const url = `http://localhost:${PORT}`;
409
- console.log(`\n⚡ Volt setup → ${url}`);
434
+ console.log(`\n⚡ Volt setup → ${url}`);
410
435
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
411
436
  const ssh = process.env.SSH_CONNECTION;
412
437
  if (ssh) {
413
438
  const host = ssh.split(" ")[2];
414
439
  const user = process.env.USER || process.env.USERNAME || "you";
415
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
440
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
416
441
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
417
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
442
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
418
443
  }
419
444
  console.log("");
420
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
445
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
421
446
  });
422
447
  }
423
448
 
@@ -425,8 +450,8 @@ function readEnvFileLines() {
425
450
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
426
451
  }
427
452
 
428
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
429
- // Not a route in the running app — it only exists while you run `--studio`, on
453
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
454
+ // Not a route in the running app — it only exists while you run `--studio`, on
430
455
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
431
456
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
432
457
  async function startStudio() {
@@ -439,7 +464,7 @@ async function startStudio() {
439
464
  try {
440
465
  store = await (await addonMod("db")).createStore();
441
466
  } catch (e) {
442
- console.error("Studio: couldn't connect the store — " + e.message);
467
+ console.error("Studio: couldn't connect the store — " + e.message);
443
468
  process.exit(1);
444
469
  }
445
470
  const PORT = configPort();
@@ -497,15 +522,15 @@ async function startStudio() {
497
522
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
498
523
  server.listen(PORT, "127.0.0.1", () => {
499
524
  const url = `http://localhost:${PORT}`;
500
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
501
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
525
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
526
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
502
527
  const ssh = process.env.SSH_CONNECTION;
503
528
  if (ssh) {
504
529
  const host = ssh.split(" ")[2];
505
530
  const user = process.env.USER || process.env.USERNAME || "you";
506
- console.log(" Remote box — from your LOCAL machine:");
531
+ console.log(" Remote box — from your LOCAL machine:");
507
532
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
508
- console.log(` …then open ${url}.`);
533
+ console.log(` …then open ${url}.`);
509
534
  }
510
535
  console.log("");
511
536
  openBrowser(url);
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -1,9 +1,9 @@
1
- // server.js — dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app — which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
25
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
26
26
 
27
- // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
27
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
28
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
29
29
  // itself on a one-off port. Explicit flag wins over PORT in .env.
30
30
  function cliPort() {
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
104
104
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
105
105
 
106
106
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
107
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
107
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
108
108
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
109
109
  const BUILTINS = new Set(Object.keys(LIB_FILE));
110
110
  async function loadAddon(name) {
@@ -128,7 +128,7 @@ function openBrowser(url) {
128
128
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
129
129
  try {
130
130
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
131
- child.on("error", () => {}); // launcher missing — emits async, don't crash
131
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
132
132
  child.unref();
133
133
  return true;
134
134
  } catch {
@@ -163,7 +163,11 @@ async function startApp() {
163
163
  if (fs.existsSync(pub)) app.use(express.static(pub));
164
164
  }
165
165
 
166
- app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
166
+ app.get("/", (_req, res, next) => {
167
+ // a themed home page (pages/index.md) takes over "/" — else the app's index.html
168
+ if (enabled.has("pages") && fs.existsSync(path.join(__dirname, "pages", "index.md"))) return next();
169
+ res.sendFile(path.join(__dirname, "views", "index.html"));
170
+ });
167
171
 
168
172
  // media uploads (POST /api/media, auth-gated; local files served at /media)
169
173
  if (enabled.has("media") && store) {
@@ -171,8 +175,8 @@ async function startApp() {
171
175
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
172
176
  }
173
177
 
174
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
175
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
178
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
179
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
176
180
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
177
181
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
178
182
 
@@ -180,7 +184,14 @@ async function startApp() {
180
184
  const io = new SocketServer(server);
181
185
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
182
186
 
183
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
187
+ // Reload connected browsers on demand used when a second `npm run dev` finds
188
+ // the app already running (see the EADDRINUSE handler below) instead of crashing.
189
+ app.get("/__volt/reload", (_req, res) => {
190
+ io.emit("volt:reload", { file: "__manual__" });
191
+ res.json({ ok: true });
192
+ });
193
+
194
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
184
195
  // are provided so add-ons can gate routes by login.
185
196
  let requireAuth = null;
186
197
  let sessionFromReq = null;
@@ -196,7 +207,7 @@ async function startApp() {
196
207
  if (typeof register === "function") {
197
208
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
198
209
  } else {
199
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
210
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
200
211
  }
201
212
  }
202
213
 
@@ -204,7 +215,7 @@ async function startApp() {
204
215
  const onChange = (file) => {
205
216
  clearTimeout(timer);
206
217
  timer = setTimeout(() => {
207
- console.log(`[volt] change: ${file ?? "?"} → reload`);
218
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
208
219
  io.emit("volt:reload", { file });
209
220
  }, 80);
210
221
  };
@@ -233,7 +244,21 @@ async function startApp() {
233
244
  }
234
245
 
235
246
  const on = [...enabled];
236
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
247
+ // If the port's taken, the app is likely already running reload it (tell the
248
+ // running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
249
+ server.on("error", async (e) => {
250
+ if (e.code === "EADDRINUSE") {
251
+ try {
252
+ await fetch(`http://localhost:${PORT}/__volt/reload`);
253
+ } catch {
254
+ /* old instance without the reload route, or not ours */
255
+ }
256
+ console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
257
+ process.exit(0);
258
+ }
259
+ throw e;
260
+ });
261
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
237
262
  }
238
263
 
239
264
  // Packages an .env's selections need, beyond what package.json already has.
@@ -257,7 +282,7 @@ function neededPackages(env) {
257
282
  function ensureDriverInstalled(driver) {
258
283
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
259
284
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
260
- console.log(`[volt] installing ${pkg} for the connection test…`);
285
+ console.log(`[volt] installing ${pkg} for the connection test…`);
261
286
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
262
287
  }
263
288
 
@@ -380,15 +405,15 @@ function startSetup() {
380
405
  server.closeIdleConnections?.();
381
406
  };
382
407
  if (added.length) {
383
- console.log(`[volt] installing ${added.join(", ")}…`);
408
+ console.log(`[volt] installing ${added.join(", ")}…`);
384
409
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
385
410
  npm.on("error", () => handoff());
386
411
  npm.on("close", () => {
387
- console.log("[volt] saved .env — starting the app…");
412
+ console.log("[volt] saved .env — starting the app…");
388
413
  handoff();
389
414
  });
390
415
  } else {
391
- console.log("[volt] saved .env — starting the app…");
416
+ console.log("[volt] saved .env — starting the app…");
392
417
  handoff();
393
418
  }
394
419
  });
@@ -406,18 +431,18 @@ function startSetup() {
406
431
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
407
432
  server.listen(PORT, "127.0.0.1", () => {
408
433
  const url = `http://localhost:${PORT}`;
409
- console.log(`\n⚡ Volt setup → ${url}`);
434
+ console.log(`\n⚡ Volt setup → ${url}`);
410
435
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
411
436
  const ssh = process.env.SSH_CONNECTION;
412
437
  if (ssh) {
413
438
  const host = ssh.split(" ")[2];
414
439
  const user = process.env.USER || process.env.USERNAME || "you";
415
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
440
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
416
441
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
417
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
442
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
418
443
  }
419
444
  console.log("");
420
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
445
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
421
446
  });
422
447
  }
423
448
 
@@ -425,8 +450,8 @@ function readEnvFileLines() {
425
450
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
426
451
  }
427
452
 
428
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
429
- // Not a route in the running app — it only exists while you run `--studio`, on
453
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
454
+ // Not a route in the running app — it only exists while you run `--studio`, on
430
455
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
431
456
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
432
457
  async function startStudio() {
@@ -439,7 +464,7 @@ async function startStudio() {
439
464
  try {
440
465
  store = await (await addonMod("db")).createStore();
441
466
  } catch (e) {
442
- console.error("Studio: couldn't connect the store — " + e.message);
467
+ console.error("Studio: couldn't connect the store — " + e.message);
443
468
  process.exit(1);
444
469
  }
445
470
  const PORT = configPort();
@@ -497,15 +522,15 @@ async function startStudio() {
497
522
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
498
523
  server.listen(PORT, "127.0.0.1", () => {
499
524
  const url = `http://localhost:${PORT}`;
500
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
501
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
525
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
526
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
502
527
  const ssh = process.env.SSH_CONNECTION;
503
528
  if (ssh) {
504
529
  const host = ssh.split(" ")[2];
505
530
  const user = process.env.USER || process.env.USERNAME || "you";
506
- console.log(" Remote box — from your LOCAL machine:");
531
+ console.log(" Remote box — from your LOCAL machine:");
507
532
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
508
- console.log(` …then open ${url}.`);
533
+ console.log(` …then open ${url}.`);
509
534
  }
510
535
  console.log("");
511
536
  openBrowser(url);
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -1,9 +1,9 @@
1
- // server.js — dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app — which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -24,7 +24,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
24
24
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
25
25
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
26
26
 
27
- // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
27
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
28
28
  // --edit/--studio dodge a port the running app already holds, and runs the app
29
29
  // itself on a one-off port. Explicit flag wins over PORT in .env.
30
30
  function cliPort() {
@@ -104,7 +104,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
104
104
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
105
105
 
106
106
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
107
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
107
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
108
108
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
109
109
  const BUILTINS = new Set(Object.keys(LIB_FILE));
110
110
  async function loadAddon(name) {
@@ -128,7 +128,7 @@ function openBrowser(url) {
128
128
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
129
129
  try {
130
130
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
131
- child.on("error", () => {}); // launcher missing — emits async, don't crash
131
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
132
132
  child.unref();
133
133
  return true;
134
134
  } catch {
@@ -163,7 +163,11 @@ async function startApp() {
163
163
  if (fs.existsSync(pub)) app.use(express.static(pub));
164
164
  }
165
165
 
166
- app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
166
+ app.get("/", (_req, res, next) => {
167
+ // a themed home page (pages/index.md) takes over "/" — else the app's index.html
168
+ if (enabled.has("pages") && fs.existsSync(path.join(__dirname, "pages", "index.md"))) return next();
169
+ res.sendFile(path.join(__dirname, "views", "index.html"));
170
+ });
167
171
 
168
172
  // media uploads (POST /api/media, auth-gated; local files served at /media)
169
173
  if (enabled.has("media") && store) {
@@ -171,8 +175,8 @@ async function startApp() {
171
175
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
172
176
  }
173
177
 
174
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
175
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
178
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
179
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
176
180
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
177
181
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
178
182
 
@@ -180,7 +184,14 @@ async function startApp() {
180
184
  const io = new SocketServer(server);
181
185
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
182
186
 
183
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
187
+ // Reload connected browsers on demand used when a second `npm run dev` finds
188
+ // the app already running (see the EADDRINUSE handler below) instead of crashing.
189
+ app.get("/__volt/reload", (_req, res) => {
190
+ io.emit("volt:reload", { file: "__manual__" });
191
+ res.json({ ok: true });
192
+ });
193
+
194
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
184
195
  // are provided so add-ons can gate routes by login.
185
196
  let requireAuth = null;
186
197
  let sessionFromReq = null;
@@ -196,7 +207,7 @@ async function startApp() {
196
207
  if (typeof register === "function") {
197
208
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
198
209
  } else {
199
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
210
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
200
211
  }
201
212
  }
202
213
 
@@ -204,7 +215,7 @@ async function startApp() {
204
215
  const onChange = (file) => {
205
216
  clearTimeout(timer);
206
217
  timer = setTimeout(() => {
207
- console.log(`[volt] change: ${file ?? "?"} → reload`);
218
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
208
219
  io.emit("volt:reload", { file });
209
220
  }, 80);
210
221
  };
@@ -233,7 +244,21 @@ async function startApp() {
233
244
  }
234
245
 
235
246
  const on = [...enabled];
236
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
247
+ // If the port's taken, the app is likely already running reload it (tell the
248
+ // running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
249
+ server.on("error", async (e) => {
250
+ if (e.code === "EADDRINUSE") {
251
+ try {
252
+ await fetch(`http://localhost:${PORT}/__volt/reload`);
253
+ } catch {
254
+ /* old instance without the reload route, or not ours */
255
+ }
256
+ console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
257
+ process.exit(0);
258
+ }
259
+ throw e;
260
+ });
261
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
237
262
  }
238
263
 
239
264
  // Packages an .env's selections need, beyond what package.json already has.
@@ -257,7 +282,7 @@ function neededPackages(env) {
257
282
  function ensureDriverInstalled(driver) {
258
283
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
259
284
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
260
- console.log(`[volt] installing ${pkg} for the connection test…`);
285
+ console.log(`[volt] installing ${pkg} for the connection test…`);
261
286
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
262
287
  }
263
288
 
@@ -380,15 +405,15 @@ function startSetup() {
380
405
  server.closeIdleConnections?.();
381
406
  };
382
407
  if (added.length) {
383
- console.log(`[volt] installing ${added.join(", ")}…`);
408
+ console.log(`[volt] installing ${added.join(", ")}…`);
384
409
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
385
410
  npm.on("error", () => handoff());
386
411
  npm.on("close", () => {
387
- console.log("[volt] saved .env — starting the app…");
412
+ console.log("[volt] saved .env — starting the app…");
388
413
  handoff();
389
414
  });
390
415
  } else {
391
- console.log("[volt] saved .env — starting the app…");
416
+ console.log("[volt] saved .env — starting the app…");
392
417
  handoff();
393
418
  }
394
419
  });
@@ -406,18 +431,18 @@ function startSetup() {
406
431
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
407
432
  server.listen(PORT, "127.0.0.1", () => {
408
433
  const url = `http://localhost:${PORT}`;
409
- console.log(`\n⚡ Volt setup → ${url}`);
434
+ console.log(`\n⚡ Volt setup → ${url}`);
410
435
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
411
436
  const ssh = process.env.SSH_CONNECTION;
412
437
  if (ssh) {
413
438
  const host = ssh.split(" ")[2];
414
439
  const user = process.env.USER || process.env.USERNAME || "you";
415
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
440
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
416
441
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
417
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
442
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
418
443
  }
419
444
  console.log("");
420
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
445
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
421
446
  });
422
447
  }
423
448
 
@@ -425,8 +450,8 @@ function readEnvFileLines() {
425
450
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
426
451
  }
427
452
 
428
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
429
- // Not a route in the running app — it only exists while you run `--studio`, on
453
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
454
+ // Not a route in the running app — it only exists while you run `--studio`, on
430
455
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
431
456
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
432
457
  async function startStudio() {
@@ -439,7 +464,7 @@ async function startStudio() {
439
464
  try {
440
465
  store = await (await addonMod("db")).createStore();
441
466
  } catch (e) {
442
- console.error("Studio: couldn't connect the store — " + e.message);
467
+ console.error("Studio: couldn't connect the store — " + e.message);
443
468
  process.exit(1);
444
469
  }
445
470
  const PORT = configPort();
@@ -497,15 +522,15 @@ async function startStudio() {
497
522
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
498
523
  server.listen(PORT, "127.0.0.1", () => {
499
524
  const url = `http://localhost:${PORT}`;
500
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
501
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
525
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
526
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
502
527
  const ssh = process.env.SSH_CONNECTION;
503
528
  if (ssh) {
504
529
  const host = ssh.split(" ")[2];
505
530
  const user = process.env.USER || process.env.USERNAME || "you";
506
- console.log(" Remote box — from your LOCAL machine:");
531
+ console.log(" Remote box — from your LOCAL machine:");
507
532
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
508
- console.log(` …then open ${url}.`);
533
+ console.log(` …then open ${url}.`);
509
534
  }
510
535
  console.log("");
511
536
  openBrowser(url);
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -0,0 +1,5 @@
1
+ // PM2 process config. Start under pm2 with `npm run pm2` (pm2 is fetched via npx
2
+ // if you don't have it installed); reload cleanly with `npm run pm2:restart` (no
3
+ // port clash), tail logs with `npm run pm2:logs`, remove with `npm run pm2:stop`.
4
+ const { name } = require("./package.json");
5
+ module.exports = { apps: [{ name, script: "server.js" }] };
@@ -7,7 +7,11 @@
7
7
  "main": "server.js",
8
8
  "scripts": {
9
9
  "start": "node server.js",
10
- "dev": "node server.js"
10
+ "dev": "node server.js",
11
+ "pm2": "npx --yes pm2 start ecosystem.config.cjs",
12
+ "pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
13
+ "pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
14
+ "pm2:logs": "npx --yes pm2 logs"
11
15
  },
12
16
  "dependencies": {
13
17
  "express": "^4.22.2",
@@ -1,9 +1,9 @@
1
- // server.js — dev server with a built-in first-run setup wizard.
1
+ // server.js — dev server with a built-in first-run setup wizard.
2
2
  //
3
3
  // First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
4
4
  // config page: tick add-ons, fill settings, Apply. Apply writes .env (a
5
5
  // VOLT_ADDONS list + settings) and adds any needed packages to package.json,
6
- // runs npm install, then starts the app — which wires whatever .env enables.
6
+ // runs npm install, then starts the app — which wires whatever .env enables.
7
7
  // Add-on code is bundled under .volt/addons; nothing is copied into your code.
8
8
  //
9
9
  // No build step, no env-file flag: .env is auto-loaded below.
@@ -25,7 +25,7 @@ const THEMES_DIR = path.join(__dirname, ".volt", "themes"); // bundled themes th
25
25
  const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
26
26
  const CONFIG_DEFAULT_PORT = 5050; // the --edit/--studio config UI's default port (its own, so it never clashes with a running app)
27
27
 
28
- // `--port <n>` (or --port=<n>) overrides the listen port for this run lets
28
+ // `--port <n>` (or --port=<n>) overrides the listen port for this run — lets
29
29
  // --edit/--studio dodge a port the running app already holds, and runs the app
30
30
  // itself on a one-off port. Explicit flag wins over PORT in .env.
31
31
  function cliPort() {
@@ -105,7 +105,7 @@ const imp = (rel) => import(pathToFileURL(path.join(__dirname, rel)).href);
105
105
  const addonMod = (n) => imp(path.join(".volt", "addons", n, "files", "lib", LIB_FILE[n]));
106
106
 
107
107
  // Built-in add-ons are wired explicitly below; everything else in VOLT_ADDONS is
108
- // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
108
+ // a third-party add-on — a local .volt/addons/<name>/index.js or an installed
109
109
  // npm package "volt-addon-<name>" exporting register(ctx). See /docs/plugins.
110
110
  const BUILTINS = new Set(Object.keys(LIB_FILE));
111
111
  async function loadAddon(name) {
@@ -129,7 +129,7 @@ function openBrowser(url) {
129
129
  const args = plat === "win32" ? ["/c", "start", "", url] : [url];
130
130
  try {
131
131
  const child = spawn(cmd, args, { stdio: "ignore", detached: true });
132
- child.on("error", () => {}); // launcher missing — emits async, don't crash
132
+ child.on("error", () => {}); // launcher missing — emits async, don't crash
133
133
  child.unref();
134
134
  return true;
135
135
  } catch {
@@ -157,7 +157,7 @@ async function startApp() {
157
157
  if (enabled.has("mailer")) mailer = await (await addonMod("mailer")).createMailer();
158
158
  if (enabled.has("auth") && store && mailer) app.use((await addonMod("auth")).authRouter({ store, mailer }));
159
159
 
160
- // notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
160
+ // notes — a per-user CRUD example (auth-gated, owner-scoped, db-backed)
161
161
  if (enabled.has("db") && enabled.has("auth") && store) {
162
162
  const guard = (await addonMod("auth")).requireAuth(store);
163
163
  const notes = store.collection("notes");
@@ -189,7 +189,11 @@ async function startApp() {
189
189
  if (fs.existsSync(pub)) app.use(express.static(pub));
190
190
  }
191
191
 
192
- app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
192
+ app.get("/", (_req, res, next) => {
193
+ // a themed home page (pages/index.md) takes over "/" — else the app's index.html
194
+ if (enabled.has("pages") && fs.existsSync(path.join(__dirname, "pages", "index.md"))) return next();
195
+ res.sendFile(path.join(__dirname, "views", "index.html"));
196
+ });
193
197
 
194
198
  // media uploads (POST /api/media, auth-gated; local files served at /media)
195
199
  if (enabled.has("media") && store) {
@@ -197,8 +201,8 @@ async function startApp() {
197
201
  app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
198
202
  }
199
203
 
200
- // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
201
- // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) before pages so /blog wins; renders in the same theme.
204
+ // markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
205
+ // blog posts (/blog, /blog/<slug>, /category, /tag, /feed.xml) — before pages so /blog wins; renders in the same theme.
202
206
  if (enabled.has("posts")) app.use(await (await addonMod("posts")).postsRouter({ dir: path.join(__dirname, "posts"), themeDir: path.join(__dirname, "pages") }));
203
207
  if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
204
208
 
@@ -206,7 +210,14 @@ async function startApp() {
206
210
  const io = new SocketServer(server);
207
211
  if (enabled.has("realtime") && store) (await addonMod("realtime")).attachRealtime(io, { store });
208
212
 
209
- // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
213
+ // Reload connected browsers on demand used when a second `npm run dev` finds
214
+ // the app already running (see the EADDRINUSE handler below) instead of crashing.
215
+ app.get("/__volt/reload", (_req, res) => {
216
+ io.emit("volt:reload", { file: "__manual__" });
217
+ res.json({ ok: true });
218
+ });
219
+
220
+ // third-party add-ons — register(ctx). When auth is on, requireAuth/sessionFromReq
210
221
  // are provided so add-ons can gate routes by login.
211
222
  let requireAuth = null;
212
223
  let sessionFromReq = null;
@@ -222,7 +233,7 @@ async function startApp() {
222
233
  if (typeof register === "function") {
223
234
  await register({ app, express, io, store, mailer, env: process.env, requireAuth, sessionFromReq, log: (...a) => console.log(`[${name}]`, ...a) });
224
235
  } else {
225
- console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
236
+ console.warn(`[volt] add-on "${name}" not found or missing a register() export — skipped`);
226
237
  }
227
238
  }
228
239
 
@@ -230,7 +241,7 @@ async function startApp() {
230
241
  const onChange = (file) => {
231
242
  clearTimeout(timer);
232
243
  timer = setTimeout(() => {
233
- console.log(`[volt] change: ${file ?? "?"} → reload`);
244
+ console.log(`[volt] change: ${file ?? "?"} → reload`);
234
245
  io.emit("volt:reload", { file });
235
246
  }, 80);
236
247
  };
@@ -259,7 +270,21 @@ async function startApp() {
259
270
  }
260
271
 
261
272
  const on = [...enabled];
262
- server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
273
+ // If the port's taken, the app is likely already running reload it (tell the
274
+ // running instance to refresh browsers) and exit, instead of an EADDRINUSE crash.
275
+ server.on("error", async (e) => {
276
+ if (e.code === "EADDRINUSE") {
277
+ try {
278
+ await fetch(`http://localhost:${PORT}/__volt/reload`);
279
+ } catch {
280
+ /* old instance without the reload route, or not ours */
281
+ }
282
+ console.log(`\n[volt] already running at http://localhost:${PORT} — reloaded it. (Stop that process, or use pm2, to restart.)`);
283
+ process.exit(0);
284
+ }
285
+ throw e;
286
+ });
287
+ server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}${on.length ? " (add-ons: " + on.join(", ") + ")" : ""}`));
263
288
  }
264
289
 
265
290
  // Packages an .env's selections need, beyond what package.json already has.
@@ -283,7 +308,7 @@ function neededPackages(env) {
283
308
  function ensureDriverInstalled(driver) {
284
309
  const pkg = { mongodb: "mongodb", mongo: "mongodb", mysql: "mysql2", postgres: "pg", postgresql: "pg", pg: "pg" }[String(driver || "").toLowerCase()];
285
310
  if (!pkg || fs.existsSync(path.join(__dirname, "node_modules", pkg))) return;
286
- console.log(`[volt] installing ${pkg} for the connection test…`);
311
+ console.log(`[volt] installing ${pkg} for the connection test…`);
287
312
  spawnSync("npm", ["install", `${pkg}@${PKG_VERSIONS[pkg] || "latest"}`], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
288
313
  }
289
314
 
@@ -406,15 +431,15 @@ function startSetup() {
406
431
  server.closeIdleConnections?.();
407
432
  };
408
433
  if (added.length) {
409
- console.log(`[volt] installing ${added.join(", ")}…`);
434
+ console.log(`[volt] installing ${added.join(", ")}…`);
410
435
  const npm = spawn("npm", ["install"], { cwd: __dirname, stdio: "inherit", shell: process.platform === "win32" });
411
436
  npm.on("error", () => handoff());
412
437
  npm.on("close", () => {
413
- console.log("[volt] saved .env — starting the app…");
438
+ console.log("[volt] saved .env — starting the app…");
414
439
  handoff();
415
440
  });
416
441
  } else {
417
- console.log("[volt] saved .env — starting the app…");
442
+ console.log("[volt] saved .env — starting the app…");
418
443
  handoff();
419
444
  }
420
445
  });
@@ -432,18 +457,18 @@ function startSetup() {
432
457
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
433
458
  server.listen(PORT, "127.0.0.1", () => {
434
459
  const url = `http://localhost:${PORT}`;
435
- console.log(`\n⚡ Volt setup → ${url}`);
460
+ console.log(`\n⚡ Volt setup → ${url}`);
436
461
  console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
437
462
  const ssh = process.env.SSH_CONNECTION;
438
463
  if (ssh) {
439
464
  const host = ssh.split(" ")[2];
440
465
  const user = process.env.USER || process.env.USERNAME || "you";
441
- console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
466
+ console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
442
467
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
443
- console.log(` …then open ${url} on your machine (the tunnel points it here).`);
468
+ console.log(` …then open ${url} on your machine (the tunnel points it here).`);
444
469
  }
445
470
  console.log("");
446
- if (openBrowser(url)) console.log(" (opening your browser…)\n");
471
+ if (openBrowser(url)) console.log(" (opening your browser…)\n");
447
472
  });
448
473
  }
449
474
 
@@ -451,8 +476,8 @@ function readEnvFileLines() {
451
476
  return fs.existsSync(ENV_PATH) ? fs.readFileSync(ENV_PATH, "utf8").split("\n") : [];
452
477
  }
453
478
 
454
- // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
455
- // Not a route in the running app — it only exists while you run `--studio`, on
479
+ // --- Studio: an ephemeral, localhost-only data browser (à la Prisma Studio).
480
+ // Not a route in the running app — it only exists while you run `--studio`, on
456
481
  // loopback, and disappears on Ctrl-C. Shell/SSH access is the auth. ---
457
482
  const HIDDEN_COLLECTIONS = new Set(["auth_tokens", "auth_sessions", "__voltcheck"]);
458
483
  async function startStudio() {
@@ -465,7 +490,7 @@ async function startStudio() {
465
490
  try {
466
491
  store = await (await addonMod("db")).createStore();
467
492
  } catch (e) {
468
- console.error("Studio: couldn't connect the store — " + e.message);
493
+ console.error("Studio: couldn't connect the store — " + e.message);
469
494
  process.exit(1);
470
495
  }
471
496
  const PORT = configPort();
@@ -523,15 +548,15 @@ async function startStudio() {
523
548
  server.on("error", (e) => { if (e.code === "EADDRINUSE") { console.error(`\n[volt] Config UI port ${PORT} is in use (is the app already running?). Set CONFIG_PORT in .env or pass --port <n>.\n`); process.exit(1); } throw e; });
524
549
  server.listen(PORT, "127.0.0.1", () => {
525
550
  const url = `http://localhost:${PORT}`;
526
- console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
527
- console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
551
+ console.log(`\n⚡ Volt Studio → ${url} (${store.name})`);
552
+ console.log(" Browse your data. localhost-only, disposable — Ctrl-C when done.");
528
553
  const ssh = process.env.SSH_CONNECTION;
529
554
  if (ssh) {
530
555
  const host = ssh.split(" ")[2];
531
556
  const user = process.env.USER || process.env.USERNAME || "you";
532
- console.log(" Remote box — from your LOCAL machine:");
557
+ console.log(" Remote box — from your LOCAL machine:");
533
558
  console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
534
- console.log(` …then open ${url}.`);
559
+ console.log(` …then open ${url}.`);
535
560
  }
536
561
  console.log("");
537
562
  openBrowser(url);