create-volt 0.3.1 → 0.5.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 +69 -0
- package/README.md +28 -0
- package/index.js +50 -11
- package/package.json +3 -2
- package/{template → templates/default}/README.md +29 -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 +36 -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 → 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
- /package/{template → templates/default}/server.js +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// sql.js — shared SQL adapter for MySQL and Postgres. Lazy-loads the relevant
|
|
2
|
+
// driver (mysql2 or pg) and normalizes the two differences that matter:
|
|
3
|
+
// • placeholders: MySQL uses `?`, Postgres uses `$1, $2, …`
|
|
4
|
+
// • query call: mysql2 returns [rows], pg returns { rows }
|
|
5
|
+
|
|
6
|
+
export async function createSqlStore({ dialect, uri }) {
|
|
7
|
+
if (!uri) throw new Error(`DB_DRIVER=${dialect} requires a connection string (DATABASE_URL)`);
|
|
8
|
+
|
|
9
|
+
let run; // (sql, params) => Promise<rows[]>
|
|
10
|
+
if (dialect === "mysql") {
|
|
11
|
+
let mysql;
|
|
12
|
+
try {
|
|
13
|
+
mysql = (await import("mysql2/promise")).default;
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error("DB_DRIVER=mysql but 'mysql2' isn't installed. Run: npm install mysql2");
|
|
16
|
+
}
|
|
17
|
+
const pool = mysql.createPool(uri);
|
|
18
|
+
run = async (sql, params = []) => {
|
|
19
|
+
const [rows] = await pool.query(sql, params);
|
|
20
|
+
return rows;
|
|
21
|
+
};
|
|
22
|
+
} else {
|
|
23
|
+
let pg;
|
|
24
|
+
try {
|
|
25
|
+
pg = await import("pg");
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error("DB_DRIVER=postgres but 'pg' isn't installed. Run: npm install pg");
|
|
28
|
+
}
|
|
29
|
+
const pool = new pg.default.Pool({ connectionString: uri });
|
|
30
|
+
run = async (sql, params = []) => (await pool.query(sql, params)).rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Placeholder for the i-th (1-based) parameter.
|
|
34
|
+
const ph = dialect === "mysql" ? () => "?" : (i) => `$${i}`;
|
|
35
|
+
const BOOL = dialect === "mysql" ? "TINYINT(1)" : "BOOLEAN";
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: dialect,
|
|
39
|
+
|
|
40
|
+
async init() {
|
|
41
|
+
await run(
|
|
42
|
+
`CREATE TABLE IF NOT EXISTS login_tokens (
|
|
43
|
+
token VARCHAR(128) PRIMARY KEY,
|
|
44
|
+
email VARCHAR(320) NOT NULL,
|
|
45
|
+
ua TEXT,
|
|
46
|
+
expires_at BIGINT NOT NULL,
|
|
47
|
+
used ${BOOL} NOT NULL DEFAULT 0
|
|
48
|
+
)`,
|
|
49
|
+
);
|
|
50
|
+
await run(
|
|
51
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
52
|
+
id VARCHAR(128) PRIMARY KEY,
|
|
53
|
+
email VARCHAR(320) NOT NULL,
|
|
54
|
+
expires_at BIGINT NOT NULL
|
|
55
|
+
)`,
|
|
56
|
+
);
|
|
57
|
+
await run(
|
|
58
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
59
|
+
id VARCHAR(64) PRIMARY KEY,
|
|
60
|
+
email VARCHAR(320) NOT NULL,
|
|
61
|
+
body TEXT NOT NULL,
|
|
62
|
+
created_at BIGINT NOT NULL
|
|
63
|
+
)`,
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async putToken(t) {
|
|
68
|
+
await run(
|
|
69
|
+
`INSERT INTO login_tokens (token, email, ua, expires_at, used)
|
|
70
|
+
VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}, ${ph(4)}, 0)`,
|
|
71
|
+
[t.token, t.email, t.ua, t.expiresAt],
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
async getToken(token) {
|
|
75
|
+
const rows = await run(`SELECT token, email, ua, expires_at, used FROM login_tokens WHERE token = ${ph(1)}`, [token]);
|
|
76
|
+
const r = rows[0];
|
|
77
|
+
if (!r) return null;
|
|
78
|
+
return { token: r.token, email: r.email, ua: r.ua, expiresAt: Number(r.expires_at), used: !!r.used };
|
|
79
|
+
},
|
|
80
|
+
async useToken(token) {
|
|
81
|
+
await run(`UPDATE login_tokens SET used = 1 WHERE token = ${ph(1)}`, [token]);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async putSession(s) {
|
|
85
|
+
await run(`INSERT INTO sessions (id, email, expires_at) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)})`, [
|
|
86
|
+
s.id,
|
|
87
|
+
s.email,
|
|
88
|
+
s.expiresAt,
|
|
89
|
+
]);
|
|
90
|
+
},
|
|
91
|
+
async getSession(id) {
|
|
92
|
+
const rows = await run(`SELECT id, email, expires_at FROM sessions WHERE id = ${ph(1)}`, [id]);
|
|
93
|
+
const r = rows[0];
|
|
94
|
+
if (!r) return null;
|
|
95
|
+
const s = { id: r.id, email: r.email, expiresAt: Number(r.expires_at) };
|
|
96
|
+
if (s.expiresAt < Date.now()) {
|
|
97
|
+
await this.delSession(id);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return s;
|
|
101
|
+
},
|
|
102
|
+
async delSession(id) {
|
|
103
|
+
await run(`DELETE FROM sessions WHERE id = ${ph(1)}`, [id]);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
async addMessage(m) {
|
|
107
|
+
await run(`INSERT INTO messages (id, email, body, created_at) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}, ${ph(4)})`, [
|
|
108
|
+
m.id,
|
|
109
|
+
m.email,
|
|
110
|
+
m.body,
|
|
111
|
+
m.createdAt,
|
|
112
|
+
]);
|
|
113
|
+
return m;
|
|
114
|
+
},
|
|
115
|
+
async listMessages(limit = 100) {
|
|
116
|
+
const rows = await run(`SELECT id, email, body, created_at FROM messages ORDER BY created_at ASC LIMIT ${Number(limit)}`);
|
|
117
|
+
return rows.map((r) => ({ id: r.id, email: r.email, body: r.body, createdAt: Number(r.created_at) }));
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volt-guestbook",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Live guestbook — a real Volt app: magic-link auth, Socket.io real-time, pluggable MongoDB/MySQL/Postgres storage (with an in-memory dev fallback).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "server.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"dev": "node server.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"express": "^4.19.2",
|
|
14
|
+
"socket.io": "^4.7.5"
|
|
15
|
+
},
|
|
16
|
+
"optionalDependencies": {
|
|
17
|
+
"mongodb": "^6.8.0",
|
|
18
|
+
"mysql2": "^3.11.0",
|
|
19
|
+
"pg": "^8.12.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// app.js — the guestbook frontend, built with Volt signals. Renders the live
|
|
2
|
+
// message list, the sign-in (magic-link) form, and the post box, all driven by
|
|
3
|
+
// a handful of signals. New messages arrive over Socket.io and update in place.
|
|
4
|
+
|
|
5
|
+
import { signal, computed, html, mount } from "/volt.js";
|
|
6
|
+
|
|
7
|
+
// --- state ---
|
|
8
|
+
const me = signal(null); // signed-in email, or null
|
|
9
|
+
const messages = signal([]); // [{ id, email, body, createdAt }]
|
|
10
|
+
const draft = signal(""); // post box
|
|
11
|
+
const emailDraft = signal(""); // sign-in box
|
|
12
|
+
const notice = signal(""); // status line
|
|
13
|
+
|
|
14
|
+
const api = async (url, body) => {
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
method: body ? "POST" : "GET",
|
|
17
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
});
|
|
20
|
+
return res.json();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fmtWho = (email) => email.replace(/@.*/, ""); // show the local part only
|
|
24
|
+
const fmtTime = (ts) => new Date(ts).toLocaleString();
|
|
25
|
+
|
|
26
|
+
// --- actions ---
|
|
27
|
+
async function sendLink() {
|
|
28
|
+
const email = emailDraft().trim();
|
|
29
|
+
if (!email) return;
|
|
30
|
+
notice("Sending…");
|
|
31
|
+
const r = await api("/api/login", { email });
|
|
32
|
+
notice(r.ok ? (r.dev ? "Magic link printed to the server console — open it to sign in." : `Magic link sent to ${email}.`) : r.error);
|
|
33
|
+
}
|
|
34
|
+
async function post() {
|
|
35
|
+
const body = draft().trim();
|
|
36
|
+
if (!body) return;
|
|
37
|
+
const r = await api("/api/messages", { body });
|
|
38
|
+
if (r.ok) draft("");
|
|
39
|
+
else notice(r.error);
|
|
40
|
+
}
|
|
41
|
+
async function logout() {
|
|
42
|
+
await api("/api/logout", {});
|
|
43
|
+
me(null);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- views ---
|
|
47
|
+
const signIn = () =>
|
|
48
|
+
html`
|
|
49
|
+
<div class="card-x p-4 mb-4">
|
|
50
|
+
<h2 class="h6 mb-3">Sign in to post</h2>
|
|
51
|
+
<div class="input-group">
|
|
52
|
+
<input class="form-control" type="email" placeholder="you@example.com"
|
|
53
|
+
value=${emailDraft}
|
|
54
|
+
oninput=${(e) => emailDraft(e.target.value)}
|
|
55
|
+
onkeydown=${(e) => e.key === "Enter" && sendLink()} />
|
|
56
|
+
<button class="btn btn-primary" onclick=${sendLink}>Send magic link</button>
|
|
57
|
+
</div>
|
|
58
|
+
</div>`;
|
|
59
|
+
|
|
60
|
+
const composer = () =>
|
|
61
|
+
html`
|
|
62
|
+
<div class="card-x p-4 mb-4">
|
|
63
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
64
|
+
<span class="small text-muted">Signed in as <span class="who">${() => fmtWho(me())}</span></span>
|
|
65
|
+
<button class="btn btn-sm btn-outline-secondary" onclick=${logout}>Sign out</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="input-group">
|
|
68
|
+
<input class="form-control" placeholder="Leave a message…"
|
|
69
|
+
value=${draft}
|
|
70
|
+
oninput=${(e) => draft(e.target.value)}
|
|
71
|
+
onkeydown=${(e) => e.key === "Enter" && post()} />
|
|
72
|
+
<button class="btn btn-primary" onclick=${post}>Post</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>`;
|
|
75
|
+
|
|
76
|
+
const messageRow = (m) =>
|
|
77
|
+
html`
|
|
78
|
+
<div class="msg py-2">
|
|
79
|
+
<div class="d-flex justify-content-between">
|
|
80
|
+
<span class="who">${fmtWho(m.email)}</span>
|
|
81
|
+
<small class="text-muted">${fmtTime(m.createdAt)}</small>
|
|
82
|
+
</div>
|
|
83
|
+
<div>${m.body}</div>
|
|
84
|
+
</div>`;
|
|
85
|
+
|
|
86
|
+
const board = () =>
|
|
87
|
+
html`
|
|
88
|
+
<div class="card-x p-4">
|
|
89
|
+
<h2 class="h6 mb-3">${computed(() => `${messages().length} message${messages().length === 1 ? "" : "s"}`)}</h2>
|
|
90
|
+
${() => (messages().length ? messages().map(messageRow) : html`<p class="text-muted mb-0">No messages yet — be the first.</p>`)}
|
|
91
|
+
</div>`;
|
|
92
|
+
|
|
93
|
+
mount(
|
|
94
|
+
"#app",
|
|
95
|
+
// sign-in box OR composer, depending on auth state
|
|
96
|
+
() => (me() ? composer() : signIn()),
|
|
97
|
+
() => (notice() ? html`<p class="small text-muted">${notice}</p>` : null),
|
|
98
|
+
board(),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// --- load + live updates ---
|
|
102
|
+
async function boot() {
|
|
103
|
+
const [{ email }, { messages: list }] = await Promise.all([api("/api/me"), api("/api/messages")]);
|
|
104
|
+
me(email);
|
|
105
|
+
messages(list || []);
|
|
106
|
+
|
|
107
|
+
if (window.io) {
|
|
108
|
+
const socket = window.io();
|
|
109
|
+
socket.on("message:new", (m) => messages([...messages(), m]));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
boot();
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// volt.js — a tiny, no-build, signals-based UI library.
|
|
2
|
+
//
|
|
3
|
+
// Not React: there is no JSX, no virtual DOM, and no "re-render the whole
|
|
4
|
+
// component" step. State lives in *signals*; reading a signal inside a piece of
|
|
5
|
+
// UI subscribes that exact piece; writing the signal re-runs only those
|
|
6
|
+
// subscribers and touches only the precise text node / attribute that changed.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to author UI, same engine underneath:
|
|
9
|
+
// 1. html`` — tagged-template markup with ${signal} holes
|
|
10
|
+
// 2. el(...) — imperative DOM helpers with function-children
|
|
11
|
+
// They interoperate freely (drop an el() node into an html`` template, etc.).
|
|
12
|
+
//
|
|
13
|
+
// Public API: signal, computed, effect, el, html, mount.
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Reactive core (signals + effects with ownership-based disposal)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
let activeEffect = null;
|
|
20
|
+
|
|
21
|
+
// A signal is a function: call with no args to read, one arg to write.
|
|
22
|
+
// const n = signal(0); n(); // read → 0
|
|
23
|
+
// n(n()+1); // write → notifies subscribers
|
|
24
|
+
export function signal(value) {
|
|
25
|
+
const subs = new Set();
|
|
26
|
+
return function sig(...args) {
|
|
27
|
+
if (args.length) {
|
|
28
|
+
const next = args[0];
|
|
29
|
+
if (next === value) return value; // no-op on identical value
|
|
30
|
+
value = next;
|
|
31
|
+
for (const eff of [...subs]) eff.run(); // copy: run() mutates subs
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
if (activeEffect) {
|
|
35
|
+
subs.add(activeEffect);
|
|
36
|
+
activeEffect.deps.add(subs);
|
|
37
|
+
}
|
|
38
|
+
return value;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// effect(fn) runs fn now, tracks every signal it reads, and re-runs it whenever
|
|
43
|
+
// any of those change. Effects created *inside* another effect are owned by it
|
|
44
|
+
// and disposed before each re-run — so dynamic regions clean up after themselves.
|
|
45
|
+
export function effect(fn) {
|
|
46
|
+
const eff = {
|
|
47
|
+
deps: new Set(),
|
|
48
|
+
children: new Set(),
|
|
49
|
+
parent: activeEffect,
|
|
50
|
+
run() {
|
|
51
|
+
disposeChildren(eff);
|
|
52
|
+
cleanupDeps(eff);
|
|
53
|
+
const prev = activeEffect;
|
|
54
|
+
activeEffect = eff;
|
|
55
|
+
try {
|
|
56
|
+
fn();
|
|
57
|
+
} finally {
|
|
58
|
+
activeEffect = prev;
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
dispose() {
|
|
62
|
+
disposeChildren(eff);
|
|
63
|
+
cleanupDeps(eff);
|
|
64
|
+
if (eff.parent) eff.parent.children.delete(eff);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
if (activeEffect) activeEffect.children.add(eff);
|
|
68
|
+
eff.run();
|
|
69
|
+
return () => eff.dispose();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// computed(fn) is a read-only derived signal: () => value, auto-updating.
|
|
73
|
+
export function computed(fn) {
|
|
74
|
+
const s = signal(undefined);
|
|
75
|
+
effect(() => s(fn()));
|
|
76
|
+
return () => s();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanupDeps(eff) {
|
|
80
|
+
for (const subs of eff.deps) subs.delete(eff);
|
|
81
|
+
eff.deps.clear();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function disposeChildren(eff) {
|
|
85
|
+
for (const child of [...eff.children]) child.dispose();
|
|
86
|
+
eff.children.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// DOM helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
// el(tag, props?, ...children) → a real DOM element.
|
|
94
|
+
// props: { onClick: fn } → event listener
|
|
95
|
+
// { class: () => ... } → reactive attribute (function = live)
|
|
96
|
+
// { id: 'x' } → static attribute
|
|
97
|
+
// children: strings, numbers, nodes, arrays, or functions (functions = live)
|
|
98
|
+
export function el(tag, props, ...children) {
|
|
99
|
+
const node = document.createElement(tag);
|
|
100
|
+
if (props) {
|
|
101
|
+
for (const [key, val] of Object.entries(props)) {
|
|
102
|
+
if (key.startsWith("on") && typeof val === "function") {
|
|
103
|
+
node.addEventListener(key.slice(2).toLowerCase(), val);
|
|
104
|
+
} else if (typeof val === "function") {
|
|
105
|
+
effect(() => setAttr(node, key, val()));
|
|
106
|
+
} else {
|
|
107
|
+
setAttr(node, key, val);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const child of children) appendChild(node, child);
|
|
112
|
+
return node;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// mount(target, ...children) appends children into target (selector or element).
|
|
116
|
+
// Top-level function-children are reactive too.
|
|
117
|
+
export function mount(target, ...children) {
|
|
118
|
+
const parent = typeof target === "string" ? document.querySelector(target) : target;
|
|
119
|
+
for (const child of children) appendChild(parent, child);
|
|
120
|
+
return parent;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setAttr(node, name, value) {
|
|
124
|
+
if (name === "value") {
|
|
125
|
+
node.value = value ?? "";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (name === "checked" || name === "disabled" || name === "selected") {
|
|
129
|
+
node[name] = !!value && value !== "false";
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (value === false || value == null) {
|
|
133
|
+
node.removeAttribute(name);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
node.setAttribute(name, value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Append a child, making function-children into self-updating dynamic regions
|
|
140
|
+
// bounded by two comment anchors (so they can render text, nodes, or lists).
|
|
141
|
+
function appendChild(parent, child) {
|
|
142
|
+
if (typeof child === "function") {
|
|
143
|
+
const start = document.createComment("");
|
|
144
|
+
const end = document.createComment("");
|
|
145
|
+
parent.appendChild(start);
|
|
146
|
+
parent.appendChild(end);
|
|
147
|
+
effect(() => renderRange(start, end, child()));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const node of toNodes(child)) parent.appendChild(node);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Replace everything between the start/end anchors with `value`'s nodes.
|
|
154
|
+
function renderRange(start, end, value) {
|
|
155
|
+
let n = start.nextSibling;
|
|
156
|
+
while (n && n !== end) {
|
|
157
|
+
const t = n.nextSibling;
|
|
158
|
+
n.remove();
|
|
159
|
+
n = t;
|
|
160
|
+
}
|
|
161
|
+
for (const node of toNodes(value)) end.parentNode.insertBefore(node, end);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Normalize any child value into an array of DOM nodes.
|
|
165
|
+
function toNodes(value) {
|
|
166
|
+
if (value == null || value === false || value === true) return [];
|
|
167
|
+
if (Array.isArray(value)) return value.flatMap(toNodes);
|
|
168
|
+
if (value instanceof Node) return [value];
|
|
169
|
+
return [document.createTextNode(String(value))];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// html`` template layer (parses once, wires holes to the same primitives)
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
const PH = (i) => `__voltph${i}__`;
|
|
177
|
+
const PH_RE = /__voltph(\d+)__/g;
|
|
178
|
+
|
|
179
|
+
// We're inside an open tag (attribute context) if the last '<' comes after the
|
|
180
|
+
// last '>' in the accumulated string.
|
|
181
|
+
function isAttrContext(str) {
|
|
182
|
+
return str.lastIndexOf("<") > str.lastIndexOf(">");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function html(strings, ...values) {
|
|
186
|
+
let acc = "";
|
|
187
|
+
strings.forEach((str, i) => {
|
|
188
|
+
acc += str;
|
|
189
|
+
if (i < values.length) {
|
|
190
|
+
acc += isAttrContext(acc) ? PH(i) : `<!--${PH(i)}-->`;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const tpl = document.createElement("template");
|
|
195
|
+
tpl.innerHTML = acc.trim();
|
|
196
|
+
|
|
197
|
+
// Bind attribute holes.
|
|
198
|
+
for (const node of tpl.content.querySelectorAll("*")) {
|
|
199
|
+
for (const attr of [...node.attributes]) {
|
|
200
|
+
PH_RE.lastIndex = 0;
|
|
201
|
+
if (PH_RE.test(attr.value)) bindAttr(node, attr, values);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Bind node holes (comment placeholders).
|
|
206
|
+
const walker = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
|
|
207
|
+
const holes = [];
|
|
208
|
+
let c;
|
|
209
|
+
while ((c = walker.nextNode())) {
|
|
210
|
+
const m = c.data.match(/^__voltph(\d+)__$/);
|
|
211
|
+
if (m) holes.push([c, Number(m[1])]);
|
|
212
|
+
}
|
|
213
|
+
for (const [comment, i] of holes) bindNodeHole(comment, values[i]);
|
|
214
|
+
|
|
215
|
+
const nodes = [...tpl.content.childNodes];
|
|
216
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function bindAttr(node, attr, values) {
|
|
220
|
+
const name = attr.name;
|
|
221
|
+
const raw = attr.value;
|
|
222
|
+
const single = raw.match(/^__voltph(\d+)__$/);
|
|
223
|
+
node.removeAttribute(name);
|
|
224
|
+
|
|
225
|
+
// onX=${fn} → event listener
|
|
226
|
+
if (name.startsWith("on") && single) {
|
|
227
|
+
node.addEventListener(name.slice(2).toLowerCase(), values[Number(single[1])]);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Otherwise a (possibly mixed) attribute value. If any hole is a function it
|
|
232
|
+
// is read inside the effect, so the attribute stays live.
|
|
233
|
+
effect(() => {
|
|
234
|
+
const text = raw.replace(PH_RE, (_, j) => {
|
|
235
|
+
const v = values[Number(j)];
|
|
236
|
+
return String(typeof v === "function" ? v() : v ?? "");
|
|
237
|
+
});
|
|
238
|
+
setAttr(node, name, text);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function bindNodeHole(comment, value) {
|
|
243
|
+
const start = document.createComment("");
|
|
244
|
+
comment.parentNode.insertBefore(start, comment); // `comment` becomes the end anchor
|
|
245
|
+
if (typeof value === "function") {
|
|
246
|
+
effect(() => renderRange(start, comment, value()));
|
|
247
|
+
} else {
|
|
248
|
+
renderRange(start, comment, value);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Hot reload client — listens for the dev server's reload event over Socket.io
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
(function startHotReload() {
|
|
257
|
+
const connect = () => {
|
|
258
|
+
if (!window.io) return false;
|
|
259
|
+
const socket = window.io();
|
|
260
|
+
socket.on("volt:reload", () => location.reload());
|
|
261
|
+
console.log("[volt] hot reload connected");
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
264
|
+
if (!connect()) window.addEventListener("load", connect);
|
|
265
|
+
})();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// router.js — all HTTP routes for the guestbook. The index view is composed
|
|
2
|
+
// with a server-side header include (per the house convention: the index file
|
|
3
|
+
// pulls its header from an include defined here in the router).
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import express from "express";
|
|
10
|
+
import {
|
|
11
|
+
requestLogin,
|
|
12
|
+
confirmLogin,
|
|
13
|
+
sessionFromReq,
|
|
14
|
+
SESSION_COOKIE,
|
|
15
|
+
cookieMaxAgeSeconds,
|
|
16
|
+
} from "./lib/auth.js";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const viewsDir = path.join(__dirname, "views");
|
|
20
|
+
|
|
21
|
+
// Render a view, expanding `<!--#include name-->` against views/partials/name.html.
|
|
22
|
+
function render(name) {
|
|
23
|
+
let html = fs.readFileSync(path.join(viewsDir, name), "utf8");
|
|
24
|
+
return html.replace(/<!--#include\s+([\w-]+)-->/g, (_m, partial) => {
|
|
25
|
+
const file = path.join(viewsDir, "partials", `${partial}.html`);
|
|
26
|
+
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const baseUrl = (req) => `${req.protocol}://${req.get("host")}`;
|
|
31
|
+
const setSessionCookie = (res, sid) =>
|
|
32
|
+
res.setHeader(
|
|
33
|
+
"Set-Cookie",
|
|
34
|
+
`${SESSION_COOKIE}=${encodeURIComponent(sid)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${cookieMaxAgeSeconds}`,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
export function createRouter({ store, mailer, io }) {
|
|
38
|
+
const router = express.Router();
|
|
39
|
+
router.use(express.json());
|
|
40
|
+
|
|
41
|
+
// --- pages ---
|
|
42
|
+
router.get("/", (_req, res) => res.type("html").send(render("index.html")));
|
|
43
|
+
router.get("/verify", (req, res) => {
|
|
44
|
+
const token = String(req.query.token || "");
|
|
45
|
+
res.type("html").send(render("confirm.html").replaceAll("{{TOKEN}}", encodeURIComponent(token)));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// --- auth ---
|
|
49
|
+
router.post("/api/login", async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
await requestLogin(store, mailer, {
|
|
52
|
+
email: req.body?.email,
|
|
53
|
+
ua: req.headers["user-agent"],
|
|
54
|
+
baseUrl: baseUrl(req),
|
|
55
|
+
});
|
|
56
|
+
res.json({ ok: true, sent: true, dev: mailer.name === "console" });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.post("/api/confirm", async (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
const { sessionId, email } = await confirmLogin(store, {
|
|
65
|
+
token: req.body?.token,
|
|
66
|
+
ua: req.headers["user-agent"],
|
|
67
|
+
});
|
|
68
|
+
setSessionCookie(res, sessionId);
|
|
69
|
+
res.json({ ok: true, email });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
router.post("/api/logout", async (req, res) => {
|
|
76
|
+
const session = await sessionFromReq(store, req);
|
|
77
|
+
if (session) await store.delSession(session.id);
|
|
78
|
+
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`);
|
|
79
|
+
res.json({ ok: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
router.get("/api/me", async (req, res) => {
|
|
83
|
+
const session = await sessionFromReq(store, req);
|
|
84
|
+
res.json({ email: session?.email || null });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// --- messages ---
|
|
88
|
+
router.get("/api/messages", async (_req, res) => {
|
|
89
|
+
res.json({ messages: await store.listMessages(100) });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
router.post("/api/messages", async (req, res) => {
|
|
93
|
+
const session = await sessionFromReq(store, req);
|
|
94
|
+
if (!session) return res.status(401).json({ ok: false, error: "Sign in to post." });
|
|
95
|
+
const body = String(req.body?.body || "").trim();
|
|
96
|
+
if (!body) return res.status(400).json({ ok: false, error: "Message is empty." });
|
|
97
|
+
if (body.length > 500) return res.status(400).json({ ok: false, error: "Message too long (max 500)." });
|
|
98
|
+
|
|
99
|
+
const message = await store.addMessage({
|
|
100
|
+
id: crypto.randomBytes(8).toString("hex"),
|
|
101
|
+
email: session.email,
|
|
102
|
+
body,
|
|
103
|
+
createdAt: Date.now(),
|
|
104
|
+
});
|
|
105
|
+
io.emit("message:new", message); // push to every open page
|
|
106
|
+
res.json({ ok: true, message });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return router;
|
|
110
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// server.js — guestbook dev/prod server. Express serves the static client and
|
|
2
|
+
// the views; Socket.io pushes new messages live; storage is pluggable
|
|
3
|
+
// (memory | mongodb | mysql | postgres) via the DB_DRIVER env var.
|
|
4
|
+
//
|
|
5
|
+
// npm run dev # in-memory store, magic links to console
|
|
6
|
+
// DB_DRIVER=postgres DATABASE_URL=... npm start
|
|
7
|
+
//
|
|
8
|
+
// Run under PM2 in production: pm2 start server.js --name guestbook ; pm2 log
|
|
9
|
+
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import express from "express";
|
|
14
|
+
import { Server as SocketServer } from "socket.io";
|
|
15
|
+
import { createStore } from "./lib/store.js";
|
|
16
|
+
import { createMailer } from "./lib/mailer.js";
|
|
17
|
+
import { createRouter } from "./router.js";
|
|
18
|
+
|
|
19
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const PORT = Number(process.env.PORT) || 26629;
|
|
21
|
+
|
|
22
|
+
const store = await createStore();
|
|
23
|
+
const mailer = await createMailer();
|
|
24
|
+
|
|
25
|
+
const app = express();
|
|
26
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
27
|
+
|
|
28
|
+
const server = http.createServer(app);
|
|
29
|
+
const io = new SocketServer(server);
|
|
30
|
+
|
|
31
|
+
app.use("/", createRouter({ store, mailer, io }));
|
|
32
|
+
|
|
33
|
+
server.listen(PORT, () => {
|
|
34
|
+
console.log(`📖 Guestbook → http://localhost:${PORT}`);
|
|
35
|
+
console.log(` storage: ${store.name} mail: ${mailer.name}`);
|
|
36
|
+
});
|