create-volt 0.3.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/README.md +49 -0
- package/addons/auth/files/lib/auth.js +121 -0
- package/addons/auth/meta.json +8 -0
- package/addons/db/files/lib/store.js +42 -0
- package/addons/db/files/lib/stores/memory.js +33 -0
- package/addons/db/files/lib/stores/mongo.js +43 -0
- package/addons/db/files/lib/stores/sql.js +79 -0
- package/addons/db/meta.json +12 -0
- package/addons/mailer/files/lib/mailer.js +33 -0
- package/addons/mailer/meta.json +10 -0
- package/addons/realtime/files/lib/realtime.js +78 -0
- package/addons/realtime/files/public/chat-client.js +30 -0
- package/addons/realtime/meta.json +8 -0
- package/config/config-app.js +168 -0
- package/config/index.html +29 -0
- package/index.js +242 -16
- package/package.json +5 -2
- package/{template → templates/default}/README.md +20 -1
- package/templates/default/server.js +213 -0
- package/templates/default/setup/index.html +27 -0
- package/templates/default/setup/setup.js +121 -0
- package/{template → templates/default}/views/index.html +3 -0
- package/templates/guestbook/README.md +71 -0
- package/templates/guestbook/gitignore +5 -0
- package/templates/guestbook/lib/auth.js +63 -0
- package/templates/guestbook/lib/mailer.js +39 -0
- package/templates/guestbook/lib/store.js +43 -0
- package/templates/guestbook/lib/stores/memory.js +49 -0
- package/templates/guestbook/lib/stores/mongo.js +68 -0
- package/templates/guestbook/lib/stores/sql.js +120 -0
- package/templates/guestbook/package.json +21 -0
- package/templates/guestbook/public/app.js +112 -0
- package/templates/guestbook/public/volt.js +265 -0
- package/templates/guestbook/router.js +110 -0
- package/templates/guestbook/server.js +48 -0
- package/templates/guestbook/views/confirm.html +53 -0
- package/templates/guestbook/views/index.html +38 -0
- package/templates/guestbook/views/partials/header.html +4 -0
- package/template/server.js +0 -64
- /package/{template → templates/default}/gitignore +0 -0
- /package/{template → templates/default}/package.json +0 -0
- /package/{template → templates/default}/public/app.js +0 -0
- /package/{template → templates/default}/public/volt.js +0 -0
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
|
+
}
|