create-volt 0.3.2 → 0.8.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 +120 -0
- package/README.md +49 -0
- package/addons/auth/files/lib/auth.js +121 -0
- package/addons/auth/meta.json +8 -0
- package/addons/db/files/lib/store.js +42 -0
- package/addons/db/files/lib/stores/memory.js +33 -0
- package/addons/db/files/lib/stores/mongo.js +43 -0
- package/addons/db/files/lib/stores/sql.js +79 -0
- package/addons/db/meta.json +12 -0
- package/addons/mailer/files/lib/mailer.js +33 -0
- package/addons/mailer/meta.json +10 -0
- package/addons/realtime/files/lib/realtime.js +78 -0
- package/addons/realtime/files/public/chat-client.js +30 -0
- package/addons/realtime/meta.json +8 -0
- package/config/config-app.js +168 -0
- package/config/index.html +29 -0
- package/index.js +242 -16
- package/package.json +5 -2
- package/{template → templates/default}/README.md +20 -1
- package/templates/default/server.js +213 -0
- package/templates/default/setup/index.html +27 -0
- package/templates/default/setup/setup.js +121 -0
- package/{template → templates/default}/views/index.html +3 -0
- package/templates/guestbook/README.md +71 -0
- package/templates/guestbook/gitignore +5 -0
- package/templates/guestbook/lib/auth.js +63 -0
- package/templates/guestbook/lib/mailer.js +39 -0
- package/templates/guestbook/lib/store.js +43 -0
- package/templates/guestbook/lib/stores/memory.js +49 -0
- package/templates/guestbook/lib/stores/mongo.js +68 -0
- package/templates/guestbook/lib/stores/sql.js +120 -0
- package/templates/guestbook/package.json +21 -0
- package/templates/guestbook/public/app.js +112 -0
- package/templates/guestbook/public/volt.js +265 -0
- package/templates/guestbook/router.js +110 -0
- package/templates/guestbook/server.js +48 -0
- package/templates/guestbook/views/confirm.html +53 -0
- package/templates/guestbook/views/index.html +38 -0
- package/templates/guestbook/views/partials/header.html +4 -0
- package/template/server.js +0 -64
- /package/{template → templates/default}/gitignore +0 -0
- /package/{template → templates/default}/package.json +0 -0
- /package/{template → templates/default}/public/app.js +0 -0
- /package/{template → templates/default}/public/volt.js +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// server.js — dev server with a built-in first-run setup wizard.
|
|
2
|
+
//
|
|
3
|
+
// First run (no .env) or `node server.js --edit` (-e) opens a disposable, local
|
|
4
|
+
// config page; click Apply and it writes .env, loads it, and starts the app
|
|
5
|
+
// in-process — then the setup page is gone. Normal runs just start the app.
|
|
6
|
+
//
|
|
7
|
+
// No build step, no env-file flag: .env is auto-loaded below.
|
|
8
|
+
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
import express from "express";
|
|
15
|
+
import { Server as SocketServer } from "socket.io";
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ENV_PATH = path.join(__dirname, ".env");
|
|
19
|
+
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
20
|
+
|
|
21
|
+
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
22
|
+
function readEnvFile() {
|
|
23
|
+
const out = {};
|
|
24
|
+
if (!fs.existsSync(ENV_PATH)) return out;
|
|
25
|
+
for (const line of fs.readFileSync(ENV_PATH, "utf8").split("\n")) {
|
|
26
|
+
const m = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$/);
|
|
27
|
+
if (m) out[m[1]] = m[2];
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function loadEnv() {
|
|
32
|
+
for (const [k, v] of Object.entries(readEnvFile())) if (!(k in process.env)) process.env[k] = v;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Which add-ons are present? (detected by their files, so the wizard only asks
|
|
36
|
+
// for settings that actually apply.)
|
|
37
|
+
function presentAddons() {
|
|
38
|
+
const has = (f) => fs.existsSync(path.join(__dirname, f));
|
|
39
|
+
return { db: has("lib/store.js"), auth: has("lib/auth.js"), mailer: has("lib/mailer.js"), realtime: has("lib/realtime.js") };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Open the default browser at `url` — but only when there's a desktop to open
|
|
43
|
+
// it on. Headless / remote (no DISPLAY) just keeps the printed link. Opt out
|
|
44
|
+
// with VOLT_NO_OPEN=1 or --no-open.
|
|
45
|
+
function openBrowser(url) {
|
|
46
|
+
if (process.env.VOLT_NO_OPEN || process.argv.includes("--no-open")) return false;
|
|
47
|
+
const plat = process.platform;
|
|
48
|
+
if (plat === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return false;
|
|
49
|
+
const cmd = plat === "darwin" ? "open" : plat === "win32" ? "cmd" : "xdg-open";
|
|
50
|
+
const args = plat === "win32" ? ["/c", "start", "", url] : [url];
|
|
51
|
+
try {
|
|
52
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
53
|
+
child.on("error", () => {}); // launcher missing (e.g. no xdg-open) emits async — don't crash
|
|
54
|
+
child.unref();
|
|
55
|
+
return true;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- the actual app ---
|
|
62
|
+
function startApp() {
|
|
63
|
+
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
64
|
+
const app = express();
|
|
65
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
66
|
+
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
67
|
+
|
|
68
|
+
const server = http.createServer(app);
|
|
69
|
+
const io = new SocketServer(server);
|
|
70
|
+
|
|
71
|
+
// hot reload: watch views/ + public/, debounce, broadcast a reload
|
|
72
|
+
let timer = null;
|
|
73
|
+
const onChange = (file) => {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
timer = setTimeout(() => {
|
|
76
|
+
console.log(`[volt] change: ${file ?? "?"} → reload`);
|
|
77
|
+
io.emit("volt:reload");
|
|
78
|
+
}, 80);
|
|
79
|
+
};
|
|
80
|
+
const watchRecursive = (dir) => {
|
|
81
|
+
try {
|
|
82
|
+
fs.watch(dir, { recursive: true }, (_e, f) => onChange(f));
|
|
83
|
+
return;
|
|
84
|
+
} catch {
|
|
85
|
+
/* fall back to per-directory watchers */
|
|
86
|
+
}
|
|
87
|
+
const w = (d) => {
|
|
88
|
+
try {
|
|
89
|
+
fs.watch(d, (_e, f) => onChange(f));
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory()) w(path.join(d, e.name));
|
|
94
|
+
};
|
|
95
|
+
w(dir);
|
|
96
|
+
};
|
|
97
|
+
for (const d of ["views", "public"]) watchRecursive(path.join(__dirname, d));
|
|
98
|
+
|
|
99
|
+
server.listen(PORT, () => console.log(`⚡ Volt → http://localhost:${PORT}`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- the disposable setup wizard (localhost only) ---
|
|
103
|
+
function startSetup() {
|
|
104
|
+
const PORT = Number(process.env.PORT) || DEFAULT_PORT;
|
|
105
|
+
const assets = {
|
|
106
|
+
"/setup.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "setup.js"))],
|
|
107
|
+
"/volt.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "public", "volt.js"))],
|
|
108
|
+
};
|
|
109
|
+
const indexHtml = fs.readFileSync(path.join(__dirname, "setup", "index.html"));
|
|
110
|
+
|
|
111
|
+
const server = http.createServer((req, res) => {
|
|
112
|
+
const u = new URL(req.url, "http://localhost");
|
|
113
|
+
const p = u.pathname;
|
|
114
|
+
if (req.method === "GET" && p === "/") {
|
|
115
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
116
|
+
return res.end(indexHtml);
|
|
117
|
+
}
|
|
118
|
+
if (req.method === "GET" && assets[p]) {
|
|
119
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
120
|
+
return res.end(assets[p][1]);
|
|
121
|
+
}
|
|
122
|
+
if (req.method === "GET" && p === "/setup/state") {
|
|
123
|
+
res.setHeader("Content-Type", "application/json");
|
|
124
|
+
return res.end(JSON.stringify({ present: presentAddons(), current: readEnvFile(), defaultPort: DEFAULT_PORT }));
|
|
125
|
+
}
|
|
126
|
+
if (req.method === "POST" && p === "/setup/test-db") {
|
|
127
|
+
let body = "";
|
|
128
|
+
req.on("data", (c) => (body += c));
|
|
129
|
+
req.on("end", async () => {
|
|
130
|
+
const keys = ["DB_DRIVER", "MONGODB_URI", "MONGODB_DATABASE", "DATABASE_URL"];
|
|
131
|
+
const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]]));
|
|
132
|
+
try {
|
|
133
|
+
const { env = {} } = JSON.parse(body);
|
|
134
|
+
for (const k of keys) {
|
|
135
|
+
if (env[k]) process.env[k] = env[k];
|
|
136
|
+
else delete process.env[k];
|
|
137
|
+
}
|
|
138
|
+
const { createStore } = await import(pathToFileURL(path.join(__dirname, "lib", "store.js")).href);
|
|
139
|
+
const store = await createStore();
|
|
140
|
+
await store.collection("__voltcheck").all(); // actually touch the connection
|
|
141
|
+
res.setHeader("Content-Type", "application/json");
|
|
142
|
+
res.end(JSON.stringify({ ok: true, driver: store.name }));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
res.setHeader("Content-Type", "application/json");
|
|
145
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
146
|
+
} finally {
|
|
147
|
+
for (const k of keys) {
|
|
148
|
+
if (saved[k] == null) delete process.env[k];
|
|
149
|
+
else process.env[k] = saved[k];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (req.method === "POST" && p === "/setup/apply") {
|
|
156
|
+
let body = "";
|
|
157
|
+
req.on("data", (c) => (body += c));
|
|
158
|
+
req.on("end", () => {
|
|
159
|
+
try {
|
|
160
|
+
const { env } = JSON.parse(body);
|
|
161
|
+
if (typeof env !== "string") throw new Error("missing env");
|
|
162
|
+
fs.writeFileSync(ENV_PATH, env);
|
|
163
|
+
// The app binds process.env.PORT if it's already set (loadEnv won't
|
|
164
|
+
// override it), else the .env PORT, else the default — redirect there.
|
|
165
|
+
const envPort = Number((env.match(/^\s*PORT\s*=\s*(\d+)/m) || [])[1]);
|
|
166
|
+
const newPort = process.env.PORT ? Number(process.env.PORT) : envPort || DEFAULT_PORT;
|
|
167
|
+
res.setHeader("Content-Type", "application/json");
|
|
168
|
+
res.end(JSON.stringify({ ok: true, port: newPort }));
|
|
169
|
+
console.log("[volt] saved .env — starting the app…");
|
|
170
|
+
res.on("finish", () => {
|
|
171
|
+
server.close(() => {
|
|
172
|
+
loadEnv();
|
|
173
|
+
startApp();
|
|
174
|
+
});
|
|
175
|
+
server.closeIdleConnections?.(); // drop the keep-alive socket so close() fires now
|
|
176
|
+
});
|
|
177
|
+
} catch (e) {
|
|
178
|
+
res.statusCode = 400;
|
|
179
|
+
res.end(JSON.stringify({ ok: false, error: e.message }));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
res.statusCode = 404;
|
|
185
|
+
res.end("not found");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// localhost-only: an unauthenticated write endpoint shouldn't be on the network
|
|
189
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
190
|
+
const url = `http://localhost:${PORT}`;
|
|
191
|
+
console.log(`\n⚡ Volt setup → ${url}`);
|
|
192
|
+
console.log(" Configure your app; it starts automatically on Apply. (reopen later: npm run dev -- --edit)");
|
|
193
|
+
const ssh = process.env.SSH_CONNECTION; // "clientIP clientPort serverIP serverPort"
|
|
194
|
+
if (ssh) {
|
|
195
|
+
const host = ssh.split(" ")[2];
|
|
196
|
+
const user = process.env.USER || process.env.USERNAME || "you";
|
|
197
|
+
console.log(" Remote box — the server is up here; bridge it from your LOCAL machine:");
|
|
198
|
+
console.log(` ssh -N -L 127.0.0.1:${PORT}:localhost:${PORT} ${user}@${host}`);
|
|
199
|
+
console.log(` …then open ${url} on your machine (the tunnel points it here).`);
|
|
200
|
+
}
|
|
201
|
+
console.log("");
|
|
202
|
+
if (openBrowser(url)) console.log(" (opening your browser…)\n");
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- gate: setup on first run / --edit, otherwise the app ---
|
|
207
|
+
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
208
|
+
if (editMode || !fs.existsSync(ENV_PATH)) {
|
|
209
|
+
startSetup();
|
|
210
|
+
} else {
|
|
211
|
+
loadEnv();
|
|
212
|
+
startApp();
|
|
213
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Set up your Volt app</title>
|
|
7
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
8
|
+
<style>
|
|
9
|
+
body { background: #0f1115; color: #e7e9ee; }
|
|
10
|
+
.accent { color: #ffd24a; }
|
|
11
|
+
.card-x { background: #161a22; border: 1px solid #232a36; border-radius: 14px; }
|
|
12
|
+
.form-control, .form-select { background: #0f1115; color: #e7e9ee; border-color: #232a36; }
|
|
13
|
+
.form-control:focus, .form-select:focus { background: #0f1115; color: #e7e9ee; border-color: #ffd24a; box-shadow: none; }
|
|
14
|
+
code { color: #ffd24a; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<main class="container py-5" style="max-width: 640px;">
|
|
19
|
+
<header class="mb-4">
|
|
20
|
+
<h1 class="h3"><span class="accent">⚡ Set up your Volt app</span></h1>
|
|
21
|
+
<p class="text-muted mb-0">Fill these in and the app starts. This page is disposable — it disappears once you click Apply. Re-open it anytime with <code>npm run dev -- --edit</code>.</p>
|
|
22
|
+
</header>
|
|
23
|
+
<div id="app"></div>
|
|
24
|
+
</main>
|
|
25
|
+
<script type="module" src="/setup.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// setup.js — the first-run / --edit settings wizard, built with Volt. Asks only
|
|
2
|
+
// for the settings the present add-ons need, writes .env, then the app starts.
|
|
3
|
+
import { signal, computed, html, mount } from "/volt.js";
|
|
4
|
+
|
|
5
|
+
const { present, current, defaultPort } = await (await fetch("/setup/state")).json();
|
|
6
|
+
|
|
7
|
+
const state = signal({
|
|
8
|
+
port: current.PORT || String(defaultPort),
|
|
9
|
+
dbDriver: current.DB_DRIVER || "memory",
|
|
10
|
+
mongoUri: current.MONGODB_URI || "",
|
|
11
|
+
mongoDb: current.MONGODB_DATABASE || "",
|
|
12
|
+
dbUrl: current.DATABASE_URL || "",
|
|
13
|
+
smtpUrl: current.SMTP_URL || "",
|
|
14
|
+
mailFrom: current.MAIL_FROM || "",
|
|
15
|
+
});
|
|
16
|
+
const set = (patch) => state({ ...state(), ...patch });
|
|
17
|
+
const status = signal("");
|
|
18
|
+
|
|
19
|
+
function genEnv(s) {
|
|
20
|
+
const out = [`PORT=${s.port}`];
|
|
21
|
+
if (present.db) {
|
|
22
|
+
out.push(`DB_DRIVER=${s.dbDriver}`);
|
|
23
|
+
if (s.dbDriver === "mongodb") {
|
|
24
|
+
out.push(`MONGODB_URI=${s.mongoUri}`);
|
|
25
|
+
if (s.mongoDb) out.push(`MONGODB_DATABASE=${s.mongoDb}`);
|
|
26
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
27
|
+
out.push(`DATABASE_URL=${s.dbUrl}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (present.mailer) {
|
|
31
|
+
if (s.smtpUrl) out.push(`SMTP_URL=${s.smtpUrl}`);
|
|
32
|
+
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
33
|
+
if (s.mailFrom) out.push(`MAIL_FROM=${s.mailFrom}`);
|
|
34
|
+
}
|
|
35
|
+
return out.join("\n") + "\n";
|
|
36
|
+
}
|
|
37
|
+
const env = computed(() => genEnv(state()));
|
|
38
|
+
|
|
39
|
+
async function apply() {
|
|
40
|
+
status("Saving & starting…");
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch("/setup/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env: env() }) });
|
|
43
|
+
const d = await r.json();
|
|
44
|
+
if (!d.ok) return status("Error: " + d.error);
|
|
45
|
+
status("Starting the app…");
|
|
46
|
+
const target = `http://localhost:${d.port}/`;
|
|
47
|
+
const go = async (n) => {
|
|
48
|
+
try {
|
|
49
|
+
await fetch(target, { mode: "no-cors" }); // app is listening
|
|
50
|
+
location.href = target;
|
|
51
|
+
} catch {
|
|
52
|
+
if (n > 0) setTimeout(() => go(n - 1), 400);
|
|
53
|
+
else location.href = target;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
setTimeout(() => go(20), 500);
|
|
57
|
+
} catch {
|
|
58
|
+
status("Network error — try again.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function testDb() {
|
|
63
|
+
const s = state();
|
|
64
|
+
const env = { DB_DRIVER: s.dbDriver };
|
|
65
|
+
if (s.dbDriver === "mongodb") {
|
|
66
|
+
env.MONGODB_URI = s.mongoUri;
|
|
67
|
+
env.MONGODB_DATABASE = s.mongoDb;
|
|
68
|
+
} else if (s.dbDriver === "mysql" || s.dbDriver === "postgres") {
|
|
69
|
+
env.DATABASE_URL = s.dbUrl;
|
|
70
|
+
}
|
|
71
|
+
status("Testing connection…");
|
|
72
|
+
try {
|
|
73
|
+
const r = await fetch("/setup/test-db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env }) });
|
|
74
|
+
const d = await r.json();
|
|
75
|
+
status(d.ok ? `✓ Connected (${d.driver}).` : `✗ ${d.error}`);
|
|
76
|
+
} catch {
|
|
77
|
+
status("Network error testing connection.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const field = (label, key, placeholder = "") =>
|
|
82
|
+
html`<div class="mb-2">
|
|
83
|
+
<label class="form-label small mb-1">${label}</label>
|
|
84
|
+
<input class="form-control" placeholder=${placeholder} value=${() => state()[key]} oninput=${(e) => set({ [key]: e.target.value })} />
|
|
85
|
+
</div>`;
|
|
86
|
+
|
|
87
|
+
const dbSettings = () =>
|
|
88
|
+
html`<div class="mb-2">
|
|
89
|
+
<label class="form-label small mb-1">Database (DB_DRIVER)</label>
|
|
90
|
+
<select class="form-select" value=${() => state().dbDriver} onchange=${(e) => set({ dbDriver: e.target.value })}>
|
|
91
|
+
<option value="memory">memory (no setup)</option>
|
|
92
|
+
<option value="mongodb">mongodb</option>
|
|
93
|
+
<option value="mysql">mysql</option>
|
|
94
|
+
<option value="postgres">postgres</option>
|
|
95
|
+
</select>
|
|
96
|
+
</div>
|
|
97
|
+
${() =>
|
|
98
|
+
state().dbDriver === "mongodb"
|
|
99
|
+
? html`${field("MONGODB_URI", "mongoUri", "mongodb://user:pass@host:27017/db")}${field("MONGODB_DATABASE", "mongoDb", "db")}`
|
|
100
|
+
: state().dbDriver === "mysql" || state().dbDriver === "postgres"
|
|
101
|
+
? field("DATABASE_URL", "dbUrl", state().dbDriver + "://user:pass@host/db")
|
|
102
|
+
: null}
|
|
103
|
+
${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
104
|
+
|
|
105
|
+
mount(
|
|
106
|
+
"#app",
|
|
107
|
+
html`<div class="card-x p-4 mb-3">
|
|
108
|
+
<h2 class="h6 mb-3">Settings</h2>
|
|
109
|
+
${field("PORT", "port", String(defaultPort))}
|
|
110
|
+
${() => (present.db ? dbSettings() : null)}
|
|
111
|
+
${() => (present.mailer ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
112
|
+
</div>`,
|
|
113
|
+
html`<div class="card-x p-4 mb-3">
|
|
114
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
115
|
+
<h2 class="h6 mb-0">.env</h2>
|
|
116
|
+
<button class="btn btn-primary btn-sm" onclick=${apply}>Apply & start →</button>
|
|
117
|
+
</div>
|
|
118
|
+
<pre class="mb-0" style="background:#0b0d11;border:1px solid #232a36;border-radius:10px;padding:12px;color:#cfe3ff;white-space:pre-wrap">${env}</pre>
|
|
119
|
+
</div>`,
|
|
120
|
+
() => (status() ? html`<p class="small accent">${status}</p>` : null),
|
|
121
|
+
);
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
|
|
31
31
|
<footer class="text-center text-muted mt-5">
|
|
32
32
|
<small>Both cards run on the same signal engine — one uses <code>el()</code>, the other <code>html``</code>.</small>
|
|
33
|
+
<div class="mt-2">
|
|
34
|
+
<small><a href="https://github.com/MIR-2025/volt#readme" target="_blank" rel="noopener">📖 How to build a Volt app →</a></small>
|
|
35
|
+
</div>
|
|
33
36
|
</footer>
|
|
34
37
|
</main>
|
|
35
38
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 📖 Guestbook — a real Volt app
|
|
2
|
+
|
|
3
|
+
A live message board built with [Volt](https://github.com/MIR-2025/volt):
|
|
4
|
+
fine-grained signals on the frontend, **Socket.io** for real-time updates,
|
|
5
|
+
**magic-link** sign-in (no passwords), and **pluggable storage** —
|
|
6
|
+
in-memory by default, or MongoDB / MySQL / Postgres for persistence.
|
|
7
|
+
|
|
8
|
+
## Run
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install # if you scaffolded with --skip-install
|
|
12
|
+
npm run dev # → http://localhost:26629
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Out of the box it uses an **in-memory store** and prints magic-link emails to
|
|
16
|
+
the **server console** — so you can try the whole flow with zero setup:
|
|
17
|
+
|
|
18
|
+
1. Enter your email, click **Send magic link**.
|
|
19
|
+
2. Copy the link printed in the terminal, open it.
|
|
20
|
+
3. Click **Confirm login** (must be the same browser).
|
|
21
|
+
4. Post a message — open a second tab to watch it appear live.
|
|
22
|
+
|
|
23
|
+
## Storage backends
|
|
24
|
+
|
|
25
|
+
Pick a backend with `DB_DRIVER` (default `memory`). Real drivers are lazy-loaded,
|
|
26
|
+
so install only the one you use.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# MongoDB
|
|
30
|
+
DB_DRIVER=mongodb MONGODB_URI="mongodb://user:<password>@host:27017/guestbook?authSource=admin" \
|
|
31
|
+
MONGODB_DATABASE=guestbook npm start
|
|
32
|
+
|
|
33
|
+
# MySQL (npm install mysql2)
|
|
34
|
+
DB_DRIVER=mysql DATABASE_URL="mysql://user:<password>@host:3306/guestbook" npm start
|
|
35
|
+
|
|
36
|
+
# Postgres (npm install pg)
|
|
37
|
+
DB_DRIVER=postgres DATABASE_URL="postgres://user:<password>@host:5432/guestbook" npm start
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Tables/collections are created automatically on first run. Never hard-code the
|
|
41
|
+
credential — keep it in your environment / `.env`.
|
|
42
|
+
|
|
43
|
+
## Email (magic links)
|
|
44
|
+
|
|
45
|
+
In dev, links are printed to the console. For real email, set `SMTP_URL` (and
|
|
46
|
+
`MAIL_FROM`) and `npm install nodemailer`:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
SMTP_URL="smtp://user:pass@smtp.example.com:587" MAIL_FROM="Guestbook <no-reply@you.com>" npm start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Layout
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
server.js Express + Socket.io + storage/mailer wiring
|
|
56
|
+
router.js routes; composes views with a header include
|
|
57
|
+
lib/store.js backend selector (memory | mongodb | mysql | postgres)
|
|
58
|
+
lib/stores/ the adapters (same interface each)
|
|
59
|
+
lib/auth.js magic-link tokens + sessions
|
|
60
|
+
lib/mailer.js console (dev) or SMTP (prod)
|
|
61
|
+
public/volt.js the Volt library (no build step)
|
|
62
|
+
public/app.js the frontend — signals + Socket.io
|
|
63
|
+
views/ index, confirm page, header partial
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Production
|
|
67
|
+
|
|
68
|
+
Runs anywhere Node does. Under PM2: `pm2 start server.js --name guestbook`.
|
|
69
|
+
Set `PORT`, `DB_DRIVER`, the connection string, and `SMTP_URL` in the environment.
|
|
70
|
+
|
|
71
|
+
Scaffolded with [`create-volt`](https://www.npmjs.com/package/create-volt) — `--template guestbook`.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// auth.js — magic-link login (no passwords), per the house auth convention:
|
|
2
|
+
// 1. user submits email → we store a one-time token + the requesting UA,
|
|
3
|
+
// and email a link containing the token
|
|
4
|
+
// 2. opening the link shows a confirm page (must be the same browser/UA)
|
|
5
|
+
// 3. clicking "Confirm" consumes the token and starts a session cookie
|
|
6
|
+
//
|
|
7
|
+
// Tokens expire after TOKEN_TTL and are single-use. Sessions are random ids
|
|
8
|
+
// kept in the store and carried in an httpOnly cookie.
|
|
9
|
+
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
|
|
12
|
+
const TOKEN_TTL = 15 * 60 * 1000; // 15 minutes
|
|
13
|
+
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
14
|
+
export const SESSION_COOKIE = "gb_sid";
|
|
15
|
+
|
|
16
|
+
const token = () => crypto.randomBytes(32).toString("hex");
|
|
17
|
+
const normalizeEmail = (e) => String(e || "").trim().toLowerCase();
|
|
18
|
+
const validEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
|
19
|
+
|
|
20
|
+
// Step 1: create + email a magic link. Returns { ok } or throws on bad input.
|
|
21
|
+
export async function requestLogin(store, mailer, { email, ua, baseUrl }) {
|
|
22
|
+
const addr = normalizeEmail(email);
|
|
23
|
+
if (!validEmail(addr)) throw new Error("Please enter a valid email address.");
|
|
24
|
+
const tok = token();
|
|
25
|
+
await store.putToken({ token: tok, email: addr, ua: ua || "", expiresAt: Date.now() + TOKEN_TTL });
|
|
26
|
+
const link = `${baseUrl}/verify?token=${tok}`;
|
|
27
|
+
await mailer.sendMagicLink(addr, link);
|
|
28
|
+
return { ok: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Step 3: confirm a token → start a session. Returns { sessionId, email }.
|
|
32
|
+
export async function confirmLogin(store, { token: tok, ua }) {
|
|
33
|
+
const rec = await store.getToken(tok);
|
|
34
|
+
if (!rec) throw new Error("This login link is invalid.");
|
|
35
|
+
if (rec.used) throw new Error("This login link was already used.");
|
|
36
|
+
if (rec.expiresAt < Date.now()) throw new Error("This login link has expired.");
|
|
37
|
+
if (rec.ua && ua && rec.ua !== ua) throw new Error("Open the link in the same browser you requested it from.");
|
|
38
|
+
|
|
39
|
+
await store.useToken(tok);
|
|
40
|
+
const sessionId = token();
|
|
41
|
+
await store.putSession({ id: sessionId, email: rec.email, expiresAt: Date.now() + SESSION_TTL });
|
|
42
|
+
return { sessionId, email: rec.email };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse a cookie header into a plain object.
|
|
46
|
+
export function parseCookies(header = "") {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const part of header.split(";")) {
|
|
49
|
+
const i = part.indexOf("=");
|
|
50
|
+
if (i === -1) continue;
|
|
51
|
+
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve the current session from a request, or null.
|
|
57
|
+
export async function sessionFromReq(store, req) {
|
|
58
|
+
const sid = parseCookies(req.headers.cookie)[SESSION_COOKIE];
|
|
59
|
+
if (!sid) return null;
|
|
60
|
+
return await store.getSession(sid);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const cookieMaxAgeSeconds = SESSION_TTL / 1000;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// mailer.js — sends the magic-link email. In dev (no SMTP configured) it just
|
|
2
|
+
// prints the link to the console so you can copy it; in production it uses
|
|
3
|
+
// nodemailer if SMTP_URL is set and the package is installed.
|
|
4
|
+
|
|
5
|
+
export async function createMailer() {
|
|
6
|
+
const smtp = process.env.SMTP_URL;
|
|
7
|
+
const from = process.env.MAIL_FROM || "Guestbook <no-reply@example.com>";
|
|
8
|
+
|
|
9
|
+
if (smtp) {
|
|
10
|
+
let nodemailer;
|
|
11
|
+
try {
|
|
12
|
+
nodemailer = (await import("nodemailer")).default;
|
|
13
|
+
} catch {
|
|
14
|
+
console.warn("[mailer] SMTP_URL set but 'nodemailer' isn't installed — falling back to console. Run: npm install nodemailer");
|
|
15
|
+
}
|
|
16
|
+
if (nodemailer) {
|
|
17
|
+
const transport = nodemailer.createTransport(smtp);
|
|
18
|
+
return {
|
|
19
|
+
name: "smtp",
|
|
20
|
+
async sendMagicLink(email, link) {
|
|
21
|
+
await transport.sendMail({
|
|
22
|
+
to: email,
|
|
23
|
+
from,
|
|
24
|
+
subject: "Your guestbook login link",
|
|
25
|
+
text: `Click to sign in: ${link}\n\nThis link expires shortly and can only be used once.`,
|
|
26
|
+
html: `<p>Click to sign in:</p><p><a href="${link}">${link}</a></p><p>This link expires shortly and can only be used once.</p>`,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
name: "console",
|
|
35
|
+
async sendMagicLink(email, link) {
|
|
36
|
+
console.log(`\n📨 Magic link for ${email}:\n ${link}\n (dev mode — paste this into your browser)\n`);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// store.js — pick a storage backend from the environment. Defaults to the
|
|
2
|
+
// in-memory store so the app runs with zero setup; point DB_DRIVER at a real
|
|
3
|
+
// database (mongodb | mysql | postgres) for persistence.
|
|
4
|
+
//
|
|
5
|
+
// DB_DRIVER=memory (default)
|
|
6
|
+
// DB_DRIVER=mongodb MONGODB_URI=... [MONGODB_DATABASE=...]
|
|
7
|
+
// DB_DRIVER=mysql DATABASE_URL=mysql://user:pass@host:3306/db
|
|
8
|
+
// DB_DRIVER=postgres DATABASE_URL=postgres://user:pass@host:5432/db
|
|
9
|
+
|
|
10
|
+
import { createMemoryStore } from "./stores/memory.js";
|
|
11
|
+
import { createMongoStore } from "./stores/mongo.js";
|
|
12
|
+
import { createSqlStore } from "./stores/sql.js";
|
|
13
|
+
|
|
14
|
+
export async function createStore() {
|
|
15
|
+
const driver = (process.env.DB_DRIVER || "memory").toLowerCase();
|
|
16
|
+
|
|
17
|
+
let store;
|
|
18
|
+
switch (driver) {
|
|
19
|
+
case "memory":
|
|
20
|
+
store = createMemoryStore();
|
|
21
|
+
break;
|
|
22
|
+
case "mongodb":
|
|
23
|
+
case "mongo":
|
|
24
|
+
store = await createMongoStore({
|
|
25
|
+
uri: process.env.MONGODB_URI,
|
|
26
|
+
dbName: process.env.MONGODB_DATABASE,
|
|
27
|
+
});
|
|
28
|
+
break;
|
|
29
|
+
case "mysql":
|
|
30
|
+
store = await createSqlStore({ dialect: "mysql", uri: process.env.DATABASE_URL });
|
|
31
|
+
break;
|
|
32
|
+
case "postgres":
|
|
33
|
+
case "postgresql":
|
|
34
|
+
case "pg":
|
|
35
|
+
store = await createSqlStore({ dialect: "postgres", uri: process.env.DATABASE_URL });
|
|
36
|
+
break;
|
|
37
|
+
default:
|
|
38
|
+
throw new Error(`Unknown DB_DRIVER "${driver}" (use memory | mongodb | mysql | postgres)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await store.init();
|
|
42
|
+
return store;
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// memory.js — in-memory store. The zero-dependency dev fallback so the app
|
|
2
|
+
// runs out of the box; data is lost on restart. Same interface as the real
|
|
3
|
+
// MongoDB / MySQL / Postgres adapters.
|
|
4
|
+
|
|
5
|
+
export function createMemoryStore() {
|
|
6
|
+
const tokens = new Map(); // token -> { token, email, ua, expiresAt, used }
|
|
7
|
+
const sessions = new Map(); // id -> { id, email, expiresAt }
|
|
8
|
+
const messages = []; // { id, email, body, createdAt }
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
name: "memory",
|
|
12
|
+
async init() {},
|
|
13
|
+
|
|
14
|
+
async putToken(t) {
|
|
15
|
+
tokens.set(t.token, { ...t, used: false });
|
|
16
|
+
},
|
|
17
|
+
async getToken(token) {
|
|
18
|
+
return tokens.get(token) || null;
|
|
19
|
+
},
|
|
20
|
+
async useToken(token) {
|
|
21
|
+
const t = tokens.get(token);
|
|
22
|
+
if (t) t.used = true;
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async putSession(s) {
|
|
26
|
+
sessions.set(s.id, s);
|
|
27
|
+
},
|
|
28
|
+
async getSession(id) {
|
|
29
|
+
const s = sessions.get(id);
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
if (s.expiresAt < Date.now()) {
|
|
32
|
+
sessions.delete(id);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
},
|
|
37
|
+
async delSession(id) {
|
|
38
|
+
sessions.delete(id);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async addMessage(m) {
|
|
42
|
+
messages.push(m);
|
|
43
|
+
return m;
|
|
44
|
+
},
|
|
45
|
+
async listMessages(limit = 100) {
|
|
46
|
+
return messages.slice(-limit);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|