webspresso 0.0.73 → 0.0.75
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 +44 -4
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/commands/upgrade.js +146 -0
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/app.js +109 -0
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +17 -13
- package/plugins/admin-panel/modules/user-management.js +118 -27
- package/plugins/data-exchange/export-xlsx.js +3 -0
- package/plugins/data-exchange/record-selection.js +21 -5
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/site-analytics/admin-component.js +88 -78
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +270 -53
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +28 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -275
package/src/server.js
CHANGED
|
@@ -11,7 +11,7 @@ const timeout = require('connect-timeout');
|
|
|
11
11
|
const { setAppContext } = require('./app-context');
|
|
12
12
|
const { mountClientRuntime } = require('./client-runtime/mount');
|
|
13
13
|
const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
14
|
-
const { mountPages } = require('./file-router');
|
|
14
|
+
const { mountPages, detectLocale } = require('./file-router');
|
|
15
15
|
const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
|
|
16
16
|
const { createPluginManager } = require('./plugin-manager');
|
|
17
17
|
|
|
@@ -57,6 +57,16 @@ function getDefaultHelmetConfig(isDev) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Use JSON error responses for `pages/api/*` routes and clients that do not prefer HTML.
|
|
62
|
+
* @param {import('express').Request} req
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function preferJsonErrorResponse(req) {
|
|
66
|
+
if (req.path && req.path.startsWith('/api')) return true;
|
|
67
|
+
return !req.accepts('html');
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
|
|
62
72
|
*/
|
|
@@ -261,6 +271,7 @@ function haltOnTimedout(req, res, next) {
|
|
|
261
271
|
* @param {Object} options.auth - Authentication manager instance (from createAuth)
|
|
262
272
|
* @param {Object} options.db - Database instance (exposed as ctx.db to plugins)
|
|
263
273
|
* @param {Object} [options.clientRuntime] - Optional client assets: `{ alpine?: boolean|object, swup?: boolean|object }`. Overridable by env `WEBSPRESSO_ALPINE` / `WEBSPRESSO_SWUP` (=1 or true). Serves `/__webspresso/client-runtime/*` when either flag is on.
|
|
274
|
+
* @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}} [options.pageAssets] - If truthy, route `load()` return values for `stylesheets` and `scripts` are reserved: removed from the root template context and passed as `pageHead` to Nunjucks, with `pageAssets: true` (for layout to emit `<link>` / `<script>`). Default off.
|
|
264
275
|
* @param {function(import('express').Express, Object): void} [options.setupRoutes] - Called after file routes and plugins, before 404 handler
|
|
265
276
|
* @returns {Object} { app, nunjucksEnv, pluginManager, authMiddleware }
|
|
266
277
|
*/
|
|
@@ -375,6 +386,12 @@ function createApp(options = {}) {
|
|
|
375
386
|
middlewares.auth = authMiddleware.auth;
|
|
376
387
|
middlewares.guest = authMiddleware.guest;
|
|
377
388
|
}
|
|
389
|
+
|
|
390
|
+
// Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
|
|
391
|
+
const runsUnderVitest = process.env.VITEST === 'true' || process.env.VITEST_WORKER_ID !== undefined;
|
|
392
|
+
if (runsUnderVitest && middlewares.fixtureRequireAuth == null) {
|
|
393
|
+
middlewares.fixtureRequireAuth = (req, res, next) => next();
|
|
394
|
+
}
|
|
378
395
|
|
|
379
396
|
// Static files (if publicDir provided)
|
|
380
397
|
if (publicDir) {
|
|
@@ -420,8 +437,8 @@ function createApp(options = {}) {
|
|
|
420
437
|
return d.toString();
|
|
421
438
|
});
|
|
422
439
|
|
|
423
|
-
// Register plugins (sync)
|
|
424
|
-
const pluginContext = { app, nunjucksEnv, options };
|
|
440
|
+
// Register plugins (sync) — middlewares is the same object later passed to mountPages
|
|
441
|
+
const pluginContext = { app, nunjucksEnv, options, middlewares };
|
|
425
442
|
pluginManager.registerSync(plugins, pluginContext);
|
|
426
443
|
|
|
427
444
|
// Request logging middleware
|
|
@@ -448,6 +465,7 @@ function createApp(options = {}) {
|
|
|
448
465
|
silent: isTest,
|
|
449
466
|
db: options.db ?? null,
|
|
450
467
|
clientRuntime,
|
|
468
|
+
pageAssets: options.pageAssets,
|
|
451
469
|
});
|
|
452
470
|
|
|
453
471
|
// Set route metadata in plugin manager
|
|
@@ -461,6 +479,7 @@ function createApp(options = {}) {
|
|
|
461
479
|
app,
|
|
462
480
|
nunjucksEnv,
|
|
463
481
|
options,
|
|
482
|
+
middlewares,
|
|
464
483
|
db: options.db ?? null,
|
|
465
484
|
routes: pluginManager.routes,
|
|
466
485
|
usePlugin: (n) => pluginManager.getPluginAPI(n),
|
|
@@ -499,7 +518,7 @@ function createApp(options = {}) {
|
|
|
499
518
|
// Helper to create error page context with fsy
|
|
500
519
|
function createErrorContext(req, extraData = {}) {
|
|
501
520
|
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
|
502
|
-
const locale = req
|
|
521
|
+
const locale = detectLocale(req);
|
|
503
522
|
|
|
504
523
|
// Create fsy helpers
|
|
505
524
|
const fsy = createHelpers({ req, res: {}, baseUrl, locale });
|
|
@@ -560,7 +579,7 @@ function createApp(options = {}) {
|
|
|
560
579
|
}
|
|
561
580
|
|
|
562
581
|
// Custom timeout template
|
|
563
|
-
if (typeof errorPages.timeout === 'string') {
|
|
582
|
+
if (typeof errorPages.timeout === 'string' && !preferJsonErrorResponse(req)) {
|
|
564
583
|
try {
|
|
565
584
|
const html = nunjucksEnv.render(errorPages.timeout, ctx);
|
|
566
585
|
return res.send(html);
|
|
@@ -570,7 +589,7 @@ function createApp(options = {}) {
|
|
|
570
589
|
}
|
|
571
590
|
|
|
572
591
|
// Default timeout response
|
|
573
|
-
if (req
|
|
592
|
+
if (!preferJsonErrorResponse(req)) {
|
|
574
593
|
return res.send(default503Html());
|
|
575
594
|
} else {
|
|
576
595
|
return res.json({ error: 'Request Timeout', status: 503 });
|
|
@@ -589,8 +608,8 @@ function createApp(options = {}) {
|
|
|
589
608
|
return errorPages.serverError(err, req, res, ctx);
|
|
590
609
|
}
|
|
591
610
|
|
|
592
|
-
// Custom template
|
|
593
|
-
if (typeof errorPages.serverError === 'string') {
|
|
611
|
+
// Custom template (skipped for /api and JSON-preferring clients so they never get HTML)
|
|
612
|
+
if (typeof errorPages.serverError === 'string' && !preferJsonErrorResponse(req)) {
|
|
594
613
|
try {
|
|
595
614
|
const html = nunjucksEnv.render(errorPages.serverError, ctx);
|
|
596
615
|
return res.send(html);
|
|
@@ -600,7 +619,7 @@ function createApp(options = {}) {
|
|
|
600
619
|
}
|
|
601
620
|
|
|
602
621
|
// Default response
|
|
603
|
-
if (req
|
|
622
|
+
if (!preferJsonErrorResponse(req)) {
|
|
604
623
|
res.send(default500Html(err, isDev));
|
|
605
624
|
} else {
|
|
606
625
|
res.json({
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Webspresso — framework reference (SSR, API, ORM)
|
|
2
|
+
|
|
3
|
+
Sections follow the source repo layout (`index.js`, `src/`, `core/orm`, `doc/index.html`).
|
|
4
|
+
|
|
5
|
+
## 1. What this framework is
|
|
6
|
+
|
|
7
|
+
- **SSR**: Express + **Nunjucks**; URLs map to files under `pages/`.
|
|
8
|
+
- **API**: File-based handlers under `pages/api/` with method suffixes and optional **Zod** validation (`req.input`).
|
|
9
|
+
- **i18n**: JSON locales; `t('key')` in templates.
|
|
10
|
+
- **ORM**: Knex-backed layer in `core/orm` — `defineModel`, `zdb` schema helpers, repositories, query builder, migrations.
|
|
11
|
+
- **Plugins**: Register in `createApp({ plugins })`; optional `db` passed as `ctx.db`.
|
|
12
|
+
|
|
13
|
+
Public API surface: `require('webspresso')` / [`index.js`](../../../index.js) — `createApp`, **`resolveClientRuntime`**, **`CLIENT_RUNTIME_BASE`**, file-router utilities, `createHelpers`, plugin manager, ORM exports, built-in plugins. **Session auth** lives in [`core/auth`](../../../core/auth) — import **`require('webspresso/core/auth')`** (`createAuth`, `quickAuth`, `hash`, `verify`, `setupAuthMiddleware`, `createRememberTokensTable`, policy helpers); wire with **`createApp({ auth })`**.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 2. Typical project layout
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
project/
|
|
21
|
+
├── server.js # listen(); uses createApp()
|
|
22
|
+
├── pages/
|
|
23
|
+
│ ├── _hooks.js # global lifecycle hooks (optional)
|
|
24
|
+
│ ├── locales/en.json # global i18n
|
|
25
|
+
│ ├── index.njk # GET /
|
|
26
|
+
│ ├── about/index.njk # GET /about
|
|
27
|
+
│ ├── blog/[slug].njk # dynamic
|
|
28
|
+
│ └── api/
|
|
29
|
+
│ ├── health.get.js
|
|
30
|
+
│ └── posts.post.js
|
|
31
|
+
├── views/ # layouts (e.g. layout.njk)
|
|
32
|
+
├── public/ # static assets
|
|
33
|
+
├── models/ # ORM models (defineModel)
|
|
34
|
+
├── migrations/
|
|
35
|
+
├── webspresso.db.js # or knexfile.js
|
|
36
|
+
└── plugins/ # app-specific plugins (optional)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 3. `createApp(options)` — essentials
|
|
42
|
+
|
|
43
|
+
**Required**
|
|
44
|
+
|
|
45
|
+
- `pagesDir` — path to `pages/`.
|
|
46
|
+
|
|
47
|
+
**Common optional**
|
|
48
|
+
|
|
49
|
+
| Option | Role |
|
|
50
|
+
|--------|------|
|
|
51
|
+
| `viewsDir` | Layouts / partials (Nunjucks paths) |
|
|
52
|
+
| `publicDir` | Static files (`express.static`) |
|
|
53
|
+
| `db` | DB instance from `createDatabase()` → **`ctx.db`** in `load`, `meta`, plugins |
|
|
54
|
+
| `middlewares` | Named map; reference by string in route/API config (`middleware: ['auth']`) |
|
|
55
|
+
| `plugins` | Array of plugin factories/objects |
|
|
56
|
+
| `errorPages` | `{ notFound, serverError, timeout }` — function or template path. File-based SSR/API errors are passed to this Express error middleware via `next(err)`. **`serverError` / `timeout` as a template path** is not used for paths under **`/api`** (those get default JSON). |
|
|
57
|
+
| `timeout` | e.g. `'30s'` or `false` |
|
|
58
|
+
| `helmet` | `true` / `false` / object |
|
|
59
|
+
| `assets` | `{ version, manifestPath, prefix }` for `fsy.asset` / `fsy.css` / `fsy.js` |
|
|
60
|
+
| `pageAssets` | Opt-in **`true`** or **`{ enabled?, stylesheets?, scripts? }`**. When on, route **`load()`** may return reserved keys **`stylesheets`** (string or list; also `{ href, media? }` objects) and **`scripts`** (string, `{ src, defer?, async?, type? }`, or list). They are removed from the root Nunjucks context and passed as **`pageHead`** with **`pageAssets: true`**. The app layout must print them (see [`views/layout.njk`](../../../views/layout.njk) in the package). Default **off** — `stylesheets` / `scripts` in **`load()`** behave as normal data keys. |
|
|
61
|
+
| `clientRuntime` | Opt-in **`{ alpine?: boolean \| object, swup?: boolean \| object }`**. Serves **`/__webspresso/client-runtime/*`** (Alpine 3, swup 4 + Head + Scripts plugins + bootstrap). Template context **`clientRuntime`**; include [`views/partials/webspresso-client-runtime.njk`](../../../views/partials/webspresso-client-runtime.njk) and set **`<main id="swup">`** when swup is on. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`** (`1` or `true`). Admin / dev dashboard HTML is unchanged (Mithril). Use **`data-no-swup`** on links for full page loads. HTMX is not used. |
|
|
62
|
+
| `auth` | `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`webspresso/core/auth`). Mounts cookie parser + **`express-session`** + per-request **`authenticate`**; sets **`req.user`**, **`req.auth`**. Injects named route middleware **`auth`** and **`guest`** (overwrites same keys in `middlewares` if you passed both — avoid reusing those names for custom handlers). |
|
|
63
|
+
| `setupRoutes(app, ctx)` | **Register custom Express routes here** — runs **after** file routes and plugins’ `onRoutesReady`, **before** 404. **`ctx.clientRuntime`** is the resolved flags. **`ctx.authMiddleware`** is set when `auth` was passed (guards: `requireAuth`, `requireGuest`, `requireCan`, `requireVerified`, …). Do not rely on `app.get` *after* `createApp` returns unless routes are appended before the 404 middleware (see [`src/server.js`](../../../src/server.js)). |
|
|
64
|
+
|
|
65
|
+
**Returns:** `{ app, nunjucksEnv, pluginManager, authMiddleware }` — `authMiddleware` is **`null`** when `auth` was not configured.
|
|
66
|
+
|
|
67
|
+
### Client runtime — implementation notes
|
|
68
|
+
|
|
69
|
+
- **Package helpers:** `resolveClientRuntime(options)` merges **`createApp({ clientRuntime })`** with env; **`CLIENT_RUNTIME_BASE`** is **`/__webspresso/client-runtime`** (script URLs under that path).
|
|
70
|
+
- **After swup navigation:** bootstrap runs **`Alpine.initTree`** on **`#swup`** on swup’s **`content:replace`** so new SSR markup gets Alpine bindings.
|
|
71
|
+
- **Default `ignoreVisit` (bootstrap):** links under **`/_admin`**, **`/_webspresso`**, elements with **`data-no-swup`**, plus swup’s usual rules (e.g. `target="_blank"`, other origin).
|
|
72
|
+
- **CSP / Helmet:** 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.
|
|
73
|
+
- **Longer doc:** **[`doc/index.html#client-runtime`](../../../doc/index.html#client-runtime)** · README **Client runtime**.
|
|
74
|
+
|
|
75
|
+
### Session authentication — essentials
|
|
76
|
+
|
|
77
|
+
- **Import:** `const { createAuth, quickAuth, hash, verify, createRememberTokensTable } = require('webspresso/core/auth')` (published `core/` tree on npm; **not** re-exported from package root).
|
|
78
|
+
- **`createAuth({ findUserById, findUserByCredentials, session: { secret }, rememberTokens?, ... })`** — adapter pattern; optional **remember-me** via `rememberTokens: { create, find, delete, deleteAllForUser }` + **`createRememberTokensTable(knex)`** for the default table shape.
|
|
79
|
+
- **`quickAuth({ db, userModel, identifierField, passwordField, rememberMe })`** — wires **`getRepository`** + bcrypt **`verify`**; optional Knex **`remember_tokens`** when `rememberMe: true`.
|
|
80
|
+
- **Request API** (after global authenticate): **`req.auth.attempt(id, password, { remember })`**, **`login`**, **`logout`**, **`check`**, **`guest`**, **`user`**, **`id`**, **`can` / `cannot` / `authorize`** (policies: **`auth.definePolicy`**, **`defineGate`**, **`beforePolicy`**).
|
|
81
|
+
- **Route config:** `middleware: ['auth']` (must be logged in) or `['guest']` (logged-out only). For JSON APIs mounted in **`setupRoutes`**, use **`ctx.authMiddleware.requireAuth({ api: true })`** for 401 JSON instead of redirect.
|
|
82
|
+
- **Login page pitfall:** a **`pages/login.njk`** can register **before** `setupRoutes` and bypass **`requireGuest`**. Prefer login GET/POST in **`setupRoutes`** with templates under **`views/`** only, or omit **`pages/login.njk`** — see [`tests/e2e/auth.spec.js`](../../../tests/e2e/auth.spec.js).
|
|
83
|
+
- **Admin panel** uses a **separate** session (`req.session.adminUser`, `/_admin/api/auth/*`); it does **not** replace **`createApp({ auth })`** for site users.
|
|
84
|
+
- **Site users in the admin UI (`userManagement`):** Opt-in on **`adminPanelPlugin`**. Set **`userManagement: { enabled: true, model: 'User', fields?: { ... } }`** so the SPA shows **Users**: sidebar **All Users** / **Add User** link to **`/_admin/models/{model}`** and **`/_admin/models/{model}/new`** (same RecordList/RecordForm as other models). **`/_admin/users`**, **`/_admin/users/new`**, **`/_admin/users/:id/edit`** remain SPA aliases that redirect there; **`/_admin/users/sessions`** is **Active Sessions**. The **`model`** must match site auth (e.g. **`quickAuth({ userModel: 'User', ... })`** / **`createAuth`**) and must have **`admin: { enabled: true, ... }`** on **`defineModel`** so admin CRUD metadata loads; otherwise Users screens look empty. Pass **`auth: authManager`** with the **same** **`AuthManager`** as **`createApp({ auth: authManager })`** for **Active Sessions** / revoke (**`rememberTokens`** / **`remember_me`**); without **`auth`**, user CRUD still works via **`db.getRepository(model)`**, but session endpoints return empty or “not enabled”.
|
|
85
|
+
- **Wiring:** `plugins: [ adminPanelPlugin({ db, auth: authManager, userManagement: { enabled: true, model: 'User' } }) ]` alongside `createApp({ ..., auth: authManager })`. Admin staff log in at **`/_admin`**; end users use your normal site login — two different cookies/sessions.
|
|
86
|
+
|
|
87
|
+
Longer narrative: **[`doc/index.html#authentication`](../../../doc/index.html#authentication)** · **[`#admin-user-management`](../../../doc/index.html#admin-user-management)** · README **Authentication (session)** and **Admin Panel Plugin**.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. File-based routing (SSR)
|
|
92
|
+
|
|
93
|
+
| Path pattern | URL |
|
|
94
|
+
|--------------|-----|
|
|
95
|
+
| `pages/index.njk` | `/` |
|
|
96
|
+
| `pages/about/index.njk` | `/about` |
|
|
97
|
+
| `pages/tools/[slug].njk` | `/tools/:slug` |
|
|
98
|
+
| `pages/docs/[...rest].njk` | `/docs/*` (catch-all) |
|
|
99
|
+
|
|
100
|
+
**Route config** — sibling `.js` file (e.g. `pages/tools/index.js`):
|
|
101
|
+
|
|
102
|
+
- `middleware` — array of Express functions, **string names** from `createApp({ middlewares })`, or **`['name', options]` tuples** when the registry entry is a **factory** `(options) => (req, res, next) => …` (bare string calls the factory with `{}`). Built-in **`auth`** / **`guest`** (with `createApp({ auth })`) are factories — e.g. **`['auth', { api: true }]`** for JSON 401 on APIs.
|
|
103
|
+
- `load(req, ctx)` — async; return object merged into template context; use **`ctx.db`** when `createApp({ db })` is set.
|
|
104
|
+
- `meta(req, ctx)` — title, description, etc.
|
|
105
|
+
- `hooks` — `beforeLoad`, `afterRender`, etc. (see hook order below).
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 5. File-based API (`pages/api/`)
|
|
110
|
+
|
|
111
|
+
| File | Route |
|
|
112
|
+
|------|-------|
|
|
113
|
+
| `api/health.get.js` | `GET /api/health` |
|
|
114
|
+
| `api/items.post.js` | `POST /api/items` |
|
|
115
|
+
| `api/users/[id].get.js` | `GET /api/users/:id` |
|
|
116
|
+
|
|
117
|
+
**Shapes**
|
|
118
|
+
|
|
119
|
+
1. **Function** — `module.exports = async (req, res) => { ... }`
|
|
120
|
+
2. **Object** — **`handler`**, optional **`middleware`** (names, functions, or **`['name', options]`** tuples with factory registry entries), optional **`schema`**
|
|
121
|
+
|
|
122
|
+
**Order:** `req.db` (if any) → **Zod** `schema` → **`middleware`** → **`handler`**.
|
|
123
|
+
|
|
124
|
+
**Zod** — `schema: ({ z }) => ({ body, query, params, response })` → **`req.input`**; invalid → **400** `{ error: 'Validation Error', issues }`.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 6. Global and route hooks
|
|
129
|
+
|
|
130
|
+
**Global:** `pages/_hooks.js` exports `onRequest`, `beforeLoad`, `afterLoad`, `beforeRender`, `afterRender`, `onError`, etc.
|
|
131
|
+
|
|
132
|
+
**`onError(ctx, err)`** — runs when an unhandled error occurs in a **file-based** SSR route or **`pages/api/*`** handler (after `console.error`, before the central `errorPages` handler). Optional second argument **`err`** matches **`ctx.error`**. Use for APM (Sentry, New Relic `noticeError`, …).
|
|
133
|
+
|
|
134
|
+
**Rough order:** global `onRequest` → route middleware chain → `load` → render → `afterRender`. (See README “Hook Execution Order” for full list.)
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 7. i18n
|
|
139
|
+
|
|
140
|
+
- **Global:** `pages/locales/<locale>.json` (nested keys).
|
|
141
|
+
- **Route-specific:** `pages/<route>/locales/<locale>.json` overrides global.
|
|
142
|
+
- Templates: `{{ t('nav.home') }}`.
|
|
143
|
+
- Env: `DEFAULT_LOCALE`, `SUPPORTED_LOCALES` (comma-separated).
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 8. Template helpers (`fsy`)
|
|
148
|
+
|
|
149
|
+
Available in all Nunjucks templates:
|
|
150
|
+
|
|
151
|
+
- **URL:** `fsy.url`, `fsy.fullUrl`, `fsy.route`, `fsy.canonical`
|
|
152
|
+
- **Request:** `fsy.q`, `fsy.param`, `fsy.hdr`
|
|
153
|
+
- **Utils:** `fsy.slugify`, `fsy.truncate`, `fsy.prettyBytes`, `fsy.prettyMs`
|
|
154
|
+
- **Dates:** `fsy.date`, `fsy.dateFormat`, `fsy.dateFromNow`, `fsy.dateDiff`, …
|
|
155
|
+
- **Assets:** `fsy.asset`, `fsy.css`, `fsy.js`, `fsy.img` (with `assets` config)
|
|
156
|
+
- **Dev:** `fsy.isDev()`
|
|
157
|
+
- **SEO:** `fsy.jsonld`
|
|
158
|
+
|
|
159
|
+
Analytics plugin adds `fsy.analyticsHead`, `fsy.verificationTags`, etc., when configured.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## 9. ORM overview
|
|
164
|
+
|
|
165
|
+
**Define schema** with **`zdb`** (`zdb.id()`, `zdb.uuid()`, `zdb.nanoid()`, `zdb.string({...})`, **`zdb.file({ maxLength, nullable })`** — URL/path string for uploaded assets, `zdb.foreignKey`, `zdb.foreignUuid`, `zdb.foreignNanoid`, `zdb.timestamp`, `zdb.json`, …).
|
|
166
|
+
|
|
167
|
+
**Define model** with **`defineModel({ name, table, schema, relations, scopes, hidden, admin })`**.
|
|
168
|
+
|
|
169
|
+
- **Relations:** `belongsTo`, `hasMany`, `hasOne` with `model: () => OtherModel`.
|
|
170
|
+
- **Scopes:** `softDelete`, `timestamps`, optional `tenant` column.
|
|
171
|
+
- **`hidden`:** columns never exposed in admin/API (e.g. `password_hash`).
|
|
172
|
+
- **Nanoid PK:** `zdb.nanoid()` / `zdb.nanoid({ maxLength: 12 })` — string primary key; migrations use `string(length)`. On **`create()`**, omitting the PK auto-fills a URL-safe id (built-in generator, same alphabet as `nanoid`). Use **`zdb.foreignNanoid('table', { maxLength })`** when the parent uses nanoid PKs; **`generateNanoid`** is exported from `webspresso` for manual ids. In API **`schema`**, use **`z.nanoid()`** / **`z.nanoid(12)`** / **`z.nanoid({ maxLength })`** (the `z` from `schema: ({ z })` is extended by Webspresso). **`zodNanoid`** / **`extendZ`** are also exported for non-route use.
|
|
173
|
+
|
|
174
|
+
**Database:** `createDatabase({ client, connection, models: './models' })` — auto-loads `models/*.js` (ignore `_prefix`).
|
|
175
|
+
|
|
176
|
+
**Repository:** `db.getRepository('User')` → `findById`, `findOne`, `findAll`, `create`, `update`, `delete`, `query()`, …
|
|
177
|
+
|
|
178
|
+
**Query builder:** `UserRepo.query().where(...).with('relation').orderBy(...).list()` / `.first()` / `.paginate()` / `.count()`. **`with()`** eager-loads relations; **`count()`** ignores builder `.limit`/`.offset` for total; see ORM docs for edge cases.
|
|
179
|
+
|
|
180
|
+
**Migrations:** `webspresso db:migrate`, `db:rollback`, `db:status`, `db:make`.
|
|
181
|
+
|
|
182
|
+
**Transactions:** `db.transaction(async (trx) => { trx.getRepository('User') })`.
|
|
183
|
+
|
|
184
|
+
**Query cache (optional):** `createDatabase({ ..., cache: true })` or `cache: { defaultStrategy: 'auto'|'smart', memory: { maxEntries, defaultTtlMs }, provider?: custom }`. Opt-in per model: `defineModel({ ..., cache: 'auto'|'smart'|true })`. API: `db.cache` → `getMetrics()`, `purge()`, `invalidateModel(name)`, `invalidateTags(tags[])`, `resetMetrics()`. Reads bypass cache when using a transaction knex. Admin UI: `ormCacheAdminPlugin({ db })` (needs `admin-panel` and `cache` enabled).
|
|
185
|
+
|
|
186
|
+
Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and plugins. **`pages/api/`** handlers receive **`req.db`** (and route **`middleware`** runs after it). Outside requests, use **`getDb()`** / **`hasDb()`**; for **`setupRoutes`**-only routes, use **`attachDbMiddleware`**.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## 10. Plugins (built-in — concise)
|
|
191
|
+
|
|
192
|
+
| Plugin | Purpose |
|
|
193
|
+
|--------|---------|
|
|
194
|
+
| `dashboardPlugin` | Dev route `/_webspresso` — route list |
|
|
195
|
+
| `sitemapPlugin` | `/sitemap.xml`, robots; optional DB-driven URLs |
|
|
196
|
+
| `analyticsPlugin` | GA / GTM / Yandex / Bing / Facebook — `fsy` helpers |
|
|
197
|
+
| `adminPanelPlugin` | SPA admin CRUD — needs **`db`**; optional **`uploadUrl`** (or infer from **`uploadPlugin`**); optional **`userManagement: { enabled, model, fields }`** + **`auth`** (same **`AuthManager`** as **`createApp({ auth })`**) for site-user CRUD + remember-me session UI — see **Session authentication** above |
|
|
198
|
+
| `dataExchangePlugin` | Admin-only **Excel export** + **CSV/XLSX import** under `${adminPath}/api/data-exchange/*`; register **after** `adminPanelPlugin` with same `db` / `adminPath`; optional `maxRows`, `maxFileBytes`; adds UI buttons + bulk `export-xlsx` |
|
|
199
|
+
| `redirectPlugin` | Configurable **301–308** redirects in `register()` — runs **before** file-based SSR routes; `rules` (`from` path or `RegExp`, `to`, `status`, `methods`), `preserveQuery`, `allowExternal`, `trailingSlash`, `defaultMethods`; docs **[`doc/index.html#plugins-redirect`](../../../doc/index.html#plugins-redirect)**, README **Redirect plugin** |
|
|
200
|
+
| `uploadPlugin` | `POST` multipart (`multer`), `createLocalFileProvider` or custom `provider`; set **`mimeAllowlist`** / **`maxBytes`** in production |
|
|
201
|
+
| `siteAnalyticsPlugin` | Self-hosted page views + admin charts |
|
|
202
|
+
| `auditLogPlugin` | Admin mutation audit trail |
|
|
203
|
+
| `recaptchaPlugin` | v2/v3 + middleware |
|
|
204
|
+
| `seoCheckerPlugin` | Dev SEO panel |
|
|
205
|
+
| `restResourcePlugin` | Opt-in REST CRUD from models; `?include=` uses ORM eager load (single-level relations only) |
|
|
206
|
+
| `ormCacheAdminPlugin` | Admin page for ORM cache metrics / purge / invalidate (`db.cache` required) |
|
|
207
|
+
|
|
208
|
+
**Custom plugin:** `name`, `version`, `register(ctx)`, `onRoutesReady(ctx)` — use `ctx.app`, `ctx.db`, `ctx.addHelper`, `ctx.addRoute`, `ctx.usePlugin('other')`. Plugin failures **warn**; app keeps running.
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 11. CLI (project directory)
|
|
213
|
+
|
|
214
|
+
| Command | Role |
|
|
215
|
+
|---------|------|
|
|
216
|
+
| `webspresso new` | Scaffold project |
|
|
217
|
+
| `webspresso new .` / `new ./` | Scaffold **into the current directory** |
|
|
218
|
+
| `webspresso new … --yes` | **Non-interactive** (agents / CI); use `-i` to install deps |
|
|
219
|
+
| `webspresso dev` / `start` | Servers |
|
|
220
|
+
| `webspresso page` / `api` | Interactive scaffolding |
|
|
221
|
+
| `webspresso db:*` | migrate, rollback, status, make |
|
|
222
|
+
| `webspresso upgrade` | Bump **`webspresso`** in **`package.json`** |
|
|
223
|
+
| `webspresso seed` | Seed data |
|
|
224
|
+
| `webspresso doctor` | Sanity checks |
|
|
225
|
+
| `webspresso skill` | Cursor **`SKILL.md`** scaffold |
|
|
226
|
+
| `webspresso skill --preset webspresso` | Copy bundled Webspresso agent skill (`SKILL.md` + `REFERENCE-*.md`) |
|
|
227
|
+
| `webspresso add tailwind` | Tailwind setup |
|
|
228
|
+
| `webspresso favicon:generate` | Favicons + manifest |
|
|
229
|
+
| `webspresso admin:setup` / `admin:password` | Admin users |
|
|
230
|
+
| `webspresso audit:prune` | Audit log retention |
|
|
231
|
+
|
|
232
|
+
**`webspresso new` — automation:** **`new .`** scaffolds in place. **Typical one-liners:** `webspresso new . --yes --no-tailwind` · `webspresso new . --yes -i`.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 12. Environment variables (common)
|
|
237
|
+
|
|
238
|
+
| Var | Notes |
|
|
239
|
+
|-----|-------|
|
|
240
|
+
| `NODE_ENV` | `development` / `production` |
|
|
241
|
+
| `DEFAULT_LOCALE` | Default locale |
|
|
242
|
+
| `SUPPORTED_LOCALES` | Comma-separated |
|
|
243
|
+
| `BASE_URL` | Canonical / links |
|
|
244
|
+
| `DATABASE_URL` | DB connection |
|
|
245
|
+
| `SESSION_SECRET` | Session cookie signing |
|
|
246
|
+
| `WEBSPRESSO_ALPINE` | Forces **`clientRuntime.alpine`** when `1` / `true` |
|
|
247
|
+
| `WEBSPRESSO_SWUP` | Forces **`clientRuntime.swup`** when `1` / `true` |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 13. Testing
|
|
252
|
+
|
|
253
|
+
- **Unit / integration:** `npm test` (Vitest).
|
|
254
|
+
- **E2E:** `npm run test:e2e` (Playwright).
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 14. Pitfalls (for agents)
|
|
259
|
+
|
|
260
|
+
1. **Custom Express routes** — use **`setupRoutes`**, not only `app.use` after `createApp` returns.
|
|
261
|
+
2. **File-router order** — understand precedence vs manual routes.
|
|
262
|
+
3. **`ctx.db`** — only when `createApp({ db })` is set.
|
|
263
|
+
4. **ORM hidden fields** — never expose to clients unintentionally.
|
|
264
|
+
5. **Zod on API** — assume **`req.input`** when schema is set.
|
|
265
|
+
6. **Built-in `auth`** — framework owns **`middlewares.auth`** / **`guest`** names.
|
|
266
|
+
7. **`pages/login.njk`** — can shadow custom login; prefer **`setupRoutes`** + **`views/`**.
|
|
267
|
+
8. **Client runtime + swup** — need **`<main id="swup">`** and the client-runtime partial when enabled.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 15. When to read this file (framework)
|
|
272
|
+
|
|
273
|
+
- Pages, API routes, ORM, migrations, **SSR `createApp`**, first-party **plugins**, i18n, **session auth**, **client runtime** (Alpine / swup).
|
|
274
|
+
- Not for: in-process **application kernel** (event bus + `kernel.createApp`) — see **`REFERENCE-kernel.md`**.
|
|
275
|
+
|
|
276
|
+
Authoritative long-form: **[README.md](../../../README.md)**, **[`doc/index.html`](../../../doc/index.html)**.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Webspresso — application kernel (event bus, flows, views)
|
|
2
|
+
|
|
3
|
+
This layer must **not** be confused with Express SSR. The package root **`createApp`** sets up file-based SSR; **`kernel.createApp`** is a lightweight in-process API with an event bus, optional view resolver, and flow registry.
|
|
4
|
+
|
|
5
|
+
## Name clash
|
|
6
|
+
|
|
7
|
+
| API | Purpose |
|
|
8
|
+
|-----|---------|
|
|
9
|
+
| `require('webspresso').createApp(...)` | SSR — `pages/`, Nunjucks, Express |
|
|
10
|
+
| `require('webspresso').kernel.createApp(...)` | Kernel — `events`, `registerPlugin`, `registerFlow`, `view` |
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```javascript
|
|
15
|
+
const { kernel } = require('webspresso');
|
|
16
|
+
const app = kernel.createApp({
|
|
17
|
+
paths: {
|
|
18
|
+
appViews: './views', // optional app override root
|
|
19
|
+
themeViews: './themes/default',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Module layout (source repo): [`core/kernel/`](../../../core/kernel/).
|
|
25
|
+
|
|
26
|
+
## Event bus
|
|
27
|
+
|
|
28
|
+
- **`events.dispatch(name, ctx)`** — Handlers for the same name run **sequentially** with `await`; `ctx` is mutable; errors stop the caller.
|
|
29
|
+
- **`events.publish(name, ctx)`** — Handlers run concurrently via **`Promise.all`** (side effects / “after” events).
|
|
30
|
+
- **`events.on(name, handler)`** / **`off`** — Subscribe / unsubscribe.
|
|
31
|
+
- **`events.buildContext(payload, { source, requestId?, userId? })`** → `{ payload, meta: { source, createdAt, … } }`; `source`: `'orm' | 'auth' | 'route' | 'plugin' | 'system'`.
|
|
32
|
+
|
|
33
|
+
Standalone bus: `kernel.createEventBus()`.
|
|
34
|
+
|
|
35
|
+
## Simulated repository (ORM lifecycle)
|
|
36
|
+
|
|
37
|
+
`kernel.BaseRepository` — in-memory store; events named by `resource`: `orm.<resource>.beforeCreate`, `afterCreate`, `beforeUpdate`, …
|
|
38
|
+
|
|
39
|
+
Implementation: [`core/kernel/base-repository.js`](../../../core/kernel/base-repository.js).
|
|
40
|
+
|
|
41
|
+
## Plugin shell
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
const { kernel } = require('webspresso');
|
|
45
|
+
const myPlugin = kernel.definePlugin({
|
|
46
|
+
name: 'my-plugin',
|
|
47
|
+
events(app) {
|
|
48
|
+
app.events.on('orm.post.afterCreate', async (ctx) => { /* ... */ });
|
|
49
|
+
},
|
|
50
|
+
views() {
|
|
51
|
+
return {
|
|
52
|
+
namespace: 'blog',
|
|
53
|
+
layouts: {},
|
|
54
|
+
pages: { home: '<h1>{{ title }}</h1>' },
|
|
55
|
+
partials: {},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
app.registerPlugin(myPlugin);
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## View resolver
|
|
63
|
+
|
|
64
|
+
- Name shape: **`namespace::page`** (e.g. `blog::home`).
|
|
65
|
+
- Resolution order: app file overrides → theme file overrides → inline plugin templates.
|
|
66
|
+
- **`app.view.renderView('ns::id', data, { layout: 'ns::layoutName' })`**
|
|
67
|
+
- **`app.view.renderPartial(...)`**
|
|
68
|
+
- Minimal templates: `{{ field }}` interpolation.
|
|
69
|
+
|
|
70
|
+
## Flow
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
app.registerFlow(
|
|
74
|
+
kernel.defineFlow({
|
|
75
|
+
trigger: 'orm.post.afterCreate',
|
|
76
|
+
when: (ctx) => ctx.payload.record?.status === 'published',
|
|
77
|
+
actions: [async (ctx, kernApp) => { /* run in order */ }],
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Demo
|
|
83
|
+
|
|
84
|
+
From the repo: `node core/kernel/run-demo.js` — plugin, flow, and view example.
|
|
85
|
+
|
|
86
|
+
## When to read this file
|
|
87
|
+
|
|
88
|
+
- Event-driven domain logic, “on afterCreate do X” automation.
|
|
89
|
+
- When you want this minimal kernel instead of SSR routes or Knex ORM `ModelEvents` alone.
|
|
90
|
+
|
|
91
|
+
Types: npm package **`index.d.ts`** (`kernel`, `KernelAppShell`, …).
|
|
92
|
+
|
|
93
|
+
HTML section: **[`doc/index.html#application-kernel`](../../../doc/index.html#application-kernel)**.
|