create-volt 0.5.0 → 0.9.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 +73 -0
- package/README.md +26 -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/index.js +49 -5
- package/package.json +2 -1
- package/templates/default/README.md +7 -1
- package/templates/default/server.js +273 -42
- package/templates/default/setup/index.html +27 -0
- package/templates/default/setup/setup.js +159 -0
- package/templates/guestbook/server.js +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ 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.9.0] - 2026-06-28
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- The setup wizard is now the single place to configure an app, and it shows
|
|
11
|
+
**all** add-ons: tick db/auth/realtime/mailer + fill their settings. Enabling
|
|
12
|
+
is pure config — **Apply writes `.env`** (a `VOLT_ADDONS` list + settings) and
|
|
13
|
+
**adds any needed packages to `package.json` + runs `npm install`**, then
|
|
14
|
+
starts the app, which **auto-wires** whatever `.env` enables (auth routes,
|
|
15
|
+
realtime sockets, db). Add-on code ships bundled under `.volt/addons`; nothing
|
|
16
|
+
is copied into your `lib/`.
|
|
17
|
+
- `create-volt config` now just opens that in-app wizard (`server.js --edit`) —
|
|
18
|
+
one implementation, localhost-only (shell/SSH access is the auth).
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
- The standalone create-volt config page and its `--host`/key flags (superseded
|
|
22
|
+
by the in-app wizard, which is localhost-only + SSH-tunnel for remote).
|
|
23
|
+
|
|
24
|
+
### Note
|
|
25
|
+
- Backend of an enabled add-on is wired automatically; the frontend UI (login
|
|
26
|
+
form, chat) is yours to build — or start from `--template guestbook`.
|
|
27
|
+
|
|
28
|
+
## [0.8.0] - 2026-06-28
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **First-run setup wizard** baked into the app: on first run (no `.env`) or with
|
|
32
|
+
`npm run dev -- --edit` (`-e`), `server.js` serves a disposable local config
|
|
33
|
+
page; click **Apply** and it writes `.env`, loads it, and starts the app
|
|
34
|
+
in-process — the setup page then disappears. It self-detects which add-ons are
|
|
35
|
+
present and only asks for their settings.
|
|
36
|
+
- **Auto-open browser** on first run (and prints the link); skips opening on
|
|
37
|
+
headless/remote boxes (no `DISPLAY`). Opt out with `--no-open` / `VOLT_NO_OPEN`.
|
|
38
|
+
- **`--start`** flag for `create-volt`: scaffold, then launch the dev server
|
|
39
|
+
(which opens the setup page) in one go.
|
|
40
|
+
- **`.env` auto-loader** in templates — no `node --env-file` needed; reads the
|
|
41
|
+
file directly, so it behaves identically on Windows/PowerShell.
|
|
42
|
+
- **Test connection** button in the wizard: actually connects with the entered
|
|
43
|
+
DB credentials before you save.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
- `create-volt config` is **localhost-only by default** (shell/SSH access is the
|
|
47
|
+
auth — no key). Expose on a LAN with `--host 0.0.0.0`, which then mints a key.
|
|
48
|
+
|
|
49
|
+
## [0.7.0] - 2026-06-28
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- `create-volt config` — a disposable, key-gated local page (built with Volt) for
|
|
53
|
+
add-ons. Tick the add-ons (or **All**), fill settings (DB driver/URL, SMTP,
|
|
54
|
+
port), then **Apply**: it copies the add-on files into the app *and* writes
|
|
55
|
+
`.env`, and shows copy-able install + wiring. Prints localhost **and** LAN
|
|
56
|
+
links plus an SSH-tunnel hint for remote/headless boxes; a random key gates the
|
|
57
|
+
page and the apply endpoint. Dependency-free (node:http). Run apps with
|
|
58
|
+
`node --env-file=.env`.
|
|
59
|
+
|
|
60
|
+
### Removed
|
|
61
|
+
- The `create-volt add` command (from 0.6.0) — applying add-ons now happens
|
|
62
|
+
through `create-volt config`, which both copies files and writes `.env`.
|
|
63
|
+
|
|
64
|
+
## [0.6.0] - 2026-06-28
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
- `create-volt add <integration>` — layer composable add-ons into an existing
|
|
68
|
+
app instead of cloning whole templates. Copies self-contained files and prints
|
|
69
|
+
the wiring (never edits your code); supports `--dry-run` and `--force`, and
|
|
70
|
+
`create-volt add` with no name lists what's available. Integrations:
|
|
71
|
+
- `db` — document store over memory / MongoDB / MySQL / Postgres
|
|
72
|
+
- `mailer` — console (dev) / SMTP (prod) email
|
|
73
|
+
- `auth` — magic-link login + sessions (builds on db + mailer)
|
|
74
|
+
- `realtime` — Socket.io chat with rooms, presence, and typing
|
|
75
|
+
|
|
7
76
|
## [0.5.0] - 2026-06-28
|
|
8
77
|
|
|
9
78
|
### Added
|
|
@@ -60,6 +129,10 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
60
129
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
61
130
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
62
131
|
|
|
132
|
+
[0.9.0]: https://github.com/MIR-2025/volt/releases/tag/v0.9.0
|
|
133
|
+
[0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
|
|
134
|
+
[0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
|
|
135
|
+
[0.6.0]: https://github.com/MIR-2025/volt/releases/tag/v0.6.0
|
|
63
136
|
[0.5.0]: https://github.com/MIR-2025/volt/releases/tag/v0.5.0
|
|
64
137
|
[0.4.0]: https://github.com/MIR-2025/volt/releases/tag/v0.4.0
|
|
65
138
|
[0.3.2]: https://github.com/MIR-2025/volt/releases/tag/v0.3.2
|
package/README.md
CHANGED
|
@@ -40,6 +40,32 @@ npm run dev # → http://localhost:26628
|
|
|
40
40
|
|
|
41
41
|
Edit `public/app.js` and save — the page hot-reloads itself.
|
|
42
42
|
|
|
43
|
+
## Add-on integrations
|
|
44
|
+
|
|
45
|
+
Apps ship with the add-ons **bundled** (under `.volt/addons`) but off. The
|
|
46
|
+
**setup wizard** turns them on — it opens on first run, or anytime with:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm run dev -- --edit # or: npx create-volt config
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Tick the features you want, fill in their settings, and **Apply**. Enabling is
|
|
53
|
+
pure config: it writes `.env` (a `VOLT_ADDONS` list + settings) and adds any
|
|
54
|
+
needed packages to `package.json` + runs `npm install`. The app then auto-wires
|
|
55
|
+
whatever's enabled. Available add-ons:
|
|
56
|
+
|
|
57
|
+
| Add-on | What it gives you |
|
|
58
|
+
| ---------- | ------------------------------------------------------------------- |
|
|
59
|
+
| `db` | document store: memory / MongoDB / MySQL / Postgres, one interface |
|
|
60
|
+
| `mailer` | console (dev) / SMTP (prod) email |
|
|
61
|
+
| `auth` | magic-link login + sessions (pulls in db + mailer) |
|
|
62
|
+
| `realtime` | Socket.io chat: rooms, presence, typing (pulls in db) |
|
|
63
|
+
|
|
64
|
+
The wizard is localhost-only (shell/SSH access is the auth; it prints an
|
|
65
|
+
SSH-tunnel hint on a remote box). Enabling an add-on wires its **backend**
|
|
66
|
+
automatically — the **frontend** UI (login form, chat) is yours to build, or
|
|
67
|
+
start from `--template guestbook`, which has it wired end-to-end.
|
|
68
|
+
|
|
43
69
|
## Updating Volt
|
|
44
70
|
|
|
45
71
|
Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// auth.js — passwordless magic-link login (no passwords), per the house auth
|
|
2
|
+
// convention: submit email → one-time token + UA stored, link emailed →
|
|
3
|
+
// opening the link shows a confirm page (same browser) → confirm starts a
|
|
4
|
+
// session cookie. Tokens are single-use and expire. Needs the `db` and
|
|
5
|
+
// `mailer` add-ons (store.collection + mailer.send).
|
|
6
|
+
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import express from "express";
|
|
9
|
+
|
|
10
|
+
const TOKEN_TTL = 15 * 60 * 1000; // 15 minutes
|
|
11
|
+
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
12
|
+
export const SESSION_COOKIE = "volt_sid";
|
|
13
|
+
|
|
14
|
+
const token = () => crypto.randomBytes(32).toString("hex");
|
|
15
|
+
const normalize = (e) => String(e || "").trim().toLowerCase();
|
|
16
|
+
const validEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
|
|
17
|
+
|
|
18
|
+
export function parseCookies(header = "") {
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const part of header.split(";")) {
|
|
21
|
+
const i = part.indexOf("=");
|
|
22
|
+
if (i !== -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function sessionFromReq(store, req) {
|
|
28
|
+
const sid = parseCookies(req.headers.cookie)[SESSION_COOKIE];
|
|
29
|
+
if (!sid) return null;
|
|
30
|
+
const s = await store.collection("auth_sessions").get(sid);
|
|
31
|
+
if (!s) return null;
|
|
32
|
+
if (s.expiresAt < Date.now()) {
|
|
33
|
+
await store.collection("auth_sessions").delete(sid);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function requireAuth(store) {
|
|
40
|
+
return async (req, res, next) => {
|
|
41
|
+
const session = await sessionFromReq(store, req);
|
|
42
|
+
if (!session) return res.status(401).json({ ok: false, error: "Sign in first." });
|
|
43
|
+
req.user = session;
|
|
44
|
+
next();
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const confirmPage = (tok) => `<!doctype html><html><head><meta charset="utf-8" />
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
50
|
+
<title>Confirm login</title>
|
|
51
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
52
|
+
<style>body{background:#0f1115;color:#e7e9ee}.card-x{background:#161a22;border:1px solid #232a36;border-radius:14px}.accent{color:#ffd24a}</style>
|
|
53
|
+
</head><body><main class="container py-5" style="max-width:480px">
|
|
54
|
+
<div class="card-x p-4 text-center">
|
|
55
|
+
<h1 class="h5 mb-3"><span class="accent">Confirm login</span></h1>
|
|
56
|
+
<p class="text-muted">Open in the same browser you requested the link from.</p>
|
|
57
|
+
<button id="b" class="btn btn-primary w-100">Confirm login</button>
|
|
58
|
+
<p id="s" class="mt-3 mb-0 small text-muted"></p>
|
|
59
|
+
</div></main><script>
|
|
60
|
+
const t=${JSON.stringify(tok)},b=document.getElementById("b"),s=document.getElementById("s");
|
|
61
|
+
b.onclick=async()=>{b.disabled=true;s.textContent="Confirming…";
|
|
62
|
+
try{const r=await fetch("/api/confirm",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:t})});
|
|
63
|
+
const d=await r.json();if(d.ok){location.href="/";}else{s.textContent=d.error||"Failed.";b.disabled=false;}}
|
|
64
|
+
catch{s.textContent="Network error.";b.disabled=false;}};
|
|
65
|
+
</script></body></html>`;
|
|
66
|
+
|
|
67
|
+
export function authRouter({ store, mailer }) {
|
|
68
|
+
const tokens = store.collection("auth_tokens");
|
|
69
|
+
const sessions = store.collection("auth_sessions");
|
|
70
|
+
const router = express.Router();
|
|
71
|
+
router.use(express.json());
|
|
72
|
+
|
|
73
|
+
router.post("/api/login", async (req, res) => {
|
|
74
|
+
const email = normalize(req.body?.email);
|
|
75
|
+
if (!validEmail(email)) return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
|
76
|
+
const tok = token();
|
|
77
|
+
await tokens.put(tok, { email, ua: req.headers["user-agent"] || "", expiresAt: Date.now() + TOKEN_TTL, used: false });
|
|
78
|
+
const link = `${req.protocol}://${req.get("host")}/verify?token=${tok}`;
|
|
79
|
+
await mailer.send({
|
|
80
|
+
to: email,
|
|
81
|
+
subject: "Your login link",
|
|
82
|
+
text: `Sign in: ${link}\n\nExpires shortly, single use.`,
|
|
83
|
+
html: `<p>Sign in: <a href="${link}">${link}</a></p>`,
|
|
84
|
+
});
|
|
85
|
+
res.json({ ok: true, dev: mailer.name === "console" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
router.get("/verify", (req, res) => res.type("html").send(confirmPage(String(req.query.token || ""))));
|
|
89
|
+
|
|
90
|
+
router.post("/api/confirm", async (req, res) => {
|
|
91
|
+
const rec = await tokens.get(String(req.body?.token || ""));
|
|
92
|
+
if (!rec) return res.status(400).json({ ok: false, error: "Invalid link." });
|
|
93
|
+
if (rec.used) return res.status(400).json({ ok: false, error: "Link already used." });
|
|
94
|
+
if (rec.expiresAt < Date.now()) return res.status(400).json({ ok: false, error: "Link expired." });
|
|
95
|
+
const ua = req.headers["user-agent"] || "";
|
|
96
|
+
if (rec.ua && ua && rec.ua !== ua) return res.status(400).json({ ok: false, error: "Open the link in the same browser." });
|
|
97
|
+
|
|
98
|
+
await tokens.put(rec.id, { ...rec, used: true });
|
|
99
|
+
const sid = token();
|
|
100
|
+
await sessions.put(sid, { email: rec.email, expiresAt: Date.now() + SESSION_TTL });
|
|
101
|
+
res.setHeader(
|
|
102
|
+
"Set-Cookie",
|
|
103
|
+
`${SESSION_COOKIE}=${encodeURIComponent(sid)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL / 1000}`,
|
|
104
|
+
);
|
|
105
|
+
res.json({ ok: true, email: rec.email });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
router.post("/api/logout", async (req, res) => {
|
|
109
|
+
const session = await sessionFromReq(store, req);
|
|
110
|
+
if (session) await sessions.delete(session.id);
|
|
111
|
+
res.setHeader("Set-Cookie", `${SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`);
|
|
112
|
+
res.json({ ok: true });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.get("/api/me", async (req, res) => {
|
|
116
|
+
const session = await sessionFromReq(store, req);
|
|
117
|
+
res.json({ email: session?.email || null });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return router;
|
|
121
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Passwordless magic-link auth: single-use token, same-browser guard, session cookie. Builds on db + mailer.",
|
|
3
|
+
"dependsOn": ["db", "mailer"],
|
|
4
|
+
"sentinel": "lib/auth.js",
|
|
5
|
+
"install": ["express"],
|
|
6
|
+
"optional": {},
|
|
7
|
+
"wiring": "import { createStore } from \"./lib/store.js\";\nimport { createMailer } from \"./lib/mailer.js\";\nimport { authRouter, sessionFromReq, requireAuth } from \"./lib/auth.js\";\n\nconst store = await createStore();\nconst mailer = await createMailer();\n\napp.use(authRouter({ store, mailer })); // adds /api/login, /verify, /api/confirm, /api/logout, /api/me\n\n// protect a route:\napp.post(\"/api/things\", requireAuth(store), (req, res) => {\n res.json({ ok: true, you: req.user.email });\n});\n\n// or check anywhere:\nconst session = await sessionFromReq(store, req); // { email } | null"
|
|
8
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// store.js — a tiny document store with swappable backends. Pick one with the
|
|
2
|
+
// DB_DRIVER env var (default: memory). Every backend exposes the same API:
|
|
3
|
+
//
|
|
4
|
+
// const store = await createStore();
|
|
5
|
+
// const col = store.collection("things");
|
|
6
|
+
// await col.put(id, doc); // upsert (doc gets an `id`)
|
|
7
|
+
// await col.get(id); // → doc | null
|
|
8
|
+
// await col.all(); // → doc[]
|
|
9
|
+
// await col.find({ k: v }); // → doc[] (simple equality match)
|
|
10
|
+
// await col.delete(id);
|
|
11
|
+
|
|
12
|
+
import { createMemoryStore } from "./stores/memory.js";
|
|
13
|
+
import { createMongoStore } from "./stores/mongo.js";
|
|
14
|
+
import { createSqlStore } from "./stores/sql.js";
|
|
15
|
+
|
|
16
|
+
export async function createStore() {
|
|
17
|
+
const driver = (process.env.DB_DRIVER || "memory").toLowerCase();
|
|
18
|
+
|
|
19
|
+
let store;
|
|
20
|
+
switch (driver) {
|
|
21
|
+
case "memory":
|
|
22
|
+
store = createMemoryStore();
|
|
23
|
+
break;
|
|
24
|
+
case "mongodb":
|
|
25
|
+
case "mongo":
|
|
26
|
+
store = await createMongoStore({ uri: process.env.MONGODB_URI, dbName: process.env.MONGODB_DATABASE });
|
|
27
|
+
break;
|
|
28
|
+
case "mysql":
|
|
29
|
+
store = await createSqlStore({ dialect: "mysql", uri: process.env.DATABASE_URL });
|
|
30
|
+
break;
|
|
31
|
+
case "postgres":
|
|
32
|
+
case "postgresql":
|
|
33
|
+
case "pg":
|
|
34
|
+
store = await createSqlStore({ dialect: "postgres", uri: process.env.DATABASE_URL });
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
throw new Error(`Unknown DB_DRIVER "${driver}" (use memory | mongodb | mysql | postgres)`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await store.init();
|
|
41
|
+
return store;
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// memory.js — in-memory document store. Zero-dependency dev fallback; data is
|
|
2
|
+
// lost on restart. Same collection API as the Mongo / SQL adapters.
|
|
3
|
+
|
|
4
|
+
export function createMemoryStore() {
|
|
5
|
+
const cols = new Map(); // name -> Map(id -> doc)
|
|
6
|
+
const col = (n) => {
|
|
7
|
+
if (!cols.has(n)) cols.set(n, new Map());
|
|
8
|
+
return cols.get(n);
|
|
9
|
+
};
|
|
10
|
+
const match = (doc, query) => Object.entries(query).every(([k, v]) => doc[k] === v);
|
|
11
|
+
|
|
12
|
+
const collection = (n) => ({
|
|
13
|
+
async put(id, doc) {
|
|
14
|
+
const saved = { ...doc, id };
|
|
15
|
+
col(n).set(id, saved);
|
|
16
|
+
return saved;
|
|
17
|
+
},
|
|
18
|
+
async get(id) {
|
|
19
|
+
return col(n).get(id) ?? null;
|
|
20
|
+
},
|
|
21
|
+
async all() {
|
|
22
|
+
return [...col(n).values()];
|
|
23
|
+
},
|
|
24
|
+
async find(query = {}) {
|
|
25
|
+
return [...col(n).values()].filter((d) => match(d, query));
|
|
26
|
+
},
|
|
27
|
+
async delete(id) {
|
|
28
|
+
col(n).delete(id);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return { name: "memory", async init() {}, collection };
|
|
33
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// mongo.js — MongoDB document store. Lazy-loads the `mongodb` driver, so it's
|
|
2
|
+
// only needed when DB_DRIVER=mongodb. Each collection maps to a Mongo
|
|
3
|
+
// collection; the document id is stored as `_id`.
|
|
4
|
+
|
|
5
|
+
export async function createMongoStore({ uri, dbName }) {
|
|
6
|
+
let MongoClient;
|
|
7
|
+
try {
|
|
8
|
+
({ MongoClient } = await import("mongodb"));
|
|
9
|
+
} catch {
|
|
10
|
+
throw new Error("DB_DRIVER=mongodb but 'mongodb' isn't installed. Run: npm install mongodb");
|
|
11
|
+
}
|
|
12
|
+
if (!uri) throw new Error("DB_DRIVER=mongodb requires MONGODB_URI");
|
|
13
|
+
|
|
14
|
+
const client = new MongoClient(uri);
|
|
15
|
+
await client.connect();
|
|
16
|
+
const db = client.db(dbName || undefined);
|
|
17
|
+
const strip = ({ _id, ...rest }) => rest;
|
|
18
|
+
|
|
19
|
+
const collection = (n) => {
|
|
20
|
+
const c = db.collection(n);
|
|
21
|
+
return {
|
|
22
|
+
async put(id, doc) {
|
|
23
|
+
await c.replaceOne({ _id: id }, { _id: id, ...doc, id }, { upsert: true });
|
|
24
|
+
return { ...doc, id };
|
|
25
|
+
},
|
|
26
|
+
async get(id) {
|
|
27
|
+
const d = await c.findOne({ _id: id });
|
|
28
|
+
return d ? strip(d) : null;
|
|
29
|
+
},
|
|
30
|
+
async all() {
|
|
31
|
+
return (await c.find().toArray()).map(strip);
|
|
32
|
+
},
|
|
33
|
+
async find(query = {}) {
|
|
34
|
+
return (await c.find(query).toArray()).map(strip);
|
|
35
|
+
},
|
|
36
|
+
async delete(id) {
|
|
37
|
+
await c.deleteOne({ _id: id });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return { name: "mongodb", async init() {}, collection };
|
|
43
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// sql.js — MySQL / Postgres document store. Documents are kept as JSON in one
|
|
2
|
+
// `documents(coll, id, data)` table, so any collection works without a schema.
|
|
3
|
+
// Lazy-loads mysql2 or pg and normalizes the two dialect differences:
|
|
4
|
+
// • placeholders: MySQL `?` Postgres `$1, $2, …`
|
|
5
|
+
// • upsert: ON DUPLICATE KEY ON CONFLICT … DO UPDATE
|
|
6
|
+
//
|
|
7
|
+
// find() filters in JS (reads the collection, then matches) — fine for an
|
|
8
|
+
// example; add real indexes/queries if a collection grows large.
|
|
9
|
+
|
|
10
|
+
export async function createSqlStore({ dialect, uri }) {
|
|
11
|
+
if (!uri) throw new Error(`DB_DRIVER=${dialect} requires a connection string (DATABASE_URL)`);
|
|
12
|
+
|
|
13
|
+
let run; // (sql, params) => rows[]
|
|
14
|
+
if (dialect === "mysql") {
|
|
15
|
+
let mysql;
|
|
16
|
+
try {
|
|
17
|
+
mysql = (await import("mysql2/promise")).default;
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error("DB_DRIVER=mysql but 'mysql2' isn't installed. Run: npm install mysql2");
|
|
20
|
+
}
|
|
21
|
+
const pool = mysql.createPool(uri);
|
|
22
|
+
run = async (sql, params = []) => (await pool.query(sql, params))[0];
|
|
23
|
+
} else {
|
|
24
|
+
let pg;
|
|
25
|
+
try {
|
|
26
|
+
pg = await import("pg");
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error("DB_DRIVER=postgres but 'pg' isn't installed. Run: npm install pg");
|
|
29
|
+
}
|
|
30
|
+
const pool = new pg.default.Pool({ connectionString: uri });
|
|
31
|
+
run = async (sql, params = []) => (await pool.query(sql, params)).rows;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const ph = dialect === "mysql" ? () => "?" : (i) => `$${i}`;
|
|
35
|
+
const upsert =
|
|
36
|
+
dialect === "mysql"
|
|
37
|
+
? `INSERT INTO documents (coll, id, data) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}) ON DUPLICATE KEY UPDATE data = VALUES(data)`
|
|
38
|
+
: `INSERT INTO documents (coll, id, data) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}) ON CONFLICT (coll, id) DO UPDATE SET data = EXCLUDED.data`;
|
|
39
|
+
|
|
40
|
+
const parse = (rows) => rows.map((r) => JSON.parse(r.data));
|
|
41
|
+
const match = (doc, query) => Object.entries(query).every(([k, v]) => doc[k] === v);
|
|
42
|
+
|
|
43
|
+
const store = {
|
|
44
|
+
name: dialect,
|
|
45
|
+
async init() {
|
|
46
|
+
await run(
|
|
47
|
+
`CREATE TABLE IF NOT EXISTS documents (
|
|
48
|
+
coll VARCHAR(64) NOT NULL,
|
|
49
|
+
id VARCHAR(128) NOT NULL,
|
|
50
|
+
data TEXT NOT NULL,
|
|
51
|
+
PRIMARY KEY (coll, id)
|
|
52
|
+
)`,
|
|
53
|
+
);
|
|
54
|
+
},
|
|
55
|
+
collection(n) {
|
|
56
|
+
return {
|
|
57
|
+
async put(id, doc) {
|
|
58
|
+
const saved = { ...doc, id };
|
|
59
|
+
await run(upsert, [n, id, JSON.stringify(saved)]);
|
|
60
|
+
return saved;
|
|
61
|
+
},
|
|
62
|
+
async get(id) {
|
|
63
|
+
const rows = await run(`SELECT data FROM documents WHERE coll = ${ph(1)} AND id = ${ph(2)}`, [n, id]);
|
|
64
|
+
return rows[0] ? JSON.parse(rows[0].data) : null;
|
|
65
|
+
},
|
|
66
|
+
async all() {
|
|
67
|
+
return parse(await run(`SELECT data FROM documents WHERE coll = ${ph(1)}`, [n]));
|
|
68
|
+
},
|
|
69
|
+
async find(query = {}) {
|
|
70
|
+
return (await this.all()).filter((d) => match(d, query));
|
|
71
|
+
},
|
|
72
|
+
async delete(id) {
|
|
73
|
+
await run(`DELETE FROM documents WHERE coll = ${ph(1)} AND id = ${ph(2)}`, [n, id]);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return store;
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Pluggable document store: memory (default) / MongoDB / MySQL / Postgres, one interface.",
|
|
3
|
+
"dependsOn": [],
|
|
4
|
+
"sentinel": "lib/store.js",
|
|
5
|
+
"install": [],
|
|
6
|
+
"optional": {
|
|
7
|
+
"mongodb": "DB_DRIVER=mongodb",
|
|
8
|
+
"mysql2": "DB_DRIVER=mysql",
|
|
9
|
+
"pg": "DB_DRIVER=postgres"
|
|
10
|
+
},
|
|
11
|
+
"wiring": "import { createStore } from \"./lib/store.js\";\n\nconst store = await createStore(); // reads DB_DRIVER (default: memory)\nconst notes = store.collection(\"notes\");\nawait notes.put(\"abc\", { text: \"hi\" });\nconst one = await notes.get(\"abc\");\nconst all = await notes.all();\nconst some = await notes.find({ done: false });\n\nEnv for a real database:\n DB_DRIVER=mongodb MONGODB_URI=... [MONGODB_DATABASE=...]\n DB_DRIVER=mysql DATABASE_URL=mysql://user:pass@host:3306/db (npm i mysql2)\n DB_DRIVER=postgres DATABASE_URL=postgres://user:pass@host:5432/db (npm i pg)"
|
|
12
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// mailer.js — sends email. In dev (no SMTP_URL) it prints messages to the
|
|
2
|
+
// console so you can see them; in production it uses nodemailer when SMTP_URL
|
|
3
|
+
// 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 || "App <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 — using console. Run: npm install nodemailer");
|
|
15
|
+
}
|
|
16
|
+
if (nodemailer) {
|
|
17
|
+
const transport = nodemailer.createTransport(smtp);
|
|
18
|
+
return {
|
|
19
|
+
name: "smtp",
|
|
20
|
+
async send({ to, subject, text, html }) {
|
|
21
|
+
await transport.sendMail({ to, from, subject, text, html });
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
name: "console",
|
|
29
|
+
async send({ to, subject, text }) {
|
|
30
|
+
console.log(`\n📨 Email to ${to} — ${subject}\n ${String(text || "").replace(/\n/g, "\n ")}\n`);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Email sender: prints to the console in dev, uses SMTP (nodemailer) when SMTP_URL is set.",
|
|
3
|
+
"dependsOn": [],
|
|
4
|
+
"sentinel": "lib/mailer.js",
|
|
5
|
+
"install": [],
|
|
6
|
+
"optional": {
|
|
7
|
+
"nodemailer": "real SMTP sending (set SMTP_URL)"
|
|
8
|
+
},
|
|
9
|
+
"wiring": "import { createMailer } from \"./lib/mailer.js\";\n\nconst mailer = await createMailer();\nawait mailer.send({ to, subject, text, html });\n\nEnv for real email:\n SMTP_URL=smtp://user:pass@smtp.example.com:587 (npm i nodemailer)\n MAIL_FROM=\"You <no-reply@you.com>\""
|
|
10
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// realtime.js — Socket.io live chat: rooms, presence, and typing indicators.
|
|
2
|
+
// Messages persist to the `messages` collection (db add-on). User identity
|
|
3
|
+
// comes from the auth session cookie when the auth add-on is present;
|
|
4
|
+
// otherwise each socket is an anonymous "guest-XXXX".
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
|
|
8
|
+
function parseCookies(header = "") {
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const part of header.split(";")) {
|
|
11
|
+
const i = part.indexOf("=");
|
|
12
|
+
if (i !== -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Resolve a display name for a socket: the signed-in email, or a guest id.
|
|
18
|
+
async function identify(socket, store) {
|
|
19
|
+
try {
|
|
20
|
+
const sid = parseCookies(socket.handshake.headers.cookie)["volt_sid"];
|
|
21
|
+
if (sid && store) {
|
|
22
|
+
const s = await store.collection("auth_sessions").get(sid);
|
|
23
|
+
if (s && s.expiresAt > Date.now()) return s.email;
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore — fall through to guest */
|
|
27
|
+
}
|
|
28
|
+
return "guest-" + crypto.randomBytes(2).toString("hex");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function attachRealtime(io, { store } = {}) {
|
|
32
|
+
const messages = store ? store.collection("messages") : null;
|
|
33
|
+
const presence = new Map(); // room -> Map(socketId -> name)
|
|
34
|
+
|
|
35
|
+
const usersIn = (room) => [...new Set([...(presence.get(room)?.values() || [])])];
|
|
36
|
+
const broadcastPresence = (room) => io.to(room).emit("chat:presence", { room, users: usersIn(room) });
|
|
37
|
+
|
|
38
|
+
io.on("connection", async (socket) => {
|
|
39
|
+
const name = await identify(socket, store);
|
|
40
|
+
let current = null;
|
|
41
|
+
|
|
42
|
+
socket.on("chat:join", async ({ room }) => {
|
|
43
|
+
if (!room) return;
|
|
44
|
+
if (current) {
|
|
45
|
+
socket.leave(current);
|
|
46
|
+
presence.get(current)?.delete(socket.id);
|
|
47
|
+
broadcastPresence(current);
|
|
48
|
+
}
|
|
49
|
+
current = String(room);
|
|
50
|
+
socket.join(current);
|
|
51
|
+
if (!presence.has(current)) presence.set(current, new Map());
|
|
52
|
+
presence.get(current).set(socket.id, name);
|
|
53
|
+
|
|
54
|
+
const history = messages ? (await messages.find({ room: current })).sort((a, b) => a.createdAt - b.createdAt).slice(-100) : [];
|
|
55
|
+
socket.emit("chat:history", { room: current, messages: history });
|
|
56
|
+
broadcastPresence(current);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
socket.on("chat:message", async ({ room, body }) => {
|
|
60
|
+
const text = String(body || "").trim();
|
|
61
|
+
if (!room || !text) return;
|
|
62
|
+
const msg = { id: crypto.randomBytes(8).toString("hex"), room: String(room), name, body: text.slice(0, 500), createdAt: Date.now() };
|
|
63
|
+
if (messages) await messages.put(msg.id, msg);
|
|
64
|
+
io.to(String(room)).emit("chat:message", msg);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
socket.on("chat:typing", ({ room }) => {
|
|
68
|
+
if (room) socket.to(String(room)).emit("chat:typing", { name });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
socket.on("disconnect", () => {
|
|
72
|
+
if (current) {
|
|
73
|
+
presence.get(current)?.delete(socket.id);
|
|
74
|
+
broadcastPresence(current);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// chat-client.js — thin browser wrapper around the Socket.io chat events.
|
|
2
|
+
// Pair it with Volt signals in your app.js. Requires the socket.io client
|
|
3
|
+
// (load /socket.io/socket.io.js before your module).
|
|
4
|
+
//
|
|
5
|
+
// import { createChat } from "/chat-client.js";
|
|
6
|
+
// const chat = createChat({
|
|
7
|
+
// onHistory: ({ room, messages }) => ...,
|
|
8
|
+
// onMessage: (msg) => ...,
|
|
9
|
+
// onPresence: ({ room, users }) => ...,
|
|
10
|
+
// onTyping: ({ name }) => ...,
|
|
11
|
+
// });
|
|
12
|
+
// chat.join("general");
|
|
13
|
+
// chat.send("general", "hello");
|
|
14
|
+
// chat.typing("general");
|
|
15
|
+
|
|
16
|
+
export function createChat({ onHistory, onMessage, onPresence, onTyping } = {}) {
|
|
17
|
+
if (!window.io) throw new Error("Socket.io client not loaded — add <script src=\"/socket.io/socket.io.js\"></script>");
|
|
18
|
+
const socket = window.io();
|
|
19
|
+
if (onHistory) socket.on("chat:history", onHistory);
|
|
20
|
+
if (onMessage) socket.on("chat:message", onMessage);
|
|
21
|
+
if (onPresence) socket.on("chat:presence", onPresence);
|
|
22
|
+
if (onTyping) socket.on("chat:typing", onTyping);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
socket,
|
|
26
|
+
join: (room) => socket.emit("chat:join", { room }),
|
|
27
|
+
send: (room, body) => socket.emit("chat:message", { room, body }),
|
|
28
|
+
typing: (room) => socket.emit("chat:typing", { room }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Socket.io live chat: rooms, presence (who's online), typing indicators. Persists messages via db; shows emails when auth is present.",
|
|
3
|
+
"dependsOn": ["db"],
|
|
4
|
+
"sentinel": "lib/realtime.js",
|
|
5
|
+
"install": ["socket.io"],
|
|
6
|
+
"optional": {},
|
|
7
|
+
"wiring": "import http from \"node:http\";\nimport { Server as SocketServer } from \"socket.io\";\nimport { createStore } from \"./lib/store.js\";\nimport { attachRealtime } from \"./lib/realtime.js\";\n\nconst server = http.createServer(app); // wrap the Express app\nconst io = new SocketServer(server);\nattachRealtime(io, { store: await createStore() });\nserver.listen(PORT); // listen on `server`, not `app`\n\nIn the page: load /socket.io/socket.io.js, then in your module:\n import { createChat } from \"/chat-client.js\";\n const chat = createChat({ onMessage, onHistory, onPresence, onTyping });\n chat.join(\"general\"); chat.send(\"general\", \"hi\");"
|
|
8
|
+
}
|