webspresso 0.0.100 ā 0.0.102
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/.agents/ADMIN.md +47 -4
- package/.agents/AGENTS.md +3 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/AUTH.md +63 -0
- package/.agents/CLI.md +81 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/EMAIL.md +84 -2
- package/.agents/EVENTS.md +179 -0
- package/.agents/ORM.md +59 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +163 -3
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +7 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/db-makemigrations.js +83 -0
- package/bin/commands/list.js +96 -0
- package/bin/commands/make-command.js +93 -0
- package/bin/commands/make-event.js +80 -0
- package/bin/commands/make-factory.js +72 -0
- package/bin/commands/make-mail.js +93 -0
- package/bin/commands/make-policy.js +106 -0
- package/bin/commands/routes.js +8 -0
- package/bin/commands/run.js +195 -0
- package/bin/webspresso.js +17 -0
- package/core/applySchema.js +18 -1
- package/core/auth/bouncer.js +196 -0
- package/core/auth/index.js +10 -0
- package/core/auth/middleware.js +15 -1
- package/core/commands/define-command.js +48 -0
- package/core/commands/index.js +18 -0
- package/core/commands/registry.js +156 -0
- package/core/commands/runner.js +76 -0
- package/core/compression/compressible.js +1 -1
- package/core/compression/middleware.js +35 -251
- package/core/content/service.js +8 -2
- package/core/events/discovery.js +142 -0
- package/core/events/dispatcher.js +552 -0
- package/core/events/index.js +23 -0
- package/core/events/signal.js +92 -0
- package/core/forms/flash.js +234 -0
- package/core/forms/form-builder.js +214 -0
- package/core/forms/index.js +16 -0
- package/core/mail/index.js +23 -0
- package/core/mail/mailable.js +250 -0
- package/core/mail/mailer.js +184 -0
- package/core/mail/transports.js +238 -0
- package/core/orm/factory.js +250 -0
- package/core/orm/index.js +6 -0
- package/core/orm/migrations/diff.js +185 -0
- package/core/orm/migrations/index.js +22 -4
- package/core/orm/model.js +4 -0
- package/index.d.ts +422 -1
- package/index.js +111 -16
- package/package.json +9 -5
- package/plugins/admin-panel/api.js +509 -3
- package/plugins/admin-panel/index.js +29 -0
- package/plugins/content/models/content-entry.js +4 -3
- package/plugins/content/models/content-type.js +4 -3
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +1 -1
- package/plugins/email/index.js +48 -0
- package/plugins/email/preview-handler.js +555 -0
- package/plugins/email/service.js +23 -12
- package/plugins/email/transport.js +30 -11
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/plugins/redirect/index.js +1 -1
- package/src/api/api-loader.js +18 -2
- package/src/api/validation-i18n.js +231 -0
- package/src/app-context.js +48 -6
- package/src/dev-reload/index.js +9 -1
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +80 -5
- package/src/file-router.js +153 -91
- package/src/helpers.js +143 -0
- package/src/njk-frontmatter.js +17 -1
- package/src/pages/page-loader.js +1 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/cascading.js +14 -10
- package/src/routing/route-table.js +1 -1
- package/src/server.js +243 -34
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
package/.agents/PLUGINS.md
CHANGED
|
@@ -151,8 +151,9 @@ Outgoing Webhook Dispatcher and Management Plugin with zero external dependencie
|
|
|
151
151
|
|
|
152
152
|
### 2.4 `adminPanelPlugin` (`plugins/admin-panel`)
|
|
153
153
|
Mithril.js SPA Admin Panel mounted at `/_admin`.
|
|
154
|
-
- **Options**: `{ path: '/_admin', db, title: 'Webspresso Admin' }`
|
|
154
|
+
- **Options**: `{ path: '/_admin', db, title: 'Webspresso Admin', lazy: false }`
|
|
155
155
|
- **Features**: Auto CRUD screens for ORM models, user staff auth, custom page registration, widgets, field renderers.
|
|
156
|
+
- **On-Demand Hydration (`lazy: true`)**: When set to `true`, the admin panel plugin wraps itself with `lazyPlugin()`, deferring route mounting and internal setup until the first HTTP request hits `/_admin`.
|
|
156
157
|
|
|
157
158
|
### 2.3 `contentPlugin` (`plugins/content`)
|
|
158
159
|
Schema-driven headless CMS & inline content editing.
|
|
@@ -168,11 +169,12 @@ Multipart file upload manager with local / cloud storage providers.
|
|
|
168
169
|
|
|
169
170
|
### 2.5 `fileManagerPlugin` (`plugins/file-manager`)
|
|
170
171
|
Filesystem-first media library, directory explorer, and modal asset picker for Admin Panel.
|
|
171
|
-
- **Options**: `{ baseDir: './public/uploads', publicBasePath: '/uploads', apiPrefix: '/files', pageTitle: 'File Manager', maxUploadBytes: 10485760, allowedExtensions, allowedMimeTypes, includePage: true }`
|
|
172
|
+
- **Options**: `{ baseDir: './public/uploads', publicBasePath: '/uploads', apiPrefix: '/files', pageTitle: 'File Manager', maxUploadBytes: 10485760, allowedExtensions, allowedMimeTypes, includePage: true, lazy: false }`
|
|
172
173
|
- **Features**:
|
|
173
174
|
- Directory tree navigation (breadcrumbs), safe subfolder creation (`mkdir`), item rename, delete, and move.
|
|
174
175
|
- Path traversal attack protection (`resolveSafePath`).
|
|
175
176
|
- Grid (thumbnail gallery) & List (table) view modes.
|
|
177
|
+
- On-demand hydration via `lazy: true`.
|
|
176
178
|
- Search by filename and type filter (Images, Documents, Videos, Audio, Archives, Code).
|
|
177
179
|
- Media preview modal with copy URL and direct playback/preview.
|
|
178
180
|
- "Choose from Media Library" modal picker integrated with `FileUploadField` and `zdb.file()` form fields.
|
|
@@ -662,7 +664,31 @@ Zero-dependency OAuth 2.0 & Social Login plugin supporting Google, GitHub, and c
|
|
|
662
664
|
- `fsy.oauthUrl('google')` ā `"/auth/google"`
|
|
663
665
|
- `fsy.oauthUrl('github', { returnTo: '/dashboard' })` ā `"/auth/github?returnTo=%2Fdashboard"`
|
|
664
666
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
667
|
+
### 2.20 `lazyPlugin` / `defineLazyPlugin` (`plugins/lazy`)
|
|
668
|
+
Zero-dependency on-demand plugin hydration wrapper that defers heavy plugin initialization, route mounting, and service registration until the first matching HTTP request or service invocation.
|
|
669
|
+
- **Why**: Drastically slashes application cold start time and peak memory footprint when mounting heavy modules (such as admin panels, media libraries, documentation portals, or internal diagnostic tools) that are not needed on every server startup.
|
|
670
|
+
- **Usage**:
|
|
671
|
+
```javascript
|
|
672
|
+
const { lazyPlugin, adminPanelPlugin, fileManagerPlugin } = require('webspresso/plugins');
|
|
673
|
+
|
|
674
|
+
// Option 1: Using built-in lazy option
|
|
675
|
+
adminPanelPlugin({ db, lazy: true });
|
|
676
|
+
fileManagerPlugin({ lazy: true });
|
|
677
|
+
|
|
678
|
+
// Option 2: Wrapping custom or 3rd-party plugins
|
|
679
|
+
const heavyPlugin = lazyPlugin(
|
|
680
|
+
() => require('./my-heavy-plugin')({ config: 123 }),
|
|
681
|
+
{
|
|
682
|
+
name: 'my-heavy-plugin',
|
|
683
|
+
prefix: '/heavy', // Hydrates when request path starts with /heavy
|
|
684
|
+
services: ['heavy.process'], // Hydrates when ctx.service('heavy.process') is called
|
|
685
|
+
helpers: ['heavyHelper'], // Hydrates when fsy.heavyHelper() is evaluated
|
|
686
|
+
events: ['heavy.*'], // Hydrates when matching event is dispatched
|
|
687
|
+
onHydrate: (plugin) => console.log('Plugin hydrated!'),
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
```
|
|
691
|
+
- **How It Works**:
|
|
692
|
+
- Mounts a lightweight Express router proxy at `prefix`.
|
|
693
|
+
- Stubs placeholder services into `ServiceRegistry` and proxy helpers into Nunjucks context.
|
|
694
|
+
- Intercepts incoming requests/calls, executes the plugin factory function, runs `register(ctx)` and `onRoutesReady(ctx)`, replaces proxies seamlessly with the real router/services, and forwards the initial call with zero dropped requests.
|
package/.agents/ROUTING.md
CHANGED
|
@@ -119,6 +119,73 @@ module.exports = {
|
|
|
119
119
|
> [!IMPORTANT]
|
|
120
120
|
> **NEVER use raw Knex queries** like `req.db('notes')` in route handlers. **ALWAYS use repositories**: `req.db.getRepository('Note')`. This guarantees schema validation, lifecycle hooks, soft deletes, and query caching are respected.
|
|
121
121
|
|
|
122
|
+
### 2.1 Smart API Validation i18n & Error Translation
|
|
123
|
+
|
|
124
|
+
Webspresso features built-in **Smart Dual-Layer Auto-Translation** for Zod schema validation errors with **zero runtime overhead on valid requests**.
|
|
125
|
+
|
|
126
|
+
#### How It Works:
|
|
127
|
+
1. **Explicit Custom Keys**: Define dictionary translation keys directly inside Zod schemas (e.g. `z.string({ message: 'auth.email_required' })` or `z.string().min(5, 'auth.password_min')`).
|
|
128
|
+
2. **Dynamic Fallbacks**: If no explicit key is matched, the engine falls back to convention-based candidate keys:
|
|
129
|
+
- `validation.fields.<field>.<code/type>` (e.g., `validation.fields.email.invalid_string`)
|
|
130
|
+
- `validation.<code/type>` (e.g., `validation.too_small`, `validation.required`, `validation.invalid_string.email`)
|
|
131
|
+
- `zod.<code/type>`
|
|
132
|
+
3. **Parameter Interpolation**: Dynamic schema parameters (e.g. `{{ min }}`, `{{ max }}`, `{{ expected }}`, `{{ received }}`) are automatically interpolated into translated messages or inline custom template strings.
|
|
133
|
+
4. **Context Injection**: `apiCtx` provides `{ locale, t, service, db, sse }` to route handlers for immediate localized responses.
|
|
134
|
+
|
|
135
|
+
#### Example Usage:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
// pages/locales/tr.json
|
|
139
|
+
{
|
|
140
|
+
"auth": {
|
|
141
|
+
"email_required": "Lütfen geçerli bir e-posta adresi giriniz"
|
|
142
|
+
},
|
|
143
|
+
"validation": {
|
|
144
|
+
"required": "Bu alan zorunludur",
|
|
145
|
+
"too_small": "En az {{ min }} karakter olmalıdır"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
// pages/api/auth/register.post.js
|
|
152
|
+
'use strict';
|
|
153
|
+
const { defineApi } = require('webspresso');
|
|
154
|
+
|
|
155
|
+
module.exports = defineApi({
|
|
156
|
+
schema: ({ z }) => ({
|
|
157
|
+
body: z.object({
|
|
158
|
+
// Explicit dictionary translation key
|
|
159
|
+
email: z.string({ message: 'auth.email_required' }).email(),
|
|
160
|
+
// Automatic fallback to validation.too_small with {{ min }} interpolation
|
|
161
|
+
password: z.string().min(8),
|
|
162
|
+
}),
|
|
163
|
+
}),
|
|
164
|
+
handler: async (req, res, { t, locale }) => {
|
|
165
|
+
// req.locale and t() are injected into handler context
|
|
166
|
+
return res.status(201).json({
|
|
167
|
+
success: true,
|
|
168
|
+
message: t('auth.registered_success', { email: req.input.body.email }),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Client requests sending `Accept-Language: tr` or query param `?lang=tr` will automatically receive fully localized validation errors in the 400 Bad Request response:
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"error": "Validation failed",
|
|
178
|
+
"issues": [
|
|
179
|
+
{
|
|
180
|
+
"field": "password",
|
|
181
|
+
"code": "too_small",
|
|
182
|
+
"key": "validation.too_small",
|
|
183
|
+
"message": "En az 8 karakter olmalıdır"
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
122
189
|
---
|
|
123
190
|
|
|
124
191
|
## 3. Dynamic Route Parameters (`[param]`)
|
|
@@ -326,7 +393,37 @@ const routeList = app.routes.list();
|
|
|
326
393
|
// ]
|
|
327
394
|
```
|
|
328
395
|
|
|
329
|
-
### 6.5
|
|
396
|
+
### 6.5 Cold Start & High-Performance Routing Architecture
|
|
397
|
+
|
|
398
|
+
Webspresso incorporates multi-tier optimizations to ensure sub-millisecond cold starts in production and instant dev-server boot times:
|
|
399
|
+
|
|
400
|
+
#### 1. Single-Pass Unified Route Discovery (`discoverAppRoutes`)
|
|
401
|
+
Instead of performing multiple independent directory traversals, `discoverAppRoutes(rootDir, options)` discovers Page routes, API routes, and Route Groups in a single traversal pass:
|
|
402
|
+
```javascript
|
|
403
|
+
const { discoverAppRoutes } = require('webspresso');
|
|
404
|
+
|
|
405
|
+
const { routeTable, pageRoutes, apiRoutes } = discoverAppRoutes(__dirname);
|
|
406
|
+
```
|
|
407
|
+
Passing `discoveredRoutes` to `createApp({ discoveredRoutes })` skips internal discovery completely.
|
|
408
|
+
|
|
409
|
+
#### 2. Dev-Mode On-Demand (JIT) Route Evaluation (`lazyRoutes: true`)
|
|
410
|
+
In development (`NODE_ENV !== 'production'`), `lazyRoutes: true` is enabled by default:
|
|
411
|
+
- Route paths and HTTP methods are registered immediately with lightweight proxy handlers.
|
|
412
|
+
- Route handler modules (`pages/**/*.js` / `pages/api/**/*.js`) are required and evaluated **on-demand on the first incoming HTTP request**.
|
|
413
|
+
- Result: Dev server boots in ~15ms even in applications with hundreds of routes.
|
|
414
|
+
- Can be explicitly controlled via `createApp({ lazyRoutes: false })` to force eager evaluation.
|
|
415
|
+
|
|
416
|
+
#### 3. Production Ahead-of-Time (AOT) Route Manifest (`routes.manifest.json`)
|
|
417
|
+
For maximum production performance, compile your route table during the build step:
|
|
418
|
+
```bash
|
|
419
|
+
npx webspresso build
|
|
420
|
+
```
|
|
421
|
+
This generates `.webspresso/routes.manifest.json`. When `createApp({ routesManifest: true })` is used (or when `.webspresso/routes.manifest.json` exists in `NODE_ENV === 'production'`), Webspresso:
|
|
422
|
+
- Bypasses filesystem directory scanning (`pages/`) entirely.
|
|
423
|
+
- Bypasses regex path compilation and route sorting at boot.
|
|
424
|
+
- Instantly binds pre-sorted routes from the manifest.
|
|
425
|
+
|
|
426
|
+
### 6.6 Server-Sent Events (SSE) Streaming (`res.sse`)
|
|
330
427
|
For real-time event streaming and LLM token outputs, use the native `res.sse()` helper (or `{ sse }` from `ctx`):
|
|
331
428
|
|
|
332
429
|
```javascript
|
|
@@ -352,7 +449,7 @@ module.exports = defineApi({
|
|
|
352
449
|
});
|
|
353
450
|
```
|
|
354
451
|
|
|
355
|
-
### 6.
|
|
452
|
+
### 6.7 Cascading Middleware (`_middleware.js`) & Nested Layouts (`_layout.njk`)
|
|
356
453
|
|
|
357
454
|
Webspresso supports folder-level cascading middleware inheritance and nested layouts across directory trees:
|
|
358
455
|
|
|
@@ -435,7 +532,70 @@ In SSR streaming mode (`stream: true` or `renderStream`):
|
|
|
435
532
|
|
|
436
533
|
---
|
|
437
534
|
|
|
438
|
-
## 7.
|
|
535
|
+
## 7. Form Handling, Flash Messages & Old Input (AdonisJS & Django-inspired)
|
|
536
|
+
|
|
537
|
+
For classic SSR forms (POST-Redirect-GET), Webspresso provides built-in flash data propagation and Django-style ModelForm rendering helpers.
|
|
538
|
+
|
|
539
|
+
### 7.1 Redirect with Old Input & Validation Errors
|
|
540
|
+
In your form submission handler (API or custom POST action):
|
|
541
|
+
```javascript
|
|
542
|
+
// pages/api/auth/register.post.js
|
|
543
|
+
module.exports = {
|
|
544
|
+
handler: async (req, res) => {
|
|
545
|
+
const { email, password } = req.body;
|
|
546
|
+
|
|
547
|
+
if (!email.includes('@')) {
|
|
548
|
+
return res
|
|
549
|
+
.withInput() // Preserves req.body (sanitizes sensitive fields like password)
|
|
550
|
+
.withErrors({ email: 'Please enter a valid email address' })
|
|
551
|
+
.withFlash('warning', 'Please review the highlighted errors')
|
|
552
|
+
.redirect('/register');
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
await req.service('user.create', { email, password });
|
|
556
|
+
return res.withFlash('success', 'Account created! Please log in.').redirect('/login');
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
### 7.2 Nunjucks Template Helpers (`fsy`)
|
|
562
|
+
In your Nunjucks templates (`pages/*.njk`):
|
|
563
|
+
```html
|
|
564
|
+
{% if fsy.hasFlash('success') %}
|
|
565
|
+
<div class="alert alert-success">{{ fsy.flash('success') }}</div>
|
|
566
|
+
{% endif %}
|
|
567
|
+
|
|
568
|
+
<form method="POST" action="/api/auth/register">
|
|
569
|
+
{{ fsy.csrf() | safe }}
|
|
570
|
+
|
|
571
|
+
<!-- Manual field with old value and inline error -->
|
|
572
|
+
<div class="mb-3">
|
|
573
|
+
<label class="form-label">Email</label>
|
|
574
|
+
<input type="email" name="email"
|
|
575
|
+
value="{{ fsy.old('email') }}"
|
|
576
|
+
class="form-control {% if fsy.hasError('email') %}is-invalid{% endif %}">
|
|
577
|
+
{% if fsy.hasError('email') %}
|
|
578
|
+
<div class="invalid-feedback">{{ fsy.error('email') }}</div>
|
|
579
|
+
{% endif %}
|
|
580
|
+
</div>
|
|
581
|
+
|
|
582
|
+
<!-- Or use the Django-inspired field helper (one-liner) -->
|
|
583
|
+
{{ fsy.field('email', { type: 'email', placeholder: 'you@example.com' }) | safe }}
|
|
584
|
+
|
|
585
|
+
<button type="submit" class="btn btn-primary">Register</button>
|
|
586
|
+
</form>
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
### 7.3 Django ModelForm Helper (`fsy.formFor`)
|
|
590
|
+
Generate an entire HTML form from an ORM model definition with automatic widgets and validation error mapping:
|
|
591
|
+
```html
|
|
592
|
+
{# Automatically excludes id, created_at, updated_at and generates appropriate widgets #}
|
|
593
|
+
{{ fsy.formFor(req.db.getRepository('Article'), { action: '/articles', submitLabel: 'Publish' }) | safe }}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## 8. Summary Checklist for Agents
|
|
439
599
|
|
|
440
600
|
When a user asks to add or modify endpoints:
|
|
441
601
|
1. Identify the URL and method (e.g. `POST /api/auth/register` or `POST /auth/login`).
|
|
@@ -42,11 +42,43 @@ webspresso start # Start production server
|
|
|
42
42
|
webspresso doctor # Run system sanity checks
|
|
43
43
|
webspresso db:migrate # Run pending Knex migrations
|
|
44
44
|
webspresso db:seed # Run database seeders
|
|
45
|
+
webspresso add:tailwind # Add Tailwind CSS v4 (Oxide engine, zero-config)
|
|
45
46
|
webspresso agents:init # Scaffold AI Agent rules and skills
|
|
46
47
|
```
|
|
47
48
|
|
|
48
49
|
---
|
|
49
50
|
|
|
51
|
+
## šØ Tailwind CSS v4 Integration
|
|
52
|
+
|
|
53
|
+
Webspresso natively supports **Tailwind CSS v4** (Oxide engine, LightningCSS, zero-config CSS-first architecture).
|
|
54
|
+
|
|
55
|
+
### Setup & Commands
|
|
56
|
+
```bash
|
|
57
|
+
# Add Tailwind CSS v4 (recommended, zero-config CSS-first)
|
|
58
|
+
npx webspresso add tailwind --v4
|
|
59
|
+
|
|
60
|
+
# Or upgrade an existing Tailwind v3 project to v4
|
|
61
|
+
npx webspresso add tailwind --upgrade
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Architecture & Conventions
|
|
65
|
+
- **CSS-First Directives**: `src/input.css` uses modern v4 `@import`:
|
|
66
|
+
```css
|
|
67
|
+
@import "tailwindcss";
|
|
68
|
+
```
|
|
69
|
+
- **Zero Configuration**: No `tailwind.config.js`, `postcss.config.js`, or `autoprefixer` required.
|
|
70
|
+
- **Compiled Output**: Compiles into `public/css/style.css` via `@tailwindcss/cli`.
|
|
71
|
+
- **Scripts in `package.json`**:
|
|
72
|
+
- `"build:css"`: `@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --minify`
|
|
73
|
+
- `"watch:css"`: `@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --watch`
|
|
74
|
+
- `"dev"`: Automatically runs CSS watcher alongside the server: `npm run watch:css & node --watch server.js`
|
|
75
|
+
- **Template Integration**: Link stylesheet in Nunjucks layouts (`views/layout.njk`):
|
|
76
|
+
```nunjucks
|
|
77
|
+
<link rel="stylesheet" href="{{ fsy.asset('/css/style.css') }}">
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
50
82
|
## šļø Core Architecture Overview
|
|
51
83
|
|
|
52
84
|
- **`pages/` & `pages/api/`**: File-based SSR templates (`.njk`), companion data loaders (`.js`), and method-suffixed API endpoints (`.get.js`, `.post.js`).
|
|
@@ -85,10 +85,10 @@ module.exports = definePage({
|
|
|
85
85
|
{% extends "layout.njk" %}
|
|
86
86
|
|
|
87
87
|
{% block content %}
|
|
88
|
-
<div class="
|
|
89
|
-
<h1>{{ product.name }}</h1>
|
|
90
|
-
<p class="
|
|
91
|
-
<p>{{ product.description }}</p>
|
|
88
|
+
<div class="max-w-4xl mx-auto p-6 bg-white dark:bg-gray-900 rounded-xl shadow-sm">
|
|
89
|
+
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ product.name }}</h1>
|
|
90
|
+
<p class="text-lg font-semibold text-emerald-600 mt-2">{{ fsy.formatCurrency(product.price) }}</p>
|
|
91
|
+
<p class="text-gray-600 dark:text-gray-300 mt-4 leading-relaxed">{{ product.description }}</p>
|
|
92
92
|
</div>
|
|
93
93
|
{% endblock %}
|
|
94
94
|
```
|
package/AGENTS.md
CHANGED
|
@@ -17,6 +17,7 @@ When tasked with a feature or bugfix, **immediately consult the corresponding to
|
|
|
17
17
|
| **Admin Panel (Page, Widget, Action, UI)** | [`.agents/ADMIN.md`](.agents/ADMIN.md) | `plugins/admin-panel/` |
|
|
18
18
|
| **Authentication (Session / JWT / Guard)** | [`.agents/AUTH.md`](.agents/AUTH.md) | `core/auth/` |
|
|
19
19
|
| **Background Jobs & Worker Queue** | [`.agents/QUEUE.md`](.agents/QUEUE.md) | `jobs/` |
|
|
20
|
+
| **Decoupled Events & Signals** | [`.agents/EVENTS.md`](.agents/EVENTS.md) | `core/events/`, `listeners/` |
|
|
20
21
|
| **API Signatures & Types Verification** | [`index.d.ts`](index.d.ts) | **Canonical Ground Truth** |
|
|
21
22
|
|
|
22
23
|
---
|
|
@@ -57,6 +58,12 @@ Every coding agent working on Webspresso **MUST** follow this 4-step execution d
|
|
|
57
58
|
- **Graceful Shutdown & Lifecycle Management**: Configured via `createApp({ server: { shutdown: { enabled: true, mode: 'graceful'|'force', timeout: 10000 } } })`. Socket connection tracking, request draining with `Connection: close`, and reverse-order plugin disposer cleanup.
|
|
58
59
|
- **HTTP Response Compression**: Zero-dependency streaming zlib compression configured via `createApp({ server: { compression: true } })` with Brotli (`br`), Gzip (`gzip`), Deflate (`deflate`), threshold buffering (1 KB), and route-level opt-out (`res.compress(false)`).
|
|
59
60
|
- **Custom Error & 404 Pages**: Configured via `createApp({ errorPages: { notFound, serverError, timeout } })`. Supports template paths (e.g. `notFound: '404.njk'`) or custom handler functions, passing `{ fsy, locale, isDev, url, method }` to templates.
|
|
61
|
+
- **Cold Start & High-Performance Optimizations**:
|
|
62
|
+
- Unified single-pass route scanner (`discoverAppRoutes`).
|
|
63
|
+
- Dev-mode on-demand (JIT) route evaluation (`lazyRoutes: true`, dev boot ~15ms).
|
|
64
|
+
- Ahead-of-time (AOT) route table manifest (`webspresso build` -> `.webspresso/routes.manifest.json`).
|
|
65
|
+
- Ahead-of-time (AOT) Nunjucks template precompilation (`webspresso build` -> `.webspresso/templates.compiled.js`).
|
|
66
|
+
- On-demand lazy plugin hydration (`lazyPlugin`, `adminPanelPlugin({ lazy: true })`, `fileManagerPlugin({ lazy: true })`).
|
|
60
67
|
- **Middleware & Hooks**: Route lifecycle hooks can be declared per-page or globally via `pages/_hooks.js`.
|
|
61
68
|
- **API Endpoints & Routing Architecture**:
|
|
62
69
|
- ā ļø **CRITICAL ANTI-PATTERN: NEVER create a `routes/` or `src/routes/` directory. NEVER use `express.Router()`, `app.get()`, or `app.post()` for application endpoints.**
|
package/README.md
CHANGED
|
@@ -31,11 +31,12 @@ Open **`http://localhost:3000`** in your browser.
|
|
|
31
31
|
| Layer | Capabilities |
|
|
32
32
|
| :--- | :--- |
|
|
33
33
|
| **Routing & SSR** | 100% file-based routing (`pages/`), method suffixes (`.get.js`, `.post.js`), nested layouts (`_layout.njk`), directory-level error boundaries (`_error.njk`), loading skeletons (`_loading.njk`), and out-of-order chunked response streaming (`res.renderStream`). |
|
|
34
|
+
| **Performance & Cold Start** | Single-pass unified route discovery, dev mode JIT on-demand route evaluation, production AOT route manifest (`webspresso build`), AOT Nunjucks template precompilation (zero AST parsing at runtime), and on-demand lazy plugin mounting. |
|
|
34
35
|
| **Database & ORM** | Built-in Knex ORM with `defineModel`, `zdb` schema builder, Repository pattern (`db.getRepository('Model')`), relations (`belongsTo`, `hasMany`), cascade soft-delete & restore, and ambient transactions via `AsyncLocalStorage`. |
|
|
35
36
|
| **Dual Auth & Sessions** | Stateful cookies for SSR + stateless HS256 JWT rotation for APIs, magic links, active device management & remote session revocation, CSRF protection, and zero external auth dependencies. |
|
|
36
37
|
| **Services & Queues** | File-based auto-discovery in `services/` with declarative Zod validation, role-based access guards, and ACID transaction propagation. Background worker queues in `jobs/`. |
|
|
37
|
-
| **Admin Panel SPA** | Integrated Mithril.js single-page admin panel mounted at `/_admin` with auto-layout wrapping, CRUD views, custom dashboard widgets, and custom field renderers. |
|
|
38
|
-
| **Plugin Ecosystem** | 20+ official plugins: Polar.sh billing, Webhooks dispatcher with HMAC signatures, Realtime (WebSocket/SSE), Site Analytics, API Keys, File Manager, CSV/Excel generation, and more. |
|
|
38
|
+
| **Admin Panel SPA** | Integrated Mithril.js single-page admin panel mounted at `/_admin` with auto-layout wrapping, CRUD views, custom dashboard widgets, and custom field renderers. Supports on-demand lazy mounting (`adminPanelPlugin({ lazy: true })`). |
|
|
39
|
+
| **Plugin Ecosystem** | 20+ official plugins: Polar.sh billing, Webhooks dispatcher with HMAC signatures, Realtime (WebSocket/SSE), Site Analytics, API Keys, File Manager, CSV/Excel generation, On-demand Lazy Plugins (`lazyPlugin`), and more. |
|
|
39
40
|
| **DevOps & Production** | Built-in streaming zlib compression (`brotli`/`gzip`), socket connection draining & graceful shutdown, `webspresso deploy:check` production readiness auditor, and Docker scaffolding. |
|
|
40
41
|
|
|
41
42
|
---
|
|
@@ -154,13 +155,25 @@ app.listen(PORT, () => {
|
|
|
154
155
|
|
|
155
156
|
---
|
|
156
157
|
|
|
158
|
+
## ā” High-Performance Architecture (Cold Start & AOT)
|
|
159
|
+
|
|
160
|
+
Webspresso is designed for blazing-fast cold starts and minimal resource consumption in both development and production:
|
|
161
|
+
|
|
162
|
+
1. **Unified Single-Pass Route Discovery (`discoverAppRoutes`)**: Traverses the filesystem only once at startup, building the `RouteTable` and companion loaders in a single pass (saving 30-40% mount time).
|
|
163
|
+
2. **Dev-Mode On-Demand (JIT) Route Evaluation**: In development, API routes and SSR companion `.js` configs are resolved on-demand when first requested. The dev server boots in **<100ms** regardless of project size.
|
|
164
|
+
3. **Production AOT Route Manifest (`webspresso build`)**: The build step generates `.webspresso/routes.manifest.json`. In production, the server mounts pre-indexed routes with **zero filesystem directory scans**.
|
|
165
|
+
4. **Nunjucks Template Precompilation (AOT / Zero-AST Parse)**: `webspresso build` precompiles `.njk` templates into JavaScript render functions (`.webspresso/templates.compiled.js`). Eliminates runtime disk I/O and template parsing overhead for ultra-low TTFB.
|
|
166
|
+
5. **On-Demand Lazy Plugins (`lazyPlugin`)**: Heavy plugins (e.g. `adminPanelPlugin({ lazy: true })`, `fileManagerPlugin({ lazy: true })`) defer initialization, session setup, and route mounting until first access to their URL prefix or service invocation.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
157
170
|
## š§° Essential CLI Commands
|
|
158
171
|
|
|
159
172
|
```bash
|
|
160
173
|
# Development & Builds
|
|
161
|
-
npx webspresso dev # Start dev server with watch & live reload
|
|
162
|
-
npx webspresso build # Compile production assets
|
|
163
|
-
npx webspresso start # Run production server
|
|
174
|
+
npx webspresso dev # Start dev server with watch & JIT live reload
|
|
175
|
+
npx webspresso build # Compile production assets, AOT route manifest & precompiled templates
|
|
176
|
+
npx webspresso start # Run production server with zero-FS discovery
|
|
164
177
|
|
|
165
178
|
# Database & Migrations
|
|
166
179
|
npx webspresso db:migrate # Run pending database migrations
|
|
@@ -171,6 +171,16 @@ module.exports = {
|
|
|
171
171
|
// Create placeholder CSS
|
|
172
172
|
fs.writeFileSync('public/css/style.css', '/* Run npm run build:css */\n');
|
|
173
173
|
console.log('ā
Created public/css/style.css');
|
|
174
|
+
|
|
175
|
+
// Ensure public/css/style.css is in .gitignore
|
|
176
|
+
if (fs.existsSync('.gitignore')) {
|
|
177
|
+
let gitignore = fs.readFileSync('.gitignore', 'utf-8');
|
|
178
|
+
if (!gitignore.includes('public/css/style.css')) {
|
|
179
|
+
gitignore = gitignore.trimEnd() + '\npublic/css/style.css\n';
|
|
180
|
+
fs.writeFileSync('.gitignore', gitignore);
|
|
181
|
+
console.log('ā
Added public/css/style.css to .gitignore');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
174
184
|
|
|
175
185
|
// Try to build CSS if tailwindcss / @tailwindcss/cli is already installed
|
|
176
186
|
const tailwindBin = path.join(process.cwd(), 'node_modules', '.bin', 'tailwindcss');
|
package/bin/commands/build.js
CHANGED
|
@@ -15,6 +15,8 @@ function registerCommand(program) {
|
|
|
15
15
|
.description('Build production assets (Tailwind CSS and esbuild JS with manifest.json)')
|
|
16
16
|
.option('--no-css', 'Skip CSS build')
|
|
17
17
|
.option('--no-js', 'Skip JS bundle build')
|
|
18
|
+
.option('--no-routes', 'Skip generating AOT route manifest')
|
|
19
|
+
.option('--no-templates', 'Skip precompiling Nunjucks templates')
|
|
18
20
|
.option('--no-hash', 'Disable content hashing in asset filenames')
|
|
19
21
|
.action(async (options = {}) => {
|
|
20
22
|
const cwd = process.cwd();
|
|
@@ -33,6 +35,8 @@ function registerCommand(program) {
|
|
|
33
35
|
|
|
34
36
|
let cssBuilt = false;
|
|
35
37
|
let jsBuilt = false;
|
|
38
|
+
let routesBuilt = false;
|
|
39
|
+
let routeCount = 0;
|
|
36
40
|
|
|
37
41
|
// 1. Build CSS (Tailwind)
|
|
38
42
|
if (options.css !== false) {
|
|
@@ -90,9 +94,39 @@ function registerCommand(program) {
|
|
|
90
94
|
}
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
// 3. Build AOT Route Manifest (.webspresso/routes.manifest.json)
|
|
98
|
+
if (options.routes !== false) {
|
|
99
|
+
try {
|
|
100
|
+
const { generateRouteManifest } = require('../../src/discovery/route-manifest');
|
|
101
|
+
const result = generateRouteManifest({ rootDir: cwd });
|
|
102
|
+
routesBuilt = true;
|
|
103
|
+
routeCount = result.routeCount;
|
|
104
|
+
console.log(`šŗļø AOT route manifest generated (${result.routeCount} routes) at: ${path.relative(cwd, result.manifestPath)}`);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('ā Route manifest generation failed:', err.message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Precompile Nunjucks Templates (.webspresso/templates.compiled.js)
|
|
111
|
+
let templatesBuilt = false;
|
|
112
|
+
let templateCount = 0;
|
|
113
|
+
if (options.templates !== false) {
|
|
114
|
+
try {
|
|
115
|
+
const { precompileTemplates } = require('../../src/template/precompile');
|
|
116
|
+
const result = precompileTemplates({ rootDir: cwd });
|
|
117
|
+
templatesBuilt = true;
|
|
118
|
+
templateCount = result.templateCount;
|
|
119
|
+
console.log(`š Precompiled Nunjucks templates (${result.templateCount} templates) at: ${path.relative(cwd, result.manifestPath)}`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('ā Template precompilation failed:', err.message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
console.log('\n⨠Asset build complete!\n');
|
|
94
126
|
if (cssBuilt) console.log(' ā CSS: public/css/style.css');
|
|
95
127
|
if (jsBuilt) console.log(' ā JS: public/dist/ & manifest.json');
|
|
128
|
+
if (routesBuilt) console.log(` ā Routes: .webspresso/routes.manifest.json (${routeCount} routes)`);
|
|
129
|
+
if (templatesBuilt) console.log(` ā Templates: .webspresso/templates.compiled.js (${templateCount} templates)`);
|
|
96
130
|
});
|
|
97
131
|
}
|
|
98
132
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DB MakeMigrations Command (Django-inspired)
|
|
3
|
+
* Auto-detect model schema changes against live database and generate migrations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
8
|
+
const { makeMigrations } = require('../../core/orm/migrations/diff');
|
|
9
|
+
|
|
10
|
+
function registerCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('db:makemigrations')
|
|
13
|
+
.alias('makemigrations')
|
|
14
|
+
.description('Auto-detect model changes against live database and generate migration files')
|
|
15
|
+
.option('-c, --config <path>', 'Path to database config file (webspresso.db.js or knexfile.js)')
|
|
16
|
+
.option('-m, --models <path>', 'Models directory (overrides config.models)')
|
|
17
|
+
.option('--migrations <path>', 'Migrations directory (overrides config.migrations.directory)')
|
|
18
|
+
.option('-n, --name <name>', 'Custom migration name override')
|
|
19
|
+
.option('--include-drops', 'Generate dropColumn operations for columns removed from models')
|
|
20
|
+
.option('--dry-run', 'Preview migrations that would be created without writing files')
|
|
21
|
+
.action(async (options) => {
|
|
22
|
+
let knex = null;
|
|
23
|
+
try {
|
|
24
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
25
|
+
console.log(`\nš¦ Using config: ${configPath}\n`);
|
|
26
|
+
|
|
27
|
+
knex = await createDbInstance(config);
|
|
28
|
+
|
|
29
|
+
const modelsDir = options.models || config.models || './models';
|
|
30
|
+
const migrationDir = path.resolve(
|
|
31
|
+
process.cwd(),
|
|
32
|
+
options.migrations || config.migrations?.directory || './migrations'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
console.log(`š Inspecting database and comparing with models in ${modelsDir}...`);
|
|
36
|
+
|
|
37
|
+
const result = await makeMigrations({
|
|
38
|
+
knex,
|
|
39
|
+
modelsDir,
|
|
40
|
+
migrationDir,
|
|
41
|
+
dryRun: Boolean(options.dryRun),
|
|
42
|
+
includeDrops: Boolean(options.includeDrops),
|
|
43
|
+
name: options.name || null,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!result.hasChanges) {
|
|
47
|
+
console.log('\n⨠No changes detected in models. Database schema is up to date.\n');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const verb = options.dryRun ? 'Would generate' : 'Generated';
|
|
52
|
+
console.log(`\nā
${verb} ${result.created.length} migration(s):\n`);
|
|
53
|
+
|
|
54
|
+
for (const change of result.created) {
|
|
55
|
+
console.log(` š ${change.file}`);
|
|
56
|
+
console.log(` Model: ${change.model} (table: ${change.table})`);
|
|
57
|
+
console.log(` Type: ${change.type}`);
|
|
58
|
+
if (change.addedColumns?.length > 0) {
|
|
59
|
+
console.log(` + Added column(s): ${change.addedColumns.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
if (change.removedColumns?.length > 0) {
|
|
62
|
+
console.log(` - Dropped column(s): ${change.removedColumns.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (options.dryRun) {
|
|
68
|
+
console.log('š” Dry-run mode: No files were written to disk.\n');
|
|
69
|
+
} else {
|
|
70
|
+
console.log('š Next step: Run "webspresso db:migrate" to apply the changes.\n');
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('\nā Migration generation failed:', err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
} finally {
|
|
76
|
+
if (knex) {
|
|
77
|
+
await knex.destroy().catch(() => {});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { registerCommand };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso CLI - list Command
|
|
5
|
+
* Lists all built-in and custom project commands with descriptions
|
|
6
|
+
* @module bin/commands/list
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { createCommandRegistry } = require('../../core/commands');
|
|
11
|
+
|
|
12
|
+
function registerCommand(program) {
|
|
13
|
+
program
|
|
14
|
+
.command('list')
|
|
15
|
+
.alias('commands')
|
|
16
|
+
.description('List all available Webspresso and custom project management commands')
|
|
17
|
+
.option('-d, --dir <path>', 'Custom commands directory (default: ./commands)')
|
|
18
|
+
.action((options) => {
|
|
19
|
+
const rootDir = process.cwd();
|
|
20
|
+
const commandsDir = options.dir
|
|
21
|
+
? path.resolve(rootDir, options.dir)
|
|
22
|
+
: path.resolve(rootDir, 'commands');
|
|
23
|
+
|
|
24
|
+
const customRegistry = createCommandRegistry({ commandsDir });
|
|
25
|
+
const customCommands = customRegistry.list();
|
|
26
|
+
|
|
27
|
+
console.log('');
|
|
28
|
+
console.log('ā Webspresso CLI - Command Palette');
|
|
29
|
+
console.log('===================================');
|
|
30
|
+
|
|
31
|
+
const sections = [
|
|
32
|
+
{
|
|
33
|
+
title: 'š Scaffolding & Code Generation',
|
|
34
|
+
commands: [
|
|
35
|
+
['make:model <name>', 'Scaffold a new ORM model definition in models/'],
|
|
36
|
+
['make:job <name>', 'Scaffold a background queue job worker in jobs/'],
|
|
37
|
+
['make:policy <name>', 'Scaffold an authorization policy in policies/'],
|
|
38
|
+
['make:factory <name>', 'Scaffold a test/seeder model factory in factories/'],
|
|
39
|
+
['make:event <name>', 'Scaffold an event listener in listeners/'],
|
|
40
|
+
['make:mail <name>', 'Scaffold a declarative Mailable class in emails/'],
|
|
41
|
+
['make:command <name>', 'Scaffold a custom management command in commands/'],
|
|
42
|
+
['page <path>', 'Scaffold a new file-based SSR page'],
|
|
43
|
+
['api <path>', 'Scaffold a new file-based API route'],
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
title: 'š¾ Database & Migrations',
|
|
48
|
+
commands: [
|
|
49
|
+
['db:migrate', 'Run all pending Knex migrations'],
|
|
50
|
+
['db:makemigrations', 'Auto-diff model schemas against database and generate migration'],
|
|
51
|
+
['db:rollback', 'Rollback the last batch of migrations'],
|
|
52
|
+
['db:status', 'Show database connection and migration status'],
|
|
53
|
+
['db:scaffold', 'Generate initial create table migrations from models/'],
|
|
54
|
+
['db:make <name>', 'Create an empty Knex migration file'],
|
|
55
|
+
['seed', 'Run database seeders'],
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
title: 'š ļø Server & Developer Experience',
|
|
60
|
+
commands: [
|
|
61
|
+
['dev', 'Start local development server with hot-reload'],
|
|
62
|
+
['start', 'Start production HTTP server'],
|
|
63
|
+
['build', 'Build client assets and precompile templates'],
|
|
64
|
+
['routes', 'Inspect all discovered SSR page and API routes'],
|
|
65
|
+
['doctor', 'Diagnose project configuration, dependencies, and health'],
|
|
66
|
+
['service [name]', 'Run and test services interactively without HTTP server'],
|
|
67
|
+
['run <command>', 'Execute a custom management command with full DI context'],
|
|
68
|
+
['list', 'Display this command index'],
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const section of sections) {
|
|
74
|
+
console.log(`\n${section.title}:`);
|
|
75
|
+
for (const [cmd, desc] of section.commands) {
|
|
76
|
+
console.log(` ${cmd.padEnd(28)} ${desc}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (customCommands.length > 0) {
|
|
81
|
+
console.log(`\nā” Custom Project Commands (${path.relative(rootDir, commandsDir) || 'commands/'}):`);
|
|
82
|
+
for (const cmd of customCommands) {
|
|
83
|
+
console.log(` ${('run ' + cmd.name).padEnd(28)} ${cmd.description || 'Custom management command'}`);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
console.log('\nā” Custom Project Commands:');
|
|
87
|
+
console.log(' (None discovered. Create with: npx webspresso make:command <name>)');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log('\nUse "webspresso <command> --help" for more information about any command.\n');
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
registerCommand,
|
|
96
|
+
};
|