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.
Files changed (44) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/README.md +49 -0
  3. package/addons/auth/files/lib/auth.js +121 -0
  4. package/addons/auth/meta.json +8 -0
  5. package/addons/db/files/lib/store.js +42 -0
  6. package/addons/db/files/lib/stores/memory.js +33 -0
  7. package/addons/db/files/lib/stores/mongo.js +43 -0
  8. package/addons/db/files/lib/stores/sql.js +79 -0
  9. package/addons/db/meta.json +12 -0
  10. package/addons/mailer/files/lib/mailer.js +33 -0
  11. package/addons/mailer/meta.json +10 -0
  12. package/addons/realtime/files/lib/realtime.js +78 -0
  13. package/addons/realtime/files/public/chat-client.js +30 -0
  14. package/addons/realtime/meta.json +8 -0
  15. package/config/config-app.js +168 -0
  16. package/config/index.html +29 -0
  17. package/index.js +242 -16
  18. package/package.json +5 -2
  19. package/{template → templates/default}/README.md +20 -1
  20. package/templates/default/server.js +213 -0
  21. package/templates/default/setup/index.html +27 -0
  22. package/templates/default/setup/setup.js +121 -0
  23. package/{template → templates/default}/views/index.html +3 -0
  24. package/templates/guestbook/README.md +71 -0
  25. package/templates/guestbook/gitignore +5 -0
  26. package/templates/guestbook/lib/auth.js +63 -0
  27. package/templates/guestbook/lib/mailer.js +39 -0
  28. package/templates/guestbook/lib/store.js +43 -0
  29. package/templates/guestbook/lib/stores/memory.js +49 -0
  30. package/templates/guestbook/lib/stores/mongo.js +68 -0
  31. package/templates/guestbook/lib/stores/sql.js +120 -0
  32. package/templates/guestbook/package.json +21 -0
  33. package/templates/guestbook/public/app.js +112 -0
  34. package/templates/guestbook/public/volt.js +265 -0
  35. package/templates/guestbook/router.js +110 -0
  36. package/templates/guestbook/server.js +48 -0
  37. package/templates/guestbook/views/confirm.html +53 -0
  38. package/templates/guestbook/views/index.html +38 -0
  39. package/templates/guestbook/views/partials/header.html +4 -0
  40. package/template/server.js +0 -64
  41. /package/{template → templates/default}/gitignore +0 -0
  42. /package/{template → templates/default}/package.json +0 -0
  43. /package/{template → templates/default}/public/app.js +0 -0
  44. /package/{template → templates/default}/public/volt.js +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,120 @@
