Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling.
You describe routes as files under pages/, render with Nunjucks, validate HTTP input with Zod, and optionally plug in Knex-backed models, migrations, and first-party plugins (dashboard, sitemap, analytics, admin, and more). This page is a condensed reference; the authoritative source is the
README.md in the repository.
Release: 0.1.0-alpha.0 ·
Stack: Hono ·
Nunjucks ·
Knex ·
Zod
· Node 18+
Highlights in 0.1.0-alpha.0. Full history: CHANGELOG.md.
| Area | Summary |
|---|---|
| Hono | createApp().app is WebspressoCompatApp — listen, fetch, Express-shaped handlers. See Hono migration. |
| Build compiler | webspresso build — manifest + handlers under .webspresso/. See Deployment. |
| Cloudflare Workers | add deploy --provider cloudflare; precompiled templates.mjs (full extends / include graph). Worker runtime: createWorkerApp. See Cloudflare Workers. |
| Subpath exports | webspresso/build, webspresso/core/auth, webspresso/core/orm, split manifest entries for Node vs Worker. |
| D1 on Workers | Wrangler env.DB → req.db / getDb() in compiled API routes. |
Webspresso is an opinionated, batteries-included server-side toolkit: one CLI scaffolds projects, watches your pages/ tree to build HTTP routes on Hono (with an Express-compatible handler API), and optional plugins add SEO, analytics, and an admin UI without bolting on a separate meta-framework. It targets teams who want Laravel- or Rails-like ergonomics on Node with transparent files on disk instead of a hidden compiler graph.
.njk files under pages/ to GET routes; nested folders become path segments.[slug] for params, [...rest] for catch-all routes.pages/api/ with optional method suffixes (.get.js, .post.js, …).schema factory using Zod; validated payloads appear on req.input.pages/locales/ plus per-route overrides; t('key') in templates._hooks.js and per-route hooks for load/render pipeline control.fsy object (URLs, dates via dayjs, SEO helpers, assets, …).createApp({ clientRuntime: { alpine, swup } }) serves Alpine.js and swup v4 (Head + Scripts plugins) under /__webspresso/client-runtime/; SSR context exposes clientRuntime for layouts. See Client runtime (Alpine + swup).dependencies, register / onRoutesReady / onReady.createDatabase({ cache: true }), per-model defineModel({ cache: 'auto' | 'smart' | false, … }), db.cache (purge, invalidateModel, metrics); reads on Knex transactions bypass the cache.createAuth / quickAuth (webspresso/core/auth), createApp({ auth }), remember-me tokens, policies/gates, route middleware auth / guest. Details: Authentication.ormCacheAdminPlugin), optional spreadsheet import/export for admin (dataExchangePlugin — Excel export, CSV/XLSX import; see Data exchange), configurable HTTP redirects before file routes (redirectPlugin; see Redirect), SEO checker (dev), schema explorer (ORM metadata), Swagger UI + OpenAPI for HTTP APIs (dev by default), HTTP /health probe, multipart file upload (uploadPlugin + createLocalFileProvider), optional REST CRUD routes from ORM models (restResourcePlugin) with batched ?include= relations.index.d.ts with WebspressoCompatApp, WebspressoRequest, and WebspressoResponse; import createApp, ORM, plugins from TypeScript. Install hono if you need core Hono types on createApp().app._hono.require('webspresso').kernel: in-process event bus (dispatch / publish), plugin shell, flow registry, minimal view resolver, simulated BaseRepository; not the SSR createApp. See Application kernel.webspresso build --adapter node|cloudflare|bun writes a route manifest and adapter entry under .webspresso/. Cloudflare Workers: precompiled Nunjucks, Wrangler bundle, optional D1. See Deployment and Cloudflare Workers.Requires Node.js 18+. Install the CLI globally or add the framework as a project dependency.
npm install -g webspresso
# or, inside a project:
npm install webspresso
After npm install webspresso, use npx webspresso or define an npm script if the binary is not on your PATH.
TypeScript: the package ships index.d.ts for the public API. Example: import { createApp, defineModel } from 'webspresso'. Route handlers use the compat (req, res, next) shape; createApp().app is a WebspressoCompatApp (Hono + listen / get / post helpers).
Database drivers, Faker, and dotenv are optional peers — install only what you use so production images stay lean.
| Package | Typical use |
|---|---|
pg | PostgreSQL via Knex |
mysql2 | MySQL / MariaDB |
better-sqlite3 | SQLite (local dev, embedded) |
dotenv | Load .env in development |
zod | webspresso new scaffold validates env with Zod (config/env.schema.js) |
@faker-js/faker | Seed scripts and factories |
Start here: step-by-step docs/getting-started.md (about 15 minutes: page, API, model, admin). Example apps live under examples/.
New projects ship with Tailwind CSS, a starter layout, i18n (e.g. en/tr), and npm scripts. Pass --no-tailwind to skip Tailwind. Presets: --template blog|admin|dashboard|landing.
webspresso new my-app
cd my-app
npm install
npm run build:css
webspresso dev
# or: npm run dev
webspresso new flows--install (-i) — runs npm install and npm run build:css, then optionally starts the dev server.package.json, creates webspresso.db.js, migrations/, models/, and DATABASE_URL in .env.example.config/load-env.js (dotenv chain: .env → .env.local → mode-specific files), config/env.schema.js (Zod), config/app.js (createApp options + optional db when webspresso.db.js exists). Dependencies: dotenv, zod.@faker-js/faker, seeds/index.js, and npm run seed.webspresso dev expects a server.js in the project root, uses Node’s watch mode on pages, models, views when present, and can run Tailwind watch:css alongside.
webspresso dev
webspresso dev --port 3001
webspresso dev --no-css # skip CSS watch if Tailwind is configured
Typical application layout (database pieces appear after you opt in or add them manually):
my-app/
├── config/
│ ├── load-env.js # .env chain (last file wins per key)
│ ├── env.schema.js # Zod validation for process.env
│ └── app.js # createApp() options (+ db if webspresso.db.js)
├── pages/
│ ├── locales/ # global i18n (en.json, de.json, …)
│ ├── _hooks.js # global lifecycle hooks
│ ├── index.njk # GET /
│ ├── about/
│ │ ├── index.njk
│ │ └── locales/ # route-level overrides
│ ├── tools/
│ │ ├── index.njk
│ │ ├── index.js # load(), meta(), middleware, hooks
│ │ ├── [slug].njk
│ │ └── [slug].js
│ └── api/
│ ├── health.get.js
│ └── echo.post.js
├── views/
│ └── layout.njk
├── public/ # static assets
├── models/ # optional ORM models
├── migrations/ # Knex migrations
├── seeds/ # optional seed entry (seeds/index.js)
├── webspresso.db.js # optional Knex config
└── server.js # loadEnv() + createApp()
| Variable | Default | Description |
|---|---|---|
NODE_ENV | development | Controls logging, plugin defaults (e.g. dashboard) |
DEFAULT_LOCALE | en | Fallback locale |
SUPPORTED_LOCALES | en | Comma-separated list (e.g. en,de) |
BASE_URL | http://localhost:3000 | Canonical URLs, sitemap, metadata |
DATABASE_URL | — | Connection string for Knex / ORM |
New project scaffold merges env files in this order (each step overrides keys): .env → .env.local → .env.<NODE_ENV> → .env.<NODE_ENV>.local. Use .env.example as a template; keep secrets in ignored *.local files.
webspresso start sets NODE_ENV=production and honors PORT via the --port flag.
Entry: webspresso (bin/webspresso.js). Use --help on any subcommand for flags.
| Command | Description |
|---|---|
new [project-name] | Scaffold a project (Tailwind, i18n, scripts; optional DB + seeds) |
page | Interactive: add SSR page + optional route config + locales |
api | Interactive: add API route + HTTP method |
dev | Development server (Node watch, optional CSS watch) |
start | Production server via server.js |
add tailwind | Add Tailwind, PostCSS, build + watch scripts |
add deploy | Scaffold deploy files — --provider cloudflare|docker|pm2 (comma-separated) |
build | Compile manifest + adapter output under .webspresso/ — --adapter node|cloudflare|bun |
db:migrate | Run pending migrations |
db:rollback | Rollback last migration batch |
db:status | Show migration status |
db:make <name> | Create migration file (optional --model scaffold) |
seed | Run seeds/index.js (Faker-based fake data) |
admin:setup | Emit admin_users migration for admin plugin |
admin:list | List admin users (needs migrated admin_users) |
admin:password | Reset admin password (interactive or -e / -p) |
audit:prune | Delete audit log rows older than --days (optional --table) |
doctor | Sanity checks: Node version vs package.json engines, expected project files, optional DB ping — run from your app root |
skill [name] | Create an Agent Skill (SKILL.md) for Cursor — interactive name/description, or --preset webspresso copies SKILL.md + REFERENCE-framework.md + REFERENCE-kernel.md into .cursor/skills/webspresso-usage/ |
favicon:generate <source> | PNG favicons, PWA manifest, Nunjucks partial |
| Command | Flags / notes |
|---|---|
new | -i, --install · --no-tailwind |
dev / start | -p, --port (default 3000) · dev --no-css · dev -a cloudflare prints Wrangler reminder |
build | -a, --adapter <name> · --skip-bundle · --fail-on-warnings |
add deploy | -p, --provider <name> (required) — cloudflare, docker, pm2 |
seed | --setup · --config · --env |
admin:password / admin:list | -c, --config · -E, --env |
favicon:generate | -o · --no-layout · PWA --name / --short-name / --theme-color |
doctor | --db (test DB when webspresso.db.js / knexfile.js exists) · --strict (exit 1 on warnings) · -e, --env (with --db) |
skill | -g, --global (write to ~/.cursor/skills/) · -d, --description · -f, --force · -p, --preset <name> (e.g. webspresso) |
createApp(options)Returns { app, nunjucksEnv, pluginManager, authMiddleware } (auth helper may be no-op if auth is not configured). app is a Hono-based compat instance (app.fetch, app.listen, Express-shaped route helpers). The framework applies secure headers, optional sessions, JSON/form body parsers, static files, and the file router. See Migrating from Express if you upgrade from an older major.
| Option | Role |
|---|---|
pagesDir | Required. Root of routes and templates resolution. |
viewsDir | Nunjucks views / layouts. |
publicDir | Static files (default public). |
db | ORM instance → ctx.db (SSR), req.db on pages/api (before middleware); getDb() / attachDbMiddleware elsewhere. |
plugins | Array of plugin factories or objects. |
middlewares | Named map: plain (req,res,next) or factories (opts) => (req,res,next). Routes use middleware: ['auth'] or tuples [['auth', { api: true }]]. |
helmet | true / false / custom secure-headers options object (CSP and related directives; mapped from the former Helmet-style config). |
logging | HTTP logging (on by default in development). |
timeout | String duration ('30s') or false — request timeout middleware (sets req.timedout for error pages). |
errorPages | notFound, serverError, timeout — handler fn or template path. |
assets | Version string or manifest path for cache-busted fsy.asset() URLs. |
clientRuntime | Optional { alpine?: boolean | object, swup?: boolean | object }. When either is truthy, mounts vendored scripts at /__webspresso/client-runtime/* and passes resolved flags to Nunjucks as clientRuntime. Env overrides: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP (1 or true). Helpers: resolveClientRuntime(), CLIENT_RUNTIME_BASE on the package root. Details: Client runtime. |
auth | Optional AuthManager from webspresso/core/auth — session stack, req.auth / req.user, named middleware: ['auth' | 'guest']. See Authentication. |
setupRoutes | (app, ctx) => {} — custom routes on the compat app after file routes / plugin onRoutesReady, before 404. ctx.authMiddleware when auth is set; ctx.nunjucksEnv for rendering in custom handlers; ctx.clientRuntime is { alpine, swup }. |
const { createApp } = require('webspresso');
const { app } = createApp({
pagesDir: './pages',
viewsDir: './views',
middlewares: {
auth: (req, res, next) => {
if (!req.session?.user) return res.redirect('/login');
next();
},
},
errorPages: {
notFound: 'errors/404.njk',
serverError: 'errors/500.njk',
},
});
Opt-in progressive enhancement for SSR pages: Alpine.js for lightweight UI state and swup for same-document navigations (default container #swup). The admin panel and dev dashboard remain separate Mithril SPAs and are not modified. Use data-no-swup on a link to force a full page load; paths under /_admin and /_webspresso are ignored by the default bootstrap.
In your layout: include the shipped partial views/partials/webspresso-client-runtime.njk (also published in the npm package), and when clientRuntime.swup is enabled, wrap the main content in <main id="swup">…</main> (or match containers in bootstrap). Dynamic data from the server can still use pages/api + fetch from Alpine.
Demo: enable clientRuntime: { alpine: true, swup: true } on createApp and include views/partials/webspresso-client-runtime.njk in your layout (npm run dev). See also examples/landing-page. Production CSP (helmet / secure-headers): allow script-src 'self' for /__webspresso/client-runtime/; if Alpine requires unsafe-eval for your version, adjust or use a CSP-friendly build.
Recent major versions run on Hono instead of Express. Most application code that uses (req, res, next) and app.listen(port) continues to work; a few Express-only APIs are gone.
| Before (Express era) | After (Hono) |
|---|---|
createApp().app was express.Application | WebspressoCompatApp — use app._hono for raw Hono |
express-session, cookie-parser, helmet, multer as direct deps | Built in: hono-sessions, secure-headers, parseBody / upload plugin |
res.render(view) | Use nunjucksEnv.render(view, data) then res.send(html) (SSR pages still use Nunjucks via the file router) |
supertest in tests | app.fetch or the framework test helper pattern in tests/helpers/http.js |
express-rate-limit peer | Optional hono-rate-limiter peer; built-in rateLimitPlugin (in-memory) |
server.js from webspresso new is unchanged: app.listen(PORT, callback) is implemented with @hono/node-server.
const { createApp } = require('webspresso');
const { app } = createApp({
pagesDir: './pages',
viewsDir: './views',
publicDir: './public',
clientRuntime: { alpine: true, swup: true },
});
kernel)
A separate optional in-process layer ships under core/kernel/.
Import kernel from the package root (not the same symbol as SSR createApp):
require('webspresso').kernel.createApp() exposes an event bus (dispatch / publish), registerPlugin, registerFlow, a minimal namespaced view resolver, and BaseRepository that emits orm.<resource>.* lifecycle events (in-memory store for demos). It does not replace HTTP file routing (createApp / Hono) or Knex ORM ModelEvents.
| Export / path | Role |
|---|---|
kernel.createApp() | Registers event bus + view engine shell; distinct from SSR createApp(options) above. |
kernel.definePlugin / defineFlow | Small plugin descriptors and trigger → condition → sequential actions. |
kernel.BaseRepository | Simulated repository with beforeCreate / afterCreate / … events. |
core/kernel/*.js | Source modules: events.js, view.js, app.js, … |
const { kernel } = require('webspresso');
const app = kernel.createApp();
// app.events.dispatch / publish / on
// app.registerPlugin(kernel.definePlugin({ name: '…', events(app) { … }, views() { … } }));
// app.registerFlow(kernel.defineFlow({ trigger: 'orm.post.afterCreate', when: (ctx) => …, actions: [ … ] }));
Runnable demo from the repository clone: node core/kernel/run-demo.js.
Types: index.d.ts (WebspressoKernel, KernelAppShell).
Agent skill: webspresso skill --preset webspresso installs REFERENCE-kernel.md next to SKILL.md.
Webspresso ships an optional, adapter-style session auth layer in core/auth. It is not re-exported from the package root — import from webspresso/core/auth (the core/ tree is published on npm). Pass an AuthManager to createApp({ auth }) so the framework mounts hono-sessions (cookie-backed session store) and a per-request authenticate middleware that fills req.user and req.auth. Session secret must be at least 32 characters.
webspresso/core/auth)| Export | Role |
|---|---|
createAuth(config) | Builds AuthManager with your findUserById, findUserByCredentials, optional rememberTokens adapter, session options, rememberMe, routes (login / redirect defaults). |
quickAuth({ db, ... }) | Opinionated createAuth wired to getRepository — default user model User, email + password fields, optional remember_tokens table via Knex. |
setupAuthMiddleware(app, authManager) | Applies hono-sessions and authenticate on the compat app; returns guards (requireAuth, requireGuest, requireCan, requireVerified, …) plus auth / guest for route configs. |
hash / verify | Bcrypt password helpers used by credentials adapters. |
createRememberTokensTable(knex) | Migration-style helper for the default remember_tokens shape (user_id, hashed token, expires_at). |
createAuthTokensTable(knex) | Migration helper for auth_tokens (password reset + email verification tokens). AuthManager exposes requestPasswordReset, completePasswordReset, requestEmailVerification, verifyEmail when an authTokens adapter is configured. |
PolicyManager | Define definePolicy / defineGate; AuthManager exposes the same via definePolicy, defineGate, beforePolicy. |
createAppsession.secret on the auth config (or ensure env vars your app reads into that field). Without a secret, getSessionConfig() throws.auth is passed to createApp, the framework registers middlewares.auth and middlewares.guest to the session guards. Avoid defining your own middleware under those names if you use built-in auth.createApp returns authMiddleware (or null). Use it inside setupRoutes(app, { authMiddleware }) for custom login/logout routes or requireAuth({ api: true }) on JSON APIs.const { createApp } = require('webspresso');
const { createAuth, verify } = require('webspresso/core/auth');
const auth = createAuth({
findUserById: (id) => userRepo.findById(id),
findUserByCredentials: async (email, password) => {
const user = await userRepo.findOne({ email });
if (user && (await verify(password, user.password))) return user;
return null;
},
session: { secret: process.env.SESSION_SECRET },
});
const { app, nunjucksEnv, authMiddleware } = createApp({
pagesDir: './pages',
viewsDir: './views',
db,
auth,
setupRoutes(app, ctx) {
const am = ctx.authMiddleware;
if (!am) return;
app.get('/login', am.requireGuest(), (req, res) => {
const html = nunjucksEnv.render('login.njk', {});
res.send(html);
});
app.post('/login', async (req, res) => {
const user = await req.auth.attempt(req.body.email, req.body.password);
if (user) return res.redirect('/');
res.redirect('/login');
});
},
});
req.auth)Bound per request after authenticate runs:
| Method | Role |
|---|---|
attempt(id, password, { remember }) | Validate credentials, log in, optional remember-me cookie when adapter configured. |
login(user, options) | Session login without re-checking password (user must have id). |
logout({ everywhere }) | Destroy session; clear / revoke remember token(s). |
check() / guest() | Boolean auth state. |
user() / id() | Current user object or id. |
can / cannot / authorize | Policy / gate checks (throws AuthorizationError on authorize). |
middleware: ['auth'] or ['guest'] in the sibling .js route config.pages/login.njk exists it may be registered before your setupRoutes handler and skip requireGuest. Prefer registering login in setupRoutes and keep the template only under views/, or omit pages/login.njk — see tests/e2e/auth.spec.js.requireAuth({ api: true }) returns 401 JSON instead of redirecting — use on /api/* handlers you mount manually.requireVerified({ field: 'email_verified_at' }) enforces an optional “verified” column pattern.If you pass a rememberTokens adapter, the manager stores a hashed token in the database and a signed cookie with the raw token. Call createRememberTokensTable(knex) or mirror its columns in a migration. quickAuth can wire the default Knex table when rememberMe: true.
Enable authTokens: true on quickAuth (or pass an authTokens adapter to createAuth) and run createAuthTokensTable(knex). Pair with emailPlugin and authEmails: { enabled: true } to send MJML mails and optionally mount POST /api/auth/forgot-password, /reset-password, /verify-email, /resend-verification. Forgot-password always returns { ok: true } (no user enumeration).
const { quickAuth, createAuthTokensTable } = require('webspresso/core/auth');
const { emailPlugin, adminPanelPlugin } = require('webspresso/plugins');
const auth = quickAuth({ db, authTokens: true });
await createAuthTokensTable(db.knex);
createApp({
pagesDir: './pages',
db,
auth,
plugins: [
emailPlugin({
db,
auth,
smtp: { host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } },
defaults: { from: process.env.MAIL_FROM },
authEmails: {
enabled: true,
baseUrl: process.env.BASE_URL,
registerRoutes: true,
},
}),
adminPanelPlugin({ db }),
],
});
The admin panel plugin uses its own session namespace (req.session.adminUser via hono-sessions) with routes under /_admin/api/auth/*. It does not replace createApp({ auth }) for your public site users — use both if you need CMS staff and end-user sessions.
Rich-text fields (customFields with type: 'rich-text'): HTML from the admin API is sanitized on the server with a narrow tag whitelist before save. Use richTextSanitize: false on adminPanelPlugin only if you accept the XSS trade-off. Rendering stored HTML on public pages still requires safe templating (| escape by default in Nunjucks; avoid raw HTML unless you trust the content).
To manage end-user accounts (the same rows your site login uses) from the admin SPA, enable userManagement on adminPanelPlugin. The model option must match your ORM user model (e.g. User with quickAuth({ userModel: 'User', ... })). The UI lives under /_admin/users, /_admin/users/new, etc., and talks to /_admin/api/users*.
auth on the plugin should be the same AuthManager you pass to createApp({ auth }) if you want Active Sessions and revoke-token APIs (requires rememberTokens / remember-me). Omit auth if you only need CRUD on users via the repository — session admin endpoints then stay empty or return a “not enabled” message.admin_users, webspresso admin:setup) are still separate from site users; staff sign in at /_admin, visitors use your normal site routes.const authManager = quickAuth({ db, userModel: 'User', identifierField: 'email', passwordField: 'password' });
const { app } = createApp({
pagesDir: './pages',
db,
auth: authManager,
plugins: [
adminPanelPlugin({
db,
auth: authManager,
userManagement: { enabled: true, model: 'User' },
}),
],
});
Screenshots are generated locally with npm run docs:admin-screenshots (Playwright). If images are missing in your checkout, run that script or refer to the admin-panel example.
Click any image to zoom. Captures use the docs fixture app with adminPanelPlugin, dataExchangePlugin, auditLogPlugin, siteAnalyticsPlugin, and ormCacheAdminPlugin. Regenerate with node scripts/capture-admin-doc-screenshots.js.
Core admin panel
userManagement) — manage end-user accounts from the same admin SPA.Admin plugins (register via adminApi.registerModule)
auditLogPlugin — CRUD audit trail for admin API actions.
siteAnalyticsPlugin — self-hosted page views, referrers, bots, client errors.
ormCacheAdminPlugin — cache hit metrics, purge, and per-model invalidate (createDatabase({ cache: true })).| File path | Route |
|---|---|
pages/index.njk | GET / |
pages/about/index.njk | GET /about |
pages/tools/[slug].njk | GET /tools/:slug |
pages/docs/[...rest].njk | GET /docs/* |
| File path | Route |
|---|---|
pages/api/health.get.js | GET /api/health |
pages/api/echo.post.js | POST /api/echo |
pages/api/users/[id].get.js | GET /api/users/:id |
pages/.../*.js next to .njk)module.exports = {
middleware: ['auth'],
async load(req, ctx) {
const posts = await ctx.db.getRepository('Post').query().limit(10).list();
return { posts };
},
meta(req, ctx) {
return { title: 'Blog', description: 'Latest posts' };
},
hooks: { beforeLoad: async () => {}, afterRender: async () => {} },
};
module.exports = async (req, res) => { }handler, optional middleware (names from createApp({ middlewares })), optional schemaPer request: req.db (if configured) → Zod schema → middleware → handler.
module.exports = {
middleware: ['requireAuth'],
schema: ({ z }) => ({ body: z.object({ q: z.string() }) }),
handler: async (req, res) => {
return res.json({ q: req.input.body.q, results: [] });
},
};
| Key | Validates |
|---|---|
body | POST / PUT / PATCH JSON body |
params | Path params (:id, …) |
query | Query string |
response | Documentation only (not enforced at runtime) |
Validated values are on req.input; failures return 400 JSON { error: 'Validation Error', issues }.
Plugins can register compat middleware ((req, res, next) or Hono handlers on app._hono), template helpers/filters, routes in onRoutesReady, and expose an api object for other plugins. Errors during registration generally log a warning instead of crashing the process.
| Built-in (require path) | Purpose |
|---|---|
studioPlugin / createApp({ studio }) | SSR developer Studio at /_webspresso — routes, plugins, health, ORM, env (see Studio) |
dashboardPlugin | Deprecated — wraps studioPlugin |
sitemapPlugin | /sitemap.xml, /robots.txt, optional DB-driven URLs |
analyticsPlugin | GA4, GTM, Yandex, Bing UET, Facebook Pixel, verification meta tags |
siteAnalyticsPlugin | Self-hosted page views + admin charts |
adminPanelPlugin | CRUD admin SPA (Mithril), auth hooks, custom modules |
dataExchangePlugin | Admin-only .xlsx export + CSV/XLSX import (/api/data-exchange/…); register after adminPanelPlugin |
ormCacheAdminPlugin | Admin UI for ORM cache stats / purge / per-model invalidate (requires adminPanelPlugin + createDatabase({ cache: … })) |
seoCheckerPlugin | Dev toolbar SEO audit (40+ checks) |
schemaExplorerPlugin | JSON schema of models + ORM components OpenAPI export |
swaggerPlugin | OpenAPI 3 for pages/api + Zod; Swagger UI (dev by default) |
healthCheckPlugin | GET /health probe (optional DB checks, custom path) |
redirectPlugin | Configurable 301/302/303/307/308 redirects in register() — runs before file-based SSR routes; see Redirect plugin |
uploadPlugin | POST multipart uploads (parseBody / built-in multipart); default createLocalFileProvider; optional mimeAllowlist / maxBytes; pairs with admin settings.uploadUrl |
rateLimitPlugin | In-memory rate limiting on selected routes (optional hono-rate-limiter peer for advanced setups) |
emailPlugin | MJML + Nodemailer email sending, template registry, optional DB logs + admin page; optional auth password-reset / email-verify bridge — see Email plugin |
restResourcePlugin | Opt-in REST CRUD per model (rest.enabled or plugin models whitelist); ?include= uses ORM eager load (single-level relations only; no nested a.b) |
redirectPlugin registers middleware in register(), which runs after static assets but before mountPages — so configured paths take precedence over SSR page files. Rules are evaluated in order; the first match wins.
rules: from (string path or RegExp on req.path), to (path or URL), optional status, optional methods ('*' or list; plugin defaults to GET + HEAD only).preserveQuery (default true): append the request query when to has no ?.allowExternal (default false): allow http(s): and protocol-relative // targets.trailingSlash: 'strip' | 'add' | false — normalize path before matching; string rules also allow a loose /a vs /a/ match when this is false.const { redirectPlugin } = require('webspresso/plugins');
createApp({
pagesDir: './pages',
plugins: [
redirectPlugin({
rules: [
{ from: '/old-blog', to: '/blog', status: 301 },
{ from: /^\/wiki\/(.*)$/, to: '/docs' },
],
}),
],
});
README: Redirect plugin.
Use uploadPlugin({ path, local: { destDir, publicBasePath }, maxBytes, mimeAllowlist, extensionAllowlist, middleware, provider }) from the package root or webspresso/plugins. The handler accepts multipart field file (configurable) and returns JSON { url, publicUrl, key? }. In production, set an explicit MIME allowlist — trusting all types is risky. ORM: zdb.file() stores the URL/path as a string column. Register uploadPlugin before adminPanelPlugin so the admin SPA gets uploadUrl, or pass adminPanelPlugin({ uploadUrl: '/api/upload' }).
Mounts GET list, GET /:id, POST, PATCH /:id, DELETE /:id under a configurable base path (default /api/rest). Requires createApp({ db }). Model metadata: defineModel({ ..., rest: { enabled: true, path: 'segment', allowInclude: ['company'] } }). List params: page, perPage, sort, order, include, trashed (soft-delete), plus equality filters on known columns.
const { createApp, restResourcePlugin } = require('webspresso');
const { app } = createApp({
pagesDir: './pages',
db,
plugins: [
restResourcePlugin({
path: '/api/rest',
middleware: [], // optional — e.g. auth (before attachDbMiddleware)
models: null, // optional whitelist of model names
excludeModels: [],
}),
],
});
Also exported from webspresso/plugins. See README section REST resources plugin for full options.
Optional dataExchangePlugin adds admin-authenticated spreadsheet endpoints (same session as adminPanelPlugin). Only models with admin.enabled participate; hidden columns are omitted from export and ignored on import. Uses exceljs and csv-parse from the framework package.
GET / POST ${adminPath}/api/data-exchange/export/:model — body/query same semantics as built-in export (ids, selectAll, filters); response is an .xlsx file.POST ${adminPath}/api/data-exchange/import/:model — multipart field file; mode=insert|upsert, upsertKey (e.g. id or a unique column). Returns JSON summary with per-row errors.db, adminPath (default /_admin), maxRows, maxFileBytes.const { adminPanelPlugin, dataExchangePlugin } = require('webspresso/plugins');
createApp({
pagesDir: './pages',
db,
plugins: [
adminPanelPlugin({ db, path: '/_admin' }),
dataExchangePlugin({ db, adminPath: '/_admin' }),
],
});
Full detail: README — dataExchangePlugin. In the model list UI, spreadsheet import/export appears in the bulk toolbar (see Admin UI screenshots — bulk actions).
auditLogPlugin records create/update/delete actions performed through the admin model API. It registers middleware on the app and adds an Audit log page under /_admin/audit-log. Prune old rows with webspresso audit:prune --days 90.
const { adminPanelPlugin, auditLogPlugin } = require('webspresso/plugins');
createApp({
pagesDir: './pages',
db,
plugins: [
adminPanelPlugin({ db }),
auditLogPlugin({ db, adminPath: '/_admin' }),
],
});
siteAnalyticsPlugin tracks page views in your database (no third-party analytics required) and adds an Analytics admin page at /_admin/analytics with charts for views over time, top pages, referrers, countries, bots, and optional client-side JS errors.
emailPlugin sends email via Nodemailer with MJML templates. Templates can come from a directory (templatesDir), a config map, runtime registerTemplate, or inline on send({ mjml, data }). Variables use {{name}} / {{user.email}} interpolation.
templatesDir scans *.mjml on Node.js only (skipped on edge/workers). On edge, use inline { mjml: "..." }, registerTemplate, or bundled auth templates.transport, smtp, or env: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_SECURE, MAIL_FROM.email_logs table when db is passed; prune with webspresso email:prune --days 90./_admin/email: test send, template preview, SMTP verify, log list (requires adminPanelPlugin).authEmails: { enabled: true } wires password-reset and email-verification MJML mails; see Password reset & email verification.webspresso build embeds emails/*.mjml (+ bundled auth templates) into manifest.emailTemplates and email-templates.mjs (Cloudflare). Pass to the plugin: emailPlugin({ manifest }) or emailPlugin({ emailTemplates: manifest.emailTemplates }).const { createApp, adminPanelPlugin, emailPlugin } = require('webspresso');
createApp({
pagesDir: './pages',
db,
plugins: [
emailPlugin({
db,
templatesDir: './emails',
smtp: { host: process.env.SMTP_HOST, port: 587, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } },
defaults: { from: process.env.MAIL_FROM },
}),
adminPanelPlugin({ db }),
],
});
// Other plugins / route handlers:
// pluginManager.getPluginAPI('email').sendTemplate('welcome', { to, subject, data });
Migration helper: ctx.usePlugin('email').getMigrationTemplate() or README Email plugin.
The ORM layers Knex with Zod: column metadata lives on Zod schemas via zdb helpers (zdb.id(), zdb.uuid(), zdb.nanoid(), zdb.string(), foreign keys, relations, soft deletes, …). Repositories provide findById, query() with pagination, transactions, and migrations via the same Knex instance. Optional query caching is enabled with createDatabase({ cache: true }) or a cache: { defaultStrategy, memory, provider } object; see below.
zdb)Helpers wrap Zod with database column metadata (same surface as README — Schema helpers).
| Helper | Description | Options |
|---|---|---|
zdb.id() | Primary key (bigint, auto-increment) | — |
zdb.uuid() | UUID primary key | — |
zdb.nanoid(opts) | Nanoid primary key (URL-safe string, VARCHAR) | maxLength (default 21) |
zdb.string(opts) | VARCHAR column | maxLength, unique, index, nullable |
zdb.text(opts) | TEXT column | nullable |
zdb.integer(opts) | INTEGER column | nullable, default |
zdb.bigint(opts) | BIGINT column | nullable |
zdb.float(opts) | FLOAT column | nullable |
zdb.decimal(opts) | DECIMAL column | precision, scale, nullable |
zdb.boolean(opts) | BOOLEAN column | default, nullable |
zdb.date(opts) | DATE column | nullable |
zdb.datetime(opts) | DATETIME column | nullable |
zdb.timestamp(opts) | TIMESTAMP column | auto: 'create'|'update', nullable |
zdb.json(opts) | JSON column | nullable |
zdb.array(itemSchema, opts) | ARRAY column (stored as JSON) | nullable |
zdb.enum(values, opts) | ENUM column | default, nullable |
zdb.foreignKey(table, opts) | Foreign key (bigint) | referenceColumn, nullable |
zdb.foreignUuid(table, opts) | Foreign key (uuid) | referenceColumn, nullable |
zdb.foreignNanoid(table, opts) | Foreign key (nanoid string) | referenceColumn, nullable, maxLength (match referenced PK) |
Nanoid columns: migration scaffolding uses table.string(column, maxLength). For a nanoid primary key, omitting the PK on repository.create() fills it with a cryptographically random ID (same default alphabet as the nanoid package; built into Webspresso, no extra npm dependency). Use generateNanoid from webspresso when you need the same generator manually. For API schema validation (params, query, body), use z.nanoid(), z.nanoid(n), or z.nanoid({ maxLength }) on the z from your route schema (or zodNanoid / extendZ outside compiled routes).
const { zdb, defineModel, createDatabase } = require('webspresso');
const User = defineModel({
name: 'User',
table: 'users',
schema: zdb.schema({
id: zdb.id(),
email: zdb.string({ unique: true }),
created_at: zdb.timestamp({ auto: 'create' }),
}),
});
const db = createDatabase({
client: 'pg',
connection: process.env.DATABASE_URL,
models: './models',
});
Pass cache: true or cache: { enabled: true, defaultStrategy: 'auto' | 'smart', memory?: { maxEntries, defaultTtlMs }, provider? } to createDatabase. Then db.cache exposes purge, invalidateTags, invalidateModel(name), and metrics helpers; it is null when caching is off.
Per model, defineModel({ cache: true | 'auto' | 'smart' | { strategy } | false }) opts in or out and picks invalidation coarseness: auto clears all cached reads for that model on any mutation; smart uses finer tags (row PK + collection) where safe. Cached read paths include findById, findOne, findAll, and query builder first / list / count / paginate when the query is classifiable; Knex transaction clients always bypass the cache.
For an admin panel page (metrics, purge, invalidate), add ormCacheAdminPlugin({ db }) next to adminPanelPlugin. Full detail: README — ORM query cache.
const { createApp, createDatabase, defineModel, ormCacheAdminPlugin, adminPanelPlugin } = require('webspresso');
const db = createDatabase({
client: 'pg',
connection: process.env.DATABASE_URL,
models: './models',
cache: true,
});
// defineModel({ ..., cache: 'smart' }) // optional per-model override
createApp({
pagesDir: './pages',
plugins: [
adminPanelPlugin({ db }),
ormCacheAdminPlugin({ db }),
],
});
npm install pg mysql2 better-sqlite3 # pick one
webspresso db:migrate
webspresso db:make add_posts_table --model Post
webspresso seed
Migrations live under migrations/ as usual for Knex; webspresso.db.js (or knexfile.js) is loaded for all DB CLI commands.
Locales merge: global JSON in pages/locales/, then route-specific folders override keys for that subtree.
{
"nav": { "home": "Home", "about": "About" }
}
<h1>{{ t('nav.home') }}</h1>
{{ fsy.canonical() | safe }}
{{ fsy.url('/blog', { page: 2 }) }}
fsy groups include URL builders, request accessors, slugify/truncate/pretty bytes, dayjs-powered dates, dev flag, JSON-LD helper, and asset tags when assets is configured — see README for the exhaustive list.
Global hook module:
// pages/_hooks.js
module.exports = {
onRequest(ctx) {},
beforeLoad(ctx) {},
afterLoad(ctx) {},
beforeRender(ctx) {},
afterRender(ctx) {},
onError(ctx, err) {},
};
Execution order (SSR)
onRequest → route onRequestbeforeMiddleware → middleware chain → afterMiddlewarebeforeLoad → load → afterLoadbeforeRender → Nunjucks render → afterRenderWebspresso bundles the pieces you usually wire by hand: a CLI for scaffolding and DB tasks, a dev runner that restarts the process when routes or templates change, and optional first-party UI for routes and SEO. Below is how those tools fit together.
The webspresso binary (Commander) exposes subcommands for projects, APIs, Tailwind, Knex migrations, seeds, admin maintenance, audit pruning, environment checks (doctor), Cursor Agent Skills (skill, including a bundled Webspresso preset), and favicon/PWA asset generation. Interactive flows use Inquirer when you run commands without full arguments (e.g. webspresso new with no name).
webspresso dev runs your project’s server.js under Node’s --watch and adds --watch-path for pages/, models/, and views/ when those directories exist, so route files and Nunjucks layouts reload without manual restarts. With Tailwind enabled, the same command can spawn watch:css; use --no-css to skip that subprocess.
Scaffolded apps use Tailwind CSS with PostCSS and Autoprefixer (webspresso add tailwind or the default new template). Compiled CSS is written under public/css/ and linked from the layout. For cache-busted assets in SSR, createApp({ assets }) supports a static version string or a Vite/Webpack-style manifest so fsy.asset() resolves hashed filenames.
createApp({ studio: true }) mounts Webspresso Studio at /_webspresso (on by default in development). See Studio and docs/studio.md. The SEO checker plugin adds a separate dev toolbar. Production requires explicit config and authentication.
webspresso favicon:generate uses sharp to resize a master PNG into Apple/Android/favicon sizes, writes a manifest.json, and can inject a Nunjucks partial into your layout — useful for consistent branding across devices.
Studio is the developer visibility panel at /_webspresso (SSR, no extra frontend framework). It is separate from the production admin panel at /_admin.
createApp({
pagesDir: './pages',
viewsDir: './views',
studio: {
enabled: true,
path: '/_webspresso',
auth: 'dev-only',
exposeEnv: false,
requestTimeline: { enabled: true, maxEntries: 100 },
},
});
Pages: overview, routes, plugins, ORM, cache, health, OpenAPI, sitemap, env, logs. Security: docs/studio-security.md.
Incoming HTTP traffic passes through the Hono compat stack (secure headers, sessions, body parsers, timeout, static files) and middleware registered by plugins, then the file router decides whether the request targets an API module under pages/api or an SSR page. API handlers may run Zod first; SSR routes run load() then Nunjucks with globals (fsy, t, …).
Client
│
▼
┌─────────────────────────────────────────┐
│ Hono compat stack: session, secure hdrs │
│ body parsers, timeout, static files │
│ Plugin register(ctx) — extend app │
└─────────────────┬───────────────────────┘
▼
mountPages (file-router)
│
┌───────────┴───────────┐
▼ ▼
pages/api/*.js pages/*.njk + route *.js
Zod → handler load() → Nunjucks → HTML
│ │
└───────────┬───────────┘
▼
HTTP response
Two layers matter: your application (a small server.js that calls createApp, plus pages/, views/, optional models/) and the webspresso package, which implements routing, rendering, plugins, and ORM primitives. The public API surface is re-exported from index.js with matching index.d.ts for TypeScript tooling.
index.js — public exports| Export area | Examples |
|---|---|
| App | createApp |
| Router utilities | mountPages, filePathToRoute, scanDirectory, i18n helpers |
| Templates & assets | createHelpers, AssetManager, configureAssets |
| Plugins | PluginManager, createPluginManager |
| ORM | defineModel, createDatabase, zdb, generateNanoid, zodNanoid, … |
| Convenience plugins | schemaExplorerPlugin, swaggerPlugin, healthCheckPlugin, uploadPlugin, restResourcePlugin, adminPanelPlugin, dataExchangePlugin, siteAnalyticsPlugin, auditLogPlugin, recaptchaPlugin |
| TypeScript | index.d.ts (package.json types) |
src/server.js — createApp pipelinesrc/http/compat-app.js) and apply secure headers via the helmet option (CSP enabled in production; relaxed in dev for hot editing).hono-sessions when auth/admin needs them), JSON/form parsers, request timeout when configured, and static file serving.register(ctx) (templates, middleware, helpers).configureAssets / createHelpers for fsy.mountPages from file-router.js to register SSR and API routes from disk.onRoutesReady on plugins so they can add routes or read the route table.onReady when the server starts listening.| Path | Responsibility |
|---|---|
src/server.js | createApp, Hono compat wiring, error pages, plugin orchestration |
src/http/ | Compat app, request/response context, sessions, multipart, secure-headers, Node listen |
src/file-router.js | Scan pages/, map files to routes, wire API handlers and SSR |
src/helpers.js | Nunjucks globals (fsy), asset URL resolution |
src/plugin-manager.js | Plugin registry, dependency metadata, lifecycle hooks |
core/orm/ | Models, repositories, Knex query builder, migrations, seeding |
core/auth/ | Session-aware helpers for protected routes / admin |
core/build/ | Production compiler — manifest, adapters (node, cloudflare), createWorkerApp |
src/router-edge.js | Edge-safe i18n / middleware helpers (no fs) |
core/compileSchema.js / applySchema.js | Zod schema compilation for validation layers |
plugins/ | Built-in dashboard, sitemap, analytics, admin, SEO checker, … |
utils/ | Shared helpers (e.g. schema cache) |
bin/ | CLI commands (Commander) |
package.json files)index.js # re-exports createApp, router utils, ORM, plugins index.d.ts # TypeScript declarations (public API) bin/ # webspresso CLI adapters/ # node, cloudflare, bun deploy adapters core/ # ORM, auth, build compiler, Zod compile/apply plugins/ # first-party plugins src/ # server.js, file-router, http/ (Hono compat), helpers utils/ # shared utilities
package.json exports)| Import | Use |
|---|---|
webspresso | createApp, router, ORM re-exports, plugins index |
webspresso/build | runBuild, build types |
webspresso/build/runtime/create-app-from-manifest | Cloudflare Worker → createWorkerApp |
webspresso/build/runtime/create-app-from-manifest-node | Node production manifest → full server.js stack |
webspresso/core/auth | Session auth (createAuth, guards) |
webspresso/core/orm | Knex ORM (defineModel, D1 client) |
webspresso/plugins/* | Individual built-in plugins |
Generated Cloudflare entries must import the worker manifest path so Wrangler does not bundle bcrypt, admin plugins, or filesystem route scanning.
Development uses webspresso dev and your project server.js (live file-router scan). Production can use the same stack on Node, or a compiled Cloudflare Worker that serves routes from a build manifest. The compiler lives in core/build/ (webspresso build CLI).
| Adapter | Output | Deploy with |
|---|---|---|
node | .webspresso/server/ | node .webspresso/server/index.mjs, Docker, PM2 |
cloudflare | .webspresso/worker/ + templates.mjs | Wrangler (wrangler dev / deploy) |
bun | .webspresso/server/ | Bun runtime (experimental) |
# 1) Scaffold provider files (once per project)
webspresso add deploy --provider cloudflare
# 2) Configure webspresso.build.js (adapter, pagesDir, viewsDir, publicDir)
# 3) Build production artifacts
npm run build:css # if using Tailwind
webspresso build --adapter cloudflare
# 4) Run locally or deploy
npx wrangler dev
npx wrangler deploy
.webspresso/
worker/ # cloudflare adapter
manifest.json # routes, i18n, template metadata, buildId
handlers.mjs # compiled API + SSR config exports
templates.mjs # precompiled Nunjucks (extends/includes from views/)
index.mjs # Worker entry (import createAppFromManifest)
assets/public/ # copy of public/ for Wrangler [assets]
server/ # node adapter (same except no templates.mjs)
meta/
build-graph.json
diagnostics.json
pages/ for SSR and API routes.extends / include, edge-incompatible require() on Cloudflare.extends / include from views/; precompile to templates.mjs (Cloudflare); collect i18n JSON.templates.mjs for Wrangler.Node production entry uses createAppFromManifest from webspresso/build/runtime/create-app-from-manifest-node (full server.js). Cloudflare uses a separate worker-only module so Wrangler never bundles auth, bcrypt, or filesystem route scanning. Details: Cloudflare Workers.
Run SSR and API routes on Cloudflare’s edge using a manifest-driven worker. The worker runtime (createWorkerApp) mounts routes from manifest.json, renders templates from precompiled Nunjucks (no runtime eval), and receives Wrangler bindings (env.DB, env.ASSETS) on each fetch.
npm i -D wranglercompatibility_flags = ["nodejs_compat"] in wrangler.toml (included in the scaffold template)views/ when pages use {% extends "layout.njk" %} — the build precompiles parent templates toowebspresso.build.js/** @type {import('webspresso/build').BuildConfig} */
module.exports = {
adapter: 'cloudflare',
pagesDir: 'pages',
viewsDir: 'views',
publicDir: 'public',
};
wrangler.toml (scaffold)name = "webspresso-app"
main = ".webspresso/worker/index.mjs"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".webspresso/worker/assets/public"
binding = "ASSETS"
[[d1_databases]]
binding = "DB"
database_name = "webspresso"
migrations_dir = "migrations"
[vars]
NODE_ENV = "production"
main points at the generated worker entry. Static files are served via the Assets binding (copied at build time). Remove the [[d1_databases]] block if you do not use D1.
After webspresso build --adapter cloudflare, .webspresso/worker/index.mjs imports the worker-only manifest helper and precompiled templates:
import { createAppFromManifest } from 'webspresso/build/runtime/create-app-from-manifest';
import manifest from './manifest.json' assert { type: 'json' };
import { handlers } from './handlers.mjs';
import precompiledTemplates from './templates.mjs';
let cached = null;
function getApp(env) {
if (!cached) {
cached = createAppFromManifest({
manifest,
handlers,
pagesDir: 'pages',
bindings: env,
precompiledTemplates,
clientRuntime: { alpine: false, swup: false },
logging: false,
});
}
return cached.app;
}
export default {
fetch(request, env, ctx) {
return getApp(env).fetch(request, env, ctx);
},
};
| Capability | Node createApp | Worker createWorkerApp |
|---|---|---|
| Route source | Filesystem scan (file-router) or manifest | Manifest only |
| Nunjucks | Live templates + watch | Precompiled templates.mjs |
| Auth / bcrypt | core/auth | Not included |
| Plugins | All built-ins | Edge-compatible only; build rejects admin/upload/data-exchange |
| Client runtime (Alpine/swup) | Optional | Off in generated entry |
| Static files | publicDir | Wrangler Assets |
| Database | Knex drivers | D1 via webspresso.db.js (optional); env.DB → req.db / getDb() in API handlers |
webspresso add deploy --provider cloudflare can create webspresso.db.js with better-sqlite3 for local dev and d1 / d1-remote for production migrations. Use WEBSPRESSO_D1_REMOTE=1 with CF_ACCOUNT_ID, CF_D1_DATABASE_ID, CF_API_TOKEN to migrate the remote database from CI.
The generated worker entry imports knex and knex-cloudflare-d1, passes dbRuntime into createAppFromManifest, and resolves bindings.DB via resolveWorkerDb — same req.db.getRepository('Model') pattern as Node.
// pages/api/notes.get.js
module.exports = async function handler(req, res) {
if (!req.db) return res.status(503).json({ error: 'Database not configured' });
const notes = await req.db.getRepository('Note').query().orderBy('created_at', 'desc').list();
res.json(notes);
};
| Code | Meaning |
|---|---|
WS_BUILD_PLUGIN_UNSUPPORTED | Plugin not marked edge-compatible (e.g. admin panel) |
WS_BUILD_EDGE_INCOMPATIBLE | Route file imports fs, bcrypt, etc. |
WS_BUILD_SESSION_MEMORY | In-memory session store not allowed on Workers |
| Symptom | What to check |
|---|---|
Many esbuild errors (crypto, fs, knex dialects) | Use --adapter cloudflare; framework skips its esbuild — Wrangler bundles the worker entry |
Wrangler: bcrypt / node-pre-gyp / aws-sdk | Entry must use worker create-app-from-manifest, not Node server path; rebuild after upgrading webspresso |
EvalError: Code generation from strings disallowed | Missing or stale templates.mjs — run webspresso build --adapter cloudflare |
| Template / layout not found | Put layouts in views/; rebuild so extends is precompiled |
| No CSS on worker | Run npm run build:css before webspresso build |
npm run build:css
webspresso build --adapter cloudflare
npx wrangler dev
npx wrangler deploy
The framework repository runs Vitest for fast unit and integration tests (CLI, ORM, routing, HTTP compat layer, plugins, etc.) and Playwright for end-to-end checks in a real browser: admin panel APIs and UI, auth flows, audit log, CLI project scaffold, the SEO checker plugin, and data exchange admin API routes (export/import). Integration tests call app.fetch via helpers in tests/helpers/http.js (no supertest).
npm test # vitest run
npm run test:watch
npm run test:coverage
In this repository, npm run check:types runs tsc --noEmit on tests/ts-smoke/ so index.d.ts stays aligned with exports. Consumers only need the published index.d.ts from npm.
Specs live under tests/e2e/; the runner starts a temporary app and exercises HTTP + DOM (Chromium by default). Use UI or headed mode when debugging flaky selectors.
npm run test:e2e # playwright test
npm run test:e2e:ui # Playwright UI
npm run test:e2e:debug
npm run test:e2e:headed
CI tip: run npm test and npm run test:e2e before release. For a quick local health pass on an app directory, use webspresso doctor (add --db to verify database connectivity).