webspresso 0.0.91 → 0.0.94
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/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
package/README.md
CHANGED
|
@@ -2,2915 +2,154 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/webspresso)
|
|
4
4
|
[](https://npmx.dev/package/webspresso)
|
|
5
|
+
[](LICENSE)
|
|
5
6
|
|
|
6
|
-
A
|
|
7
|
+
A lightweight, zero-dependency-sprawl Express SSR framework featuring file-based routing, server-side Nunjucks rendering, an intuitive Knex-based ORM with ambient `AsyncLocalStorage` transactions, dual authentication (Session + JWT), zero-dependency streaming compression, graceful shutdown, and a customizable Mithril.js SPA Admin Panel.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
- **File-Based Routing**: Create pages by adding `.njk` files to a `pages/` directory
|
|
11
|
-
- **Dynamic Routes**: Use `[param]` for dynamic params and `[...rest]` for catch-all routes
|
|
12
|
-
- **API Endpoints**: Add `.js` files to `pages/api/` with method suffixes (e.g., `health.get.js`)
|
|
13
|
-
- **Schema Validation**: Zod-based request validation for body, params, and query
|
|
14
|
-
- **Built-in i18n**: JSON-based translations with automatic locale detection
|
|
15
|
-
- **Lifecycle Hooks**: Global and route-level hooks for request processing
|
|
16
|
-
- **Template Helpers**: Laravel-inspired helper functions available in templates
|
|
17
|
-
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
18
|
-
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models, optional admin UI for ORM query cache metrics and purge, optional **admin-only spreadsheet exchange** (Excel export, CSV/XLSX import via `dataExchangePlugin`)
|
|
19
|
-
- **Session authentication** (optional): `createAuth` / `quickAuth` in **`webspresso/core/auth`** — pass the manager to **`createApp({ auth })`** for `express-session`, `req.user` / `req.auth`, remember-me tokens, and policy-style authorization. Full walkthrough: **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
20
|
-
- **Optional client runtime** (Alpine.js + [swup](https://swup.js.org/)): **`createApp({ clientRuntime: { alpine, swup } })`** serves scripts under **`/__webspresso/client-runtime/`** and exposes **`clientRuntime`** in Nunjucks; layouts can include **`views/partials/webspresso-client-runtime.njk`**. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`**. Demo: **`examples/alpine-swup-demo/`**. Details: **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
|
|
21
|
-
- **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
|
|
22
|
-
- **Application kernel (optional)**: In-process **`kernel`** API (`require('webspresso').kernel`) — event bus (`dispatch` / `publish`), **`kernel.createApp()`** (namespaced differently from SSR **`createApp`**), **`definePlugin`** / **`defineFlow`**, minimal **`{{ }}` view resolver**, and simulated **`BaseRepository`** with `orm.<resource>.*` events. Ships as **`core/kernel/`** on npm. Demo: **`node core/kernel/run-demo.js`**. Docs: **[`doc/index.html#application-kernel`](doc/index.html#application-kernel)**.
|
|
23
|
-
|
|
24
|
-
## Installation
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
npm install -g webspresso
|
|
28
|
-
# or
|
|
29
|
-
npm install webspresso
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
## TypeScript
|
|
33
|
-
|
|
34
|
-
The npm package ships with **[`index.d.ts`](index.d.ts)** so consumers get typings for the public API (`createApp`, `defineModel`, `createDatabase`, `zdb`, plugins, etc.). No extra `@types/webspresso` package is required.
|
|
35
|
-
|
|
36
|
-
```typescript
|
|
37
|
-
import { createApp, defineModel, zdb } from 'webspresso';
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Install **`@types/express`** in your app if you want full **`Express.Application`** / **`Request`** / **`Response`** inference when you touch `createApp().app` or write middleware. **`knex`** and **`zod`** bring their own types.
|
|
41
|
-
|
|
42
|
-
Framework development (this repo): run **`npm run check:types`** to typecheck the declarations against a small smoke file (`tests/ts-smoke/`).
|
|
43
|
-
|
|
44
|
-
## Application kernel (`kernel`)
|
|
45
|
-
|
|
46
|
-
Do **not** confuse **`kernel.createApp()`** with the package root **`createApp`** used for SSR—it returns a different object (event bus, optional flows, and a minimal view resolver). It does **not** modify Knex ORM behavior or Express routing.
|
|
47
|
-
|
|
48
|
-
```javascript
|
|
49
|
-
const { kernel } = require('webspresso');
|
|
50
|
-
const app = kernel.createApp();
|
|
51
|
-
app.events.on('orm.post.afterCreate', async (ctx) => { /* ... */ });
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
See **[`doc/index.html#application-kernel`](doc/index.html#application-kernel)** · source modules: [`core/kernel/`](core/kernel/). Cursor skill: **`REFERENCE-kernel.md`** (installed via `webspresso skill --preset webspresso`).
|
|
9
|
+
---
|
|
55
10
|
|
|
56
|
-
## Quick Start
|
|
11
|
+
## ⚡ Quick Start (3 Minutes)
|
|
57
12
|
|
|
58
|
-
|
|
13
|
+
Scaffold a new project with interactive database and styling setup:
|
|
59
14
|
|
|
60
15
|
```bash
|
|
61
|
-
# Create a new project
|
|
62
|
-
webspresso new my-app
|
|
16
|
+
# Create a new project interactively
|
|
17
|
+
npx webspresso new my-app --install
|
|
63
18
|
|
|
64
|
-
# Navigate
|
|
19
|
+
# Navigate and start development server
|
|
65
20
|
cd my-app
|
|
66
|
-
|
|
67
|
-
# Install dependencies
|
|
68
|
-
npm install
|
|
69
|
-
|
|
70
|
-
# Build Tailwind CSS
|
|
71
|
-
npm run build:css
|
|
72
|
-
|
|
73
|
-
# Start development server (watches both CSS and server)
|
|
74
|
-
webspresso dev
|
|
75
|
-
# or
|
|
76
21
|
npm run dev
|
|
77
22
|
```
|
|
78
23
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
## CLI Commands
|
|
82
|
-
|
|
83
|
-
### `webspresso new [project-name]`
|
|
84
|
-
|
|
85
|
-
Create a new Webspresso project with Tailwind CSS (default).
|
|
86
|
-
|
|
87
|
-
```bash
|
|
88
|
-
# Create in a new directory
|
|
89
|
-
webspresso new my-app
|
|
90
|
-
|
|
91
|
-
# Create in current directory (interactive)
|
|
92
|
-
webspresso new
|
|
93
|
-
# → Prompts: "Install in current directory?"
|
|
94
|
-
# → If yes, asks for project name (for package.json)
|
|
95
|
-
|
|
96
|
-
# Auto install dependencies and build CSS
|
|
97
|
-
webspresso new my-app --install
|
|
98
|
-
|
|
99
|
-
# Without Tailwind
|
|
100
|
-
webspresso new my-app --no-tailwind
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
**Interactive Mode (no arguments):**
|
|
104
|
-
- Asks if you want to install in the current directory
|
|
105
|
-
- If current directory is not empty, shows a warning
|
|
106
|
-
- Prompts for project name (defaults to current folder name)
|
|
107
|
-
- Asks if you will use a database (SQLite, PostgreSQL, or MySQL)
|
|
108
|
-
- If yes, adds the appropriate driver to `package.json` and creates `webspresso.db.js` config
|
|
109
|
-
- After project creation, asks if you want to install dependencies
|
|
110
|
-
- If yes, runs `npm install` and `npm run build:css`
|
|
111
|
-
- Then asks if you want to start the development server
|
|
112
|
-
- If yes, starts `npm run dev` automatically
|
|
113
|
-
|
|
114
|
-
**Auto Installation:**
|
|
115
|
-
```bash
|
|
116
|
-
# With --install flag (semi-interactive)
|
|
117
|
-
webspresso new my-app --install
|
|
118
|
-
# → Automatically runs: npm install && npm run build:css
|
|
119
|
-
# → Then prompts: "Start development server?" [Y/n]
|
|
120
|
-
# → If yes: starts npm run dev (with watch:css if Tailwind enabled)
|
|
121
|
-
|
|
122
|
-
# Without --install flag (fully interactive)
|
|
123
|
-
webspresso new my-app
|
|
124
|
-
# → Prompts: "Install dependencies and build CSS now?" [Y/n]
|
|
125
|
-
# → If yes: runs npm install && npm run build:css
|
|
126
|
-
# → Then: "Start development server?" [Y/n]
|
|
127
|
-
# → If yes: starts npm run dev (with watch:css if Tailwind enabled)
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
**Note:** When dev server starts with Tailwind CSS, it automatically runs `watch:css` in the background to watch for CSS changes.
|
|
131
|
-
|
|
132
|
-
**Database Selection:**
|
|
133
|
-
During project creation, you'll be asked if you want to use a database:
|
|
134
|
-
- **SQLite** (better-sqlite3) - Recommended for development and small projects
|
|
135
|
-
- **PostgreSQL** (pg) - For production applications
|
|
136
|
-
- **MySQL** (mysql2) - Alternative SQL database
|
|
137
|
-
|
|
138
|
-
If you select a database:
|
|
139
|
-
- The appropriate driver is added to `package.json` dependencies
|
|
140
|
-
- `webspresso.db.js` config file is created with proper settings
|
|
141
|
-
- `migrations/` directory is created
|
|
142
|
-
- `models/` directory is created
|
|
143
|
-
- `DATABASE_URL` is added to `.env.example` with a template
|
|
144
|
-
|
|
145
|
-
**Scaffold: `config/` and environment files**
|
|
146
|
-
|
|
147
|
-
New projects include:
|
|
148
|
-
|
|
149
|
-
- **`config/load-env.js`** — loads, in order, `.env`, `.env.local`, `.env.${NODE_ENV}`, and `.env.${NODE_ENV}.local` (each file overrides keys from earlier ones).
|
|
150
|
-
- **`config/env.schema.js`** — validates `process.env` with **Zod** before the app starts (`PORT`, `NODE_ENV`, i18n vars, `BASE_URL`, optional `DATABASE_URL`).
|
|
151
|
-
- **`config/app.js`** — returns `createApp()` options (paths; if `webspresso.db.js` exists, also **`createDatabase`** as `db`).
|
|
152
|
-
- **`server.js`** — calls `loadEnv()`, then `createApp(getCreateAppOptions())`, then `listen` using the parsed `PORT`.
|
|
153
|
-
|
|
154
|
-
Copy **`.env.example`** to **`.env`** (and use `.env.local` for machine-specific overrides). Patterns such as `.env.development.local` are gitignored via `.env.*.local`.
|
|
155
|
-
|
|
156
|
-
**Seed Data Generation:**
|
|
157
|
-
After selecting a database, you'll be asked if you want to generate seed data:
|
|
158
|
-
- If yes, `@faker-js/faker` is added to dependencies
|
|
159
|
-
- `seeds/` directory is created with `seeds/index.js`
|
|
160
|
-
- `npm run seed` script is added to `package.json`
|
|
161
|
-
- The seed script automatically detects models in `models/` directory and generates fake data based on their schemas
|
|
162
|
-
|
|
163
|
-
To run seeds after creating models:
|
|
164
|
-
```bash
|
|
165
|
-
npm run seed
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
The seed script will:
|
|
169
|
-
- Load all models from `models/` directory
|
|
170
|
-
- Generate 10 fake records per model (by default)
|
|
171
|
-
- Use smart field detection based on column names (email, name, title, etc.)
|
|
172
|
-
|
|
173
|
-
You can always add database support later by:
|
|
174
|
-
1. Installing the driver: `npm install better-sqlite3` (or `pg`, `mysql2`)
|
|
175
|
-
2. Creating `webspresso.db.js` config file
|
|
176
|
-
3. Adding `DATABASE_URL` to your `.env` file
|
|
177
|
-
4. Creating `models/` directory and defining your models
|
|
178
|
-
5. Optionally adding seed support: `npm install @faker-js/faker` and creating `seeds/index.js`
|
|
179
|
-
|
|
180
|
-
Options:
|
|
181
|
-
- `-i, --install` - Auto run `npm install` and `npm run build:css` (non-interactive)
|
|
182
|
-
- `--no-tailwind` - Skip Tailwind CSS setup
|
|
183
|
-
|
|
184
|
-
The project includes:
|
|
185
|
-
- Tailwind CSS with build process
|
|
186
|
-
- Optimized layout template with navigation and footer
|
|
187
|
-
- Responsive starter page
|
|
188
|
-
- i18n setup (e.g. en/de)
|
|
189
|
-
- Development and production scripts
|
|
190
|
-
|
|
191
|
-
### `webspresso page`
|
|
192
|
-
|
|
193
|
-
Add a new page to your project (interactive prompt).
|
|
194
|
-
|
|
195
|
-
```bash
|
|
196
|
-
webspresso page
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
The CLI will ask you:
|
|
200
|
-
- Route path (e.g., `/about` or `/blog/post`)
|
|
201
|
-
- Whether to add a route config file
|
|
202
|
-
- Whether to add locale files
|
|
203
|
-
|
|
204
|
-
### `webspresso api`
|
|
205
|
-
|
|
206
|
-
Add a new API endpoint (interactive prompt).
|
|
207
|
-
|
|
208
|
-
```bash
|
|
209
|
-
webspresso api
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
The CLI will ask you:
|
|
213
|
-
- API route path (e.g., `/api/users` or `/api/users/[id]`)
|
|
214
|
-
- HTTP method (GET, POST, PUT, PATCH, DELETE)
|
|
215
|
-
|
|
216
|
-
### `webspresso dev`
|
|
217
|
-
|
|
218
|
-
Start development server with hot reload.
|
|
219
|
-
|
|
220
|
-
```bash
|
|
221
|
-
webspresso dev
|
|
222
|
-
# or with custom port
|
|
223
|
-
webspresso dev --port 3001
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
### `webspresso start`
|
|
227
|
-
|
|
228
|
-
Start production server.
|
|
229
|
-
|
|
230
|
-
```bash
|
|
231
|
-
webspresso start
|
|
232
|
-
# or with custom port
|
|
233
|
-
webspresso start --port 3000
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### `webspresso doctor`
|
|
237
|
-
|
|
238
|
-
Check Node.js version, `package.json` / `engines.node`, typical project files (`server.js`, `pages/`), and whether `webspresso.db.js` or `knexfile.js` exists. Use `--db` to run a quick connection test when a config is present. Warnings alone exit with code `0`; pass `--strict` to fail (exit `1`) on any warning—useful in CI.
|
|
239
|
-
|
|
240
|
-
```bash
|
|
241
|
-
webspresso doctor
|
|
242
|
-
webspresso doctor --db
|
|
243
|
-
webspresso doctor --strict
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
### `webspresso skill`
|
|
247
|
-
|
|
248
|
-
Scaffold an **Agent Skill**: creates `.agents/skills/<name>/SKILL.md` with valid YAML frontmatter (`name`, `description`) and a short markdown template for AI tooling. Use `--global` to write under `~/.agents/skills/` instead of the current project.
|
|
249
|
-
|
|
250
|
-
**Bundled preset:** `--preset webspresso` copies **`SKILL.md`** (short index), **`REFERENCE-framework.md`** (SSR `createApp`, routes, ORM, auth, plugins, CLI), and **`REFERENCE-kernel.md`** (`kernel` event bus / flows) into **`.agents/skills/webspresso-usage/`** — no prompts.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
```bash
|
|
254
|
-
webspresso skill my-workflow
|
|
255
|
-
webspresso skill review-pr --description "Runs PR review checklist. Use when reviewing pull requests."
|
|
256
|
-
webspresso skill deploy-check -g
|
|
257
|
-
|
|
258
|
-
# Install the bundled Webspresso agent reference (same content shipped in templates/skills/)
|
|
259
|
-
webspresso skill --preset webspresso
|
|
260
|
-
webspresso skill -p webspresso --global
|
|
261
|
-
```
|
|
262
|
-
|
|
263
|
-
### `webspresso add tailwind`
|
|
264
|
-
|
|
265
|
-
Add Tailwind CSS to your project with build process.
|
|
266
|
-
|
|
267
|
-
```bash
|
|
268
|
-
webspresso add tailwind
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
This command will:
|
|
272
|
-
- Install Tailwind CSS, PostCSS, and Autoprefixer as dev dependencies
|
|
273
|
-
- Create `tailwind.config.js` and `postcss.config.js`
|
|
274
|
-
- Create `src/input.css` with Tailwind directives
|
|
275
|
-
- Add build scripts to `package.json`
|
|
276
|
-
- Update your layout to use the built CSS instead of CDN
|
|
277
|
-
- Create `public/css/style.css` for the compiled output
|
|
278
|
-
|
|
279
|
-
After running this command:
|
|
280
|
-
```bash
|
|
281
|
-
npm install
|
|
282
|
-
npm run build:css # Build CSS once
|
|
283
|
-
npm run watch:css # Watch and rebuild CSS on changes
|
|
284
|
-
npm run dev # Starts both CSS watch and dev server
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
### `webspresso favicon:generate <source.png>`
|
|
288
|
-
|
|
289
|
-
Generate favicon PNG files and `favicons.njk` partial from a single source PNG.
|
|
290
|
-
|
|
291
|
-
```bash
|
|
292
|
-
# Basic usage (creates files in public/, views/partials/)
|
|
293
|
-
webspresso favicon:generate logo.png
|
|
294
|
-
|
|
295
|
-
# With PWA manifest options
|
|
296
|
-
webspresso favicon:generate logo.png --name "My App" --short-name "App" --theme-color "#22c55e"
|
|
297
|
-
|
|
298
|
-
# Custom output directory
|
|
299
|
-
webspresso favicon:generate logo.png -o static
|
|
300
|
-
|
|
301
|
-
# Skip adding include to layout.njk
|
|
302
|
-
webspresso favicon:generate logo.png --no-layout
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
This command will:
|
|
306
|
-
- Resize the source PNG to all required sizes (Apple touch 57–180px, Android 192px, favicon 16/32/96px, MS Tile 144px)
|
|
307
|
-
- Write PNGs to `public/` (or `-o` path)
|
|
308
|
-
- Create `public/manifest.json` (PWA format)
|
|
309
|
-
- Create `views/partials/favicons.njk` with `<link>` and `<meta>` tags
|
|
310
|
-
- Add `{% include "partials/favicons.njk" %}` to `views/layout.njk` (unless `--no-layout`)
|
|
311
|
-
|
|
312
|
-
**Options:**
|
|
313
|
-
- `-o, --output-dir <path>` – Output directory for PNGs (default: `public`)
|
|
314
|
-
- `--partial-dir <path>` – Directory for favicons.njk (default: `views/partials`)
|
|
315
|
-
- `--layout-file <path>` – Layout file to update (default: `views/layout.njk`)
|
|
316
|
-
- `--theme-color <hex>` – theme-color and msapplication-TileColor (default: `#ffffff`)
|
|
317
|
-
- `--name <string>` – manifest.json `name` (PWA)
|
|
318
|
-
- `--short-name <string>` – manifest.json `short_name` (PWA)
|
|
319
|
-
- `--no-layout` – Do not add include to layout.njk
|
|
320
|
-
|
|
321
|
-
### Admin Panel Commands
|
|
322
|
-
|
|
323
|
-
```bash
|
|
324
|
-
# Create admin_users table migration
|
|
325
|
-
webspresso admin:setup
|
|
326
|
-
|
|
327
|
-
# List all admin users
|
|
328
|
-
webspresso admin:list
|
|
329
|
-
|
|
330
|
-
# Reset admin password (interactive)
|
|
331
|
-
webspresso admin:password
|
|
332
|
-
|
|
333
|
-
# Reset with options
|
|
334
|
-
webspresso admin:password -e admin@example.com -p yeni_sifre123
|
|
335
|
-
webspresso admin:password -c ./webspresso.db.js -E production
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
> **Note:** Requires `webspresso.db.js` or `knexfile.js` in project root. Run from project directory.
|
|
24
|
+
Open **`http://localhost:3000`** in your browser.
|
|
339
25
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
Create your app with this structure:
|
|
343
|
-
|
|
344
|
-
```
|
|
345
|
-
my-app/
|
|
346
|
-
├── pages/
|
|
347
|
-
│ ├── locales/ # Global i18n translations
|
|
348
|
-
│ │ ├── en.json
|
|
349
|
-
│ │ └── de.json
|
|
350
|
-
│ ├── _hooks.js # Global lifecycle hooks
|
|
351
|
-
│ ├── index.njk # Home page (GET /)
|
|
352
|
-
│ ├── about/
|
|
353
|
-
│ │ ├── index.njk # About page (GET /about)
|
|
354
|
-
│ │ └── locales/ # Route-specific translations
|
|
355
|
-
│ ├── tools/
|
|
356
|
-
│ │ ├── index.njk # Tools list (GET /tools)
|
|
357
|
-
│ │ ├── index.js # Route config with load()
|
|
358
|
-
│ │ ├── [slug].njk # Dynamic tool page (GET /tools/:slug)
|
|
359
|
-
│ │ └── [slug].js # Route config for dynamic page
|
|
360
|
-
│ └── api/
|
|
361
|
-
│ ├── health.get.js # GET /api/health
|
|
362
|
-
│ └── echo.post.js # POST /api/echo
|
|
363
|
-
├── views/
|
|
364
|
-
│ └── layout.njk # Base layout template
|
|
365
|
-
├── public/ # Static files
|
|
366
|
-
└── server.js
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
## API
|
|
370
|
-
|
|
371
|
-
### `createApp(options)`
|
|
372
|
-
|
|
373
|
-
Creates and configures the Express app.
|
|
374
|
-
|
|
375
|
-
**Options:**
|
|
376
|
-
- `pagesDir` (required): Path to pages directory
|
|
377
|
-
- `viewsDir` (optional): Path to views/layouts directory
|
|
378
|
-
- `publicDir` (optional): Path to public/static directory
|
|
379
|
-
- `db` (optional): Database instance — exposed as `ctx.db` in plugin hooks (`register`, `onRoutesReady`) and in page `load`/`meta` functions; also registered for [`getDb()` / `getAppContext()`](#app-context-getdb-hasdb-getappcontext) below
|
|
380
|
-
- `logging` (optional): Enable request logging (default: true in development)
|
|
381
|
-
- `helmet` (optional): Helmet security configuration
|
|
382
|
-
- `true` or `undefined`: Use default secure configuration
|
|
383
|
-
- `false`: Disable Helmet
|
|
384
|
-
- `Object`: Custom Helmet configuration (merged with defaults)
|
|
385
|
-
- `middlewares` (optional): Named middleware registry for routes
|
|
386
|
-
- `clientRuntime` (optional): **`{ alpine?: boolean | object, swup?: boolean | object }`**. When either flag is enabled, mounts vendored Alpine 3 / swup 4 (+ Head + Scripts plugins) at **`/__webspresso/client-runtime/*`** and passes resolved **`{ alpine, swup }`** into SSR templates as **`clientRuntime`**. Override with env **`WEBSPRESSO_ALPINE`** / **`WEBSPRESSO_SWUP`** (`1` or `true`). Package exports **`resolveClientRuntime`** and **`CLIENT_RUNTIME_BASE`**. See **[Client runtime](#client-runtime)** below and **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
|
|
387
|
-
- `auth` (optional): `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`require('webspresso/core/auth')`). Registers session + cookie parsing, attaches **`req.auth`** / **`req.user`**, and injects named route middleware **`auth`** and **`guest`** (do not reuse those names for custom handlers if you pass `auth`). See **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
388
|
-
- `setupRoutes(app, ctx)` (optional): Register custom Express routes after file routes and plugin `onRoutesReady`, before the 404 handler — use for login/logout handlers when using the auth module; **`ctx.authMiddleware`** exposes `requireAuth`, `requireGuest`, etc.; **`ctx.clientRuntime`** is **`{ alpine, swup }`**
|
|
389
|
-
|
|
390
|
-
**Example with middlewares:**
|
|
391
|
-
|
|
392
|
-
```javascript
|
|
393
|
-
const { createApp } = require('webspresso');
|
|
394
|
-
|
|
395
|
-
const { app } = createApp({
|
|
396
|
-
pagesDir: './pages',
|
|
397
|
-
viewsDir: './views',
|
|
398
|
-
middlewares: {
|
|
399
|
-
auth: (req, res, next) => {
|
|
400
|
-
if (!req.session?.user) {
|
|
401
|
-
return res.redirect('/login');
|
|
402
|
-
}
|
|
403
|
-
next();
|
|
404
|
-
},
|
|
405
|
-
admin: (req, res, next) => {
|
|
406
|
-
if (req.session?.user?.role !== 'admin') {
|
|
407
|
-
return res.status(403).send('Forbidden');
|
|
408
|
-
}
|
|
409
|
-
next();
|
|
410
|
-
},
|
|
411
|
-
rateLimit: require('express-rate-limit')({ windowMs: 60000, max: 100 })
|
|
412
|
-
}
|
|
413
|
-
});
|
|
414
|
-
```
|
|
26
|
+
---
|
|
415
27
|
|
|
416
|
-
|
|
28
|
+
## ✨ Why Webspresso?
|
|
417
29
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
30
|
+
- **Zero-Dependency Sprawl**: Core framework features (Dual Auth, JWT signing, Error handling, Streaming Compression, Graceful Shutdown, Template Helpers) rely strictly on native Node.js standard modules (`crypto`, `path`, `fs`, `events`, `zlib`, `async_hooks`) without bloated dependency trees.
|
|
31
|
+
- **File-Based Routing**: Templates in `pages/` automatically become public URL routes with support for dynamic params (`[id]`) and catch-all wildcards (`[...slug]`).
|
|
32
|
+
- **Server Data Prefetching (`load()`)**: Page routes export `async function load({ req, res, db, ctx })` to fetch data server-side before template compilation.
|
|
33
|
+
- **Services Layer**: File-based auto-discovery in `services/` with declarative Zod validation, role/predicate authorization guards, and memoization.
|
|
34
|
+
- **Ambient Transactions**: Knex database transactions automatically bind to `AsyncLocalStorage` (`core/orm/transaction.js`). Scoped repositories seamlessly participate in active transactions without manual `trx` passing.
|
|
35
|
+
- **Mithril.js Admin Panel SPA**: Built-in, isolated single-page admin panel mounted at `/_admin` with auto-layout wrapping for custom pages, widgets, and field renderers.
|
|
36
|
+
- **TypeScript First-Class Support**: Shipped with complete [`index.d.ts`](index.d.ts) type definitions for IDE autocompletion and type checking.
|
|
424
37
|
|
|
425
|
-
|
|
426
|
-
module.exports = {
|
|
427
|
-
middleware: ['auth', 'rateLimit'],
|
|
428
|
-
handler: (req, res) => res.json({ data: 'protected' })
|
|
429
|
-
};
|
|
430
|
-
```
|
|
38
|
+
---
|
|
431
39
|
|
|
432
|
-
|
|
40
|
+
## 🛠️ Minimal Example
|
|
433
41
|
|
|
42
|
+
### 1. Server Entry Point (`server.js`)
|
|
434
43
|
```javascript
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
handler: (req, res) => res.json({ ok: true }),
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
// server.js — custom factory
|
|
442
|
-
middlewares: {
|
|
443
|
-
oauth: (opts) => (req, res, next) => {
|
|
444
|
-
if (opts.google && !req.headers['x-google']) return res.status(401).end();
|
|
445
|
-
next();
|
|
446
|
-
},
|
|
447
|
-
};
|
|
448
|
-
// pages/example/index.js → middleware: [['oauth', { google: true }], 'auth']
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
Plain `(req, res, next) => …` handlers still work as today. Tuple form **requires** a factory for that name (you get a clear error if you mix a plain handler with `['name', opts]`).
|
|
452
|
-
|
|
453
|
-
### Client runtime
|
|
454
|
-
|
|
455
|
-
Alpine.js + swup — opt-in progressive enhancement for SSR pages:
|
|
456
|
-
|
|
457
|
-
- **`clientRuntime: { alpine: true, swup: true }`** (each can be toggled independently). Default is off; no scripts are injected when both are disabled.
|
|
458
|
-
- Include **`{% include "partials/webspresso-client-runtime.njk" %}`** in your layout (copy from the npm package’s **`views/partials/`** or the framework repo). When **swup** is on, wrap the main content in **`<main id="swup">…</main>`** so transitions replace the correct region.
|
|
459
|
-
- **swup** uses Head + Scripts plugins; **Alpine** is re-bound after each visit via **`Alpine.initTree`** on the container. Use **`data-no-swup`** on links for a full page load. Paths **`/_admin`** and **`/_webspresso`** are ignored by the default bootstrap; the admin panel and dev dashboard stay separate Mithril apps.
|
|
460
|
-
- Dynamic UI can call **`pages/api/*`** from Alpine with **`fetch`** (see **`examples/alpine-swup-demo/`**).
|
|
461
|
-
- **Helmet / CSP**: production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
|
|
44
|
+
const path = require('path');
|
|
45
|
+
require('./config/load-env');
|
|
46
|
+
const { createApp, createDatabase } = require('webspresso');
|
|
462
47
|
|
|
463
|
-
|
|
464
|
-
const { createApp } = require('webspresso');
|
|
48
|
+
const db = createDatabase(require('./webspresso.db'));
|
|
465
49
|
|
|
466
50
|
const { app } = createApp({
|
|
467
|
-
pagesDir: './pages',
|
|
468
|
-
viewsDir: './views',
|
|
469
|
-
publicDir: './public',
|
|
470
|
-
clientRuntime: { alpine: true, swup: true },
|
|
471
|
-
});
|
|
472
|
-
```
|
|
473
|
-
|
|
474
|
-
### App context (`req.db`, `getDb`, `attachDbMiddleware`)
|
|
475
|
-
|
|
476
|
-
With **`createApp({ db })`**, file-based **API** routes (`pages/api/*.js`) get the same ORM instance on **`req.db`** before your **`middleware`** array and the handler run — no extra `require` in the handler:
|
|
477
|
-
|
|
478
|
-
```javascript
|
|
479
|
-
module.exports = async function handler(req, res) {
|
|
480
|
-
if (!req.db) {
|
|
481
|
-
return res.status(503).json({ error: 'Database not configured' });
|
|
482
|
-
}
|
|
483
|
-
const posts = await req.db.getRepository('Post').query().limit(10).list();
|
|
484
|
-
res.json(posts);
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
module.exports.middleware = ['auth']; // can use req.db too
|
|
488
|
-
```
|
|
489
|
-
|
|
490
|
-
The framework also registers that instance for **non-request** code (scripts, jobs) and for tests:
|
|
491
|
-
|
|
492
|
-
```javascript
|
|
493
|
-
const { getDb, hasDb } = require('webspresso');
|
|
494
|
-
// hasDb() / getDb() — getDb() throws if createApp had no db
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
For routes you add manually in **`setupRoutes`**, run **`attachDbMiddleware`** early so those handlers get `req.db`:
|
|
498
|
-
|
|
499
|
-
```javascript
|
|
500
|
-
const { createApp, attachDbMiddleware } = require('webspresso');
|
|
501
|
-
|
|
502
|
-
createApp({
|
|
503
|
-
pagesDir: './pages',
|
|
504
51
|
db,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
52
|
+
pagesDir: path.join(__dirname, 'pages'),
|
|
53
|
+
viewsDir: path.join(__dirname, 'views'),
|
|
54
|
+
server: {
|
|
55
|
+
shutdown: { enabled: true, timeout: 10000 },
|
|
56
|
+
compression: true,
|
|
508
57
|
},
|
|
509
58
|
});
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
| Export | Role |
|
|
513
|
-
|--------|------|
|
|
514
|
-
| `req.db` | Set on each API request when `createApp({ db })` was used (file-based API routes + after `attachDbMiddleware`) |
|
|
515
|
-
| `getDb()` | Same instance as `req.db`; **throws** if no `db` was passed to `createApp` |
|
|
516
|
-
| `hasDb()` | `true` if `createApp` was given `db` |
|
|
517
|
-
| `getAppContext()` | `{ db }` — `db` may be `null` |
|
|
518
|
-
| `attachDbMiddleware` | Express middleware to populate `req.db` for non–file-router routes |
|
|
519
|
-
| `resetAppContext()` | Clears context (mainly for tests) |
|
|
520
|
-
| `setAppContext(partial)` | Low-level merge; normally only `createApp` uses this |
|
|
521
|
-
|
|
522
|
-
**Custom Error Pages:**
|
|
523
|
-
|
|
524
|
-
```javascript
|
|
525
|
-
const { createApp } = require('webspresso');
|
|
526
59
|
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
errorPages: {
|
|
531
|
-
// 404 Not Found: Template path (relative to viewsDir) or custom handler fn
|
|
532
|
-
notFound: '404.njk', // or 'errors/404.njk', or (req, res, ctx) => res.render('404.njk', ctx)
|
|
533
|
-
|
|
534
|
-
// 500 Internal Server Error: Template path or custom handler fn
|
|
535
|
-
serverError: 'errors/500.njk', // or (err, req, res, ctx) => ...
|
|
536
|
-
|
|
537
|
-
// 503 Request Timeout: Template path or custom handler fn
|
|
538
|
-
timeout: 'errors/503.njk' // or (req, res, ctx) => ...
|
|
539
|
-
}
|
|
60
|
+
const PORT = process.env.PORT || 3000;
|
|
61
|
+
app.listen(PORT, () => {
|
|
62
|
+
console.log(`Server listening at http://localhost:${PORT}`);
|
|
540
63
|
});
|
|
541
64
|
```
|
|
542
65
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
- **`500.njk` (`serverError`)**: `{ fsy, locale, isDev, url, method, error, status }` — sets HTTP status `500` (or `err.status`).
|
|
548
|
-
- **`503.njk` (`timeout`)**: `{ fsy, locale, isDev, url, method }` — sets HTTP status `503`.
|
|
549
|
-
|
|
550
|
-
```njk
|
|
551
|
-
{# Example views/404.njk #}
|
|
552
|
-
{% extends "layout.njk" %}
|
|
66
|
+
### 2. Server-Rendered Page (`pages/index.njk` + `pages/index.js`)
|
|
67
|
+
```html
|
|
68
|
+
<!-- pages/index.njk -->
|
|
69
|
+
{% extends "views/layout.njk" %}
|
|
553
70
|
|
|
554
71
|
{% block content %}
|
|
555
|
-
<div class="max-w-
|
|
556
|
-
<h1 class="text-
|
|
557
|
-
<p class="text-
|
|
558
|
-
<a href="{{ fsy.url('/') }}" class="mt-6 inline-block bg-blue-600 text-white px-6 py-2 rounded-lg">
|
|
559
|
-
Back to Home
|
|
560
|
-
</a>
|
|
72
|
+
<div class="max-w-4xl mx-auto py-12 px-4">
|
|
73
|
+
<h1 class="text-3xl font-bold mb-4">{{ title }}</h1>
|
|
74
|
+
<p class="text-slate-600">Welcome, {{ user.name or "Guest" }}!</p>
|
|
561
75
|
</div>
|
|
562
76
|
{% endblock %}
|
|
563
77
|
```
|
|
564
78
|
|
|
565
|
-
**How errors reach this handler**
|
|
566
|
-
|
|
567
|
-
Unhandled errors from file-based routes (`pages/**/*.njk` `load()` / middleware / render, and `pages/api/**/*.js` handlers) are forwarded with `next(err)`, so they go through this 4-argument Express error middleware. That means `errorPages.serverError` and `errorPages.timeout` apply to those failures as well (not only to routes you add with `setupRoutes`).
|
|
568
|
-
|
|
569
|
-
- **`pages/_hooks.js` `onError(ctx, err)`** runs **before** the central handler (for both SSR and API file routes). Use it for logging or APM (`Sentry.captureException`, `newrelic.noticeError`, etc.). The error is also on **`ctx.error`**.
|
|
570
|
-
|
|
571
|
-
- **JSON vs HTML:** Requests whose path starts with **`/api`** always get a **JSON** error body from the default branch (and the default **string** `serverError` / `timeout` Nunjucks templates are skipped for those paths). Other clients use **`Accept`** as before: prefer HTML when the client accepts HTML.
|
|
572
|
-
|
|
573
|
-
**Request Timeout:**
|
|
574
|
-
|
|
575
|
-
Configure request timeout with `connect-timeout`:
|
|
576
|
-
|
|
577
|
-
```javascript
|
|
578
|
-
const { app } = createApp({
|
|
579
|
-
pagesDir: './pages',
|
|
580
|
-
timeout: '30s', // Default: 30 seconds
|
|
581
|
-
// timeout: '1m', // 1 minute
|
|
582
|
-
// timeout: false, // Disable timeout
|
|
583
|
-
});
|
|
584
|
-
```
|
|
585
|
-
|
|
586
|
-
**Asset Management & Cache-Busting:**
|
|
587
|
-
|
|
588
|
-
Webspresso includes a built-in `AssetManager` that automates static asset URL resolution, query-string cache-busting, Vite/Webpack build manifest lookups, and CDN prefixing in Nunjucks SSR templates.
|
|
589
|
-
|
|
590
|
-
Configure asset handling in `createApp`:
|
|
591
|
-
|
|
592
|
-
```javascript
|
|
593
|
-
const { createApp } = require('webspresso');
|
|
594
|
-
const path = require('path');
|
|
595
|
-
|
|
596
|
-
const { app } = createApp({
|
|
597
|
-
pagesDir: './pages',
|
|
598
|
-
viewsDir: './views',
|
|
599
|
-
publicDir: './public', // default: 'public'
|
|
600
|
-
assets: {
|
|
601
|
-
// 1. Static query-string versioning (Cache Busting)
|
|
602
|
-
// Appends ?v=1.2.3 (or &v=... if existing query params) to asset URLs
|
|
603
|
-
version: process.env.APP_VERSION || '1.0.0', // or Git commit hash, build timestamp
|
|
604
|
-
|
|
605
|
-
// 2. Build Tool Manifest (Vite, Webpack, etc.)
|
|
606
|
-
// Resolves hashed bundle filenames automatically (e.g. style.css -> assets/style-abc123.css)
|
|
607
|
-
manifestPath: path.join(__dirname, 'public/.vite/manifest.json'),
|
|
608
|
-
|
|
609
|
-
// 3. Asset URL Prefix / CDN
|
|
610
|
-
// Prepended to all resolved asset paths
|
|
611
|
-
prefix: process.env.CDN_URL || '' // e.g. 'https://cdn.example.com' or '/static'
|
|
612
|
-
}
|
|
613
|
-
});
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
#### How Asset Resolution Works
|
|
617
|
-
|
|
618
|
-
1. **Manifest Match First**: If `manifestPath` is configured and an entry matches the asset key, `AssetManager` resolves to the hashed filename (from Vite or Webpack manifest) and prepends `prefix`.
|
|
619
|
-
2. **Fallback to Path & Versioning**: If no manifest is provided or the asset is not listed in the manifest, `AssetManager` uses the original path, prepends `prefix`, and appends `?v=<version>` (or `&v=<version>`) if `version` is set.
|
|
620
|
-
3. **Leading Slash Normalization**: Paths with or without leading slashes (e.g., `'css/style.css'` or `'/css/style.css'`) are handled seamlessly.
|
|
621
|
-
|
|
622
|
-
```json
|
|
623
|
-
// Example Vite manifest format (.vite/manifest.json)
|
|
624
|
-
{
|
|
625
|
-
"css/style.css": { "file": "assets/style-abc123.css", "src": "css/style.css" },
|
|
626
|
-
"js/app.js": { "file": "assets/app-xyz789.js", "src": "js/app.js" }
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Example Webpack manifest format (manifest.json)
|
|
630
|
-
{
|
|
631
|
-
"/css/style.css": "/dist/style.abc123.css",
|
|
632
|
-
"/js/app.js": "/dist/app.xyz789.js"
|
|
633
|
-
}
|
|
634
|
-
```
|
|
635
|
-
|
|
636
|
-
#### Template Helpers (`fsy`)
|
|
637
|
-
|
|
638
|
-
Use `fsy` helpers inside your Nunjucks layouts and pages:
|
|
639
|
-
|
|
640
|
-
```njk
|
|
641
|
-
{# 1. URL string resolution #}
|
|
642
|
-
<link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
|
|
643
|
-
{# Outputs: <link rel="stylesheet" href="/css/style.css?v=1.0.0"> (or /assets/style-abc123.css) #}
|
|
644
|
-
|
|
645
|
-
{# 2. CSS link tag helper #}
|
|
646
|
-
{{ fsy.css('/css/style.css') | safe }}
|
|
647
|
-
{# With custom attributes (media, id, integrity, etc.): #}
|
|
648
|
-
{{ fsy.css('/css/print.css', { media: 'print', id: 'print-styles' }) | safe }}
|
|
649
|
-
|
|
650
|
-
{# 3. JS script tag helper #}
|
|
651
|
-
{{ fsy.js('/js/app.js') | safe }}
|
|
652
|
-
{# Supports boolean flags (async, defer) and module type: #}
|
|
653
|
-
{{ fsy.js('/js/main.js', { defer: true, type: 'module' }) | safe }}
|
|
654
|
-
{# Outputs: <script src="/js/main.js?v=1.0.0" defer type="module"></script> #}
|
|
655
|
-
|
|
656
|
-
{# 4. Image tag helper #}
|
|
657
|
-
{{ fsy.img('/images/logo.png', 'Company Logo') | safe }}
|
|
658
|
-
{# With attributes (loading, class, width, height): #}
|
|
659
|
-
{{ fsy.img('/images/hero.jpg', 'Hero Banner', { loading: 'lazy', class: 'w-full rounded', width: 1200, height: 600 }) | safe }}
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
#### Programmatic Asset Manager API
|
|
663
|
-
|
|
664
|
-
You can also import and use `AssetManager` directly:
|
|
665
|
-
|
|
666
|
-
```javascript
|
|
667
|
-
const { AssetManager, configureAssets, getAssetManager } = require('webspresso');
|
|
668
|
-
|
|
669
|
-
// Configure global manager
|
|
670
|
-
configureAssets({
|
|
671
|
-
version: '2.0.0',
|
|
672
|
-
prefix: 'https://cdn.example.com'
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
const manager = getAssetManager();
|
|
676
|
-
console.log(manager.asset('/css/style.css')); // -> 'https://cdn.example.com/css/style.css?v=2.0.0'
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
**Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is `null` when `auth` was not passed.
|
|
680
|
-
|
|
681
|
-
### Authentication (session)
|
|
682
|
-
|
|
683
|
-
Optional **session-based** auth lives under **`webspresso/core/auth`**: **`createAuth`**, **`quickAuth`**, **`hash`** / **`verify`**, **`setupAuthMiddleware`**, **`createRememberTokensTable`**, and policy helpers. Pass the manager to **`createApp({ auth })`** so routes can use **`middleware: ['auth']`** or **`['guest']`** and handlers can call **`req.auth.attempt()`**, **`req.auth.logout()`**, **`req.auth.can()`**, etc.
|
|
684
|
-
|
|
685
|
-
The **admin panel** plugin uses its **own** session and `/api/auth/*` routes (`req.session.adminUser`) — it is separate from **`createApp({ auth })`**.
|
|
686
|
-
|
|
687
|
-
For integration patterns (remember-me table, `setupRoutes`, file-router ordering), see **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
688
|
-
|
|
689
|
-
## Plugin System
|
|
690
|
-
|
|
691
|
-
Webspresso has a built-in plugin system with version control and dependency management.
|
|
692
|
-
|
|
693
|
-
**Error handling:** On plugin errors (missing dependencies, version mismatch, `register()` or `onRoutesReady()` failure), the app does not crash; only a `console.warn` is logged and other plugins continue to run.
|
|
694
|
-
|
|
695
|
-
### Using Plugins
|
|
696
|
-
|
|
697
|
-
```javascript
|
|
698
|
-
const { createApp } = require('webspresso');
|
|
699
|
-
const { sitemapPlugin, analyticsPlugin, dashboardPlugin } = require('webspresso/plugins');
|
|
700
|
-
|
|
701
|
-
const { app } = createApp({
|
|
702
|
-
pagesDir: './pages',
|
|
703
|
-
viewsDir: './views',
|
|
704
|
-
plugins: [
|
|
705
|
-
dashboardPlugin(), // Dev dashboard at /_webspresso
|
|
706
|
-
sitemapPlugin({
|
|
707
|
-
hostname: 'https://example.com',
|
|
708
|
-
exclude: ['/admin/*', '/api/*'],
|
|
709
|
-
i18n: true,
|
|
710
|
-
locales: ['en', 'de']
|
|
711
|
-
}),
|
|
712
|
-
analyticsPlugin({
|
|
713
|
-
google: {
|
|
714
|
-
measurementId: 'G-XXXXXXXXXX',
|
|
715
|
-
verificationCode: 'xxxxx'
|
|
716
|
-
},
|
|
717
|
-
yandex: {
|
|
718
|
-
counterId: '12345678',
|
|
719
|
-
verificationCode: 'xxxxx'
|
|
720
|
-
},
|
|
721
|
-
bing: {
|
|
722
|
-
uetId: '12345678',
|
|
723
|
-
verificationCode: 'xxxxx'
|
|
724
|
-
}
|
|
725
|
-
})
|
|
726
|
-
]
|
|
727
|
-
});
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
### Built-in Plugins
|
|
731
|
-
|
|
732
|
-
**Dashboard Plugin:**
|
|
733
|
-
- Development dashboard at `/_webspresso`
|
|
734
|
-
- Monitor all routes (SSR pages and API endpoints)
|
|
735
|
-
- View loaded plugins and configuration
|
|
736
|
-
- Filter and search routes
|
|
737
|
-
- Only active in development mode (disabled in production)
|
|
738
|
-
|
|
739
79
|
```javascript
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
80
|
+
// pages/index.js
|
|
81
|
+
module.exports = {
|
|
82
|
+
async load({ req, ctx }) {
|
|
83
|
+
return {
|
|
84
|
+
title: 'Home Page',
|
|
85
|
+
user: req.user || null,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
748
89
|
```
|
|
749
90
|
|
|
750
|
-
|
|
751
|
-
- `path` - Custom dashboard path (default: `/_webspresso`)
|
|
752
|
-
- `enabled` - Force enable/disable (default: auto based on NODE_ENV)
|
|
753
|
-
|
|
754
|
-
**Redirect plugin:**
|
|
755
|
-
- Runs in `register()` **before** file-based routes, so configured paths override SSR pages.
|
|
756
|
-
- Rules: `from` (string or `RegExp`), `to` (path or URL), optional `status` (301/302/303/307/308), optional `methods` (`'*'` or a list; default plugin methods are `GET` and `HEAD` only).
|
|
757
|
-
- `preserveQuery` (default `true`) appends the request query when `to` has no `?`. External `to` values require `allowExternal: true`.
|
|
758
|
-
|
|
91
|
+
### 3. Validated JSON API Endpoint (`pages/api/greet.post.js`)
|
|
759
92
|
```javascript
|
|
760
|
-
|
|
93
|
+
module.exports = {
|
|
94
|
+
schema({ z }) {
|
|
95
|
+
return {
|
|
96
|
+
body: z.object({
|
|
97
|
+
name: z.string().min(2),
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
},
|
|
761
101
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
102
|
+
async handler(req, res) {
|
|
103
|
+
// req.input is typed and validated by Zod
|
|
104
|
+
const result = await req.service('user.greet', req.input.body);
|
|
105
|
+
res.json(result);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
768
108
|
```
|
|
769
109
|
|
|
770
|
-
|
|
771
|
-
- Generates `/sitemap.xml` from routes automatically
|
|
772
|
-
- **Dynamic Database Content**: Generate URLs from database records
|
|
773
|
-
- Excludes dynamic routes and API endpoints
|
|
774
|
-
- Supports i18n with hreflang tags
|
|
775
|
-
- Generates `/robots.txt`
|
|
776
|
-
- Configurable caching for performance
|
|
777
|
-
|
|
778
|
-
```javascript
|
|
779
|
-
sitemapPlugin({
|
|
780
|
-
hostname: 'https://example.com',
|
|
781
|
-
db, // Database instance
|
|
782
|
-
dynamicSources: [
|
|
783
|
-
{
|
|
784
|
-
model: 'Post', // Model name
|
|
785
|
-
urlPattern: '/blog/:slug', // URL pattern
|
|
786
|
-
lastmodField: 'updated_at', // Field for lastmod
|
|
787
|
-
filter: (r) => r.published, // Filter records
|
|
788
|
-
priority: 0.9,
|
|
789
|
-
},
|
|
790
|
-
{
|
|
791
|
-
// Custom query function
|
|
792
|
-
query: async (db) => {
|
|
793
|
-
return db.getRepository('Product')
|
|
794
|
-
.query()
|
|
795
|
-
.where('active', true)
|
|
796
|
-
.list();
|
|
797
|
-
},
|
|
798
|
-
urlPattern: '/products/:slug',
|
|
799
|
-
},
|
|
800
|
-
],
|
|
801
|
-
})
|
|
802
|
-
```
|
|
110
|
+
---
|
|
803
111
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
-
|
|
810
|
-
-
|
|
112
|
+
## 📚 Documentation
|
|
113
|
+
|
|
114
|
+
The complete Webspresso documentation is organized into 5 focused pillars:
|
|
115
|
+
|
|
116
|
+
### 🚀 Getting Started
|
|
117
|
+
- **[Installation Guide](docs/getting-started/installation.md)** — Node.js 20 LTS prerequisites, CLI setup, and TypeScript.
|
|
118
|
+
- **[Creating Your First App](docs/getting-started/first-app.md)** — Step-by-step walkthrough building an SSR page, API endpoint, and service.
|
|
119
|
+
- **[Project Structure](docs/getting-started/project-structure.md)** — Anatomy of `pages/`, `views/`, `services/`, `models/`, and `config/`.
|
|
120
|
+
- **[Deployment & Production](docs/getting-started/deployment.md)** — Production configuration, Docker, PM2, and Nginx.
|
|
121
|
+
|
|
122
|
+
### 📖 Task Guides ("How do I...?")
|
|
123
|
+
- **[File-Based Routing](docs/guides/routing.md)** — Static routes, dynamic params (`[id]`), and catch-all wildcards.
|
|
124
|
+
- **[SSR & Templates](docs/guides/ssr-and-templates.md)** — Nunjucks layouts, `load()` loaders, `fsy` helper catalog, and chunked streaming.
|
|
125
|
+
- **[Services Layer](docs/guides/services.md)** — Defining services, Zod validation, auth role guards, and ACID transactions.
|
|
126
|
+
- **[Database & ORM](docs/guides/database-and-orm.md)** — Model definitions (`defineModel`), `zdb` schema builder, and Knex migrations.
|
|
127
|
+
- **[Dual Authentication](docs/guides/authentication.md)** — Stateful Session cookies for SSR and stateless JWT rotation for REST APIs.
|
|
128
|
+
- **[Admin Panel Customization](docs/guides/admin-panel.md)** — Custom Mithril.js SPA pages, KPI widgets, and custom field renderers.
|
|
129
|
+
|
|
130
|
+
### 🧠 Concepts & Architecture ("How does it work and why?")
|
|
131
|
+
- **[System Architecture](docs/concepts/architecture.md)** — Layered architecture, subsystem boundaries, and design principles.
|
|
132
|
+
- **[Request Lifecycle](docs/concepts/request-lifecycle.md)** — End-to-end trace from TCP socket to template render and response stream.
|
|
133
|
+
- **[Service Execution Pipeline](docs/concepts/service-execution-pipeline.md)** — Sanitization, Zod validation, auth guards, ambient transactions, and caching.
|
|
134
|
+
- **[Ambient Transaction Propagation](docs/concepts/transaction-propagation.md)** — How `AsyncLocalStorage` manages ACID database transactions across nested services.
|
|
135
|
+
|
|
136
|
+
### 📚 API Reference ("What is the exact contract?")
|
|
137
|
+
- **[`createApp()` Configuration](docs/reference/create-app.md)** — Full `CreateAppOptions` parameter reference.
|
|
138
|
+
- **[CLI Reference](docs/reference/cli.md)** — Complete `webspresso` CLI command manual.
|
|
139
|
+
- **[Service Definition Spec](docs/reference/service-definition.md)** — `defineService` options and `ServiceContext` properties.
|
|
140
|
+
- **[Model Definition Spec](docs/reference/model-definition.md)** — `defineModel` options, `zdb` column types, and `Repository` query methods.
|
|
141
|
+
- **[Exceptions Catalog](docs/reference/exceptions.md)** — Built-in framework exception classes and HTTP status codes.
|
|
811
142
|
|
|
812
|
-
|
|
143
|
+
---
|
|
813
144
|
|
|
814
|
-
|
|
815
|
-
<head>
|
|
816
|
-
{{ fsy.verificationTags() | safe }}
|
|
817
|
-
{{ fsy.analyticsHead() | safe }}
|
|
818
|
-
</head>
|
|
819
|
-
<body>
|
|
820
|
-
{{ fsy.analyticsBodyOpen() | safe }}
|
|
821
|
-
...
|
|
822
|
-
</body>
|
|
823
|
-
```
|
|
145
|
+
## 🤖 For AI Coding Agents
|
|
824
146
|
|
|
825
|
-
|
|
147
|
+
If you are an autonomous coding assistant or LLM working on a Webspresso codebase:
|
|
148
|
+
- Read **[`AGENTS.md`](AGENTS.md)** for authoritative architectural guidelines, zero-regression policies, and critical signatures.
|
|
149
|
+
- Consult **[`llms.txt`](llms.txt)** for a machine-readable sitemap of all documentation resources.
|
|
826
150
|
|
|
827
|
-
|
|
828
|
-
- Modular admin panel with SPA (Mithril.js)
|
|
829
|
-
- Model CRUD UI (auto-generated from ORM)
|
|
830
|
-
- Extensible via custom pages, menu items, API routes, and dashboard widgets
|
|
831
|
-
- Other plugins (e.g. site-analytics) can register their own admin pages
|
|
832
|
-
|
|
833
|
-
```javascript
|
|
834
|
-
const { adminPanelPlugin } = require('webspresso/plugins');
|
|
835
|
-
|
|
836
|
-
const { app } = createApp({
|
|
837
|
-
pagesDir: './pages',
|
|
838
|
-
plugins: [
|
|
839
|
-
adminPanelPlugin({
|
|
840
|
-
db,
|
|
841
|
-
path: '/_admin', // Admin URL (default: /_admin)
|
|
842
|
-
auth: authManager, // Optional: for user management
|
|
843
|
-
userManagement: { enabled: true, model: 'User' },
|
|
844
|
-
})
|
|
845
|
-
]
|
|
846
|
-
});
|
|
847
|
-
```
|
|
848
|
-
|
|
849
|
-
Options:
|
|
850
|
-
- `db` (required) - Database instance
|
|
851
|
-
- `path` - Admin panel path (default: `/_admin`)
|
|
852
|
-
- `auth` - Same **`AuthManager`** instance as **`createApp({ auth })`** when you use **`userManagement`** — enables **Active Sessions** / revoke APIs if **`rememberTokens`** (remember-me) is configured; optional for user CRUD-only
|
|
853
|
-
- `userManagement` - Site-user admin UI (`enabled`, `model` matching ORM user table, optional `fields` map). SPA routes: `/_admin/users`, `/_admin/users/new`, …; APIs: `/_admin/api/users*`. Admin staff still use **`admin_users`** / `/_admin` login; this is separate from **`req.user`** on the public site
|
|
854
|
-
- `configure` - Callback `(registry) => void` for manual setup
|
|
855
|
-
|
|
856
|
-
See **`doc/index.html#admin-user-management`** and **Session authentication** in **`.agents/skills/webspresso-usage/REFERENCE-framework.md`** for the split between **`adminUser`** and **`createApp({ auth })`**.
|
|
857
|
-
|
|
858
|
-
**3-State Table Column Sorting & Configuration:**
|
|
859
|
-
|
|
860
|
-
Admin Panel table headers support interactive 3-state column sorting:
|
|
861
|
-
- **State 1 (`Unsorted`)**: Default state (`↕` icon).
|
|
862
|
-
- **State 2 (`ASC`)**: 1st click sorts ascending (`▲` icon, `?sort=col&order=asc`).
|
|
863
|
-
- **State 3 (`DESC`)**: 2nd click sorts descending (`▼` icon, `?sort=col&order=desc`).
|
|
864
|
-
- **State 4 (`Unsorted`)**: 3rd click clears sorting and reverts to default primary key ordering.
|
|
865
|
-
|
|
866
|
-
Configuration options:
|
|
867
|
-
- **Column-level**: Standard scalar columns (`string`, `number`, `boolean`, `timestamp`) are sortable by default. Disable or enable per column:
|
|
868
|
-
```javascript
|
|
869
|
-
schema: zdb.schema({
|
|
870
|
-
name: zdb.string({ sortable: true }),
|
|
871
|
-
secret_notes: zdb.text().config({ sortable: false }), // or zdb.text({ sortable: false })
|
|
872
|
-
})
|
|
873
|
-
```
|
|
874
|
-
- **Model-level**: Restrict sortable columns or disable sorting completely on a model in `defineModel`:
|
|
875
|
-
```javascript
|
|
876
|
-
admin: {
|
|
877
|
-
enabled: true,
|
|
878
|
-
sortableColumns: ['name', 'price'], // Only name and price are sortable
|
|
879
|
-
// Or disable all sorting:
|
|
880
|
-
// sortable: false,
|
|
881
|
-
}
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
**Custom Admin Pages (registerModule):**
|
|
887
|
-
|
|
888
|
-
Plugins can add custom admin pages using `registerModule` (or `registerPageDir`) in `onRoutesReady`:
|
|
889
|
-
|
|
890
|
-
```javascript
|
|
891
|
-
// In your plugin's onRoutesReady(ctx)
|
|
892
|
-
const adminApi = ctx.usePlugin('admin-panel');
|
|
893
|
-
if (adminApi) {
|
|
894
|
-
// Option 1: File-based registration with auto Layout wrapping
|
|
895
|
-
adminApi.registerModule({
|
|
896
|
-
id: 'my-module',
|
|
897
|
-
|
|
898
|
-
pages: [{
|
|
899
|
-
id: 'reports',
|
|
900
|
-
title: 'Reports',
|
|
901
|
-
path: '/reports',
|
|
902
|
-
icon: 'chart',
|
|
903
|
-
description: 'View reports',
|
|
904
|
-
componentFile: path.join(__dirname, 'admin/reports.js'), // JS component file
|
|
905
|
-
layout: true, // Auto-wraps view in Admin Layout (default: true)
|
|
906
|
-
}],
|
|
907
|
-
|
|
908
|
-
menu: [{ id: 'reports', label: 'Reports', path: '/reports', icon: 'chart', order: 5 }],
|
|
909
|
-
|
|
910
|
-
api: {
|
|
911
|
-
prefix: '/reports',
|
|
912
|
-
routes: [
|
|
913
|
-
{ method: 'get', path: '/summary', handler: getSummaryHandler, auth: true },
|
|
914
|
-
],
|
|
915
|
-
},
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
// Option 2: Folder-based auto registration (dir containing page.json + component.js)
|
|
919
|
-
adminApi.registerPageDir(path.join(__dirname, 'admin/pages'));
|
|
920
|
-
}
|
|
921
|
-
```
|
|
922
|
-
|
|
923
|
-
| Field | Description |
|
|
924
|
-
|-------|-------------|
|
|
925
|
-
| `id` | Unique module identifier (required) |
|
|
926
|
-
| `pages` | Custom admin pages (`id`, `title`, `path`, `icon`, `description`, `url`, `iframeUrl`, `html`, `htmlFile`, `componentFile`, `component`, `layout`) |
|
|
927
|
-
| `pagesDir` | Path to directory containing page folders/configs for automatic discovery |
|
|
928
|
-
| `menu` | Sidebar menu items (`id`, `label`, `path`, `icon`, `order`) |
|
|
929
|
-
| `menuGroups` | Collapsible menu groups (`id`, `label`, `order`) |
|
|
930
|
-
| `api` | API routes (`prefix`, `routes`: `method`, `path`, `handler`, `auth`) |
|
|
931
|
-
| `widgets` | Dashboard widgets (`id`, `title`, `dataLoader`) |
|
|
932
|
-
| `scripts` | External script URLs, script objects, or inline scripts injected into SSR Admin Panel (`registerScript`) |
|
|
933
|
-
| `styles` | External stylesheet URLs or inline CSS injected into SSR Admin Panel (`registerStyle`) |
|
|
934
|
-
|
|
935
|
-
Pages support JavaScript client components (`componentFile`, `pagesDir`, `component`), external URLs (`url` or `iframeUrl` inside `<iframe>`), as well as **native custom HTML pages** (`html` or `htmlFile` for local HTML files like EUIX Engine apps, Vue, or Alpine). HTML pages render directly in the page DOM with full Tailwind CSS styling, dark mode, and script execution without iframe overhead. Set `iframe: true` to isolate in an iframe if needed. By default (`layout: true`), custom page views are automatically wrapped in the Admin Layout with Breadcrumbs. Set `layout: false` for full-screen / custom canvas pages.
|
|
936
|
-
|
|
937
|
-
**Example component file (`admin/reports.js`):**
|
|
938
|
-
|
|
939
|
-
```javascript
|
|
940
|
-
// Clean Mithril.js component object (no manual m(Layout, ...) needed!)
|
|
941
|
-
({
|
|
942
|
-
oninit: async (vnode) => {
|
|
943
|
-
vnode.state.data = [];
|
|
944
|
-
try {
|
|
945
|
-
const res = await api.get('/reports/summary');
|
|
946
|
-
vnode.state.data = res.data || [];
|
|
947
|
-
m.redraw();
|
|
948
|
-
} catch (err) {
|
|
949
|
-
console.error('Failed to load report data:', err);
|
|
950
|
-
}
|
|
951
|
-
},
|
|
952
|
-
view: (vnode) => {
|
|
953
|
-
const data = vnode.state.data || [];
|
|
954
|
-
return m('div.space-y-4', [
|
|
955
|
-
m('h1.text-2xl.font-bold.text-gray-900.dark:text-slate-100', 'Report Summary'),
|
|
956
|
-
m('p.text-gray-600.dark:text-slate-400', `Total records: ${data.length}`),
|
|
957
|
-
]);
|
|
958
|
-
}
|
|
959
|
-
})
|
|
960
|
-
```
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
**Manual registry API** (alternative to registerModule):
|
|
965
|
-
|
|
966
|
-
```javascript
|
|
967
|
-
adminPanelPlugin({
|
|
968
|
-
db,
|
|
969
|
-
configure(registry) {
|
|
970
|
-
registry.registerPage('custom', { title: 'Custom', path: '/custom', icon: 'tool' });
|
|
971
|
-
registry.registerClientComponent('custom', 'window.__customPages["custom"] = { view: () => m("p","Hi") };');
|
|
972
|
-
registry.registerMenuItem({ id: 'custom', label: 'Custom', path: '/custom', icon: 'tool' });
|
|
973
|
-
},
|
|
974
|
-
})
|
|
975
|
-
```
|
|
976
|
-
|
|
977
|
-
### Data exchange plugin (`dataExchangePlugin`)
|
|
978
|
-
|
|
979
|
-
- **Admin session only** — same `requireAuth` / `req.session.adminUser` as the admin panel; paths live under your admin prefix (default `/_admin`).
|
|
980
|
-
- **Excel export** (`.xlsx`) for models with `admin.enabled`; record selection matches the built-in JSON/CSV export (`ids`, `selectAll`, `filters` via query or POST body).
|
|
981
|
-
- **Import** — `multipart/form-data` field `file` (`.csv` or `.xlsx`); query/body `mode=insert|upsert`, `upsertKey` (default primary key). Rows are validated through the ORM (`repository.create` / `update`). Hidden columns are excluded; caps: `maxRows`, `maxFileBytes`.
|
|
982
|
-
- **UI** — when the plugin is loaded, the admin model list adds **Export Excel**, **Import**, and bulk **Export Excel** (alongside the existing JSON/CSV export actions). Registers bulk action `export-xlsx` for download URLs.
|
|
983
|
-
- **Dependencies** — implemented with `exceljs` and `csv-parse` (declared on the `webspresso` package).
|
|
984
|
-
|
|
985
|
-
Register **after** `adminPanelPlugin` so session middleware and the admin registry exist; use the **same** `db` and `adminPath`:
|
|
986
|
-
|
|
987
|
-
```javascript
|
|
988
|
-
const { adminPanelPlugin, dataExchangePlugin } = require('webspresso/plugins');
|
|
989
|
-
|
|
990
|
-
const { app } = createApp({
|
|
991
|
-
pagesDir: './pages',
|
|
992
|
-
db,
|
|
993
|
-
plugins: [
|
|
994
|
-
adminPanelPlugin({ db, path: '/_admin' }),
|
|
995
|
-
dataExchangePlugin({
|
|
996
|
-
db,
|
|
997
|
-
adminPath: '/_admin',
|
|
998
|
-
maxRows: 10_000,
|
|
999
|
-
maxFileBytes: 10 * 1024 * 1024,
|
|
1000
|
-
}),
|
|
1001
|
-
],
|
|
1002
|
-
});
|
|
1003
|
-
```
|
|
1004
|
-
|
|
1005
|
-
Options:
|
|
1006
|
-
- `db` — database instance (defaults to `ctx.db` in `onRoutesReady` if omitted but `createApp({ db })` was set)
|
|
1007
|
-
- `adminPath` — must match the admin panel path (default `/_admin`)
|
|
1008
|
-
- `enabled` — set `false` to skip registering routes (default `true`)
|
|
1009
|
-
- `maxRows` — export and import row limit (default `10000`)
|
|
1010
|
-
- `maxFileBytes` — multipart upload limit (default 10 MiB)
|
|
1011
|
-
|
|
1012
|
-
API (all require admin cookie):
|
|
1013
|
-
- `GET|POST ${adminPath}/api/data-exchange/export/:model` — spreadsheet download
|
|
1014
|
-
- `POST ${adminPath}/api/data-exchange/import/:model` — import summary `{ success, created, updated, failed, errors: [{ row, message }] }`
|
|
1015
|
-
|
|
1016
|
-
**Site Analytics Plugin:**
|
|
1017
|
-
- Self-hosted page view analytics (no external services required)
|
|
1018
|
-
- Automatic page view tracking via Express middleware
|
|
1019
|
-
- Bot detection (40+ patterns: Googlebot, GPTBot, curl, etc.)
|
|
1020
|
-
- Country detection (CDN headers, Accept-Language fallback)
|
|
1021
|
-
- Admin panel dashboard with Chart.js visualizations
|
|
1022
|
-
- Privacy-first: IP addresses are hashed, no cookies required
|
|
1023
|
-
|
|
1024
|
-
```javascript
|
|
1025
|
-
const { siteAnalyticsPlugin, adminPanelPlugin } = require('webspresso/plugins');
|
|
1026
|
-
|
|
1027
|
-
const { app } = createApp({
|
|
1028
|
-
pagesDir: './pages',
|
|
1029
|
-
plugins: [
|
|
1030
|
-
adminPanelPlugin({ db }),
|
|
1031
|
-
siteAnalyticsPlugin({
|
|
1032
|
-
db,
|
|
1033
|
-
excludePaths: ['/health', '/favicon.ico'],
|
|
1034
|
-
trackBots: true, // Record bot visits separately (default: true)
|
|
1035
|
-
}),
|
|
1036
|
-
]
|
|
1037
|
-
});
|
|
1038
|
-
```
|
|
1039
|
-
|
|
1040
|
-
Admin panel analytics page includes:
|
|
1041
|
-
- **Summary cards**: Total views, unique visitors, unique pages, sessions
|
|
1042
|
-
- **Views over time**: Line chart (Chart.js) with daily views/visitors/sessions
|
|
1043
|
-
- **Bot activity**: Bot request counts with horizontal bar visualization
|
|
1044
|
-
- **Top pages**: Most viewed pages sorted by view count
|
|
1045
|
-
- **Recent activity**: Latest page views with country flags and timestamps
|
|
1046
|
-
- **Country stats**: Country breakdown with flag emojis and bar charts
|
|
1047
|
-
- **Date filtering**: Last 7, 30, or 90 days toggle
|
|
1048
|
-
|
|
1049
|
-
Options:
|
|
1050
|
-
- `db` (required) - Database instance
|
|
1051
|
-
- `excludePaths` - Additional paths to exclude from tracking (admin, API, and static files are auto-excluded)
|
|
1052
|
-
- `trackBots` - Whether to record bot visits (default: `true`)
|
|
1053
|
-
- `tableName` - Custom table name (default: `analytics_page_views`)
|
|
1054
|
-
|
|
1055
|
-
The `analytics_page_views` table is automatically created on first request.
|
|
1056
|
-
|
|
1057
|
-
**Audit log plugin:**
|
|
1058
|
-
- Records successful (`2xx`) admin panel model mutations: `create`, `update`, `delete`, `restore` on `${adminPath}/api/models/:model/records…`
|
|
1059
|
-
- Actor from `req.session.adminUser` after login; optional IP / user-agent; update metadata stores changed field names only (not full body)
|
|
1060
|
-
- `GET ${adminPath}/api/audit-logs` with pagination and filters (`page`, `perPage`, `model`, `action`, `from`, `to`) — use from custom admin pages or the bundled Mithril list (`includeDefaultPage` default `true`)
|
|
1061
|
-
- Run `webspresso db:migrate` after adding the migration (see `plugins/audit-log/migration-template.js` or the example under `migrations/`). Prune old rows with the CLI (recommended on a schedule):
|
|
1062
|
-
|
|
1063
|
-
```bash
|
|
1064
|
-
npx webspresso audit:prune --days 90
|
|
1065
|
-
```
|
|
1066
|
-
|
|
1067
|
-
```javascript
|
|
1068
|
-
const { adminPanelPlugin, auditLogPlugin } = require('webspresso/plugins');
|
|
1069
|
-
|
|
1070
|
-
const { app } = createApp({
|
|
1071
|
-
pagesDir: './pages',
|
|
1072
|
-
plugins: [
|
|
1073
|
-
adminPanelPlugin({ db }),
|
|
1074
|
-
auditLogPlugin({
|
|
1075
|
-
db,
|
|
1076
|
-
// adminPath: '/_admin', // must match admin panel `path`
|
|
1077
|
-
// tableName: 'audit_logs',
|
|
1078
|
-
// includeDefaultPage: true,
|
|
1079
|
-
// apiPrefix: '/audit-logs',
|
|
1080
|
-
}),
|
|
1081
|
-
],
|
|
1082
|
-
});
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
Programmatic API (other plugins): `ctx.usePlugin('audit-log')` exposes `queryLogs`, `purgeAuditLogs`, and `getMigrationTemplate()`.
|
|
1086
|
-
|
|
1087
|
-
**Email plugin:**
|
|
1088
|
-
- Sends email via **Nodemailer** with **MJML** templates (`{{variable}}` interpolation)
|
|
1089
|
-
- Template sources: `templatesDir` (`*.mjml`, **Node.js only** — skipped on edge/workers), `templates` map with inline `{ mjml }`, runtime `registerTemplate`, or inline `send({ mjml, html, data })`
|
|
1090
|
-
- Transport: `transport`, `smtp`, or env (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`, `MAIL_FROM`)
|
|
1091
|
-
- Optional DB logging to `email_logs` when `db` is passed; prune with `webspresso email:prune --days 90`
|
|
1092
|
-
- **Build:** `webspresso build` embeds `emails/*.mjml` (config `emailDir`) + bundled auth templates into `manifest.emailTemplates` and `email-templates.mjs` (Cloudflare). Use `emailPlugin({ manifest })` or `emailPlugin({ emailTemplates: manifest.emailTemplates })` on edge.
|
|
1093
|
-
- Admin page at `/_admin/email` (test send, preview, SMTP verify, logs) when `adminPanelPlugin` is loaded
|
|
1094
|
-
- Optional **auth bridge**: `authEmails: { enabled: true }` sends password-reset / email-verification mails and can register `POST /api/auth/forgot-password`, `/reset-password`, `/verify-email`, `/resend-verification`
|
|
1095
|
-
|
|
1096
|
-
```javascript
|
|
1097
|
-
const { quickAuth, createAuthTokensTable } = require('webspresso/core/auth');
|
|
1098
|
-
const { adminPanelPlugin, emailPlugin } = require('webspresso/plugins');
|
|
1099
|
-
|
|
1100
|
-
const auth = quickAuth({ db, authTokens: true });
|
|
1101
|
-
await createAuthTokensTable(db.knex);
|
|
1102
|
-
|
|
1103
|
-
const { app, pluginManager } = createApp({
|
|
1104
|
-
pagesDir: './pages',
|
|
1105
|
-
db,
|
|
1106
|
-
auth,
|
|
1107
|
-
plugins: [
|
|
1108
|
-
emailPlugin({
|
|
1109
|
-
db,
|
|
1110
|
-
auth,
|
|
1111
|
-
templatesDir: './emails',
|
|
1112
|
-
smtp: {
|
|
1113
|
-
host: process.env.SMTP_HOST,
|
|
1114
|
-
port: 587,
|
|
1115
|
-
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
|
|
1116
|
-
},
|
|
1117
|
-
defaults: { from: process.env.MAIL_FROM },
|
|
1118
|
-
authEmails: {
|
|
1119
|
-
enabled: true,
|
|
1120
|
-
baseUrl: process.env.BASE_URL,
|
|
1121
|
-
registerRoutes: true,
|
|
1122
|
-
},
|
|
1123
|
-
}),
|
|
1124
|
-
adminPanelPlugin({ db }),
|
|
1125
|
-
],
|
|
1126
|
-
});
|
|
1127
|
-
|
|
1128
|
-
// From a route handler or another plugin:
|
|
1129
|
-
await pluginManager.getPluginAPI('email').sendTemplate('welcome', {
|
|
1130
|
-
to: 'user@example.com',
|
|
1131
|
-
subject: 'Welcome',
|
|
1132
|
-
data: { name: 'Ali' },
|
|
1133
|
-
});
|
|
1134
|
-
```
|
|
1135
|
-
|
|
1136
|
-
Programmatic API: `ctx.usePlugin('email')` exposes `send`, `sendTemplate`, `preview`, `registerTemplate`, `listTemplates`, `verifyConnection`, `queryLogs`, `purgeLogs`, `getMigrationTemplate()`.
|
|
1137
|
-
|
|
1138
|
-
**ORM cache admin plugin:**
|
|
1139
|
-
- Depends on **`adminPanelPlugin`** and a database instance created with **`createDatabase({ cache: true | { … } })`** so **`db.cache`** is non-null
|
|
1140
|
-
- Registers an **ORM Cache** admin page (metrics, full purge, per-model invalidation, reset counters) backed by **`db.cache`**
|
|
1141
|
-
- If `db.cache` is disabled, the plugin logs a warning and skips registration
|
|
1142
|
-
|
|
1143
|
-
```javascript
|
|
1144
|
-
const { adminPanelPlugin, ormCacheAdminPlugin } = require('webspresso');
|
|
1145
|
-
|
|
1146
|
-
const { app } = createApp({
|
|
1147
|
-
pagesDir: './pages',
|
|
1148
|
-
plugins: [
|
|
1149
|
-
adminPanelPlugin({ db }),
|
|
1150
|
-
ormCacheAdminPlugin({ db }),
|
|
1151
|
-
],
|
|
1152
|
-
});
|
|
1153
|
-
```
|
|
1154
|
-
|
|
1155
|
-
**reCAPTCHA plugin:**
|
|
1156
|
-
- Google reCAPTCHA **v2** (checkbox) or **v3** (score): server verification via `https://www.google.com/recaptcha/api/siteverify` (no extra npm dependency; Node 18+ `fetch`)
|
|
1157
|
-
- Registers CSP entries for Google scripts/iframes; Nunjucks helpers: `recaptchaScript`, `recaptchaWidget` (v2), `recaptchaV3Token` (hidden input + execute for v3 — use with `version: 'v3'` and `recaptchaScript` loads `api.js?render=siteKey`)
|
|
1158
|
-
- Secret key: `options.secretKey` or env `RECAPTCHA_SECRET_KEY` (never expose to templates)
|
|
1159
|
-
|
|
1160
|
-
```javascript
|
|
1161
|
-
const {
|
|
1162
|
-
recaptchaPlugin,
|
|
1163
|
-
createRecaptchaMiddleware,
|
|
1164
|
-
resolveRecaptchaMiddlewareParams,
|
|
1165
|
-
} = require('webspresso/plugins/recaptcha');
|
|
1166
|
-
|
|
1167
|
-
const recaptchaConfig = {
|
|
1168
|
-
siteKey: process.env.RECAPTCHA_SITE_KEY,
|
|
1169
|
-
secretKey: process.env.RECAPTCHA_SECRET_KEY,
|
|
1170
|
-
version: 'v2', // or 'v3'
|
|
1171
|
-
minScore: 0.5,
|
|
1172
|
-
expectedAction: 'contact', // v3 verification only
|
|
1173
|
-
defaultV3Action: 'submit', // for recaptchaV3Token helper
|
|
1174
|
-
};
|
|
1175
|
-
|
|
1176
|
-
const { app } = createApp({
|
|
1177
|
-
pagesDir: './pages',
|
|
1178
|
-
plugins: [recaptchaPlugin(recaptchaConfig)],
|
|
1179
|
-
middlewares: {
|
|
1180
|
-
recaptcha: createRecaptchaMiddleware({
|
|
1181
|
-
...resolveRecaptchaMiddlewareParams(recaptchaConfig),
|
|
1182
|
-
bodyField: 'g-recaptcha-response',
|
|
1183
|
-
}),
|
|
1184
|
-
},
|
|
1185
|
-
});
|
|
1186
|
-
```
|
|
1187
|
-
|
|
1188
|
-
**File-based API** (`pages/api/...post.js`): use the named middleware instead of duplicating verification in the handler; on success, `req.recaptcha` contains a short summary of Google’s response.
|
|
1189
|
-
|
|
1190
|
-
```javascript
|
|
1191
|
-
// pages/api/contact.post.js
|
|
1192
|
-
module.exports = async function post(req, res) {
|
|
1193
|
-
// recaptcha middleware already returned 400 if token invalid
|
|
1194
|
-
return res.json({ ok: true, hostname: req.recaptcha.hostname });
|
|
1195
|
-
};
|
|
1196
|
-
|
|
1197
|
-
module.exports.middleware = ['recaptcha'];
|
|
1198
|
-
```
|
|
1199
|
-
|
|
1200
|
-
Optional: after `createApp`, use `pluginManager.getPluginAPI('recaptcha').createMiddleware({ bodyField: '...' })` to override plugin defaults and attach to the `app.use` chain (after body parsers).
|
|
1201
|
-
|
|
1202
|
-
Low-level usage (without middleware): import `verifyRecaptcha` / `getRemoteIp` from `webspresso/plugins/recaptcha/verify`.
|
|
1203
|
-
|
|
1204
|
-
**SEO Checker Plugin:**
|
|
1205
|
-
- Client-side SEO analysis tool (inspired by django-check-seo)
|
|
1206
|
-
- Integrated with dev toolbar
|
|
1207
|
-
- 40+ SEO checks across 7 categories
|
|
1208
|
-
- Real-time analysis with score calculation
|
|
1209
|
-
- Only active in development mode
|
|
1210
|
-
|
|
1211
|
-
```javascript
|
|
1212
|
-
const { seoCheckerPlugin } = require('webspresso/plugins');
|
|
1213
|
-
|
|
1214
|
-
const { app } = createApp({
|
|
1215
|
-
pagesDir: './pages',
|
|
1216
|
-
plugins: [
|
|
1217
|
-
seoCheckerPlugin({
|
|
1218
|
-
settings: {
|
|
1219
|
-
titleLength: [30, 60], // Min/max title length
|
|
1220
|
-
descriptionLength: [50, 160], // Min/max description length
|
|
1221
|
-
minContentWords: 300, // Minimum content words
|
|
1222
|
-
minInternalLinks: 1, // Minimum internal links
|
|
1223
|
-
minExternalLinks: 1, // Minimum external links
|
|
1224
|
-
maxUrlLength: 75, // Maximum URL length
|
|
1225
|
-
maxUrlDepth: 3 // Maximum URL depth
|
|
1226
|
-
}
|
|
1227
|
-
})
|
|
1228
|
-
]
|
|
1229
|
-
});
|
|
1230
|
-
```
|
|
1231
|
-
|
|
1232
|
-
SEO Check Categories:
|
|
1233
|
-
| Category | Checks |
|
|
1234
|
-
|----------|--------|
|
|
1235
|
-
| **Meta** | Title, Description, Canonical, Viewport, Robots, Charset, Lang |
|
|
1236
|
-
| **Headings** | H1 existence, Single H1, Hierarchy, Non-empty headings |
|
|
1237
|
-
| **Content** | Word count, Paragraphs, Keyword usage, Keywords early |
|
|
1238
|
-
| **Links** | Internal links, External links, Nofollow, Anchor text |
|
|
1239
|
-
| **Images** | Alt text, Descriptive alt, Dimensions, Lazy loading |
|
|
1240
|
-
| **Structured** | Open Graph, Twitter Card, JSON-LD, Hreflang |
|
|
1241
|
-
| **URL** | Length, Depth, Readability, HTTPS |
|
|
1242
|
-
|
|
1243
|
-
The SEO Checker panel appears as a floating widget and can be opened via:
|
|
1244
|
-
- Dev toolbar "SEO Check" button
|
|
1245
|
-
- Floating toggle button (🔍) in bottom-right corner
|
|
1246
|
-
|
|
1247
|
-
**Basic Auth Plugin:**
|
|
1248
|
-
- Zero-dependency HTTP Basic Authentication (RFC 7617) with timing-safe password verification (`crypto.timingSafeEqual`).
|
|
1249
|
-
- Can protect the entire application globally or expose named route middleware (`middleware: ['basicAuth']`).
|
|
1250
|
-
|
|
1251
|
-
```javascript
|
|
1252
|
-
const { basicAuthPlugin } = require('webspresso/plugins');
|
|
1253
|
-
|
|
1254
|
-
const { app } = createApp({
|
|
1255
|
-
pagesDir: './pages',
|
|
1256
|
-
plugins: [
|
|
1257
|
-
basicAuthPlugin({
|
|
1258
|
-
global: false, // Set to true to protect the whole app
|
|
1259
|
-
users: {
|
|
1260
|
-
admin: 'supersecret',
|
|
1261
|
-
},
|
|
1262
|
-
realm: 'Protected Area',
|
|
1263
|
-
}),
|
|
1264
|
-
],
|
|
1265
|
-
});
|
|
1266
|
-
```
|
|
1267
|
-
|
|
1268
|
-
**Realtime Layer & Plugin:**
|
|
1269
|
-
- Framework-agnostic, plugin-based, and adapter-supported realtime layer (`core/realtime` and `plugins/realtime`).
|
|
1270
|
-
- Adapters for **Generic WebSocket**, **SSE (Server-Sent Events)**, and **Socket.IO**.
|
|
1271
|
-
- Universal API for browsers and SSR safety (never initializes network connections during server-side rendering).
|
|
1272
|
-
- Built-in exponential backoff reconnection with jitter, multi-strategy authentication (token, cookie, custom), auto-recovery of subscriptions on reconnect, and token sanitization.
|
|
1273
|
-
|
|
1274
|
-
```javascript
|
|
1275
|
-
const { createApp } = require('webspresso');
|
|
1276
|
-
const { realtimePlugin, websocket } = require('webspresso/plugins');
|
|
1277
|
-
|
|
1278
|
-
// 1. App Plugin Setup
|
|
1279
|
-
const { app } = createApp({
|
|
1280
|
-
plugins: [
|
|
1281
|
-
realtimePlugin({
|
|
1282
|
-
adapter: websocket({ url: '/realtime' }),
|
|
1283
|
-
auth: { strategy: 'cookie' },
|
|
1284
|
-
autoConnect: true,
|
|
1285
|
-
}),
|
|
1286
|
-
],
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
// 2. Subscription & RPC
|
|
1290
|
-
const sub = app.realtime.subscribe('project:123', { role: 'editor' }, {
|
|
1291
|
-
connected() {
|
|
1292
|
-
console.log('Connected to project:123');
|
|
1293
|
-
},
|
|
1294
|
-
received(data) {
|
|
1295
|
-
console.log('Live message:', data);
|
|
1296
|
-
},
|
|
1297
|
-
});
|
|
1298
|
-
|
|
1299
|
-
sub.perform('user_typing', { userId: 42, isTyping: true });
|
|
1300
|
-
sub.send({ text: 'Hello team!' });
|
|
1301
|
-
|
|
1302
|
-
// 3. Standalone Client Usage (with Token Auth & Refresh)
|
|
1303
|
-
const { realtime, websocket } = require('webspresso');
|
|
1304
|
-
|
|
1305
|
-
const client = realtime({
|
|
1306
|
-
adapter: websocket({ url: 'wss://api.example.com/realtime' }),
|
|
1307
|
-
auth: {
|
|
1308
|
-
strategy: 'token',
|
|
1309
|
-
getToken: async () => localStorage.getItem('token'),
|
|
1310
|
-
refreshToken: async () => {
|
|
1311
|
-
const res = await fetch('/api/auth/refresh', { method: 'POST' });
|
|
1312
|
-
const { token } = await res.json();
|
|
1313
|
-
localStorage.setItem('token', token);
|
|
1314
|
-
return token;
|
|
1315
|
-
},
|
|
1316
|
-
onUnauthorized: (err) => {
|
|
1317
|
-
window.location.href = '/login';
|
|
1318
|
-
},
|
|
1319
|
-
},
|
|
1320
|
-
});
|
|
1321
|
-
```
|
|
1322
|
-
|
|
1323
|
-
### Creating Custom Plugins
|
|
1324
|
-
|
|
1325
|
-
```javascript
|
|
1326
|
-
const myPlugin = {
|
|
1327
|
-
name: 'my-plugin',
|
|
1328
|
-
version: '1.0.0',
|
|
1329
|
-
|
|
1330
|
-
// Optional: depend on other plugins
|
|
1331
|
-
dependencies: {
|
|
1332
|
-
'analytics': '^1.0.0'
|
|
1333
|
-
},
|
|
1334
|
-
|
|
1335
|
-
// Optional: expose API for other plugins
|
|
1336
|
-
api: {
|
|
1337
|
-
getData() { return this.data; }
|
|
1338
|
-
},
|
|
1339
|
-
|
|
1340
|
-
// Called during registration
|
|
1341
|
-
register(ctx) {
|
|
1342
|
-
// Access Express app
|
|
1343
|
-
ctx.app.use((req, res, next) => next());
|
|
1344
|
-
|
|
1345
|
-
// Access database (when createApp({ db }) is used)
|
|
1346
|
-
if (ctx.db) {
|
|
1347
|
-
// Use ctx.db.getRepository('Model'), ctx.db.knex, etc.
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
// Add template helpers
|
|
1351
|
-
ctx.addHelper('myHelper', () => 'Hello!');
|
|
1352
|
-
|
|
1353
|
-
// Add Nunjucks filters
|
|
1354
|
-
ctx.addFilter('myFilter', (val) => val.toUpperCase());
|
|
1355
|
-
|
|
1356
|
-
// Use other plugins
|
|
1357
|
-
const analytics = ctx.usePlugin('analytics');
|
|
1358
|
-
},
|
|
1359
|
-
|
|
1360
|
-
// Called after all routes are mounted
|
|
1361
|
-
onRoutesReady(ctx) {
|
|
1362
|
-
// ctx.db available when createApp({ db }) is used
|
|
1363
|
-
// Access route metadata
|
|
1364
|
-
console.log('Routes:', ctx.routes);
|
|
1365
|
-
|
|
1366
|
-
// Add custom routes
|
|
1367
|
-
ctx.addRoute('get', '/my-route', (req, res) => {
|
|
1368
|
-
res.json({ hello: 'world' });
|
|
1369
|
-
});
|
|
1370
|
-
},
|
|
1371
|
-
|
|
1372
|
-
// Called before server starts
|
|
1373
|
-
onReady(ctx) {
|
|
1374
|
-
console.log('Server ready!');
|
|
1375
|
-
}
|
|
1376
|
-
};
|
|
1377
|
-
|
|
1378
|
-
// Use as factory function for configuration
|
|
1379
|
-
function myPluginFactory(options = {}) {
|
|
1380
|
-
return {
|
|
1381
|
-
name: 'my-plugin',
|
|
1382
|
-
version: '1.0.0',
|
|
1383
|
-
_options: options,
|
|
1384
|
-
register(ctx) {
|
|
1385
|
-
// ctx.options contains the passed options
|
|
1386
|
-
}
|
|
1387
|
-
};
|
|
1388
|
-
}
|
|
1389
|
-
```
|
|
1390
|
-
|
|
1391
|
-
### Plugin Error Handling
|
|
1392
|
-
|
|
1393
|
-
The plugin system does not crash the app on errors; it only logs warnings:
|
|
1394
|
-
|
|
1395
|
-
- **Missing dependency**: If a plugin in `dependencies` is not loaded → warning, plugin still loads
|
|
1396
|
-
- **Version mismatch**: If a dependent plugin version is incompatible → warning, plugin still loads
|
|
1397
|
-
- **Circular dependency**: If two plugins depend on each other → warning, plugins in the cycle are skipped
|
|
1398
|
-
- **Duplicate plugin name**: Two plugins with the same name → warning, second one is skipped
|
|
1399
|
-
- **`register()` error**: If `register(ctx)` throws → warning, plugin is removed from the registry
|
|
1400
|
-
- **`onRoutesReady()` error**: If the hook throws → warning, server stays up
|
|
1401
|
-
|
|
1402
|
-
A single faulty plugin does not block the entire application.
|
|
1403
|
-
|
|
1404
|
-
## File-Based Routing
|
|
1405
|
-
|
|
1406
|
-
### SSR Pages
|
|
1407
|
-
|
|
1408
|
-
Create `.njk` files in the `pages/` directory:
|
|
1409
|
-
|
|
1410
|
-
| File Path | Route |
|
|
1411
|
-
|-----------|-------|
|
|
1412
|
-
| `pages/index.njk` | `/` |
|
|
1413
|
-
| `pages/about/index.njk` | `/about` |
|
|
1414
|
-
| `pages/tools/[slug].njk` | `/tools/:slug` |
|
|
1415
|
-
| `pages/docs/[...rest].njk` | `/docs/*` |
|
|
1416
|
-
|
|
1417
|
-
### API Routes
|
|
1418
|
-
|
|
1419
|
-
Create `.js` files in `pages/api/` with optional method suffixes:
|
|
1420
|
-
|
|
1421
|
-
| File Path | Route |
|
|
1422
|
-
|-----------|-------|
|
|
1423
|
-
| `pages/api/health.get.js` | `GET /api/health` |
|
|
1424
|
-
| `pages/api/echo.post.js` | `POST /api/echo` |
|
|
1425
|
-
| `pages/api/users/[id].get.js` | `GET /api/users/:id` |
|
|
1426
|
-
|
|
1427
|
-
**Basic API Handler:**
|
|
1428
|
-
|
|
1429
|
-
```javascript
|
|
1430
|
-
// pages/api/health.get.js
|
|
1431
|
-
module.exports = async function handler(req, res) {
|
|
1432
|
-
res.json({ status: 'ok' });
|
|
1433
|
-
};
|
|
1434
|
-
```
|
|
1435
|
-
|
|
1436
|
-
**Object export (`handler`, `middleware`, `schema`):**
|
|
1437
|
-
|
|
1438
|
-
Use a single export object when you need **named middleware** from `createApp({ middlewares })` and/or a **Zod** `schema`. Order at runtime: **`req.db`** (if configured) → **schema** (`req.input`) → **middleware** chain → **handler**.
|
|
1439
|
-
|
|
1440
|
-
```javascript
|
|
1441
|
-
// pages/api/search.post.js
|
|
1442
|
-
module.exports = {
|
|
1443
|
-
middleware: ['requireAuth'],
|
|
1444
|
-
schema: ({ z }) => ({
|
|
1445
|
-
body: z.object({ q: z.string() }),
|
|
1446
|
-
}),
|
|
1447
|
-
handler: async (req, res) => {
|
|
1448
|
-
const { q } = req.input.body;
|
|
1449
|
-
const results = [];
|
|
1450
|
-
return res.json({ results, q });
|
|
1451
|
-
},
|
|
1452
|
-
};
|
|
1453
|
-
```
|
|
1454
|
-
|
|
1455
|
-
Register `requireAuth` (and any other names) on the app:
|
|
1456
|
-
|
|
1457
|
-
```javascript
|
|
1458
|
-
createApp({
|
|
1459
|
-
pagesDir: './pages',
|
|
1460
|
-
middlewares: {
|
|
1461
|
-
requireAuth: (req, res, next) => {
|
|
1462
|
-
if (!req.session?.user) return res.status(401).json({ error: 'Unauthorized' });
|
|
1463
|
-
next();
|
|
1464
|
-
},
|
|
1465
|
-
},
|
|
1466
|
-
});
|
|
1467
|
-
```
|
|
1468
|
-
|
|
1469
|
-
### Authentication & JWT Support (`quickAuth`)
|
|
1470
|
-
|
|
1471
|
-
Webspresso provides a zero-dependency **Dual Authentication** system (`webspresso/core/auth`):
|
|
1472
|
-
- **Session + Cookie**: Stateful auth for browser SSR routes and Admin Panel (`/`, `/_admin`).
|
|
1473
|
-
- **JWT + Refresh Token**: Stateless HS256 auth for REST APIs, Mobile Apps, and SPAs (`/api/*`).
|
|
1474
|
-
|
|
1475
|
-
```javascript
|
|
1476
|
-
// createApp setup with quickAuth
|
|
1477
|
-
const { quickAuth } = require('webspresso/core/auth');
|
|
1478
|
-
|
|
1479
|
-
const auth = quickAuth({
|
|
1480
|
-
db,
|
|
1481
|
-
userModel: 'User',
|
|
1482
|
-
jwt: {
|
|
1483
|
-
expiresIn: '15m', // Access token duration (default: 24h)
|
|
1484
|
-
refreshExpiresIn: '30d', // Refresh token duration (default: 30d)
|
|
1485
|
-
},
|
|
1486
|
-
});
|
|
1487
|
-
|
|
1488
|
-
createApp({
|
|
1489
|
-
pagesDir: './pages',
|
|
1490
|
-
db,
|
|
1491
|
-
auth,
|
|
1492
|
-
});
|
|
1493
|
-
```
|
|
1494
|
-
|
|
1495
|
-
**File-Based API Route Examples (`pages/api/*`):**
|
|
1496
|
-
|
|
1497
|
-
```javascript
|
|
1498
|
-
// 1. pages/api/auth/login.post.js (Login & Token Generation)
|
|
1499
|
-
module.exports = {
|
|
1500
|
-
schema: ({ z }) => ({
|
|
1501
|
-
body: z.object({
|
|
1502
|
-
email: z.string().email(),
|
|
1503
|
-
password: z.string().min(1),
|
|
1504
|
-
}),
|
|
1505
|
-
}),
|
|
1506
|
-
async handler(req, res) {
|
|
1507
|
-
const { email, password } = req.input.body;
|
|
1508
|
-
const user = await req.auth.attempt(email, password);
|
|
1509
|
-
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
1510
|
-
|
|
1511
|
-
const accessToken = req.auth.generateUserToken(user);
|
|
1512
|
-
const refreshToken = req.auth.generateRefreshToken(user);
|
|
1513
|
-
|
|
1514
|
-
return res.json({ accessToken, refreshToken, user: { id: user.id, email: user.email } });
|
|
1515
|
-
},
|
|
1516
|
-
};
|
|
1517
|
-
```
|
|
1518
|
-
|
|
1519
|
-
```javascript
|
|
1520
|
-
// 2. pages/api/auth/refresh.post.js (Token Rotation Endpoint)
|
|
1521
|
-
module.exports = {
|
|
1522
|
-
schema: ({ z }) => ({
|
|
1523
|
-
body: z.object({
|
|
1524
|
-
refreshToken: z.string(),
|
|
1525
|
-
}),
|
|
1526
|
-
}),
|
|
1527
|
-
async handler(req, res) {
|
|
1528
|
-
const { refreshToken } = req.input.body;
|
|
1529
|
-
try {
|
|
1530
|
-
const result = await req.auth.refreshAccessToken(refreshToken, { rotate: true });
|
|
1531
|
-
return res.json({ accessToken: result.accessToken, refreshToken: result.refreshToken });
|
|
1532
|
-
} catch (err) {
|
|
1533
|
-
return res.status(401).json({ error: err.message });
|
|
1534
|
-
}
|
|
1535
|
-
},
|
|
1536
|
-
};
|
|
1537
|
-
```
|
|
1538
|
-
|
|
1539
|
-
```javascript
|
|
1540
|
-
// 3. pages/api/profile.get.js (Protected REST API Route)
|
|
1541
|
-
module.exports = {
|
|
1542
|
-
middleware: ['jwt'],
|
|
1543
|
-
async handler(req, res) {
|
|
1544
|
-
return res.json({ user: req.user, token: req.token });
|
|
1545
|
-
},
|
|
1546
|
-
};
|
|
1547
|
-
```
|
|
1548
|
-
|
|
1549
|
-
**With Schema Validation (same object shape):**
|
|
1550
|
-
|
|
1551
|
-
```javascript
|
|
1552
|
-
// pages/api/posts.post.js
|
|
1553
|
-
module.exports = {
|
|
1554
|
-
schema: ({ z }) => ({
|
|
1555
|
-
body: z.object({
|
|
1556
|
-
title: z.string().min(3).max(100),
|
|
1557
|
-
content: z.string(),
|
|
1558
|
-
tags: z.array(z.string()).optional()
|
|
1559
|
-
}),
|
|
1560
|
-
query: z.object({
|
|
1561
|
-
draft: z.coerce.boolean().default(false)
|
|
1562
|
-
})
|
|
1563
|
-
}),
|
|
1564
|
-
|
|
1565
|
-
async handler(req, res) {
|
|
1566
|
-
// Validated & parsed data available in req.input
|
|
1567
|
-
const { title, content, tags } = req.input.body;
|
|
1568
|
-
const { draft } = req.input.query;
|
|
1569
|
-
|
|
1570
|
-
// Original req.body, req.query remain untouched
|
|
1571
|
-
res.json({ success: true, title, draft });
|
|
1572
|
-
}
|
|
1573
|
-
};
|
|
1574
|
-
```
|
|
1575
|
-
|
|
1576
|
-
**Schema Options:**
|
|
1577
|
-
|
|
1578
|
-
| Key | Description |
|
|
1579
|
-
|-----|-------------|
|
|
1580
|
-
| `body` | Validates `req.body` (POST/PUT/PATCH) |
|
|
1581
|
-
| `params` | Validates route parameters (e.g., `:id`) |
|
|
1582
|
-
| `query` | Validates query string parameters |
|
|
1583
|
-
| `response` | Response schema (for documentation, not enforced) |
|
|
1584
|
-
|
|
1585
|
-
All schemas use [Zod](https://zod.dev) for validation. Invalid requests receive **`400 JSON`**: `{ error: 'Validation Error', issues: [...] }` (no `handler` / `middleware` run).
|
|
1586
|
-
|
|
1587
|
-
For **nanoid** route parameters (same alphabet and default length as **`generateNanoid`** / **`zdb.nanoid()`**), use **`z.nanoid()`** on the `z` passed into your API schema (Webspresso extends it; no extra import). Optional: **`z.nanoid(12)`** or **`z.nanoid({ maxLength: 12 })`** (matches **`zdb.nanoid({ maxLength })`**). For scripts or tests outside `schema: ({ z })`, **`zodNanoid(z, size?)`** and **`extendZ(z)`** are exported from `webspresso`.
|
|
1588
|
-
|
|
1589
|
-
```javascript
|
|
1590
|
-
module.exports = {
|
|
1591
|
-
schema: ({ z }) => ({
|
|
1592
|
-
params: z.object({
|
|
1593
|
-
id: z.nanoid(), // default 21 chars
|
|
1594
|
-
shortId: z.nanoid(12), // numeric length
|
|
1595
|
-
other: z.nanoid({ maxLength: 8 }), // zdb-style options
|
|
1596
|
-
}),
|
|
1597
|
-
}),
|
|
1598
|
-
async handler(req, res) {
|
|
1599
|
-
const { id } = req.input.params;
|
|
1600
|
-
// ...
|
|
1601
|
-
},
|
|
1602
|
-
};
|
|
1603
|
-
```
|
|
1604
|
-
|
|
1605
|
-
**Dynamic Route with Params Validation:**
|
|
1606
|
-
|
|
1607
|
-
```javascript
|
|
1608
|
-
// pages/api/users/[id].get.js
|
|
1609
|
-
module.exports = {
|
|
1610
|
-
schema: ({ z }) => ({
|
|
1611
|
-
params: z.object({
|
|
1612
|
-
id: z.string().uuid()
|
|
1613
|
-
}),
|
|
1614
|
-
query: z.object({
|
|
1615
|
-
fields: z.string().optional()
|
|
1616
|
-
})
|
|
1617
|
-
}),
|
|
1618
|
-
|
|
1619
|
-
async handler(req, res) {
|
|
1620
|
-
const { id } = req.input.params; // Validated UUID
|
|
1621
|
-
const user = await getUser(id);
|
|
1622
|
-
res.json(user);
|
|
1623
|
-
}
|
|
1624
|
-
};
|
|
1625
|
-
```
|
|
1626
|
-
|
|
1627
|
-
### Route Config
|
|
1628
|
-
|
|
1629
|
-
Add a `.js` file alongside your `.njk` file to configure the route:
|
|
1630
|
-
|
|
1631
|
-
```javascript
|
|
1632
|
-
// pages/tools/index.js
|
|
1633
|
-
module.exports = {
|
|
1634
|
-
// Middleware for this route
|
|
1635
|
-
middleware: [(req, res, next) => next()],
|
|
1636
|
-
|
|
1637
|
-
// Load data for SSR
|
|
1638
|
-
async load(req, ctx) {
|
|
1639
|
-
// ctx.db is available when createApp({ db }) is used
|
|
1640
|
-
if (ctx.db) {
|
|
1641
|
-
const posts = await ctx.db.getRepository('Post').query().limit(10);
|
|
1642
|
-
return { posts };
|
|
1643
|
-
}
|
|
1644
|
-
return { tools: await fetchTools() };
|
|
1645
|
-
},
|
|
1646
|
-
|
|
1647
|
-
// Override meta tags
|
|
1648
|
-
meta(req, ctx) {
|
|
1649
|
-
return {
|
|
1650
|
-
title: 'Tools',
|
|
1651
|
-
description: 'Developer tools'
|
|
1652
|
-
};
|
|
1653
|
-
},
|
|
1654
|
-
|
|
1655
|
-
// Route-level hooks
|
|
1656
|
-
hooks: {
|
|
1657
|
-
beforeLoad: async (ctx) => {},
|
|
1658
|
-
afterRender: async (ctx) => {}
|
|
1659
|
-
}
|
|
1660
|
-
};
|
|
1661
|
-
```
|
|
1662
|
-
|
|
1663
|
-
## i18n & Localization
|
|
1664
|
-
|
|
1665
|
-
Webspresso features a zero-dependency, ultra-fast internationalization engine built directly on native Node.js `Intl` APIs (`Intl.PluralRules`, `Intl.NumberFormat`, `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`).
|
|
1666
|
-
|
|
1667
|
-
### 1. Global & Route-Specific Translations
|
|
1668
|
-
|
|
1669
|
-
Add JSON files to `pages/locales/` for global dictionary entries, or inside any route directory `pages/catalog/locales/` for route-specific overrides:
|
|
1670
|
-
|
|
1671
|
-
```json
|
|
1672
|
-
// pages/locales/en.json
|
|
1673
|
-
{
|
|
1674
|
-
"nav": {
|
|
1675
|
-
"home": "Home",
|
|
1676
|
-
"about": "About"
|
|
1677
|
-
},
|
|
1678
|
-
"cart": {
|
|
1679
|
-
"zero": "Your cart is empty",
|
|
1680
|
-
"one": "You have 1 item in your cart",
|
|
1681
|
-
"other": "You have {{count}} items in your cart"
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
```
|
|
1685
|
-
|
|
1686
|
-
### 2. Basic Translation & Interpolation
|
|
1687
|
-
|
|
1688
|
-
```nunjucks
|
|
1689
|
-
{# Direct function call #}
|
|
1690
|
-
<h1>{{ t('nav.home') }}</h1>
|
|
1691
|
-
|
|
1692
|
-
{# With parameter interpolation #}
|
|
1693
|
-
<p>{{ t('welcome', { name: currentUser.username }) }}</p>
|
|
1694
|
-
|
|
1695
|
-
{# With Nunjucks filter #}
|
|
1696
|
-
<h1>{{ 'nav.home' | t }}</h1>
|
|
1697
|
-
<p>{{ 'welcome' | t({ name: currentUser.username }) }}</p>
|
|
1698
|
-
```
|
|
1699
|
-
|
|
1700
|
-
### 3. Native Pluralization (`Intl.PluralRules`)
|
|
1701
|
-
|
|
1702
|
-
Pass `count` (or `n`, `cardinal`) to automatically select the correct CLDR plural category (`zero`, `one`, `two`, `few`, `many`, `other`) or exact number (`0`, `1`):
|
|
1703
|
-
|
|
1704
|
-
```nunjucks
|
|
1705
|
-
<p>{{ t('cart', { count: cart.items.length }) }}</p>
|
|
1706
|
-
```
|
|
1707
|
-
|
|
1708
|
-
You can also use the inline `t.plural()` helper:
|
|
1709
|
-
```nunjucks
|
|
1710
|
-
<p>{{ t.plural(items.length, { one: '1 item', other: '{{count}} items' }) }}</p>
|
|
1711
|
-
```
|
|
1712
|
-
|
|
1713
|
-
### 4. Fallback Locales Chain
|
|
1714
|
-
|
|
1715
|
-
If a translation key is missing in the requested locale (e.g. `tr`), Webspresso automatically falls back to `DEFAULT_LOCALE` (default: `en`) before returning the key name or default fallback value. Check key existence with `t.has('key')` or `t.exists('key')`.
|
|
1716
|
-
|
|
1717
|
-
### 5. Built-in `Intl` Formatters on `t`
|
|
1718
|
-
|
|
1719
|
-
Format numbers, currencies, dates, and relative times according to the active locale without external date/number libraries:
|
|
1720
|
-
|
|
1721
|
-
```nunjucks
|
|
1722
|
-
{# Numbers #}
|
|
1723
|
-
<span>{{ t.number(1234567.89) }}</span> {# TR: 1.234.567,89 | EN: 1,234,567.89 #}
|
|
1724
|
-
|
|
1725
|
-
{# Currencies #}
|
|
1726
|
-
<span>{{ t.currency(199.99, 'TRY') }}</span> {# ₺199,99 #}
|
|
1727
|
-
<span>{{ t.currency(49.99, 'USD') }}</span> {# $49.99 #}
|
|
1728
|
-
|
|
1729
|
-
{# Dates #}
|
|
1730
|
-
<span>{{ t.date(post.created_at, { dateStyle: 'long' }) }}</span>
|
|
1731
|
-
|
|
1732
|
-
{# Relative Time #}
|
|
1733
|
-
<span>{{ t.relativeTime(-2, 'day') }}</span> {# "2 days ago" or "2 gün önce" #}
|
|
1734
|
-
```
|
|
1735
|
-
|
|
1736
|
-
### 6. Language Switcher Helpers
|
|
1737
|
-
|
|
1738
|
-
Use `fsy.localeUrl(targetLocale)` to generate a URL with the current page path and query parameters preserved:
|
|
1739
|
-
|
|
1740
|
-
```nunjucks
|
|
1741
|
-
<a href="{{ fsy.localeUrl('en') }}">English</a>
|
|
1742
|
-
<a href="{{ fsy.localeUrl('tr') }}">Türkçe</a>
|
|
1743
|
-
<a href="{{ fsy.localeUrl('de') }}">Deutsch</a>
|
|
1744
|
-
```
|
|
1745
|
-
|
|
1746
|
-
## Template Helpers
|
|
1747
|
-
|
|
1748
|
-
The `fsy` object is available in all templates:
|
|
1749
|
-
|
|
1750
|
-
```nunjucks
|
|
1751
|
-
{# URL helpers #}
|
|
1752
|
-
{{ fsy.url('/tools', { page: 1 }) }}
|
|
1753
|
-
{{ fsy.fullUrl('/tools') }}
|
|
1754
|
-
{{ fsy.route('/tools/:slug', { slug: 'test' }) }}
|
|
1755
|
-
|
|
1756
|
-
{# Request helpers #}
|
|
1757
|
-
{{ fsy.q('page', 1) }}
|
|
1758
|
-
{{ fsy.param('slug') }}
|
|
1759
|
-
{{ fsy.hdr('User-Agent') }}
|
|
1760
|
-
|
|
1761
|
-
{# Utility helpers #}
|
|
1762
|
-
{{ fsy.slugify('Hello World') }}
|
|
1763
|
-
{{ fsy.truncate(text, 100) }}
|
|
1764
|
-
{{ fsy.prettyBytes(1024) }}
|
|
1765
|
-
{{ fsy.prettyMs(5000) }}
|
|
1766
|
-
|
|
1767
|
-
{# Date/Time helpers (dayjs) #}
|
|
1768
|
-
{{ fsy.dateFormat(post.created_at, 'YYYY-MM-DD HH:mm') }}
|
|
1769
|
-
{{ fsy.dateFromNow(post.created_at) }} {# "2 hours ago" #}
|
|
1770
|
-
{{ fsy.dateAgo(post.created_at) }} {# "2 hours ago" #}
|
|
1771
|
-
{{ fsy.dateUntil(event.date) }} {# "in 2 hours" #}
|
|
1772
|
-
{{ fsy.date(post.created_at).format('MMMM D, YYYY') }} {# Full dayjs API #}
|
|
1773
|
-
{% if fsy.dateIsBefore(post.created_at, fsy.date()) %}Published{% endif %}
|
|
1774
|
-
{{ fsy.dateDiff(post.created_at, fsy.date(), 'day') }} days ago
|
|
1775
|
-
{{ fsy.dateAdd(post.created_at, 7, 'day').format('YYYY-MM-DD') }}
|
|
1776
|
-
{{ fsy.dateStartOf(post.created_at, 'month').format('YYYY-MM-DD') }}
|
|
1777
|
-
|
|
1778
|
-
{# Environment #}
|
|
1779
|
-
{% if fsy.isDev() %}Dev mode{% endif %}
|
|
1780
|
-
|
|
1781
|
-
{# SEO #}
|
|
1782
|
-
{{ fsy.canonical() }}
|
|
1783
|
-
{{ fsy.jsonld(schema) | safe }}
|
|
1784
|
-
```
|
|
1785
|
-
|
|
1786
|
-
## Lifecycle Hooks
|
|
1787
|
-
|
|
1788
|
-
### Global Hooks
|
|
1789
|
-
|
|
1790
|
-
Create `pages/_hooks.js`:
|
|
1791
|
-
|
|
1792
|
-
```javascript
|
|
1793
|
-
module.exports = {
|
|
1794
|
-
onRequest(ctx) {},
|
|
1795
|
-
beforeLoad(ctx) {},
|
|
1796
|
-
afterLoad(ctx) {},
|
|
1797
|
-
beforeRender(ctx) {},
|
|
1798
|
-
afterRender(ctx) {},
|
|
1799
|
-
onError(ctx, err) {}
|
|
1800
|
-
};
|
|
1801
|
-
```
|
|
1802
|
-
|
|
1803
|
-
### Hook Execution Order
|
|
1804
|
-
|
|
1805
|
-
1. Global `onRequest`
|
|
1806
|
-
2. Route `onRequest`
|
|
1807
|
-
3. Route `beforeMiddleware`
|
|
1808
|
-
4. Route middleware
|
|
1809
|
-
5. Route `afterMiddleware`
|
|
1810
|
-
6. Route `beforeLoad`
|
|
1811
|
-
7. Route `load()`
|
|
1812
|
-
8. Route `afterLoad`
|
|
1813
|
-
9. Route `beforeRender`
|
|
1814
|
-
10. Nunjucks render
|
|
1815
|
-
11. Route `afterRender`
|
|
1816
|
-
|
|
1817
|
-
## Services Layer
|
|
1818
|
-
|
|
1819
|
-
Webspresso includes a lightweight, framework-agnostic **Services Layer** to organize reusable business logic, query flows, and multi-step mutations under `services/`.
|
|
1820
|
-
|
|
1821
|
-
Services are automatically discovered from the filesystem and can be invoked from SSR data loaders (`ctx.service()`), API routes (`req.service()`), background jobs, cron, CLI tasks, or other services.
|
|
1822
|
-
|
|
1823
|
-
### 1. File Structure & Service Names
|
|
1824
|
-
|
|
1825
|
-
Files inside `services/` map directly to dot-separated service names:
|
|
1826
|
-
|
|
1827
|
-
```text
|
|
1828
|
-
services/
|
|
1829
|
-
user/
|
|
1830
|
-
get.js → user.get
|
|
1831
|
-
create.js → user.create
|
|
1832
|
-
profile/
|
|
1833
|
-
get.js → user.profile.get
|
|
1834
|
-
post/
|
|
1835
|
-
publish.js → post.publish
|
|
1836
|
-
listByUser.js → post.listByUser
|
|
1837
|
-
payment/
|
|
1838
|
-
refund.js → payment.refund
|
|
1839
|
-
```
|
|
1840
|
-
|
|
1841
|
-
### 2. Service Definition
|
|
1842
|
-
|
|
1843
|
-
Services export a handler and an optional input validation schema:
|
|
1844
|
-
|
|
1845
|
-
```javascript
|
|
1846
|
-
// services/user/get.js
|
|
1847
|
-
module.exports = {
|
|
1848
|
-
schema: {
|
|
1849
|
-
id: 'number',
|
|
1850
|
-
},
|
|
1851
|
-
|
|
1852
|
-
async handler({ id }, ctx) {
|
|
1853
|
-
return ctx.db.users.findById(id);
|
|
1854
|
-
},
|
|
1855
|
-
};
|
|
1856
|
-
```
|
|
1857
|
-
|
|
1858
|
-
You can also use the `defineService` helper:
|
|
1859
|
-
|
|
1860
|
-
```javascript
|
|
1861
|
-
// services/payment/refund.js
|
|
1862
|
-
const { defineService } = require('webspresso');
|
|
1863
|
-
|
|
1864
|
-
module.exports = defineService({
|
|
1865
|
-
// Zod functional schema syntax ({ z }) with built-in nanoid support
|
|
1866
|
-
schema: ({ z }) => z.object({
|
|
1867
|
-
transactionId: z.string().min(3),
|
|
1868
|
-
amount: z.number().positive(),
|
|
1869
|
-
reason: z.string().optional(),
|
|
1870
|
-
}),
|
|
1871
|
-
|
|
1872
|
-
async handler({ transactionId, amount, reason }, ctx) {
|
|
1873
|
-
// Process refund logic...
|
|
1874
|
-
return { refundId: 'ref_123', transactionId, amount, status: 'succeeded' };
|
|
1875
|
-
},
|
|
1876
|
-
});
|
|
1877
|
-
```
|
|
1878
|
-
|
|
1879
|
-
### 3. Service-to-Service Composition
|
|
1880
|
-
|
|
1881
|
-
Services can seamlessly call other services via `ctx.service()` while sharing the exact same request context (`ctx.db`, `ctx.auth`, `ctx.session`, `ctx.user`):
|
|
1882
|
-
|
|
1883
|
-
```javascript
|
|
1884
|
-
// services/post/publish.js
|
|
1885
|
-
module.exports = {
|
|
1886
|
-
schema: ({ z }) => z.object({
|
|
1887
|
-
title: z.string(),
|
|
1888
|
-
authorId: z.number(),
|
|
1889
|
-
}),
|
|
1890
|
-
|
|
1891
|
-
async handler({ title, authorId }, ctx) {
|
|
1892
|
-
const author = await ctx.service('user.get', { id: authorId });
|
|
1893
|
-
|
|
1894
|
-
return ctx.db.posts.create({
|
|
1895
|
-
title,
|
|
1896
|
-
author_id: author.id,
|
|
1897
|
-
published_at: new Date(),
|
|
1898
|
-
});
|
|
1899
|
-
},
|
|
1900
|
-
};
|
|
1901
|
-
```
|
|
1902
|
-
|
|
1903
|
-
> **Circular Call Protection**: Webspresso automatically tracks service call stacks and detects recursive/circular dependencies (e.g. `user.get -> profile.get -> user.get`), throwing an informative `Circular service call detected` error.
|
|
1904
|
-
|
|
1905
|
-
### 4. Invoking from SSR & API Routes
|
|
1906
|
-
|
|
1907
|
-
In SSR page data loaders (`pages/users/[id].njk`):
|
|
1908
|
-
|
|
1909
|
-
```javascript
|
|
1910
|
-
// pages/users/[id].js
|
|
1911
|
-
module.exports = {
|
|
1912
|
-
async load(req, ctx) {
|
|
1913
|
-
const user = await ctx.service('user.get', { id: Number(req.params.id) });
|
|
1914
|
-
return { user };
|
|
1915
|
-
},
|
|
1916
|
-
};
|
|
1917
|
-
```
|
|
1918
|
-
|
|
1919
|
-
In API routes or Express middleware:
|
|
1920
|
-
|
|
1921
|
-
```javascript
|
|
1922
|
-
// pages/api/users.post.js
|
|
1923
|
-
module.exports = async function(req, res) {
|
|
1924
|
-
const user = await req.service('user.create', req.body);
|
|
1925
|
-
res.status(201).json(user);
|
|
1926
|
-
};
|
|
1927
|
-
```
|
|
1928
|
-
|
|
1929
|
-
### 5. Service Caching & Memoization (`cache: '5m'`)
|
|
1930
|
-
|
|
1931
|
-
Services can be cached in-memory with automatic TTL expiration and deterministic key serialization:
|
|
1932
|
-
|
|
1933
|
-
```javascript
|
|
1934
|
-
// services/product/stats.js
|
|
1935
|
-
module.exports = {
|
|
1936
|
-
// Cache result for 5 minutes based on input arguments
|
|
1937
|
-
cache: '5m', // supports '30s', '5m', '1h', '1d', ms numbers, or { ttl: '5m', key: (input) => input.id }
|
|
1938
|
-
|
|
1939
|
-
async handler({ productId }, ctx) {
|
|
1940
|
-
return ctx.db.stats.getProductStats(productId);
|
|
1941
|
-
},
|
|
1942
|
-
};
|
|
1943
|
-
```
|
|
1944
|
-
|
|
1945
|
-
You can invalidate cache keys explicitly on mutations:
|
|
1946
|
-
|
|
1947
|
-
```javascript
|
|
1948
|
-
// From any service or route:
|
|
1949
|
-
ctx.service.invalidate('product.stats', { productId: 42 });
|
|
1950
|
-
|
|
1951
|
-
// Or clear all cached entries for a service:
|
|
1952
|
-
ctx.service.clearCache('product.stats');
|
|
1953
|
-
```
|
|
1954
|
-
|
|
1955
|
-
You can also use `memoize` as a standalone utility for any async function:
|
|
1956
|
-
|
|
1957
|
-
```javascript
|
|
1958
|
-
const { memoize } = require('webspresso');
|
|
1959
|
-
|
|
1960
|
-
const fetchRemoteRates = memoize(async () => {
|
|
1961
|
-
return fetchExchangeRates();
|
|
1962
|
-
}, { ttl: '10m', maxSize: 100 });
|
|
1963
|
-
```
|
|
1964
|
-
|
|
1965
|
-
### 6. Timeouts & Database Transactions (`timeout`, `transaction`)
|
|
1966
|
-
|
|
1967
|
-
You can protect services against hanging handlers and ensure automatic ACID transaction rollbacks:
|
|
1968
|
-
|
|
1969
|
-
```javascript
|
|
1970
|
-
// services/order/checkout.js
|
|
1971
|
-
module.exports = {
|
|
1972
|
-
// Abort with 504 Gateway Timeout if execution exceeds 5 seconds
|
|
1973
|
-
timeout: '5s',
|
|
1974
|
-
|
|
1975
|
-
// Wrap execution in an automatic Knex transaction (rolls back on any error)
|
|
1976
|
-
transaction: true,
|
|
1977
|
-
|
|
1978
|
-
async handler({ orderId, items }, ctx) {
|
|
1979
|
-
// ctx.trx and ctx.db are automatically bound to the active transaction
|
|
1980
|
-
await ctx.db('orders').where({ id: orderId }).update({ status: 'paid' });
|
|
1981
|
-
await ctx.service('inventory.deduct', { items }); // Nested services reuse the exact same transaction!
|
|
1982
|
-
|
|
1983
|
-
return { orderId, success: true };
|
|
1984
|
-
},
|
|
1985
|
-
};
|
|
1986
|
-
```
|
|
1987
|
-
|
|
1988
|
-
### 7. Authorization & Role Guards (`auth`)
|
|
1989
|
-
|
|
1990
|
-
Services can enforce authentication and role-based access control (RBAC) automatically:
|
|
1991
|
-
|
|
1992
|
-
```javascript
|
|
1993
|
-
// services/admin/billing.js
|
|
1994
|
-
module.exports = {
|
|
1995
|
-
// Requires authenticated user with 'admin' or 'finance' role
|
|
1996
|
-
auth: ['admin', 'finance'], // or auth: true (any logged in user) or auth: (user, ctx) => boolean
|
|
1997
|
-
|
|
1998
|
-
async handler(input, ctx) {
|
|
1999
|
-
// Guaranteed to have authorized ctx.user
|
|
2000
|
-
return ctx.db('billing_records').select();
|
|
2001
|
-
},
|
|
2002
|
-
};
|
|
2003
|
-
```
|
|
2004
|
-
|
|
2005
|
-
Throws `UnauthorizedError` (401) when unauthenticated or `ForbiddenError` (403) when role permissions are insufficient.
|
|
2006
|
-
|
|
2007
|
-
### 8. Interactive CLI Runner (`webspresso service`)
|
|
2008
|
-
|
|
2009
|
-
You can test and run any service directly from the command line without starting the HTTP server:
|
|
2010
|
-
|
|
2011
|
-
```bash
|
|
2012
|
-
# Interactive selection & prompt mode
|
|
2013
|
-
npx webspresso service
|
|
2014
|
-
|
|
2015
|
-
# Run a specific service with JSON payload
|
|
2016
|
-
npx webspresso service user.get -i '{"id": 42}'
|
|
2017
|
-
|
|
2018
|
-
# Run with key=value query string
|
|
2019
|
-
npx webspresso service payment.refund -i 'transactionId=tx_123 amount=49.99'
|
|
2020
|
-
```
|
|
2021
|
-
|
|
2022
|
-
### 7. Programmatic Usage & Testing
|
|
2023
|
-
|
|
2024
|
-
You can execute services directly in unit tests or background scripts:
|
|
2025
|
-
|
|
2026
|
-
```javascript
|
|
2027
|
-
const { createServiceRegistry } = require('webspresso');
|
|
2028
|
-
|
|
2029
|
-
const services = createServiceRegistry({ servicesDir: './services' });
|
|
2030
|
-
const user = await services.call('user.get', { id: 42 }, { db: mockDb });
|
|
2031
|
-
```
|
|
2032
|
-
|
|
2033
|
-
---
|
|
2034
|
-
|
|
2035
|
-
## Environment Variables
|
|
2036
|
-
|
|
2037
|
-
| Variable | Default | Description |
|
|
2038
|
-
|----------|---------|-------------|
|
|
2039
|
-
| `NODE_ENV` | `development` | Environment |
|
|
2040
|
-
| `DEFAULT_LOCALE` | `en` | Default locale |
|
|
2041
|
-
| `SUPPORTED_LOCALES` | `en` | Comma-separated locales |
|
|
2042
|
-
| `BASE_URL` | `http://localhost:3000` | Base URL for canonical URLs |
|
|
2043
|
-
| `DATABASE_URL` | - | Database connection string (for ORM) |
|
|
2044
|
-
|
|
2045
|
-
## ORM (Database)
|
|
2046
|
-
|
|
2047
|
-
Webspresso includes a minimal, Eloquent-inspired ORM built on Knex with Zod schemas as the single source of truth.
|
|
2048
|
-
|
|
2049
|
-
### Quick Start
|
|
2050
|
-
|
|
2051
|
-
```javascript
|
|
2052
|
-
const { zdb, defineModel, createDatabase } = require('webspresso');
|
|
2053
|
-
|
|
2054
|
-
// 1. Define your schema with database metadata
|
|
2055
|
-
const UserSchema = zdb.schema({
|
|
2056
|
-
id: zdb.id(),
|
|
2057
|
-
email: zdb.string({ unique: true, index: true }),
|
|
2058
|
-
name: zdb.string({ maxLength: 100 }),
|
|
2059
|
-
status: zdb.enum(['active', 'inactive'], { default: 'active' }),
|
|
2060
|
-
company_id: zdb.foreignKey('companies', { nullable: true }),
|
|
2061
|
-
created_at: zdb.timestamp({ auto: 'create' }),
|
|
2062
|
-
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
2063
|
-
deleted_at: zdb.timestamp({ nullable: true }),
|
|
2064
|
-
});
|
|
2065
|
-
|
|
2066
|
-
// 3. Define your model
|
|
2067
|
-
const User = defineModel({
|
|
2068
|
-
name: 'User',
|
|
2069
|
-
table: 'users',
|
|
2070
|
-
schema: UserSchema,
|
|
2071
|
-
relations: {
|
|
2072
|
-
company: { type: 'belongsTo', model: () => Company, foreignKey: 'company_id' },
|
|
2073
|
-
posts: { type: 'hasMany', model: () => Post, foreignKey: 'user_id' },
|
|
2074
|
-
},
|
|
2075
|
-
scopes: { softDelete: true, timestamps: true },
|
|
2076
|
-
});
|
|
2077
|
-
|
|
2078
|
-
// 4. Create database (models auto-loaded from ./models directory)
|
|
2079
|
-
const db = createDatabase({
|
|
2080
|
-
client: 'pg',
|
|
2081
|
-
connection: process.env.DATABASE_URL,
|
|
2082
|
-
models: './models', // Optional, defaults to './models'
|
|
2083
|
-
});
|
|
2084
|
-
|
|
2085
|
-
// Models are automatically loaded from models/ directory
|
|
2086
|
-
// Use getRepository with model name
|
|
2087
|
-
const UserRepo = db.getRepository('User');
|
|
2088
|
-
const user = await UserRepo.findById(1, { with: ['company', 'posts'] });
|
|
2089
|
-
```
|
|
2090
|
-
|
|
2091
|
-
### Schema Helpers (zdb)
|
|
2092
|
-
|
|
2093
|
-
The `zdb` helpers wrap Zod schemas with database column metadata:
|
|
2094
|
-
|
|
2095
|
-
| Helper | Description | Options |
|
|
2096
|
-
|--------|-------------|---------|
|
|
2097
|
-
| `zdb.id()` | Primary key (bigint, auto-increment) | |
|
|
2098
|
-
| `zdb.uuid()` | UUID primary key | |
|
|
2099
|
-
| `zdb.nanoid(opts)` | Nanoid primary key (URL-safe string, stored as VARCHAR) | `maxLength` (default `21`) |
|
|
2100
|
-
| `zdb.string(opts)` | VARCHAR column | `maxLength`, `unique`, `index`, `nullable` |
|
|
2101
|
-
| `zdb.text(opts)` | TEXT column | `nullable` |
|
|
2102
|
-
| `zdb.integer(opts)` | INTEGER column | `nullable`, `default` |
|
|
2103
|
-
| `zdb.bigint(opts)` | BIGINT column | `nullable` |
|
|
2104
|
-
| `zdb.float(opts)` | FLOAT column | `nullable` |
|
|
2105
|
-
| `zdb.decimal(opts)` | DECIMAL column | `precision`, `scale`, `nullable` |
|
|
2106
|
-
| `zdb.boolean(opts)` | BOOLEAN column | `default`, `nullable` |
|
|
2107
|
-
| `zdb.date(opts)` | DATE column | `nullable` |
|
|
2108
|
-
| `zdb.datetime(opts)` | DATETIME column | `nullable` |
|
|
2109
|
-
| `zdb.timestamp(opts)` | TIMESTAMP column | `auto: 'create'\|'update'`, `nullable` |
|
|
2110
|
-
| `zdb.json(opts)` | JSON column | `nullable` |
|
|
2111
|
-
| `zdb.array(itemSchema, opts)` | ARRAY column (stored as JSON) | `nullable` |
|
|
2112
|
-
| `zdb.enum(values, opts)` | ENUM column | `default`, `nullable` |
|
|
2113
|
-
| `zdb.foreignKey(table, opts)` | Foreign key (bigint) | `referenceColumn`, `nullable` |
|
|
2114
|
-
| `zdb.foreignUuid(table, opts)` | Foreign key (uuid) | `referenceColumn`, `nullable` |
|
|
2115
|
-
| `zdb.foreignNanoid(table, opts)` | Foreign key (nanoid string) | `referenceColumn`, `nullable`, `maxLength` (must match referenced PK) |
|
|
2116
|
-
|
|
2117
|
-
**Nanoid columns:** Migration scaffolding emits `table.string(column, maxLength)`. For a **nanoid primary key**, if you omit the primary key on `repository.create()`, Webspresso fills it with a cryptographically random ID using the same default alphabet as the [`nanoid`](https://github.com/ai/nanoid) package (implemented in-framework; no extra dependency). You can also call **`generateNanoid`** (exported from `webspresso`) anywhere you need the same generator.
|
|
2118
|
-
|
|
2119
|
-
### Model Definition
|
|
2120
|
-
|
|
2121
|
-
```javascript
|
|
2122
|
-
const User = defineModel({
|
|
2123
|
-
name: 'User', // Model name
|
|
2124
|
-
table: 'users', // Database table
|
|
2125
|
-
schema: UserSchema, // Zod schema
|
|
2126
|
-
primaryKey: 'id', // Primary key column (default: 'id')
|
|
2127
|
-
|
|
2128
|
-
relations: {
|
|
2129
|
-
// belongsTo: this model has foreign key
|
|
2130
|
-
company: {
|
|
2131
|
-
type: 'belongsTo',
|
|
2132
|
-
model: () => Company,
|
|
2133
|
-
foreignKey: 'company_id',
|
|
2134
|
-
},
|
|
2135
|
-
// hasMany: related model has foreign key
|
|
2136
|
-
posts: {
|
|
2137
|
-
type: 'hasMany',
|
|
2138
|
-
model: () => Post,
|
|
2139
|
-
foreignKey: 'user_id',
|
|
2140
|
-
},
|
|
2141
|
-
// hasOne: like hasMany but returns single record
|
|
2142
|
-
profile: {
|
|
2143
|
-
type: 'hasOne',
|
|
2144
|
-
model: () => Profile,
|
|
2145
|
-
foreignKey: 'user_id',
|
|
2146
|
-
},
|
|
2147
|
-
},
|
|
2148
|
-
|
|
2149
|
-
scopes: {
|
|
2150
|
-
softDelete: true, // Use deleted_at column
|
|
2151
|
-
timestamps: true, // Auto-manage created_at/updated_at
|
|
2152
|
-
tenant: 'tenant_id', // Multi-tenant column (optional)
|
|
2153
|
-
},
|
|
2154
|
-
|
|
2155
|
-
hidden: ['password_hash', 'api_token'], // Never expose in API/templates (security)
|
|
2156
|
-
});
|
|
2157
|
-
```
|
|
2158
|
-
|
|
2159
|
-
**Hidden columns:** Add column names to `hidden` so they are never exposed in admin API responses, exports, or when passing to templates. Use for sensitive data like `password_hash`, `api_token`, `secret_key`. The admin panel will exclude these from list views and forms automatically.
|
|
2160
|
-
|
|
2161
|
-
### Auto-Loading Models
|
|
2162
|
-
|
|
2163
|
-
Models are automatically loaded from the `models/` directory when you create a database instance:
|
|
2164
|
-
|
|
2165
|
-
```javascript
|
|
2166
|
-
// models/User.js
|
|
2167
|
-
const { defineModel, zdb } = require('webspresso');
|
|
2168
|
-
|
|
2169
|
-
module.exports = defineModel({
|
|
2170
|
-
name: 'User',
|
|
2171
|
-
table: 'users',
|
|
2172
|
-
schema: zdb.schema({
|
|
2173
|
-
id: zdb.id(),
|
|
2174
|
-
email: zdb.string({ unique: true }),
|
|
2175
|
-
name: zdb.string(),
|
|
2176
|
-
created_at: zdb.timestamp({ auto: 'create' }),
|
|
2177
|
-
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
2178
|
-
}),
|
|
2179
|
-
});
|
|
2180
|
-
|
|
2181
|
-
// In your application code
|
|
2182
|
-
const db = createDatabase({
|
|
2183
|
-
client: 'pg',
|
|
2184
|
-
connection: process.env.DATABASE_URL,
|
|
2185
|
-
models: './models', // Optional, defaults to './models'
|
|
2186
|
-
});
|
|
2187
|
-
|
|
2188
|
-
// Models are automatically loaded, use getRepository with model name
|
|
2189
|
-
const UserRepo = db.getRepository('User');
|
|
2190
|
-
```
|
|
2191
|
-
|
|
2192
|
-
Pass `db` to `createApp({ db })` to expose it as `ctx.db` in plugin hooks and page `load`/`meta` functions.
|
|
2193
|
-
|
|
2194
|
-
**Model File Structure:**
|
|
2195
|
-
- Place model files in `models/` directory (or custom path via `config.models`)
|
|
2196
|
-
- Each file should export a model defined with `defineModel()`
|
|
2197
|
-
- Files starting with `_` are ignored (useful for shared utilities)
|
|
2198
|
-
- Models are loaded in alphabetical order
|
|
2199
|
-
|
|
2200
|
-
### Repository API
|
|
2201
|
-
|
|
2202
|
-
```javascript
|
|
2203
|
-
const db = createDatabase({ client: 'pg', connection: '...' });
|
|
2204
|
-
const UserRepo = db.getRepository('User'); // Use model name string
|
|
2205
|
-
|
|
2206
|
-
// Find by ID (with eager loading)
|
|
2207
|
-
const user = await UserRepo.findById(1, { with: ['company', 'posts'] });
|
|
2208
|
-
|
|
2209
|
-
// Find one by conditions
|
|
2210
|
-
const admin = await UserRepo.findOne({ email: 'admin@example.com' });
|
|
2211
|
-
|
|
2212
|
-
// Find all
|
|
2213
|
-
const users = await UserRepo.findAll({ with: ['company'] });
|
|
2214
|
-
|
|
2215
|
-
// Create
|
|
2216
|
-
const newUser = await UserRepo.create({
|
|
2217
|
-
email: 'new@example.com',
|
|
2218
|
-
name: 'New User',
|
|
2219
|
-
});
|
|
2220
|
-
|
|
2221
|
-
// Create many
|
|
2222
|
-
const users = await UserRepo.createMany([
|
|
2223
|
-
{ email: 'user1@test.com', name: 'User 1' },
|
|
2224
|
-
{ email: 'user2@test.com', name: 'User 2' },
|
|
2225
|
-
]);
|
|
2226
|
-
|
|
2227
|
-
// Update
|
|
2228
|
-
const updated = await UserRepo.update(1, { name: 'Updated Name' });
|
|
2229
|
-
|
|
2230
|
-
// Update where
|
|
2231
|
-
await UserRepo.updateWhere({ status: 'inactive' }, { status: 'banned' });
|
|
2232
|
-
|
|
2233
|
-
// Delete (soft delete if enabled)
|
|
2234
|
-
await UserRepo.delete(1);
|
|
2235
|
-
|
|
2236
|
-
// Force delete (permanent)
|
|
2237
|
-
await UserRepo.forceDelete(1);
|
|
2238
|
-
|
|
2239
|
-
// Restore soft-deleted
|
|
2240
|
-
await UserRepo.restore(1);
|
|
2241
|
-
|
|
2242
|
-
// Count
|
|
2243
|
-
const count = await UserRepo.count({ status: 'active' });
|
|
2244
|
-
|
|
2245
|
-
// Exists
|
|
2246
|
-
const exists = await UserRepo.exists({ email: 'test@example.com' });
|
|
2247
|
-
```
|
|
2248
|
-
|
|
2249
|
-
### Query Builder
|
|
2250
|
-
|
|
2251
|
-
```javascript
|
|
2252
|
-
const users = await UserRepo.query()
|
|
2253
|
-
.where({ status: 'active' })
|
|
2254
|
-
.where('created_at', '>', '2024-01-01')
|
|
2255
|
-
.whereIn('role', ['admin', 'moderator'])
|
|
2256
|
-
.whereNotNull('email_verified_at')
|
|
2257
|
-
.orderBy('name', 'asc')
|
|
2258
|
-
.orderBy('created_at', 'desc')
|
|
2259
|
-
.limit(10)
|
|
2260
|
-
.offset(20)
|
|
2261
|
-
.with('company', 'posts')
|
|
2262
|
-
.list();
|
|
2263
|
-
|
|
2264
|
-
// First result
|
|
2265
|
-
const user = await UserRepo.query()
|
|
2266
|
-
.where({ email: 'admin@example.com' })
|
|
2267
|
-
.first();
|
|
2268
|
-
|
|
2269
|
-
// Count
|
|
2270
|
-
const count = await UserRepo.query()
|
|
2271
|
-
.where({ status: 'active' })
|
|
2272
|
-
.count();
|
|
2273
|
-
|
|
2274
|
-
// Pagination
|
|
2275
|
-
const result = await UserRepo.query()
|
|
2276
|
-
.where({ status: 'active' })
|
|
2277
|
-
.orderBy('created_at', 'desc')
|
|
2278
|
-
.paginate(1, 20); // page 1, 20 per page
|
|
2279
|
-
|
|
2280
|
-
// result = { data: [...], total: 150, page: 1, perPage: 20, totalPages: 8 }
|
|
2281
|
-
|
|
2282
|
-
// Soft delete scopes
|
|
2283
|
-
await UserRepo.query().withTrashed().list(); // Include deleted
|
|
2284
|
-
await UserRepo.query().onlyTrashed().list(); // Only deleted
|
|
2285
|
-
|
|
2286
|
-
// Multi-tenant
|
|
2287
|
-
await UserRepo.query().forTenant(tenantId).list();
|
|
2288
|
-
```
|
|
2289
|
-
|
|
2290
|
-
`list()`, `first()`, and `paginate()` emit the same `beforeFind` / `afterFind` lifecycle hooks as `findAll` / `findOne` (one `afterFind` per row). `count()` ignores any `.limit()` / `.offset()` on the builder so it returns the full matching row count.
|
|
2291
|
-
|
|
2292
|
-
`query().delete()` runs a SQL `DELETE` for matching rows. On models with soft deletes, use `UserRepo.delete(id)` (or equivalent) to set `deleted_at`; the query builder does not convert deletes to soft deletes.
|
|
2293
|
-
|
|
2294
|
-
### Transactions
|
|
2295
|
-
|
|
2296
|
-
```javascript
|
|
2297
|
-
await db.transaction(async (trx) => {
|
|
2298
|
-
const userRepo = trx.getRepository('User'); // Use model name
|
|
2299
|
-
const postRepo = trx.getRepository('Post');
|
|
2300
|
-
|
|
2301
|
-
const user = await userRepo.create({ email: 'new@test.com', name: 'New' });
|
|
2302
|
-
await postRepo.create({ title: 'First Post', user_id: user.id });
|
|
2303
|
-
|
|
2304
|
-
// All changes committed on success
|
|
2305
|
-
// Rolled back on error
|
|
2306
|
-
});
|
|
2307
|
-
```
|
|
2308
|
-
|
|
2309
|
-
**ORM reads inside `db.transaction()` always bypass the query cache** (Knex transaction client), so you never serve stale rows mid-transaction.
|
|
2310
|
-
|
|
2311
|
-
### ORM query cache (optional)
|
|
2312
|
-
|
|
2313
|
-
Enable an in-process, tag-based cache for common read paths. Default provider is in-memory (`createMemoryCacheProvider`); you can pass a custom **`provider`** with the same shape (`get`, `set` with optional `tags` / `ttlMs`, `invalidateTags`, `clear`, `getSizeStats`).
|
|
2314
|
-
|
|
2315
|
-
**Turn it on:**
|
|
2316
|
-
|
|
2317
|
-
```javascript
|
|
2318
|
-
const db = createDatabase({
|
|
2319
|
-
client: 'pg',
|
|
2320
|
-
connection: process.env.DATABASE_URL,
|
|
2321
|
-
models: './models',
|
|
2322
|
-
cache: true, // same as { enabled: true, defaultStrategy: 'auto', memory provider }
|
|
2323
|
-
// cache: {
|
|
2324
|
-
// enabled: true,
|
|
2325
|
-
// defaultStrategy: 'auto', // or 'smart'
|
|
2326
|
-
// memory: { maxEntries: 50_000, defaultTtlMs: undefined },
|
|
2327
|
-
// // provider: myRedisLikeAdapter,
|
|
2328
|
-
// },
|
|
2329
|
-
});
|
|
2330
|
-
```
|
|
2331
|
-
|
|
2332
|
-
When enabled, **`db.cache`** exposes **`purge()`**, **`invalidateTags(string[])`**, **`invalidateModel('ModelName')`**, **`getMetrics()`**, and **`resetMetrics()`**. When the cache is off, **`db.cache`** is **`null`**.
|
|
2333
|
-
|
|
2334
|
-
**Per-model opt-in / strategy** (inherits the database **`defaultStrategy`** when set to `true`):
|
|
2335
|
-
|
|
2336
|
-
```javascript
|
|
2337
|
-
defineModel({
|
|
2338
|
-
name: 'User',
|
|
2339
|
-
table: 'users',
|
|
2340
|
-
schema: UserSchema,
|
|
2341
|
-
cache: true, // use db defaultStrategy
|
|
2342
|
-
// cache: 'auto', // invalidate all cached reads for this model on any row change
|
|
2343
|
-
// cache: 'smart', // finer-grained tags: PK reads vs list/collection queries
|
|
2344
|
-
// cache: { strategy: 'smart' },
|
|
2345
|
-
// cache: false, // never cache this model
|
|
2346
|
-
});
|
|
2347
|
-
```
|
|
2348
|
-
|
|
2349
|
-
**What gets cached:** **`findById`**, **`findOne`**, **`findAll`**, and query builder **`first`**, **`list`**, **`count`**, **`paginate`** — only when the model participates in caching and the read is not on a transaction connection. Some query shapes are never cached (e.g. raw `where` fragments); the layer increments a **`bypassed`** metric for those.
|
|
2350
|
-
|
|
2351
|
-
**Invalidation:** Hooks on **`afterCreate`**, **`afterUpdate`**, **`afterDelete`**, and **`afterRestore`** schedule tag invalidation after the Knex transaction commits (when applicable). With **`auto`**, every mutation clears all cache entries tagged for that model. With **`smart`**, creates invalidate collection query tags; updates/deletes target the row’s primary key tag plus collection tags when the record exposes an id (falls back to full-model invalidation if the id is missing). Bulk **`updateWhere`** / query-builder **`update`** / **`delete`** that affect rows call **`invalidateModelAll`** for safety.
|
|
2352
|
-
|
|
2353
|
-
**Exports:** **`createMemoryCacheProvider`**, **`OrmCacheLayer`**, and **`createOrmCacheFromConfig`** are available from **`webspresso`** for advanced/testing setups.
|
|
2354
|
-
|
|
2355
|
-
### Migrations
|
|
2356
|
-
|
|
2357
|
-
**CLI Commands:**
|
|
2358
|
-
|
|
2359
|
-
```bash
|
|
2360
|
-
# Run pending migrations
|
|
2361
|
-
webspresso db:migrate
|
|
2362
|
-
|
|
2363
|
-
# Rollback last batch
|
|
2364
|
-
webspresso db:rollback
|
|
2365
|
-
|
|
2366
|
-
# Rollback all
|
|
2367
|
-
webspresso db:rollback --all
|
|
2368
|
-
|
|
2369
|
-
# Show migration status
|
|
2370
|
-
webspresso db:status
|
|
2371
|
-
|
|
2372
|
-
# Create empty migration
|
|
2373
|
-
webspresso db:make create_posts_table
|
|
2374
|
-
|
|
2375
|
-
# Create migration from model (scaffolding)
|
|
2376
|
-
webspresso db:make create_users_table --model User
|
|
2377
|
-
|
|
2378
|
-
# Scaffold create-table migrations for all models in models/
|
|
2379
|
-
webspresso db:scaffold
|
|
2380
|
-
webspresso db:scaffold --only User,Post
|
|
2381
|
-
webspresso db:scaffold --dry-run
|
|
2382
|
-
|
|
2383
|
-
# Admin Panel Setup
|
|
2384
|
-
webspresso admin:setup # Create admin_users migration
|
|
2385
|
-
webspresso admin:list # List all admin users
|
|
2386
|
-
webspresso admin:password # Reset admin password (interactive or -e -p)
|
|
2387
|
-
```
|
|
2388
|
-
|
|
2389
|
-
**Admin CLI Commands:**
|
|
2390
|
-
|
|
2391
|
-
```bash
|
|
2392
|
-
# Create admin_users table migration
|
|
2393
|
-
webspresso admin:setup
|
|
2394
|
-
|
|
2395
|
-
# List all admin users
|
|
2396
|
-
webspresso admin:list
|
|
2397
|
-
|
|
2398
|
-
# Reset admin password (interactive: prompts for email and password)
|
|
2399
|
-
webspresso admin:password
|
|
2400
|
-
|
|
2401
|
-
# Reset with options
|
|
2402
|
-
webspresso admin:password -e admin@example.com -p yeni_sifre123
|
|
2403
|
-
|
|
2404
|
-
# Use custom config or environment
|
|
2405
|
-
webspresso admin:password -c ./webspresso.db.js -E production
|
|
2406
|
-
webspresso admin:list -c ./webspresso.db.js
|
|
2407
|
-
```
|
|
2408
|
-
|
|
2409
|
-
> **Note:** Database config is loaded from `webspresso.db.js` or `knexfile.js` in the project root. Run commands from your project directory.
|
|
2410
|
-
|
|
2411
|
-
**Database Config File (`webspresso.db.js`):**
|
|
2412
|
-
|
|
2413
|
-
```javascript
|
|
2414
|
-
module.exports = {
|
|
2415
|
-
client: 'pg', // or 'mysql2', 'better-sqlite3'
|
|
2416
|
-
connection: process.env.DATABASE_URL,
|
|
2417
|
-
migrations: {
|
|
2418
|
-
directory: './migrations',
|
|
2419
|
-
tableName: 'knex_migrations',
|
|
2420
|
-
},
|
|
2421
|
-
|
|
2422
|
-
// Environment overrides
|
|
2423
|
-
production: {
|
|
2424
|
-
connection: process.env.DATABASE_URL,
|
|
2425
|
-
pool: { min: 2, max: 10 },
|
|
2426
|
-
},
|
|
2427
|
-
};
|
|
2428
|
-
```
|
|
2429
|
-
|
|
2430
|
-
**Programmatic API:**
|
|
2431
|
-
|
|
2432
|
-
```javascript
|
|
2433
|
-
const db = createDatabase({
|
|
2434
|
-
client: 'pg',
|
|
2435
|
-
connection: process.env.DATABASE_URL,
|
|
2436
|
-
migrations: { directory: './migrations' },
|
|
2437
|
-
});
|
|
2438
|
-
|
|
2439
|
-
await db.migrate.latest(); // Run pending
|
|
2440
|
-
await db.migrate.rollback(); // Rollback last batch
|
|
2441
|
-
await db.migrate.rollback({ all: true }); // Rollback all
|
|
2442
|
-
const status = await db.migrate.status(); // Get status
|
|
2443
|
-
```
|
|
2444
|
-
|
|
2445
|
-
### Migration Scaffolding
|
|
2446
|
-
|
|
2447
|
-
Generate migration from model schema:
|
|
2448
|
-
|
|
2449
|
-
```javascript
|
|
2450
|
-
const { scaffoldMigration } = require('webspresso');
|
|
2451
|
-
|
|
2452
|
-
const migration = scaffoldMigration(User);
|
|
2453
|
-
// Outputs complete migration file content with:
|
|
2454
|
-
// - All columns with proper types
|
|
2455
|
-
// - Indexes
|
|
2456
|
-
// - Foreign key constraints
|
|
2457
|
-
// - Up and down functions
|
|
2458
|
-
```
|
|
2459
|
-
|
|
2460
|
-
### Supported Databases
|
|
2461
|
-
|
|
2462
|
-
Install the appropriate driver as a peer dependency:
|
|
2463
|
-
|
|
2464
|
-
```bash
|
|
2465
|
-
# PostgreSQL
|
|
2466
|
-
npm install pg
|
|
2467
|
-
|
|
2468
|
-
# MySQL
|
|
2469
|
-
npm install mysql2
|
|
2470
|
-
|
|
2471
|
-
# SQLite
|
|
2472
|
-
npm install better-sqlite3
|
|
2473
|
-
```
|
|
2474
|
-
|
|
2475
|
-
### Design Philosophy
|
|
2476
|
-
|
|
2477
|
-
| Boundary | Zod's Job | ORM's Job |
|
|
2478
|
-
|----------|-----------|-----------|
|
|
2479
|
-
| Schema definition | Type shape, validation rules | Column metadata extraction |
|
|
2480
|
-
| Input validation | `.parse()` / `.safeParse()` | Never - pass through to Zod |
|
|
2481
|
-
| Query building | N/A | Full ownership |
|
|
2482
|
-
| Relation resolution | N/A | Eager loading with batch queries |
|
|
2483
|
-
| Timestamps/SoftDelete | N/A | Auto-inject on operations |
|
|
2484
|
-
|
|
2485
|
-
**N+1 Prevention:** Relations are always loaded with batch `WHERE IN (...)` queries, never with individual queries per record.
|
|
2486
|
-
|
|
2487
|
-
### Database Seeding
|
|
2488
|
-
|
|
2489
|
-
**CLI Command:**
|
|
2490
|
-
|
|
2491
|
-
The easiest way to seed your database is using the CLI command:
|
|
2492
|
-
|
|
2493
|
-
```bash
|
|
2494
|
-
# Run seeds (requires seeds/index.js)
|
|
2495
|
-
webspresso seed
|
|
2496
|
-
|
|
2497
|
-
# Setup seed files if they don't exist
|
|
2498
|
-
webspresso seed --setup
|
|
2499
|
-
|
|
2500
|
-
# Use custom database config
|
|
2501
|
-
webspresso seed --config ./custom-db-config.js
|
|
2502
|
-
|
|
2503
|
-
# Use different environment
|
|
2504
|
-
webspresso seed --env production
|
|
2505
|
-
```
|
|
2506
|
-
|
|
2507
|
-
The `webspresso seed` command:
|
|
2508
|
-
- Automatically loads all models from `models/` directory
|
|
2509
|
-
- Generates fake data based on model schemas
|
|
2510
|
-
- Creates 10 records per model by default
|
|
2511
|
-
- Uses smart field detection for appropriate fake data
|
|
2512
|
-
|
|
2513
|
-
**Manual Setup:**
|
|
2514
|
-
|
|
2515
|
-
Generate fake data for testing and development using `@faker-js/faker`:
|
|
2516
|
-
|
|
2517
|
-
```bash
|
|
2518
|
-
npm install @faker-js/faker
|
|
2519
|
-
```
|
|
2520
|
-
|
|
2521
|
-
**Basic Usage:**
|
|
2522
|
-
|
|
2523
|
-
```javascript
|
|
2524
|
-
const { faker } = require('@faker-js/faker');
|
|
2525
|
-
const db = createDatabase({ /* config */ });
|
|
2526
|
-
|
|
2527
|
-
const seeder = db.seeder(faker);
|
|
2528
|
-
|
|
2529
|
-
// Generate a single record
|
|
2530
|
-
const user = await seeder.factory('User').create();
|
|
2531
|
-
|
|
2532
|
-
// Generate multiple records
|
|
2533
|
-
const users = await seeder.factory('User').create(10);
|
|
2534
|
-
|
|
2535
|
-
// Generate without saving (for testing)
|
|
2536
|
-
const userData = seeder.factory('User').make();
|
|
2537
|
-
```
|
|
2538
|
-
|
|
2539
|
-
**Define Factories with Defaults and States:**
|
|
2540
|
-
|
|
2541
|
-
```javascript
|
|
2542
|
-
seeder.defineFactory('User', {
|
|
2543
|
-
// Default values
|
|
2544
|
-
defaults: {
|
|
2545
|
-
status: 'pending',
|
|
2546
|
-
},
|
|
2547
|
-
|
|
2548
|
-
// Custom generators
|
|
2549
|
-
generators: {
|
|
2550
|
-
username: (f) => f.internet.username().toLowerCase(),
|
|
2551
|
-
},
|
|
2552
|
-
|
|
2553
|
-
// Named states for variations
|
|
2554
|
-
states: {
|
|
2555
|
-
admin: { role: 'admin', status: 'active' },
|
|
2556
|
-
verified: (f) => ({
|
|
2557
|
-
status: 'verified',
|
|
2558
|
-
verified_at: f.date.past().toISOString(),
|
|
2559
|
-
}),
|
|
2560
|
-
},
|
|
2561
|
-
});
|
|
2562
|
-
|
|
2563
|
-
// Use states
|
|
2564
|
-
const admin = await seeder.factory('User').state('admin').create();
|
|
2565
|
-
const verified = await seeder.factory('User').state('verified').create();
|
|
2566
|
-
```
|
|
2567
|
-
|
|
2568
|
-
**Smart Field Detection:**
|
|
2569
|
-
|
|
2570
|
-
The seeder automatically generates appropriate fake data based on column names:
|
|
2571
|
-
|
|
2572
|
-
| Field Name Pattern | Generated Data |
|
|
2573
|
-
|-------------------|----------------|
|
|
2574
|
-
| `email`, `*_email` | Valid email address |
|
|
2575
|
-
| `name`, `first_name`, `last_name` | Person names |
|
|
2576
|
-
| `username` | Username |
|
|
2577
|
-
| `title` | Short sentence |
|
|
2578
|
-
| `content`, `body`, `description` | Paragraphs |
|
|
2579
|
-
| `slug` | URL-safe slug |
|
|
2580
|
-
| `phone`, `tel` | Phone number |
|
|
2581
|
-
| `address`, `city`, `country` | Location data |
|
|
2582
|
-
| `price`, `amount`, `cost` | Decimal numbers |
|
|
2583
|
-
| `*_url`, `avatar`, `image` | URLs |
|
|
2584
|
-
|
|
2585
|
-
**Override and Custom Generators:**
|
|
2586
|
-
|
|
2587
|
-
```javascript
|
|
2588
|
-
const user = await seeder.factory('User')
|
|
2589
|
-
.override({ email: 'test@example.com' })
|
|
2590
|
-
.generators({
|
|
2591
|
-
code: (f) => `USR-${f.string.alphanumeric(8)}`,
|
|
2592
|
-
})
|
|
2593
|
-
.create();
|
|
2594
|
-
```
|
|
2595
|
-
|
|
2596
|
-
**Batch Seeding:**
|
|
2597
|
-
|
|
2598
|
-
```javascript
|
|
2599
|
-
// Seed multiple models at once
|
|
2600
|
-
const results = await seeder.run([
|
|
2601
|
-
{ model: 'Company', count: 5 },
|
|
2602
|
-
{ model: 'User', count: 20, state: 'active' },
|
|
2603
|
-
{ model: 'Post', count: 50 },
|
|
2604
|
-
]);
|
|
2605
|
-
|
|
2606
|
-
// Access results
|
|
2607
|
-
console.log(results.Company); // Array of 5 companies
|
|
2608
|
-
console.log(results.User); // Array of 20 users
|
|
2609
|
-
```
|
|
2610
|
-
|
|
2611
|
-
**Cleanup:**
|
|
2612
|
-
|
|
2613
|
-
```javascript
|
|
2614
|
-
// Truncate specific tables
|
|
2615
|
-
await seeder.truncate('User');
|
|
2616
|
-
await seeder.truncate(['User', 'Post']);
|
|
2617
|
-
|
|
2618
|
-
// Clear all registered model tables
|
|
2619
|
-
await seeder.clearAll();
|
|
2620
|
-
```
|
|
2621
|
-
|
|
2622
|
-
### Schema Explorer Plugin
|
|
2623
|
-
|
|
2624
|
-
A plugin that exposes ORM schema information via API endpoints. Useful for frontend code generation, documentation, or admin tools.
|
|
2625
|
-
|
|
2626
|
-
**Setup:**
|
|
2627
|
-
|
|
2628
|
-
```javascript
|
|
2629
|
-
const { createApp, schemaExplorerPlugin } = require('webspresso');
|
|
2630
|
-
|
|
2631
|
-
const app = createApp({
|
|
2632
|
-
plugins: [
|
|
2633
|
-
schemaExplorerPlugin({
|
|
2634
|
-
path: '/_schema', // Endpoint path (default: '/_schema')
|
|
2635
|
-
enabled: true, // Force enable (default: auto based on NODE_ENV)
|
|
2636
|
-
exclude: ['Secret'], // Exclude specific models
|
|
2637
|
-
includeColumns: true, // Include column metadata
|
|
2638
|
-
includeRelations: true, // Include relation metadata
|
|
2639
|
-
includeScopes: true, // Include scope configuration
|
|
2640
|
-
authorize: (req) => { // Custom authorization
|
|
2641
|
-
return req.headers['x-api-key'] === 'secret';
|
|
2642
|
-
},
|
|
2643
|
-
}),
|
|
2644
|
-
],
|
|
2645
|
-
});
|
|
2646
|
-
```
|
|
2647
|
-
|
|
2648
|
-
**Endpoints:**
|
|
2649
|
-
|
|
2650
|
-
- `GET /_schema` - List all models
|
|
2651
|
-
- `GET /_schema/:modelName` - Get single model details
|
|
2652
|
-
- `GET /_schema/openapi` - Export in OpenAPI 3.0 schema format
|
|
2653
|
-
|
|
2654
|
-
**Example Response (`GET /_schema`):**
|
|
2655
|
-
|
|
2656
|
-
```json
|
|
2657
|
-
{
|
|
2658
|
-
"meta": {
|
|
2659
|
-
"version": "1.0.0",
|
|
2660
|
-
"generatedAt": "2024-01-01T12:00:00.000Z",
|
|
2661
|
-
"modelCount": 2
|
|
2662
|
-
},
|
|
2663
|
-
"models": [
|
|
2664
|
-
{
|
|
2665
|
-
"name": "User",
|
|
2666
|
-
"table": "users",
|
|
2667
|
-
"primaryKey": "id",
|
|
2668
|
-
"columns": [
|
|
2669
|
-
{ "name": "id", "type": "bigint", "primary": true, "autoIncrement": true },
|
|
2670
|
-
{ "name": "email", "type": "string", "unique": true },
|
|
2671
|
-
{ "name": "company_id", "type": "bigint", "references": "companies" }
|
|
2672
|
-
],
|
|
2673
|
-
"relations": [
|
|
2674
|
-
{ "name": "company", "type": "belongsTo", "relatedModel": "Company", "foreignKey": "company_id" }
|
|
2675
|
-
],
|
|
2676
|
-
"scopes": { "softDelete": true, "timestamps": true, "tenant": null }
|
|
2677
|
-
}
|
|
2678
|
-
]
|
|
2679
|
-
}
|
|
2680
|
-
```
|
|
2681
|
-
|
|
2682
|
-
**Plugin API (programmatic usage):**
|
|
2683
|
-
|
|
2684
|
-
```javascript
|
|
2685
|
-
const plugin = schemaExplorerPlugin();
|
|
2686
|
-
|
|
2687
|
-
// Plugin API can be used by other plugins or in code
|
|
2688
|
-
const models = plugin.api.getModels(); // All models
|
|
2689
|
-
const user = plugin.api.getModel('User'); // Single model
|
|
2690
|
-
const names = plugin.api.getModelNames(); // Model names
|
|
2691
|
-
```
|
|
2692
|
-
|
|
2693
|
-
### REST resources plugin
|
|
2694
|
-
|
|
2695
|
-
Registers **REST-style CRUD** routes for ORM models (`GET` list, `GET /:id`, `POST`, `PATCH /:id`, `DELETE /:id`). Relations are eager-loaded only when the client passes **`?include=relation1,relation2`**; loading uses the ORM batch eager loader (no classic N+1 from relation queries). **Nested includes** (e.g. `posts.comments`) are **not** supported—only top-level relation names defined on the model.
|
|
2696
|
-
|
|
2697
|
-
**Model opt-in** via `defineModel({ ..., rest: { enabled: true, path: 'optional-segment', allowInclude: ['company'] } })`. If you pass **`models: ['User', 'Post']`** to the plugin, those names are exposed even when `rest.enabled` is false.
|
|
2698
|
-
|
|
2699
|
-
**Setup:**
|
|
2700
|
-
|
|
2701
|
-
```javascript
|
|
2702
|
-
const { createApp, restResourcePlugin } = require('webspresso');
|
|
2703
|
-
|
|
2704
|
-
const { app } = createApp({
|
|
2705
|
-
pagesDir: './pages',
|
|
2706
|
-
db,
|
|
2707
|
-
plugins: [
|
|
2708
|
-
restResourcePlugin({
|
|
2709
|
-
path: '/api/rest',
|
|
2710
|
-
middleware: [], // optional Express handlers (e.g. auth) — `attachDbMiddleware` is applied after these
|
|
2711
|
-
models: null, // optional whitelist of model names; when omitted, only `rest.enabled` models are exposed
|
|
2712
|
-
excludeModels: [],
|
|
2713
|
-
filter: null, // optional (model) => boolean
|
|
2714
|
-
}),
|
|
2715
|
-
],
|
|
2716
|
-
});
|
|
2717
|
-
```
|
|
2718
|
-
|
|
2719
|
-
List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed` (`only` / `include` when the model uses soft delete), plus **equality filters** on real columns (unknown keys are ignored).
|
|
2720
|
-
|
|
2721
|
-
### File upload plugin
|
|
2722
|
-
|
|
2723
|
-
Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file(s) via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns public URL paths). Use **`mimeAllowlist`** / **`extensionAllowlist`**, **`maxBytes`** (default **10 MiB**), **`multiple`** (default **`false`**), and **`maxFiles`** (default **10**) on the server; production apps should prefer an explicit MIME allowlist.
|
|
2724
|
-
|
|
2725
|
-
**Setup:**
|
|
2726
|
-
|
|
2727
|
-
```javascript
|
|
2728
|
-
const { createApp, uploadPlugin, adminPanelPlugin } = require('webspresso');
|
|
2729
|
-
|
|
2730
|
-
const { app } = createApp({
|
|
2731
|
-
pagesDir: './pages',
|
|
2732
|
-
publicDir: './public',
|
|
2733
|
-
db,
|
|
2734
|
-
plugins: [
|
|
2735
|
-
uploadPlugin({
|
|
2736
|
-
path: '/api/upload',
|
|
2737
|
-
local: {
|
|
2738
|
-
destDir: './public/uploads',
|
|
2739
|
-
publicBasePath: '/uploads',
|
|
2740
|
-
},
|
|
2741
|
-
maxBytes: 10 * 1024 * 1024,
|
|
2742
|
-
mimeAllowlist: ['image/jpeg', 'image/png', 'application/pdf'],
|
|
2743
|
-
extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
|
|
2744
|
-
middleware: [], // optional Express handlers (e.g. session auth)
|
|
2745
|
-
fieldName: 'file',
|
|
2746
|
-
multiple: false, // set to true to allow multiple file uploads in a single request
|
|
2747
|
-
maxFiles: 10, // max number of files to accept when multiple is true
|
|
2748
|
-
}),
|
|
2749
|
-
adminPanelPlugin({
|
|
2750
|
-
db,
|
|
2751
|
-
// uploadUrl optional: defaults to app.get('webspresso.uploadPath') set by uploadPlugin
|
|
2752
|
-
}),
|
|
2753
|
-
],
|
|
2754
|
-
});
|
|
2755
|
-
```
|
|
2756
|
-
|
|
2757
|
-
- **ORM (Single File):** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
|
|
2758
|
-
- **ORM (Multiple Files):** `zdb.files({ nullable: true })` — array column that stores a list of file URLs/paths as a JSON field in the database; migrations use `table.json()`.
|
|
2759
|
-
- **Admin forms:** columns with `zdb.file()` or `zdb.files()` automatically render a drag-and-drop upload widget (or a manual URL text field when `uploadUrl` is not configured). Multiple file upload fields (`zdb.files()`) allow adding and removing multiple files concurrently and show preview grids. Image URLs show an inline thumbnail preview; use `ui.accept: 'image/*'` for image-only fields. Optional `ui: { label, hint, accept, maxBytes }` on the column customizes the widget. For existing `zdb.string()` columns you can use `admin.customFields: { columnName: { type: 'file-upload' } }` instead of changing the schema type.
|
|
2760
|
-
- **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'`, `'files'` or `customFields` type `file-upload`) POST to that URL with credentials; the saved record stores the returned **`url`** / **`publicUrl`** string (or an array of URLs for `'files'`).
|
|
2761
|
-
- **Response:** Single: `{ url, publicUrl, key? }`. Multiple: `[{ url, publicUrl, key? }, ...]`.
|
|
2762
|
-
- **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
|
|
2763
|
-
|
|
2764
|
-
**Admin model example** (`zdb.file()` and `zdb.files()` pick up the upload widgets from schema; no extra `customFields` needed):
|
|
2765
|
-
|
|
2766
|
-
```javascript
|
|
2767
|
-
const { defineModel, zdb } = require('webspresso');
|
|
2768
|
-
|
|
2769
|
-
const Post = defineModel({
|
|
2770
|
-
name: 'Post',
|
|
2771
|
-
table: 'posts',
|
|
2772
|
-
schema: zdb.schema({
|
|
2773
|
-
id: zdb.id(),
|
|
2774
|
-
title: zdb.string(),
|
|
2775
|
-
cover_image: zdb.file({
|
|
2776
|
-
maxLength: 2048,
|
|
2777
|
-
nullable: true,
|
|
2778
|
-
ui: { label: 'Cover', accept: 'image/*', hint: 'JPEG or PNG' },
|
|
2779
|
-
}),
|
|
2780
|
-
gallery: zdb.files({
|
|
2781
|
-
nullable: true,
|
|
2782
|
-
ui: { label: 'Gallery Images', accept: 'image/*' },
|
|
2783
|
-
}),
|
|
2784
|
-
}),
|
|
2785
|
-
admin: { enabled: true, label: 'Posts' },
|
|
2786
|
-
});
|
|
2787
|
-
```
|
|
2788
|
-
|
|
2789
|
-
For a plain string column, use `admin.customFields: { attachment: { type: 'file-upload' } }` instead of `zdb.file()`.
|
|
2790
|
-
|
|
2791
|
-
### Health check plugin
|
|
2792
|
-
|
|
2793
|
-
Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
|
|
2794
|
-
|
|
2795
|
-
**Setup:**
|
|
2796
|
-
|
|
2797
|
-
```javascript
|
|
2798
|
-
const { createApp, healthCheckPlugin } = require('webspresso');
|
|
2799
|
-
|
|
2800
|
-
const app = createApp({
|
|
2801
|
-
plugins: [
|
|
2802
|
-
healthCheckPlugin({
|
|
2803
|
-
path: '/health', // default
|
|
2804
|
-
verbose: true, // timestamp, uptime, NODE_ENV, framework name/version
|
|
2805
|
-
authorize: (req) => true, // optional — restrict who can read the endpoint
|
|
2806
|
-
checks: async ({ db }) => {
|
|
2807
|
-
if (db) await db.knex.raw('select 1');
|
|
2808
|
-
return { database: 'ok' };
|
|
2809
|
-
},
|
|
2810
|
-
}),
|
|
2811
|
-
],
|
|
2812
|
-
});
|
|
2813
|
-
```
|
|
2814
|
-
|
|
2815
|
-
- **`checks`**: If this function throws, the handler responds with **503** and `{ status: 'unhealthy', error, ... }`. Return a plain object to merge into `checks` on success (e.g. dependency status).
|
|
2816
|
-
- Use a **custom `path`** if your app already serves `GET /health` from `pages/`.
|
|
2817
|
-
|
|
2818
|
-
### Swagger / OpenAPI plugin
|
|
2819
|
-
|
|
2820
|
-
Serves **OpenAPI 3** for file-based `pages/api` routes and optional [Zod](https://zod.dev) `schema` exports, plus a **Swagger UI** page. Defaults to **development only** (same idea as the schema explorer).
|
|
2821
|
-
|
|
2822
|
-
**Setup:**
|
|
2823
|
-
|
|
2824
|
-
```javascript
|
|
2825
|
-
const { createApp, swaggerPlugin } = require('webspresso');
|
|
2826
|
-
|
|
2827
|
-
const app = createApp({
|
|
2828
|
-
plugins: [
|
|
2829
|
-
swaggerPlugin({
|
|
2830
|
-
path: '/_swagger', // UI: GET /_swagger, spec: GET /_swagger/openapi.json
|
|
2831
|
-
enabled: true, // default: true in development, false in production
|
|
2832
|
-
title: 'My API', // optional OpenAPI info.title
|
|
2833
|
-
serverUrl: 'https://api.example.com', // optional servers[0].url (else BASE_URL or localhost)
|
|
2834
|
-
includeOrmSchemas: false, // merge ORM model schemas into components.schemas
|
|
2835
|
-
ormExclude: ['Secret'], // when includeOrmSchemas is true
|
|
2836
|
-
authorize: (req) => true, // optional gate for both UI and JSON
|
|
2837
|
-
}),
|
|
2838
|
-
],
|
|
2839
|
-
});
|
|
2840
|
-
```
|
|
2841
|
-
|
|
2842
|
-
**Endpoints:**
|
|
2843
|
-
|
|
2844
|
-
- `GET /_swagger/openapi.json` — Full OpenAPI document (`paths` from API routes; request/response shapes from exported `schema({ z })` when present).
|
|
2845
|
-
- `GET /_swagger` — Swagger UI (loads the JSON above; requires network access for CDN assets).
|
|
2846
|
-
|
|
2847
|
-
In production, keep the plugin disabled or protect it with `authorize` / your own middleware.
|
|
2848
|
-
|
|
2849
|
-
### CSRF protection plugin
|
|
2850
|
-
|
|
2851
|
-
Protects mutating requests against Cross-Site Request Forgery (CSRF) using the double-submit token pattern. Supports session storage (when `req.session` is available) and automatically falls back to cookie-based storage.
|
|
2852
|
-
|
|
2853
|
-
**Setup:**
|
|
2854
|
-
|
|
2855
|
-
```javascript
|
|
2856
|
-
const { createApp, csrfPlugin } = require('webspresso');
|
|
2857
|
-
|
|
2858
|
-
const app = createApp({
|
|
2859
|
-
plugins: [
|
|
2860
|
-
csrfPlugin({
|
|
2861
|
-
global: true, // Enable globally for all routes
|
|
2862
|
-
cookie: true, // Use cookie-based storage (default uses session if present)
|
|
2863
|
-
ignorePaths: ['/api/webhook'], // Skip CSRF verification for specific routes
|
|
2864
|
-
}),
|
|
2865
|
-
],
|
|
2866
|
-
});
|
|
2867
|
-
```
|
|
2868
|
-
|
|
2869
|
-
**Route-Specific Middleware:**
|
|
2870
|
-
|
|
2871
|
-
If `global: false` (default), you can protect specific routes by applying the named route middleware `csrf` in your route configuration:
|
|
2872
|
-
|
|
2873
|
-
```javascript
|
|
2874
|
-
// pages/submit.js
|
|
2875
|
-
module.exports = {
|
|
2876
|
-
middleware: ['csrf'],
|
|
2877
|
-
post(req, res) {
|
|
2878
|
-
res.send('Success!');
|
|
2879
|
-
}
|
|
2880
|
-
};
|
|
2881
|
-
```
|
|
2882
|
-
|
|
2883
|
-
**Template Helpers:**
|
|
2884
|
-
|
|
2885
|
-
The plugin registers two Nunjucks template helpers:
|
|
2886
|
-
- `{{ fsy.csrfToken() }}`: Returns the raw token string (useful for API fetch/headers).
|
|
2887
|
-
- `{{ fsy.csrfInput() }}`: Renders a hidden input field: `<input type="hidden" name="_csrf" value="...">`.
|
|
2888
|
-
|
|
2889
|
-
## Development
|
|
2890
|
-
|
|
2891
|
-
Native addons (**better-sqlite3**, **bcrypt**, **sharp**) are compiled for your current Node ABI. After switching Node major versions (e.g. nvm, fnm, Volta), run **`npm run rebuild:native`** or a clean install: `rm -rf node_modules && npm ci`. **chokidar** is not ABI-tied like those drivers; if file watching misbehaves, reinstall dependencies. The repo includes [`.nvmrc`](.nvmrc) (Node 20 LTS) as a known-good default for this project.
|
|
2892
|
-
|
|
2893
|
-
```bash
|
|
2894
|
-
# Install dependencies
|
|
2895
|
-
npm install
|
|
2896
|
-
|
|
2897
|
-
# If you changed Node version and see MODULE_VERSION or .node load errors:
|
|
2898
|
-
npm run rebuild:native
|
|
2899
|
-
|
|
2900
|
-
# Run tests
|
|
2901
|
-
npm test
|
|
2902
|
-
|
|
2903
|
-
# Run tests in watch mode
|
|
2904
|
-
npm run test:watch
|
|
2905
|
-
|
|
2906
|
-
# Run tests with coverage
|
|
2907
|
-
npm run test:coverage
|
|
2908
|
-
|
|
2909
|
-
# Micro-benchmarks (Vitest bench; CI: main push uploads benchmark-baseline artifact, PRs compare against it)
|
|
2910
|
-
npm run bench
|
|
2911
|
-
# Local baseline + compare: npm run bench:ci:local
|
|
2912
|
-
```
|
|
151
|
+
---
|
|
2913
152
|
|
|
2914
|
-
## License
|
|
153
|
+
## 📄 License
|
|
2915
154
|
|
|
2916
|
-
MIT
|
|
155
|
+
MIT © [Webspresso Contributors](LICENSE)
|