1
+ # Changelog
2
+
3
+ All notable changes to `create-volt` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.8.0] - 2026-06-28
8
+
9
+ ### Added
10
+ - **First-run setup wizard** baked into the app: on first run (no `.env`) or with
11
+ `npm run dev -- --edit` (`-e`), `server.js` serves a disposable local config
12
+ page; click **Apply** and it writes `.env`, loads it, and starts the app
13
+ in-process — the setup page then disappears. It self-detects which add-ons are
14
+ present and only asks for their settings.
15
+ - **Auto-open browser** on first run (and prints the link); skips opening on
16
+ headless/remote boxes (no `DISPLAY`). Opt out with `--no-open` / `VOLT_NO_OPEN`.
17
+ - **`--start`** flag for `create-volt`: scaffold, then launch the dev server
18
+ (which opens the setup page) in one go.
19
+ - **`.env` auto-loader** in templates — no `node --env-file` needed; reads the
20
+ file directly, so it behaves identically on Windows/PowerShell.
21
+ - **Test connection** button in the wizard: actually connects with the entered
22
+ DB credentials before you save.
23
+
24
+ ### Changed
25
+ - `create-volt config` is **localhost-only by default** (shell/SSH access is the
26
+ auth — no key). Expose on a LAN with `--host 0.0.0.0`, which then mints a key.
27
+
28
+ ## [0.7.0] - 2026-06-28
29
+
30
+ ### Added
31
+ - `create-volt config` — a disposable, key-gated local page (built with Volt) for
32
+ add-ons. Tick the add-ons (or **All**), fill settings (DB driver/URL, SMTP,
33
+ port), then **Apply**: it copies the add-on files into the app *and* writes
34
+ `.env`, and shows copy-able install + wiring. Prints localhost **and** LAN
35
+ links plus an SSH-tunnel hint for remote/headless boxes; a random key gates the
36
+ page and the apply endpoint. Dependency-free (node:http). Run apps with
37
+ `node --env-file=.env`.
38
+
39
+ ### Removed
40
+ - The `create-volt add` command (from 0.6.0) — applying add-ons now happens
41
+ through `create-volt config`, which both copies files and writes `.env`.
42
+
43
+ ## [0.6.0] - 2026-06-28
44
+
45
+ ### Added
46
+ - `create-volt add <integration>` — layer composable add-ons into an existing
47
+ app instead of cloning whole templates. Copies self-contained files and prints
48
+ the wiring (never edits your code); supports `--dry-run` and `--force`, and
49
+ `create-volt add` with no name lists what's available. Integrations:
50
+ - `db` — document store over memory / MongoDB / MySQL / Postgres
51
+ - `mailer` — console (dev) / SMTP (prod) email
52
+ - `auth` — magic-link login + sessions (builds on db + mailer)
53
+ - `realtime` — Socket.io chat with rooms, presence, and typing
54
+
55
+ ## [0.5.0] - 2026-06-28
56
+
57
+ ### Added
58
+ - Multiple starter templates via `--template <name>`. The default stays the
59
+ Counter + Todos demo; `--template guestbook` scaffolds a full real-world app:
60
+ magic-link auth, Socket.io real-time, and pluggable **MongoDB / MySQL /
61
+ Postgres** storage (with an in-memory dev fallback so it runs with no setup).
62
+
63
+ ### Changed
64
+ - Templates now live under `templates/<name>/` (was a single `template/`).
65
+ - The "files created" summary is derived from the chosen template.
66
+
67
+ ## [0.4.0] - 2026-06-28
68
+
69
+ ### Added
70
+ - `create-volt update` command: refresh `public/volt.js` in an existing app to
71
+ the library version bundled with create-volt. Run `npx create-volt@latest
72
+ update` inside an app. Only touches the library file — never your `app.js`,
73
+ `server.js`, or chosen port. Supports `--dry-run` to check without writing.
74
+
75
+ ## [0.3.2] - 2026-06-28
76
+
77
+ ### Changed
78
+ - Scaffolded apps' `README.md` now has a **Dev port** section explaining the
79
+ date-derived port and how to override it (`PORT` env / `--port`).
80
+ - Package README shows `--port` directly in the Usage block.
81
+
82
+ ## [0.3.1] - 2026-06-28
83
+
84
+ ### Changed
85
+ - Internal: releases now publish from GitHub Actions via npm **Trusted
86
+ Publishing** (OIDC, with provenance) — no functional changes to scaffolded apps.
87
+
88
+ ## [0.3.0] - 2026-06-28
89
+
90
+ ### Added
91
+ - `--port <number>` flag to set the new app's dev port.
92
+ - The dev port now **defaults to the creation date** (two-digit year + month +
93
+ two-digit day, e.g. `2026-06-28` → `26628`), so apps scaffolded on different
94
+ days don't collide. The chosen port is stamped into the generated `server.js`.
95
+
96
+ ## [0.2.0] - 2026-06-28
97
+
98
+ ### Added
99
+ - Git auto-init: scaffolded apps start as a git repository with an initial
100
+ commit (`--no-git` to skip).
101
+ - `--dry-run` flag: preview the files and actions without writing anything.
102
+
103
+ ## [0.1.0] - 2026-06-28
104
+
105
+ ### Added
106
+ - Initial release. Scaffolds a no-build, signals-based Volt app: the `volt.js`
107
+ library, a Counter + Todos demo, an Express + Socket.io dev server with file
108
+ watching and full-page hot reload. Supports `--skip-install` and `--force`,
109
+ and auto-detects npm / pnpm / yarn / bun for the install step.
110
+
111
+ [0.8.0]: https://github.com/MIR-2025/volt/releases/tag/v0.8.0
112
+ [0.7.0]: https://github.com/MIR-2025/volt/releases/tag/v0.7.0
113
+ [0.6.0]: https://github.com/MIR-2025/volt/releases/tag/v0.6.0
114
+ [0.5.0]: https://github.com/MIR-2025/volt/releases/tag/v0.5.0
115
+ [0.4.0]: https://github.com/MIR-2025/volt/releases/tag/v0.4.0
116
+ [0.3.2]: https://github.com/MIR-2025/volt/releases/tag/v0.3.2
117
+ [0.3.1]: https://github.com/MIR-2025/volt/releases/tag/v0.3.1
118
+ [0.3.0]: https://github.com/MIR-2025/volt/releases/tag/v0.3.0
119
+ [0.2.0]: https://github.com/MIR-2025/volt/releases/tag/v0.2.0
120
+ [0.1.0]: https://github.com/MIR-2025/volt/releases/tag/v0.1.0
package/README.md CHANGED
@@ -17,8 +17,20 @@ bun create volt my-app
17
17
 
