create-volt 0.15.1 → 0.17.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 +28 -0
- package/addons/media/files/lib/media.js +99 -0
- package/addons/media/meta.json +8 -0
- package/index.js +5 -0
- package/package.json +1 -1
- package/templates/default/Dockerfile +20 -0
- package/templates/default/Procfile +1 -0
- package/templates/default/dockerignore +6 -0
- package/templates/default/fly.toml +15 -0
- package/templates/default/render.yaml +15 -0
- package/templates/default/server.js +15 -3
- package/templates/default/setup/setup.js +32 -0
- package/templates/guestbook/Dockerfile +20 -0
- package/templates/guestbook/Procfile +1 -0
- package/templates/guestbook/dockerignore +6 -0
- package/templates/guestbook/fly.toml +15 -0
- package/templates/guestbook/render.yaml +15 -0
- package/templates/starter/Dockerfile +20 -0
- package/templates/starter/Procfile +1 -0
- package/templates/starter/dockerignore +6 -0
- package/templates/starter/fly.toml +15 -0
- package/templates/starter/render.yaml +15 -0
- package/templates/starter/server.js +15 -3
- package/templates/starter/setup/setup.js +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ 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.17.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **PaaS deploy targets** — every scaffold now ships a `Dockerfile`,
|
|
11
|
+
`.dockerignore`, `render.yaml`, `fly.toml`, and `Procfile`, so a Volt app
|
|
12
|
+
deploys to Render / Fly.io / Railway / DO App Platform (which handle the
|
|
13
|
+
server, DNS, and TLS) with config supplied as platform env vars. New
|
|
14
|
+
`/docs/deploy` guide covering the PaaS and PM2+nginx paths.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- The server boots straight into app mode (no setup wizard) when
|
|
18
|
+
`NODE_ENV=production` or `VOLT_ADDONS` is set via env — so a container/PaaS
|
|
19
|
+
runs the app from platform env vars without a committed `.env`.
|
|
20
|
+
|
|
21
|
+
## [0.16.0] - 2026-06-29
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **`media` add-on** — file uploads with a swappable storage driver: `local`
|
|
25
|
+
(disk, served at `/media`) or `s3` (any S3-compatible store: AWS S3,
|
|
26
|
+
DigitalOcean Spaces, …). `POST /api/media` is auth-gated (depends on the auth
|
|
27
|
+
add-on); uploads are size-capped (`MEDIA_MAX_MB`, default 10), restricted to
|
|
28
|
+
raster images + PDF (SVG rejected), stored under a random key, and returned as
|
|
29
|
+
a public URL. Driver + S3 settings are configured in the setup wizard. Pulls in
|
|
30
|
+
`busboy` (and `@aws-sdk/client-s3` when `MEDIA_DRIVER=s3`), both tracked by the
|
|
31
|
+
dependency auto-updater and exercised by the smoke gate.
|
|
32
|
+
|
|
7
33
|
## [0.15.1] - 2026-06-29
|
|
8
34
|
|
|
9
35
|
### Fixed
|
|
@@ -230,6 +256,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
230
256
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
231
257
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
232
258
|
|
|
259
|
+
[0.17.0]: https://github.com/MIR-2025/volt/releases/tag/v0.17.0
|
|
260
|
+
[0.16.0]: https://github.com/MIR-2025/volt/releases/tag/v0.16.0
|
|
233
261
|
[0.15.1]: https://github.com/MIR-2025/volt/releases/tag/v0.15.1
|
|
234
262
|
[0.15.0]: https://github.com/MIR-2025/volt/releases/tag/v0.15.0
|
|
235
263
|
[0.14.0]: https://github.com/MIR-2025/volt/releases/tag/v0.14.0
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// media.js — media uploads with a swappable storage driver: `local` (disk,
|
|
2
|
+
// served at /media) or `s3` (any S3-compatible store: AWS S3, DigitalOcean
|
|
3
|
+
// Spaces, etc.). POST /api/media is auth-gated; objects are stored under a
|
|
4
|
+
// random key and a public URL is returned. express/busboy/aws-sdk are imported
|
|
5
|
+
// lazily so the pure helpers below load without those deps installed.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
// allowlist — raster images + PDF (no SVG: it can carry script). Extend via code.
|
|
11
|
+
const ALLOWED = { "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "application/pdf": "pdf" };
|
|
12
|
+
export const extFor = (mime) => ALLOWED[mime] || null;
|
|
13
|
+
export const isAllowed = (mime) => !!ALLOWED[mime];
|
|
14
|
+
export const genKey = (mime) => `${crypto.randomBytes(10).toString("hex")}.${extFor(mime)}`;
|
|
15
|
+
|
|
16
|
+
function localDriver({ dir }) {
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
return {
|
|
19
|
+
name: "local",
|
|
20
|
+
async put(key, buf) {
|
|
21
|
+
fs.writeFileSync(path.join(dir, key), buf);
|
|
22
|
+
return `/media/${key}`;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function s3Driver(env) {
|
|
28
|
+
const { S3Client, PutObjectCommand } = await import("@aws-sdk/client-s3");
|
|
29
|
+
const endpoint = (env.S3_ENDPOINT || "").replace(/\/$/, "");
|
|
30
|
+
const bucket = env.S3_BUCKET;
|
|
31
|
+
const client = new S3Client({
|
|
32
|
+
endpoint: endpoint || undefined,
|
|
33
|
+
region: env.S3_REGION || "us-east-1",
|
|
34
|
+
credentials: { accessKeyId: env.S3_KEY, secretAccessKey: env.S3_SECRET },
|
|
35
|
+
forcePathStyle: true, // safest across S3-compatible endpoints (e.g. Spaces)
|
|
36
|
+
});
|
|
37
|
+
const base = (env.S3_PUBLIC_BASE || (endpoint ? `${endpoint}/${bucket}` : "")).replace(/\/$/, "");
|
|
38
|
+
return {
|
|
39
|
+
name: "s3",
|
|
40
|
+
async put(key, buf, mime) {
|
|
41
|
+
await client.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: buf, ContentType: mime, ACL: "public-read" }));
|
|
42
|
+
return `${base}/${key}`;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function mediaRouter({ requireAuth, dir, env = process.env }) {
|
|
48
|
+
const express = (await import("express")).default;
|
|
49
|
+
const busboy = (await import("busboy")).default;
|
|
50
|
+
const driver = env.MEDIA_DRIVER === "s3" ? await s3Driver(env) : localDriver({ dir });
|
|
51
|
+
const maxMb = Number(env.MEDIA_MAX_MB) || 10;
|
|
52
|
+
const r = express.Router();
|
|
53
|
+
|
|
54
|
+
if (driver.name === "local") r.use("/media", express.static(dir)); // public reads
|
|
55
|
+
|
|
56
|
+
r.post("/api/media", requireAuth, (req, res) => {
|
|
57
|
+
let bb;
|
|
58
|
+
try {
|
|
59
|
+
bb = busboy({ headers: req.headers, limits: { files: 1, fileSize: maxMb * 1024 * 1024 } });
|
|
60
|
+
} catch {
|
|
61
|
+
return res.status(400).json({ ok: false, error: "bad upload" });
|
|
62
|
+
}
|
|
63
|
+
let done = false;
|
|
64
|
+
const finish = (code, body) => {
|
|
65
|
+
if (!done) {
|
|
66
|
+
done = true;
|
|
67
|
+
res.status(code).json(body);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
bb.on("file", (_name, stream, info) => {
|
|
71
|
+
if (!isAllowed(info.mimeType)) {
|
|
72
|
+
stream.resume();
|
|
73
|
+
return finish(415, { ok: false, error: "unsupported file type" });
|
|
74
|
+
}
|
|
75
|
+
const chunks = [];
|
|
76
|
+
let tooBig = false;
|
|
77
|
+
stream.on("data", (c) => chunks.push(c));
|
|
78
|
+
stream.on("limit", () => {
|
|
79
|
+
tooBig = true;
|
|
80
|
+
finish(413, { ok: false, error: `file exceeds ${maxMb}MB` });
|
|
81
|
+
});
|
|
82
|
+
stream.on("end", async () => {
|
|
83
|
+
if (tooBig || done) return;
|
|
84
|
+
try {
|
|
85
|
+
const key = genKey(info.mimeType);
|
|
86
|
+
const url = await driver.put(key, Buffer.concat(chunks), info.mimeType);
|
|
87
|
+
finish(200, { ok: true, url, key, driver: driver.name });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
finish(500, { ok: false, error: e.message });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
bb.on("error", () => finish(500, { ok: false, error: "upload failed" }));
|
|
94
|
+
bb.on("close", () => finish(400, { ok: false, error: "no file uploaded" }));
|
|
95
|
+
req.pipe(bb);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return r;
|
|
99
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Media uploads to local disk or any S3-compatible store (AWS S3, DigitalOcean Spaces, etc.). POST /api/media (signed-in only) returns a public URL. Choose the driver in the wizard.",
|
|
3
|
+
"dependsOn": ["auth"],
|
|
4
|
+
"sentinel": "lib/media.js",
|
|
5
|
+
"install": ["busboy"],
|
|
6
|
+
"optional": { "s3": ["@aws-sdk/client-s3"] },
|
|
7
|
+
"wiring": "Set MEDIA_DRIVER=local|s3. For s3 set S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_KEY, S3_SECRET (and optionally S3_PUBLIC_BASE for a CDN URL). Uploads require a signed-in user; uploaded objects are public-read."
|
|
8
|
+
}
|
package/index.js
CHANGED
|
@@ -250,6 +250,11 @@ const shippedEnv = path.join(targetDir, "env");
|
|
|
250
250
|
if (fs.existsSync(shippedEnv)) {
|
|
251
251
|
fs.renameSync(shippedEnv, path.join(targetDir, ".env"));
|
|
252
252
|
}
|
|
253
|
+
// ship "dockerignore" → ".dockerignore" (same npm-safety dance)
|
|
254
|
+
const shippedDockerignore = path.join(targetDir, "dockerignore");
|
|
255
|
+
if (fs.existsSync(shippedDockerignore)) {
|
|
256
|
+
fs.renameSync(shippedDockerignore, path.join(targetDir, ".dockerignore"));
|
|
257
|
+
}
|
|
253
258
|
|
|
254
259
|
// Bundle the add-on sources so the app's setup wizard can enable them later
|
|
255
260
|
// (only for templates that ship the wizard, i.e. have a setup/ dir).
|
package/package.json
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Volt app — production container. Runs on Render, Fly.io, Railway, DO App
|
|
2
|
+
# Platform, and any container host. They handle the server, DNS, and TLS; you
|
|
3
|
+
# just set config as env vars.
|
|
4
|
+
#
|
|
5
|
+
# Configure via the platform's env vars (NOT a committed .env):
|
|
6
|
+
# VOLT_ADDONS=db,auth,... DB_DRIVER=... MONGODB_URI / DATABASE_URL
|
|
7
|
+
# MEDIA_DRIVER=s3 S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET etc.
|
|
8
|
+
#
|
|
9
|
+
# Tip: run the local wizard first (`npm run dev`) so the add-on packages are
|
|
10
|
+
# saved into package.json; commit package.json, then deploy and set the same
|
|
11
|
+
# config as env vars here. NODE_ENV=production makes the app boot straight up
|
|
12
|
+
# (no setup wizard).
|
|
13
|
+
FROM node:22-alpine
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
ENV NODE_ENV=production
|
|
16
|
+
COPY package*.json ./
|
|
17
|
+
RUN npm install --omit=dev
|
|
18
|
+
COPY . .
|
|
19
|
+
EXPOSE 8080
|
|
20
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: node server.js
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Fly.io — run `fly launch` (it uses the Dockerfile), then set config:
|
|
2
|
+
# fly secrets set VOLT_ADDONS=db,auth DB_DRIVER=mongodb MONGODB_URI=...
|
|
3
|
+
app = "volt-app"
|
|
4
|
+
|
|
5
|
+
[build]
|
|
6
|
+
|
|
7
|
+
[http_service]
|
|
8
|
+
internal_port = 8080
|
|
9
|
+
force_https = true
|
|
10
|
+
auto_stop_machines = true
|
|
11
|
+
auto_start_machines = true
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
PORT = "8080"
|
|
15
|
+
NODE_ENV = "production"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Render blueprint — https://render.com/docs/blueprint-spec
|
|
2
|
+
# Push this repo to GitHub, then in Render: New → Blueprint → pick the repo.
|
|
3
|
+
# Render builds the Dockerfile, gives you HTTPS + a domain, and runs it.
|
|
4
|
+
services:
|
|
5
|
+
- type: web
|
|
6
|
+
name: volt-app
|
|
7
|
+
runtime: docker
|
|
8
|
+
plan: starter
|
|
9
|
+
envVars:
|
|
10
|
+
- key: VOLT_ADDONS
|
|
11
|
+
sync: false # set your add-ons (e.g. "db,auth") in the dashboard
|
|
12
|
+
# Add the rest in the dashboard as needed:
|
|
13
|
+
# DB_DRIVER, MONGODB_URI / DATABASE_URL,
|
|
14
|
+
# MEDIA_DRIVER, S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET,
|
|
15
|
+
# SMTP_URL, MAIL_FROM
|
|
@@ -21,8 +21,8 @@ const ENV_PATH = path.join(__dirname, ".env");
|
|
|
21
21
|
const PKG_PATH = path.join(__dirname, "package.json");
|
|
22
22
|
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
23
23
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
24
|
-
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
|
|
25
|
-
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
|
|
24
|
+
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
|
|
25
|
+
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
|
|
26
26
|
|
|
27
27
|
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
28
28
|
function readEnvFile() {
|
|
@@ -111,6 +111,12 @@ async function startApp() {
|
|
|
111
111
|
|
|
112
112
|
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
113
113
|
|
|
114
|
+
// media uploads (POST /api/media, auth-gated; local files served at /media)
|
|
115
|
+
if (enabled.has("media") && store) {
|
|
116
|
+
const requireAuth = (await addonMod("auth")).requireAuth(store);
|
|
117
|
+
app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
// markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
|
|
115
121
|
if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
|
|
116
122
|
|
|
@@ -160,6 +166,8 @@ function neededPackages(env) {
|
|
|
160
166
|
if (driver === "postgres") want.push("pg");
|
|
161
167
|
if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
|
|
162
168
|
if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
|
|
169
|
+
if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
|
|
170
|
+
if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
|
|
163
171
|
return want.filter((p) => !deps[p]);
|
|
164
172
|
}
|
|
165
173
|
|
|
@@ -389,9 +397,13 @@ async function startStudio() {
|
|
|
389
397
|
|
|
390
398
|
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
391
399
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
400
|
+
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
401
|
+
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
402
|
+
// the first-run wizard when nothing is configured and we're not in production.
|
|
403
|
+
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
392
404
|
if (process.argv.includes("--studio")) {
|
|
393
405
|
startStudio();
|
|
394
|
-
} else if (editMode || !
|
|
406
|
+
} else if (editMode || !configured) {
|
|
395
407
|
startSetup();
|
|
396
408
|
} else {
|
|
397
409
|
loadEnv();
|
|
@@ -17,6 +17,13 @@ const state = signal({
|
|
|
17
17
|
dbUrl: current.DATABASE_URL || "",
|
|
18
18
|
smtpUrl: current.SMTP_URL || "",
|
|
19
19
|
mailFrom: current.MAIL_FROM || "",
|
|
20
|
+
mediaDriver: current.MEDIA_DRIVER || "local",
|
|
21
|
+
s3Endpoint: current.S3_ENDPOINT || "",
|
|
22
|
+
s3Region: current.S3_REGION || "",
|
|
23
|
+
s3Bucket: current.S3_BUCKET || "",
|
|
24
|
+
s3Key: current.S3_KEY || "",
|
|
25
|
+
s3Secret: current.S3_SECRET || "",
|
|
26
|
+
s3PublicBase: current.S3_PUBLIC_BASE || "",
|
|
20
27
|
port: current.PORT || String(defaultPort),
|
|
21
28
|
});
|
|
22
29
|
const set = (patch) => state({ ...state(), ...patch });
|
|
@@ -53,6 +60,17 @@ function genEnv(s) {
|
|
|
53
60
|
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
54
61
|
if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
|
|
55
62
|
}
|
|
63
|
+
if (eff.includes("media")) {
|
|
64
|
+
out.push(`MEDIA_DRIVER=${clean(s.mediaDriver)}`);
|
|
65
|
+
if (s.mediaDriver === "s3") {
|
|
66
|
+
out.push(`S3_ENDPOINT=${clean(s.s3Endpoint)}`);
|
|
67
|
+
out.push(`S3_REGION=${clean(s.s3Region)}`);
|
|
68
|
+
out.push(`S3_BUCKET=${clean(s.s3Bucket)}`);
|
|
69
|
+
out.push(`S3_KEY=${clean(s.s3Key)}`);
|
|
70
|
+
out.push(`S3_SECRET=${clean(s.s3Secret)}`);
|
|
71
|
+
if (s.s3PublicBase) out.push(`S3_PUBLIC_BASE=${clean(s.s3PublicBase)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
return out.join("\n") + "\n";
|
|
57
75
|
}
|
|
58
76
|
const env = computed(() => genEnv(state()));
|
|
@@ -134,6 +152,19 @@ const dbSettings = () =>
|
|
|
134
152
|
: null}
|
|
135
153
|
${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
136
154
|
|
|
155
|
+
const mediaSettings = () =>
|
|
156
|
+
html`<div class="mb-2">
|
|
157
|
+
<label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
|
|
158
|
+
<select class="form-select" value=${() => state().mediaDriver} onchange=${(e) => set({ mediaDriver: e.target.value })}>
|
|
159
|
+
<option value="local">local (disk)</option>
|
|
160
|
+
<option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
|
|
161
|
+
</select>
|
|
162
|
+
</div>
|
|
163
|
+
${() =>
|
|
164
|
+
state().mediaDriver === "s3"
|
|
165
|
+
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
166
|
+
: null}`;
|
|
167
|
+
|
|
137
168
|
mount(
|
|
138
169
|
"#app",
|
|
139
170
|
available.length
|
|
@@ -148,6 +179,7 @@ mount(
|
|
|
148
179
|
${field("PORT", "port", String(defaultPort))}
|
|
149
180
|
${() => (eff().includes("db") ? dbSettings() : null)}
|
|
150
181
|
${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
182
|
+
${() => (eff().includes("media") ? mediaSettings() : null)}
|
|
151
183
|
</div>`,
|
|
152
184
|
html`<div class="card-x p-4 mb-3">
|
|
153
185
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Volt app — production container. Runs on Render, Fly.io, Railway, DO App
|
|
2
|
+
# Platform, and any container host. They handle the server, DNS, and TLS; you
|
|
3
|
+
# just set config as env vars.
|
|
4
|
+
#
|
|
5
|
+
# Configure via the platform's env vars (NOT a committed .env):
|
|
6
|
+
# VOLT_ADDONS=db,auth,... DB_DRIVER=... MONGODB_URI / DATABASE_URL
|
|
7
|
+
# MEDIA_DRIVER=s3 S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET etc.
|
|
8
|
+
#
|
|
9
|
+
# Tip: run the local wizard first (`npm run dev`) so the add-on packages are
|
|
10
|
+
# saved into package.json; commit package.json, then deploy and set the same
|
|
11
|
+
# config as env vars here. NODE_ENV=production makes the app boot straight up
|
|
12
|
+
# (no setup wizard).
|
|
13
|
+
FROM node:22-alpine
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
ENV NODE_ENV=production
|
|
16
|
+
COPY package*.json ./
|
|
17
|
+
RUN npm install --omit=dev
|
|
18
|
+
COPY . .
|
|
19
|
+
EXPOSE 8080
|
|
20
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: node server.js
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Fly.io — run `fly launch` (it uses the Dockerfile), then set config:
|
|
2
|
+
# fly secrets set VOLT_ADDONS=db,auth DB_DRIVER=mongodb MONGODB_URI=...
|
|
3
|
+
app = "volt-app"
|
|
4
|
+
|
|
5
|
+
[build]
|
|
6
|
+
|
|
7
|
+
[http_service]
|
|
8
|
+
internal_port = 8080
|
|
9
|
+
force_https = true
|
|
10
|
+
auto_stop_machines = true
|
|
11
|
+
auto_start_machines = true
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
PORT = "8080"
|
|
15
|
+
NODE_ENV = "production"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Render blueprint — https://render.com/docs/blueprint-spec
|
|
2
|
+
# Push this repo to GitHub, then in Render: New → Blueprint → pick the repo.
|
|
3
|
+
# Render builds the Dockerfile, gives you HTTPS + a domain, and runs it.
|
|
4
|
+
services:
|
|
5
|
+
- type: web
|
|
6
|
+
name: volt-app
|
|
7
|
+
runtime: docker
|
|
8
|
+
plan: starter
|
|
9
|
+
envVars:
|
|
10
|
+
- key: VOLT_ADDONS
|
|
11
|
+
sync: false # set your add-ons (e.g. "db,auth") in the dashboard
|
|
12
|
+
# Add the rest in the dashboard as needed:
|
|
13
|
+
# DB_DRIVER, MONGODB_URI / DATABASE_URL,
|
|
14
|
+
# MEDIA_DRIVER, S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET,
|
|
15
|
+
# SMTP_URL, MAIL_FROM
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Volt app — production container. Runs on Render, Fly.io, Railway, DO App
|
|
2
|
+
# Platform, and any container host. They handle the server, DNS, and TLS; you
|
|
3
|
+
# just set config as env vars.
|
|
4
|
+
#
|
|
5
|
+
# Configure via the platform's env vars (NOT a committed .env):
|
|
6
|
+
# VOLT_ADDONS=db,auth,... DB_DRIVER=... MONGODB_URI / DATABASE_URL
|
|
7
|
+
# MEDIA_DRIVER=s3 S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET etc.
|
|
8
|
+
#
|
|
9
|
+
# Tip: run the local wizard first (`npm run dev`) so the add-on packages are
|
|
10
|
+
# saved into package.json; commit package.json, then deploy and set the same
|
|
11
|
+
# config as env vars here. NODE_ENV=production makes the app boot straight up
|
|
12
|
+
# (no setup wizard).
|
|
13
|
+
FROM node:22-alpine
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
ENV NODE_ENV=production
|
|
16
|
+
COPY package*.json ./
|
|
17
|
+
RUN npm install --omit=dev
|
|
18
|
+
COPY . .
|
|
19
|
+
EXPOSE 8080
|
|
20
|
+
CMD ["node", "server.js"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: node server.js
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Fly.io — run `fly launch` (it uses the Dockerfile), then set config:
|
|
2
|
+
# fly secrets set VOLT_ADDONS=db,auth DB_DRIVER=mongodb MONGODB_URI=...
|
|
3
|
+
app = "volt-app"
|
|
4
|
+
|
|
5
|
+
[build]
|
|
6
|
+
|
|
7
|
+
[http_service]
|
|
8
|
+
internal_port = 8080
|
|
9
|
+
force_https = true
|
|
10
|
+
auto_stop_machines = true
|
|
11
|
+
auto_start_machines = true
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
PORT = "8080"
|
|
15
|
+
NODE_ENV = "production"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Render blueprint — https://render.com/docs/blueprint-spec
|
|
2
|
+
# Push this repo to GitHub, then in Render: New → Blueprint → pick the repo.
|
|
3
|
+
# Render builds the Dockerfile, gives you HTTPS + a domain, and runs it.
|
|
4
|
+
services:
|
|
5
|
+
- type: web
|
|
6
|
+
name: volt-app
|
|
7
|
+
runtime: docker
|
|
8
|
+
plan: starter
|
|
9
|
+
envVars:
|
|
10
|
+
- key: VOLT_ADDONS
|
|
11
|
+
sync: false # set your add-ons (e.g. "db,auth") in the dashboard
|
|
12
|
+
# Add the rest in the dashboard as needed:
|
|
13
|
+
# DB_DRIVER, MONGODB_URI / DATABASE_URL,
|
|
14
|
+
# MEDIA_DRIVER, S3_ENDPOINT/S3_REGION/S3_BUCKET/S3_KEY/S3_SECRET,
|
|
15
|
+
# SMTP_URL, MAIL_FROM
|
|
@@ -22,8 +22,8 @@ const ENV_PATH = path.join(__dirname, ".env");
|
|
|
22
22
|
const PKG_PATH = path.join(__dirname, "package.json");
|
|
23
23
|
const ADDONS_DIR = path.join(__dirname, ".volt", "addons"); // bundled add-on sources
|
|
24
24
|
const DEFAULT_PORT = 26628; // create-volt stamps this with the project's date-port
|
|
25
|
-
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5" };
|
|
26
|
-
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js" };
|
|
25
|
+
const PKG_VERSIONS = { mongodb: "^6.21.0", mysql2: "^3.22.5", pg: "^8.22.0", nodemailer: "^6.10.1", marked: "^18.0.5", busboy: "^1.6.0", "@aws-sdk/client-s3": "^3.1075.0" };
|
|
26
|
+
const LIB_FILE = { db: "store.js", mailer: "mailer.js", auth: "auth.js", realtime: "realtime.js", pages: "pages.js", media: "media.js" };
|
|
27
27
|
|
|
28
28
|
// --- tiny .env loader (no dependency); never overrides an existing env var ---
|
|
29
29
|
function readEnvFile() {
|
|
@@ -137,6 +137,12 @@ async function startApp() {
|
|
|
137
137
|
|
|
138
138
|
app.get("/", (_req, res) => res.sendFile(path.join(__dirname, "views", "index.html")));
|
|
139
139
|
|
|
140
|
+
// media uploads (POST /api/media, auth-gated; local files served at /media)
|
|
141
|
+
if (enabled.has("media") && store) {
|
|
142
|
+
const requireAuth = (await addonMod("auth")).requireAuth(store);
|
|
143
|
+
app.use(await (await addonMod("media")).mediaRouter({ requireAuth, dir: path.join(__dirname, "media") }));
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
// markdown pages (/<slug> ← pages/<slug>.md) — mounted last, so app routes win
|
|
141
147
|
if (enabled.has("pages")) app.use(await (await addonMod("pages")).pagesRouter({ dir: path.join(__dirname, "pages") }));
|
|
142
148
|
|
|
@@ -186,6 +192,8 @@ function neededPackages(env) {
|
|
|
186
192
|
if (driver === "postgres") want.push("pg");
|
|
187
193
|
if (/^\s*SMTP_URL\s*=\s*\S/m.test(env)) want.push("nodemailer");
|
|
188
194
|
if (/^\s*VOLT_ADDONS\s*=.*\bpages\b/m.test(env)) want.push("marked");
|
|
195
|
+
if (/^\s*VOLT_ADDONS\s*=.*\bmedia\b/m.test(env)) want.push("busboy");
|
|
196
|
+
if (/^\s*MEDIA_DRIVER\s*=\s*s3\b/m.test(env)) want.push("@aws-sdk/client-s3");
|
|
189
197
|
return want.filter((p) => !deps[p]);
|
|
190
198
|
}
|
|
191
199
|
|
|
@@ -415,9 +423,13 @@ async function startStudio() {
|
|
|
415
423
|
|
|
416
424
|
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
417
425
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
426
|
+
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
427
|
+
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
428
|
+
// the first-run wizard when nothing is configured and we're not in production.
|
|
429
|
+
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
418
430
|
if (process.argv.includes("--studio")) {
|
|
419
431
|
startStudio();
|
|
420
|
-
} else if (editMode || !
|
|
432
|
+
} else if (editMode || !configured) {
|
|
421
433
|
startSetup();
|
|
422
434
|
} else {
|
|
423
435
|
loadEnv();
|
|
@@ -17,6 +17,13 @@ const state = signal({
|
|
|
17
17
|
dbUrl: current.DATABASE_URL || "",
|
|
18
18
|
smtpUrl: current.SMTP_URL || "",
|
|
19
19
|
mailFrom: current.MAIL_FROM || "",
|
|
20
|
+
mediaDriver: current.MEDIA_DRIVER || "local",
|
|
21
|
+
s3Endpoint: current.S3_ENDPOINT || "",
|
|
22
|
+
s3Region: current.S3_REGION || "",
|
|
23
|
+
s3Bucket: current.S3_BUCKET || "",
|
|
24
|
+
s3Key: current.S3_KEY || "",
|
|
25
|
+
s3Secret: current.S3_SECRET || "",
|
|
26
|
+
s3PublicBase: current.S3_PUBLIC_BASE || "",
|
|
20
27
|
port: current.PORT || String(defaultPort),
|
|
21
28
|
});
|
|
22
29
|
const set = (patch) => state({ ...state(), ...patch });
|
|
@@ -53,6 +60,17 @@ function genEnv(s) {
|
|
|
53
60
|
else out.push("# SMTP_URL= # unset → emails print to the console");
|
|
54
61
|
if (s.mailFrom) out.push(`MAIL_FROM=${clean(s.mailFrom)}`);
|
|
55
62
|
}
|
|
63
|
+
if (eff.includes("media")) {
|
|
64
|
+
out.push(`MEDIA_DRIVER=${clean(s.mediaDriver)}`);
|
|
65
|
+
if (s.mediaDriver === "s3") {
|
|
66
|
+
out.push(`S3_ENDPOINT=${clean(s.s3Endpoint)}`);
|
|
67
|
+
out.push(`S3_REGION=${clean(s.s3Region)}`);
|
|
68
|
+
out.push(`S3_BUCKET=${clean(s.s3Bucket)}`);
|
|
69
|
+
out.push(`S3_KEY=${clean(s.s3Key)}`);
|
|
70
|
+
out.push(`S3_SECRET=${clean(s.s3Secret)}`);
|
|
71
|
+
if (s.s3PublicBase) out.push(`S3_PUBLIC_BASE=${clean(s.s3PublicBase)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
return out.join("\n") + "\n";
|
|
57
75
|
}
|
|
58
76
|
const env = computed(() => genEnv(state()));
|
|
@@ -134,6 +152,19 @@ const dbSettings = () =>
|
|
|
134
152
|
: null}
|
|
135
153
|
${() => (state().dbDriver !== "memory" ? html`<button class="btn btn-sm btn-outline-secondary mb-2" onclick=${testDb}>Test connection</button>` : null)}`;
|
|
136
154
|
|
|
155
|
+
const mediaSettings = () =>
|
|
156
|
+
html`<div class="mb-2">
|
|
157
|
+
<label class="form-label small mb-1">Media storage (MEDIA_DRIVER)</label>
|
|
158
|
+
<select class="form-select" value=${() => state().mediaDriver} onchange=${(e) => set({ mediaDriver: e.target.value })}>
|
|
159
|
+
<option value="local">local (disk)</option>
|
|
160
|
+
<option value="s3">s3 — AWS S3 / DigitalOcean Spaces</option>
|
|
161
|
+
</select>
|
|
162
|
+
</div>
|
|
163
|
+
${() =>
|
|
164
|
+
state().mediaDriver === "s3"
|
|
165
|
+
? html`${field("S3_ENDPOINT", "s3Endpoint", "https://nyc3.digitaloceanspaces.com")}${field("S3_REGION", "s3Region", "us-east-1")}${field("S3_BUCKET", "s3Bucket", "my-space")}${field("S3_KEY", "s3Key", "access key")}${field("S3_SECRET", "s3Secret", "secret key")}${field("S3_PUBLIC_BASE (optional CDN base)", "s3PublicBase", "https://cdn.example.com")}`
|
|
166
|
+
: null}`;
|
|
167
|
+
|
|
137
168
|
mount(
|
|
138
169
|
"#app",
|
|
139
170
|
available.length
|
|
@@ -148,6 +179,7 @@ mount(
|
|
|
148
179
|
${field("PORT", "port", String(defaultPort))}
|
|
149
180
|
${() => (eff().includes("db") ? dbSettings() : null)}
|
|
150
181
|
${() => (eff().includes("mailer") ? html`${field("SMTP_URL (optional)", "smtpUrl", "smtp://user:pass@smtp.host:587")}${field("MAIL_FROM", "mailFrom", "App <no-reply@you.com>")}` : null)}
|
|
182
|
+
${() => (eff().includes("media") ? mediaSettings() : null)}
|
|
151
183
|
</div>`,
|
|
152
184
|
html`<div class="card-x p-4 mb-3">
|
|
153
185
|
<div class="d-flex justify-content-between align-items-center mb-2">
|