A fast, convention-based MVC framework for Node.js — built on top of Express.
Vulkano is a lightweight MVC framework for building web applications and APIs with Node.js. It wires together Express, MongoDB (Mongoose), Socket.io, i18n, JWT, file uploads, cron jobs, and more — so you spend time writing features, not boilerplate.
Inspired by KumbiaPHP.
For the full project generator/scaffolding (frontend + backend structure), see the framework: https://github.com/vulkanojs/vulkano
Two supported lines:
master(this branch) is v2.x, built on Express 5 — the active line for new projects. The1.xbranch stays on Express 4 for existing projects that aren't ready to upgrade; it gets maintenance fixes only, no new features. See1.x's own README for its requirements and support policy.
- Node.js
>=24 - MongoDB (optional — only needed if you use models)
- Redis (optional — only needed for Socket.io Redis adapter or sessions)
npm install @vulkano/corePORT=8000
MONGO_URI=mongodb://localhost:27017/myapp
SALT_KEY=random-string
JWT_SECRET_KEY=supersecret
const vulkano = require('@vulkano/core');
vulkano();your-app/
├── app.js # Entry point
├── app/ # Your application
│ ├── config/
│ │ ├── settings.js # App-wide settings (port, database, JWT…)
│ │ ├── routes.js # Explicit route overrides (optional)
│ │ ├── env/ # Per-environment config overrides
│ │ ├── express/ # Express middleware customization
│ │ ├── settings.js # App-wide settings (port, database, JWT…)
│ │ ├── routes.js # Explicit route overrides (optional)
│ │ └── locales/ # i18n translation files (en.js, es.js, etc.)
│ ├── controllers/ # Request handlers
│ ├── models/ # Mongoose model definitions
│ └── services/ # Shared services & libs (auto-loaded as globals)
└── public/ # Static files served over HTTP
│ ├── css/
│ ├── js/
│ ├── img/
│ └── files/ # Uploaded files
Vulkano resolves routes by convention — no route file required for standard CRUD.
The URL segments map to /:resource/:method?/:param?, resolving to <Resource>Controller.<method>(param).
The resource segment is always the requesting controller's own filename (UsersController → users).
GET /users/edit/1
│ │ │
│ │ └── param → passed as the method argument
│ └─────── method → UsersController.edit
└───────────-─ resource ("users") → UsersController
| HTTP method | URL | Resolves to |
|---|---|---|
GET |
/users |
UsersController.get |
POST |
/users |
UsersController.post |
PUT |
/users/42 |
UsersController['put :id'] |
PATCH |
/users/42 |
UsersController['patch :id'] |
DELETE |
/users/42 |
UsersController['delete :id'] |
POST |
/users/save |
UsersController['post save'] |
GET |
/users/42/info |
UsersController['get :id/info'] |
Controllers can live in subfolders, at any nesting depth, to group them by module. Each folder segment becomes a URL namespace, in order, before the controller's own resource segment:
controllers/UsersController.js → /users/...
controllers/api/ProductsController.js → /api/products/...
controllers/api/config/VatTypesController.js → /api/config/vat-types/...
controllers/api/config/billing/InvoiceController.js → /api/config/billing/invoice/...
The same controller filename can exist at several depths at once — VatTypesController.js at the
root, under api/, and under api/config/ resolve to /vat-types/, /api/vat-types/, and
/api/config/vat-types/ respectively, fully independent of each other. No config needed; it's
purely the folder path on disk.
A controller method key is <path tail> on its own, or '<verb> <path tail>' when the verb isn't GET. The auto-router only reassigns the HTTP method when the key has a space-separated verb prefix — otherwise it defaults to GET.
- A custom action name with no verb prefix (no space in the key) is still
GET, e.g.me(req, res)onAuthController→GET /auth/current. Don't write'get current'; it's redundant. - A custom action that isn't
GETneeds the verb spelled out, e.g.'post login'→POST /auth/login. - The path tail can carry arbitrary nested segments and multiple params:
// controllers/api/UsersController.js
module.exports = {
// GET /api/users/123/orders/988
'get :id/orders/:orderId': (req, res) => {
// req.params → { id: '123', orderId: '988' }
}
};
// controllers/api/AuthController.js
module.exports = {
// GET /api/auth/current — no verb prefix needed, GET is the default
current(req, res) { },
// POST /api/auth/login
'post login': (req, res) => { },
// POST /api/auth/logout
'post logout': (req, res) => { }
};Controllers only orchestrate the HTTP request/response cycle: read params, call the model, send the response with res.vsr(...) (REST API) or res.render(...) (server-side rendering). They should not contain business logic, validation rules, or data manipulation — that belongs on the model (instance/static methods, hooks, or virtuals), so it stays reusable outside the HTTP layer (crontabs, sockets, other models, tests).
// vulkano/controllers/UserController.js
module.exports = {
get(req, res) {
res.vsr(Promise.resolve({ users: [] }));
},
'get :id': (req, res) => {
res.vsr(Promise.resolve({ id: req.params.id }));
},
post(req, res) {
res.vsr(Promise.resolve({ data: req.body }));
},
'put :id': (req, res) => {
res.vsr(Promise.resolve({ updated: req.params.id, data: req.body }));
},
'delete :id': (req, res) => {
res.vsr(Promise.resolve({ deleted: req.params.id }));
}
};routes.js exists for whatever the convention can't resolve on its own — an absolute path, a catch-all for a frontend router, or breaking the "resource segment = controller filename" rule entirely. For everything else, don't add entries here; a redundant explicit entry just gives the route two sources of truth that can drift apart.
module.exports = {
// Routes as string — simple and easy to use
'/about-me': 'AboutController.get',
// Catch-all for a frontend router (SPA)
'/admin*': 'AdminController.get',
// Routes as definition — most flexible
'/test': (req, res) => {
res.json({ message: 'Hello, world!' });
},
// Routes as method — more advanced (`app.vulkano` is the Express instance)
custom() {
app.vulkano.get('/test2', (req, res) => {
res.json({ hello: 'world2' });
});
}
};Every controller action uses res.vsr(promise). It wraps the resolved value in a standard envelope:
{ "success": true, "statusCode": 200, "data": { … } }Errors are handled automatically:
// Custom error with status code
res.vsr(VSError.reject('Not allowed', 403));
// 404
res.vsr(VSError.notFound('User'));
// Rejection without a statusCode → 400 BadRequest
res.vsr(Promise.reject(new Error('Something went wrong')));res.vsr() also accepts a function instead of a Promise — an async one, or a
plain one that returns a Promise. VSR calls it and funnels both a throw and
an awaited rejection into the same .catch() that already handles a
rejected Promise — you don't need a try/catch in the controller either
way:
// Promise style
get(req, res) {
res.vsr(Product.getByField(req.params.id));
}
// async/await style — equivalent, no try/catch
get(req, res) {
res.vsr(async () => {
const product = await Product.getByField(req.params.id);
if (!product) {
throw new VSError('Not found', 404); // caught by VSR, not the process
}
return product;
});
}Point a controller at a model and get a full REST API for free:
// app/controllers/api/ProductsController.js
module.exports = {
scaffold: 'Product', // Mongoose model name — must exist as global.Product
allowedMethods: ['get', 'post', 'put', 'patch', 'delete']
};scaffold is the model name directly, so no separate model field is needed. If the string
doesn't match a loaded model (global.Product in this example), Vulkano throws at startup instead
of silently registering an empty controller.
You may also see
scaffold: truepaired with a separatemodel: 'Product'field — both forms are supported and behave identically, butscaffold: 'Product'is the recommended, shorter form.
This automatically exposes:
| Method | Path | Action |
|---|---|---|
GET |
/api/products |
List (paginated) |
GET |
/api/products/:id |
Get by ID |
POST |
/api/products |
Create |
PUT |
/api/products/:id |
Replace |
PATCH |
/api/products/:id |
Partial update |
DELETE |
/api/products/:id |
Soft-delete |
Query string params supported on list: page, per_page, sort, search, fields.
Add a subdocs array to expose generic CRUD routes for the model's own subdocument (embedded
array) fields — off by default, same on/off convention as allowedMethods:
// app/controllers/api/ProductsController.js
module.exports = {
scaffold: 'Product',
allowedMethods: ['get', 'post', 'put', 'patch', 'delete'],
subdocs: ['lines', { reviews: ['GET', 'POST'] }]
// 'lines' → Product.attributes.lines must be an array of subdocuments, all methods allowed
// reviews: → same, but only GET/POST routes are registered for this key
};This generates:
| Method | Path | Action |
|---|---|---|
POST |
/api/products/:id/:key |
Add a subdocument (Model.createSubdoc) |
GET |
/api/products/:id/:key |
List every item under :key (via getByField) |
GET |
/api/products/:id/:key/:subId |
Find one subdocument (Model.getSubdoc) |
PUT |
/api/products/:id/:key/:subId |
Update one subdocument (Model.updateSubdoc) |
DELETE |
/api/products/:id/:key/:subId |
Remove one subdocument (Model.removeSubdoc) |
:key is restricted to the names listed in subdocs — a request for any other key (including a
real but non-array field, like a plain String attribute) 404s instead of reaching
Model.createSubdoc() and blowing up on a field that isn't an array. Omit subdocs (or leave it
empty) to disable this entirely — the 5 base CRUD routes above are unaffected either way.
Per-key method restriction: each subdocs entry can be a plain string (no restriction, all
methods allowed) or an object { key: ['GET', 'POST'] } to limit that key's subdoc routes to
the listed HTTP methods (case-insensitive, normalized to uppercase). A method not in the list
returns 405 before touching the model — independent from the base 5 routes' allowedMethods.
A scaffold controller wires each allowed HTTP method to the matching standard CRUD method on the model
(getAll, get<ModelName>, create, update, delete — see Models below), so the model
still needs those methods implemented or auto-generated.
NOTE: To find examples with the best practices, look in examples/controllers to find a well-structured controller for server side rendering, like ExampleController.js, REST API like RestExampleController.js and Scaffold REST API like RestScaffoldController.js.
Models live in app/models/ are auto-loaded as globals. A file Project.js becomes global.Project (singular).
Every model gets attributes (Mongoose schema fields), plus active, createdAt, updatedAt automatically.
Models are where validation, data manipulation, and business rules belong — not just the raw Mongoose schema. Controllers should only ever call methods on the model; they shouldn't reach into Model.find(...) or manipulate documents directly.
Every model is expected to expose this same set of methods, so controllers can call them the same way regardless of the resource:
| Method | Purpose |
|---|---|
getAll(props) |
List/paginate records. props = { page, perPage, search, sort } |
get<ModelName>(id) |
Get a single record by id (e.g. getProduct(id)) |
create(data) |
Create a new record |
update(id, data) |
Update a record by id |
delete(id) |
Soft-delete a record (sets active: false) |
// app/models/Product.js
module.exports = {
attributes: {
name: { type: String, required: true },
price: { type: Number, default: 0 },
tags: { type: [String] }
},
};NOTE: To find examples with the best practices for available methods ahd hooks, look in examples/models and read the file Example.js, and Scaffold Model API ExampleWithScaffold.js.
The controller passes req.query straight through as props
(res.vsr(Product.getAll(req.query))) — so props is attacker-controlled.
Paginate.serializeQuery() only ever reads flat top-level keys from it
(page, per_page, sort, search, searchType, fields) and never a
filter key, precisely so a request can't reach into the Mongo filter
directly. Follow the same rule in your own getAll overrides: destructure
only the specific fields you expect, and build filter yourself — never
spread props (or a props.filter) into it.
// app/models/Product.js
module.exports = {
attributes: { /* ... */ },
getAll(props) {
const defaultProps = {
sort: 'createdAt|DESC',
searchBy: ['name'],
filter: {
active: true // soft-delete scope — must not be overridable by the request
}
};
// Safe: only two known, explicitly-named fields ever reach the filter.
const { category, inStock } = props || {};
if (category) {
defaultProps.filter.category = category;
}
if (inStock !== undefined) {
defaultProps.filter.inStock = inStock === 'true';
}
// UNSAFE — never do this: it lets a request send ?filter[active]=false
// (or any other field) and override the soft-delete scope above.
// defaultProps.filter = { ...defaultProps.filter, ...props.filter };
const query = Paginate.serializeQuery(defaultProps, props);
return Paginate.get(this, query);
}
};?fields= and ?populate=rel:fields are request data too: Paginate.get and
_buildPopulate drop any +field override and any select: false path
(the model's own _sanitizeFields, inherited from the scaffold), so mark secrets (password: { type: String, select: false })
and they never leave through a list or a populate. Read them in model code with
.select('+password').
@vulkano/core's database/mongodb.js auto-injects createdAt: Date and updatedAt: Date attributes into every model schema if the model doesn't already define them (if (!attributes.createdAt) { ... }, same for updatedAt). Never add a manual timestamp field (at, date, timestamp, createdAt, updatedAt, etc.) to a model's attributes — they're already automatic in Vulkano, so a hand-rolled one is redundant, and if named anything other than createdAt/updatedAt it also fights the framework's own sort/index defaults (database/scaffold.js defaults sort: 'createdAt|DESC'). Use createdAt and updatedAt directly in indexes, sort strings, and business logic.
@vulkano/core pairs each model with a controller by name, so the naming convention is what makes the auto-routing work:
- Model → singular PascalCase (e.g.,
Product.js→global.Product) - Controller → plural PascalCase +
Controllersuffix (e.g.,ProductsController.js)
All files in app/services/ are auto-loaded as globals. The framework also exposes:
| Global | Description |
|---|---|
VSError |
Structured error factory (reject, notFound) |
Jwt |
Sign / verify JWT tokens |
Paginate |
Query serialization and pagination helpers |
Merge |
Deep-merge utility (deepmerge-compatible, full option support) |
Encrypter |
Reversible encryption (encrypt/dencrypt) — never passwords |
Filter |
Input sanitization helpers |
Crontab |
Schedule recurring jobs with cron expressions |
ApiClient |
HTTP client for calling external APIs (native fetch + undici) |
Download |
File download helper |
Upload |
Validate, save and return the local path of an uploaded file |
i18n |
Internationalization via i18next |
mongoose |
Mongoose instance |
View |
View.render(view, data) → Promise, renders outside the request/response cycle |
ApiClient verifies SSL by default. A single call can opt out with rejectUnauthorized: false
(and force it back on with rejectUnauthorized: true), and the default itself can be flipped for
the whole app via .env:
# .env — disables SSL verification by default for every ApiClient call
# (e.g. calling other services on the same server over self-signed certs).
# A per-call rejectUnauthorized always overrides this, in either direction.
API_CLIENT_REJECT_UNAUTHORIZED=false
Vulkano uses Multer v2. Files are available on req.files after a multipart/form-data POST — Multer writes them straight into PUBLIC_PATH/files under a temporary name.
Multer rejects any single file over 25MB by default (limits.fileSize) — before it's fully
buffered to disk, unlike Upload.file()'s own maxSize check which only runs after. Override it
in app/config/express/multer.js:
// app/config/express/multer.js
module.exports = {
limits: { fileSize: 100 * 1024 * 1024 } // 100MB
};Upload.file(files, opts) validates a single uploaded file (mimetype, extension, size, write
permission) and renames it to its final, safe filename inside PUBLIC_PATH/files. It only ever
touches the local disk — it never uploads anywhere. If your app needs to push the result to a
cloud provider, chain your own service off the returned path:
// app/controllers/UploadController.js
module.exports = {
'post upload': (req, res) => {
const props = {
allowed: ['jpg', 'jpeg', 'png', 'webp'],
// For custom mimetypes or mime-to-extension mapping
// mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
maxSize: 10 * 1024 * 1024,
lang: req.query.lang // 'en' (default) or 'es' — controls the error language
};
res.vsr(
Upload
.file(req.files || [], props)
.then((file) => Cloud.upload(file.path).then((url) => ({ ...file, url })))
);
}
};Upload.file() resolves with { name, path } — name is the destination filename, path the
absolute local path. SVGs get their content sanitized regardless of the rename strategy —
<script> tags, inline event handler attributes (onload, onclick, ...) and javascript: URIs
are stripped before the file is saved, since an SVG is just XML that can otherwise carry
executable code.
opts:
| Option | Description |
|---|---|
allowed |
Array of allowed extensions (e.g. ['jpg', 'png']). Skipped if omitted |
maxSize |
Max size in bytes. Defaults to 10MB |
name |
Restrict to a specific form fieldname. Defaults to the first file sent |
lang |
Language for validation error messages. Defaults to en |
rename |
Naming strategy for the saved file — see below. Defaults to none |
rename controls the destination filename:
Upload.file(req.files, { rename: true }); // uuid, e.g. "3fa2...c9.png"
Upload.file(req.files, {}); // sanitized original name
Upload.file(req.files, { rename: (file) => `user-${req.auth._id}` }); // custom base namerename: true→ a random uuid (collision-free, extension kept)- omitted /
false→ the sanitized original filename, unchanged - a function
(file) => string→ its return value becomes the base name (still sanitized — never trusted as-is, since it could carry../)
In every case except the uuid one, if the resulting filename already exists in PUBLIC_PATH/files
a short random suffix is appended so the new upload doesn't silently overwrite it — a plain
upload with no collision keeps a clean name.
Any of the 5 opts keys above (allowed, maxSize, name, lang, rename) can also be set once
as project-wide defaults in app/config/upload.js — it's a plain JS module merged under the
opts passed on each call, so per-call values win:
// app/config/upload.js
module.exports = {
allowed: ['jpg', 'jpeg', 'png', 'webp'],
maxSize: 10 * 1024 * 1024
// name, lang and rename are usually left per-call since they tend to
// depend on the request, but they're valid here too
};Validation errors are VSErrors translated through the i18n keys upload.notUploaded,
upload.noPermission, upload.invalidMimeType, upload.extensionNotAllowed and
upload.maxSizeExceeded — see i18n below to override their text per locale.
Same options as Upload.file(), but validates and saves every file sent (optionally restricted to
one fieldname via opts.name) and resolves with an array:
res.vsr(
Upload.files(req.files || [], { allowed: ['jpg', 'png'], name: 'gallery' })
);
// → [{ name, path }, { name, path }, ...]If any file fails validation, the whole call rejects (same as Promise.all) — no files are
partially saved.
Vulkano integrates JWT internally to validate protected routes, using
express-jwt (route middleware) and
jwt-simple (encode/decode) under the hood. Configured
in app/config/express/jwt.js — see examples/config/express/jwt.js
for a full example configuration.
When enabled: true, every request under path is checked by an express-jwt middleware, which
calls Jwt.getToken(req) to pull the token from the configured header (x-token-auth by default),
cookie, or query parameter, then validates it with Jwt.decode().
Jwt.encode(data) requires an expiration field in the payload (a millisecond timestamp) —
without it, Jwt.decode() rejects the token by default:
// app/controllers/api/AuthController.js
module.exports = {
'post login'(req, res) {
res.vsr(User.login(req.body).then((user) => ({
user,
token: Jwt.encode({
_id: user._id,
expiration: String(Date.now() + 24 * 60 * 60 * 1000) // 24h from now
})
})));
}
};To issue tokens that never expire, set
expiration: falseinapp/config/express/jwt.js— this disables the expiration check onJwt.decode(), not just for tokens missing the field.
req.auth isn't set automatically — decode the token in your own middleware
(app/config/middlewares/) or per-controller. See
examples/config/middlewares/auth.js for a full example:
const { _id } = Jwt.decode(Jwt.getToken(req)) || {};When Vulkano starts, you can configure your own tasks to run at a given time.
timeZone defaults to UTC when omitted — pass it explicitly (e.g. 'America/New_York') for a
task that should run relative to a specific local time instead.
// app/config/bootstrap.js
module.exports = (start) => {
start(() => {
Crontab.schedule({
time: '0 0 11 * * 5',
timeZone: 'America/New_York',
task: () => {
console.log('Crontab every Friday (5) at 11');
Weekly.report().catch( () => {});
},
onComplete: () => {
console.log(`Weekly report job completed at ${new Date()}`);
}
});
});
};Vulkano wires up i18next automatically. One file per locale in
app/config/locales/, keyed by filename — no manual registration needed:
// app/config/locales/en.js
module.exports = {
welcome: 'Welcome',
goodbye: 'Goodbye'
};
// app/config/locales/es.js
module.exports = {
welcome: 'Bienvenido',
goodbye: 'Adiós'
};The global i18n is the configured i18next instance — use i18n.t('key') anywhere in your app
(controllers, models, services):
// app/controllers/HomeController.js
module.exports = {
get(req, res) {
res.vsr(Promise.resolve({ message: i18n.t('welcome') })); // "Welcome"
}
};Default language is en, with en as the fallback if a key or locale is missing. To switch the
active language at runtime, call i18n.changeLanguage('es').
The core also ships its own default en/es locale files (currently the upload.* keys used by
the Upload lib). They're deep-merged under your project's app/config/locales/, so any key you
declare there wins over the core default — you only need to override the keys you want to change.
Enabled via app/config/sockets/config.js. Adapters for Redis and MongoDB are included out of the box (default is in-memory).
// app/config/sockets/config.js
module.exports = {
// Enable sockets
enabled: true,
// Socket IO Adapter (redis|mongodb|memory)
adapter: 'memory',
// Socket configuration
config: {
transports: ['websocket', 'polling'],
timeout: 4000,
interval: 2000,
},
// Connections
connections: {
users: 0,
clients: {}
}
};Events map socket event names to a controller action, the same folder.<Name>Controller.method convention used by routes.js:
// app/config/sockets/events.js
module.exports = {
'echo': 'sockets.EchoController.echo'
};// app/controllers/sockets/EchoController.js
module.exports = {
echo({ socket, body }, callback) {
callback({
echo: body,
userId: (socket.request.user || {})._id || null
});
}
};Handler signature is always ({ socket, body }, callback).
Optional CORS check (app/config/sockets/cors.js):
module.exports = (req, callback) => {
const { origin, host } = req.headers || {};
const realOrigin = origin || host;
const allowedOrigin = ['localhost', 'yourdomain.com'];
const found = allowedOrigin.some((o) => (realOrigin || '').indexOf(o) !== -1);
if (found) {
callback(null, true);
} else {
callback(new Error(`Invalid origin ${realOrigin} - Socket CORS`));
}
};Optional auth middleware, run before a socket connection is accepted (app/config/sockets/middlewares/auth.js):
module.exports = (socket, next) => {
const user = Jwt.socket(socket);
const { _id } = user || {};
if (!_id) {
next(new Error(`Invalid user ${_id || 'or token'}`));
}
socket.request.user = user || {};
next();
};Redis/MongoDB adapter settings live in app/config/sockets/adapters/redis.js and app/config/sockets/adapters/mongodb.js:
// app/config/sockets/adapters/redis.js
module.exports = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || '6379',
password: process.env.REDIS_PASSWORD || ''
};// app/config/sockets/adapters/mongodb.js
module.exports = {
connection: process.env.SOCKETS_MONGO_URI || null,
collection: process.env.SOCKETS_MONGO_COLLECTION || 'socket.io-adapter-events'
};Available globally as io (the Socket.io server instance) and app.socket (the current socket).
See test/fixtures/app/config/sockets and test/fixtures/app/controllers/sockets for the full working example used by the integration tests.
module.exports = {
// PORT to listen on
port: process.env.PORT || 3000,
// Database configuration
database: {
// MONGO_URI connection
connection: process.env.MONGO_URI,
// Settings before to connect
settings: {
strictQuery: false,
debug: false
},
// Additional config to mongoose
config: {
useNewUrlParser: true,
useUnifiedTopology: true,
// family: 4 // 4 (IPv4), 6 (IPv6), or null (default: OS family)
// useFindAndModify: false,
// useCreateIndex: true
}
}
};Each file in app/config/express/ configures one Express middleware. All are optional — omitted
files fall back to sane defaults — and every file is auto-merged into the final config used by
bootstrap/server.js. Full working examples for every file live in
examples/config/express/.
| File | Configures | Package used |
|---|---|---|
settings.js |
Core server behavior (poweredBy, timeout, uploadPath, trustProxy) |
— (native Express) |
cookies.js |
Cookie parsing + session secret | cookie-parser, express-session |
cors.js |
Cross-Origin Resource Sharing | — (handled with a custom middleware, no cors package) |
jwt.js |
JWT authentication middleware | express-jwt, jwt-simple |
csp.js |
Content Security Policy rules | — (custom header builder) |
helmet.js |
Security headers | helmet |
permissionPolicy.js |
Permission-Policy header |
— (custom header builder) |
rateLimit.js |
Request rate limiting; path scopes it to /api or a custom set of endpoints (defaults to '*', whole app) |
express-rate-limit |
session.js |
Express session (requires cookies.js enabled) |
express-session |
multer.js |
File upload destination + limits (default fileSize 25MB) |
multer |
morgan.js |
HTTP request logging | morgan |
compression.js |
Response compression | compression |
urlencoded.js |
URL-encoded body parser options | — (native express.urlencoded()) |
frameguard.js |
X-Frame-Options header |
frameguard |
json.js |
JSON body parser MIME types | — (native express.json()) |
@vulkano/core v2.x is built on Express 5, used natively — no compatibility
layer restoring old Express 4 API surface (req.param(), legacy res.*
signatures, etc.). If your app calls those directly in its own controllers,
update it to Express 5's native equivalents when you upgrade (see
Express's own migration guide).
Two things Vulkano does keep translating — these are Vulkano's own routing conventions, not Express 4 compat, so they're permanent:
- Wildcard routes: bare
'*'/'/*'(whole-app catch-all) and a wildcard glued or slash-separated onto a prefix ('/admin*','/admin/*'— the documented SPA catch-all convention above) are translated automatically to path-to-regexp v8 syntax, in your controllers,app/config/routes.js, and therateLimit.path/jwt.path/cors.pathconfig options (including when those are arrays). Express 5 captures the matched tail asreq.params.splat(an array of path segments) instead of Express 4'sreq.params[0]string — update any code reading the old index form. - Optional-param routes (
'/user/:id?'): translated to Express 5's'/user{/:id}'syntax too. Vulkano's own generated routes never use this, but Express 5 rejects the raw?syntax at boot time (not request time), so an app's own controller using it would otherwise fail to start on upgrade.
Verified, not just assumed, to need no action:
- Query-string parsing: left as Express 5's native
'simple'default (not forced to'extended'). Scaffold'sgetAll(req.query)/Paginate.serializeQuery()only ever read flat top-level keys (page,per_page,sort,search,searchType,fields) — verified they never read afilterkey from the request query at all, so a query like?filter[active]=falsecannot bypass a model's own hardcoded soft-deletefilter: { active: true }default either way. If your own model code needs nested query objects for something outside Paginate, set it explicitly:app.vulkano.set('query parser', 'extended')in yourapp/config/bootstrap.js. express.static()'sdotfilesdefault is'ignore'in both Express 4's and Express 5's underlyingsendpackage — no behavior change.express.urlencoded()'sextendedoption default flipped fromtruetofalsein Express 5 — butbootstrap/express.js's own defaults already seturlencoded: { extended: true }explicitly, so this is a non-issue by design, not by luck. Don't remove that explicit default as "redundant."res.render()/Nunjucks rendering works unchanged (Express 5 enforces async view-engine callbacks; Nunjucks's integration already satisfies it).app.router/app._router(used only for the startup route-listing log) is handled with a fallback for Express 5's renamed lazy getter.
Residual cases that cannot be shimmed safely (real ambiguity, not an oversight) — if your app uses one of these, check it manually:
- Routes with multiple parameters pegged with no separator
(
'/:foo:bar'): unsupported since path-to-regexp v6 already, use a literal separator ('/:foo-:bar') or two segments. - Raw regex routes (
app.get(/^\/foo\/(bar|baz)$/, ...)) and a wildcard in the middle of a path ('/api/*/edit'): path-to-regexp v8 doesn't support either as a mechanical rewrite — both require restructuring the route (split into multiple explicit routes, or a named wildcard segment) rather than a text substitution. Neither pattern is used anywhere in this codebase. - Middleware that reassigns
req.querywholesale (req.query = {...}): Express 5 madereq.querya getter — reassigning the whole object no longer works; mutating existing keys (req.query.foo = 'bar') still does. req.acceptsCharset()/acceptsEncoding()/acceptsLanguage()(singular, deprecated Express 4 names): removed outright in Express 5, no shim — use the plural forms (acceptsCharsets/acceptsEncodings/acceptsLanguages) that already existed in Express 4. No usage of the singular forms was found anywhere in this codebase or its examples.req.paramsno longer hasObject.prototypeon string routes — code doingreq.params.hasOwnProperty(...)or relying on inherited object methods onreq.paramsshould useObject.prototype.hasOwnProperty.call(...)instead. Not used anywhere in this codebase.res.clearCookie()ignoresmaxAge/expiresoptions now (Express 5) — pass them tores.cookie()when setting the cookie instead. Not used with those options anywhere in this codebase.req.hostnow includes the port number (e.g.example.com:3000) instead of stripping it — arguably a bugfix, but a behavior change for code that assumes a bare hostname. Not used anywhere in this codebase.
npm test # all suites
npm run test:watch # watch mode
npm run test:coverage # with coverage reportTests require Node.js 22. Use
nvm use 22if needed.
MIT © Vulkano Team