18
18
  # choose the dev port (default: derived from today's date)
19
19
  npm create volt@latest my-app -- --port 26630
20
+
21
+ # pick a starter template
22
+ npm create volt@latest my-app -- --template guestbook
20
23
  ```
21
24
 
25
+ ## Templates
26
+
27
+ Pick one with `--template` (default: `default`):
28
+
29
+ | Template | What you get |
30
+ | ----------- | -------------------------------------------------------------------- |
31
+ | `default` | The Counter + Todos demo on the Volt signal engine. Minimal. |
32
+ | `guestbook` | A real app: magic-link auth, Socket.io real-time message board, and pluggable **MongoDB / MySQL / Postgres** storage (in-memory by default, so it runs with zero setup). |
33
+
22
34
  Then:
23
35
 
24
36
  ```bash
@@ -28,6 +40,43 @@ npm run dev # → http://localhost:26628
28
40
 
29
41
  Edit `public/app.js` and save — the page hot-reloads itself.
30
42
 
43
+ ## Add-on integrations
44
+
45
+ Layer features into an existing app from a disposable local page — pick the
46
+ add-ons, fill in their settings, and it copies the files **and** writes your
47
+ `.env`. Run from the app directory:
48
+
49
+ ```bash
50
+ npx create-volt@latest config
51
+ ```
52
+
53
+ It prints a `localhost` link (plus LAN links and an SSH-tunnel hint for remote
54
+ boxes), key-gated for safety. Available add-ons:
55
+
56
+ | Add-on | What it gives you |
57
+ | ---------- | ------------------------------------------------------------------- |
58
+ | `db` | document store: memory / MongoDB / MySQL / Postgres, one interface |
59
+ | `mailer` | console (dev) / SMTP (prod) email |
60
+ | `auth` | magic-link login + sessions (uses db + mailer) |
61
+ | `realtime` | Socket.io chat: rooms, presence, typing (uses db) |
62
+
63
+ Tick **All** to add the whole stack at once. The page only writes into the
64
+ current folder, applies once, and disappears when you Ctrl-C it. Afterwards run
65
+ your app with `node --env-file=.env server.js`.
66
+
67
+ ## Updating Volt
68
+
69
+ Volt is vendored as a single file (`public/volt.js`), not an npm dependency.
70
+ To pull the latest library into an existing app, run from its directory:
71
+
72
+ ```bash
73
+ npx create-volt@latest update # refresh public/volt.js
74
+ npx create-volt@latest update --dry-run # just check if an update is available
75
+ ```
76
+
77
+ It only rewrites `public/volt.js` — your `app.js`, `server.js`, and dev port are
78
+ left untouched. Review the change with `git diff public/volt.js`.
79
+
31
80
  ## Options
32
81
 
33
82
  | Flag | Effect |
@@ -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
+ }