webspresso 0.0.104 → 0.0.105
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/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +1 -1
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +8 -17
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +71 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +120 -19
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +616 -1
- package/index.js +148 -0
- package/package.json +1 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/csrf/index.js +5 -9
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +9 -7
- package/plugins/rls/index.js +170 -0
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +4 -3
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/file-router.js +1 -3
- package/src/helpers.js +159 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +136 -25
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- package/src/template/precompile.js +8 -19
package/.agents/ORM.md
CHANGED
|
@@ -116,11 +116,33 @@ const product = await productRepo.findById(1);
|
|
|
116
116
|
const activeProducts = await productRepo.find({ active: true });
|
|
117
117
|
const singleProduct = await productRepo.findOne({ slug: 'laptop' });
|
|
118
118
|
|
|
119
|
-
// 2. Insert, Update, Delete
|
|
119
|
+
// 2. Insert, Update, Delete & Batch Operations
|
|
120
120
|
const newProduct = await productRepo.create({ name: 'Laptop', price: 999, category_id: 1 });
|
|
121
121
|
const updated = await productRepo.update(newProduct.id, { price: 899 });
|
|
122
122
|
await productRepo.delete(newProduct.id); // Soft or hard delete based on model config
|
|
123
123
|
|
|
124
|
+
// 2.1 Batch Insert (Auto-Chunked with SQL Variable Limit Protection)
|
|
125
|
+
// Automatically chunks rows to stay safely within SQLite/Postgres parameter limits (prevents 'too many SQL variables' error)
|
|
126
|
+
// and executes inside an atomic transaction (all succeed or all rollback).
|
|
127
|
+
const products = await productRepo.createBatch([
|
|
128
|
+
{ name: 'Tablet', price: 499, category_id: 1 },
|
|
129
|
+
{ name: 'Phone', price: 799, category_id: 1 },
|
|
130
|
+
], { chunkSize: 500, transaction: true });
|
|
131
|
+
|
|
132
|
+
// 2.2 Upsert & Batch Upsert (onConflict Merge)
|
|
133
|
+
// Inserts new records or updates existing records on conflict target (e.g. sku or primaryKey)
|
|
134
|
+
const upserted = await productRepo.upsert(
|
|
135
|
+
{ sku: 'SKU-001', name: 'Keyboard', price: 59 },
|
|
136
|
+
'sku',
|
|
137
|
+
['name', 'price']
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const batchUpserted = await productRepo.upsertBatch(
|
|
141
|
+
catalogItems,
|
|
142
|
+
'sku',
|
|
143
|
+
{ updateColumns: ['name', 'price', 'updated_at'] }
|
|
144
|
+
);
|
|
145
|
+
|
|
124
146
|
// 3. Query Builder & Pagination
|
|
125
147
|
const result = await productRepo.query()
|
|
126
148
|
.where('price', '>', 500)
|
|
@@ -468,5 +490,137 @@ const user = await UserFactory.create({ db });
|
|
|
468
490
|
const admins = await UserFactory.count(3).apply('admin').create({ db });
|
|
469
491
|
```
|
|
470
492
|
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## 12. Dynamic Model Row-Level Security (RLS)
|
|
496
|
+
|
|
497
|
+
Webspresso ORM features zero-dependency, declarative Row-Level Security (RLS) directly on model definitions, ensuring data isolation across users, organizations, and multi-tenant systems.
|
|
498
|
+
|
|
499
|
+
### 12.1 Declaring RLS Policies on Models
|
|
500
|
+
|
|
501
|
+
Models declare row-level access rules with `rls` inside `defineModel`:
|
|
502
|
+
|
|
503
|
+
```javascript
|
|
504
|
+
const { defineModel, zdb } = require('webspresso');
|
|
505
|
+
|
|
506
|
+
module.exports = defineModel({
|
|
507
|
+
name: 'Document',
|
|
508
|
+
table: 'documents',
|
|
509
|
+
schema: zdb.schema({
|
|
510
|
+
id: zdb.id(),
|
|
511
|
+
user_id: zdb.integer(),
|
|
512
|
+
tenant_id: zdb.string().optional(),
|
|
513
|
+
title: zdb.string(),
|
|
514
|
+
content: zdb.text(),
|
|
515
|
+
status: zdb.string().default('draft'),
|
|
516
|
+
is_public: zdb.boolean().default(false),
|
|
517
|
+
}),
|
|
518
|
+
rls: {
|
|
519
|
+
// Shorthand: automatically scopes queries and mutations to user.id
|
|
520
|
+
userColumn: 'user_id',
|
|
521
|
+
|
|
522
|
+
// Multi-tenant shorthand: scopes to user.tenant_id or scopeContext.tenantId
|
|
523
|
+
tenantColumn: 'tenant_id',
|
|
524
|
+
|
|
525
|
+
// Role-based bypass: admins and superadmins bypass RLS by default
|
|
526
|
+
bypassRoles: ['admin', 'superadmin'],
|
|
527
|
+
|
|
528
|
+
// Granular action rules:
|
|
529
|
+
// Read / Select: public documents are visible to anyone, drafts only to author
|
|
530
|
+
read: (user, qb) => {
|
|
531
|
+
if (!user) return { is_public: true };
|
|
532
|
+
return (q) => q.where('user_id', user.id).orWhere('is_public', true);
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
// Update: only allow modifying draft documents
|
|
536
|
+
update: (user, qb) => {
|
|
537
|
+
if (!user) return false;
|
|
538
|
+
return (q) => q.where('user_id', user.id).where('status', 'draft');
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
// Delete: completely disabled or owner only
|
|
542
|
+
delete: (user, qb) => {
|
|
543
|
+
if (!user) return false;
|
|
544
|
+
return { user_id: user.id };
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
// Create: auto-populates user_id, validates permissions or modifies payload
|
|
548
|
+
create: (user, data) => {
|
|
549
|
+
if (!user) return false; // Throws UnauthorizedError/ForbiddenError
|
|
550
|
+
return { status: 'draft' }; // Enforce default state
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### 12.2 Scoping Repositories and Query Builders
|
|
557
|
+
|
|
558
|
+
Repositories and query builders provide fluent scoping methods:
|
|
559
|
+
|
|
560
|
+
```javascript
|
|
561
|
+
const repo = db.getRepository('Document');
|
|
562
|
+
|
|
563
|
+
// Scoped to specific user:
|
|
564
|
+
const userRepo = repo.forUser(currentUser);
|
|
565
|
+
const myDocs = await userRepo.findAll();
|
|
566
|
+
const myDoc = await userRepo.findById(docId); // null if not owned
|
|
567
|
+
|
|
568
|
+
// Auto-populates user_id and rejects spoofed user IDs:
|
|
569
|
+
const newDoc = await userRepo.create({ title: 'My Report' });
|
|
570
|
+
|
|
571
|
+
// QueryBuilder chaining:
|
|
572
|
+
const count = await repo.query().forUser(currentUser).where('status', 'draft').count();
|
|
573
|
+
const paged = await repo.query().forUser(currentUser).paginate(1, 10);
|
|
574
|
+
|
|
575
|
+
// Explicit bypass (system/admin operations):
|
|
576
|
+
const allDocs = await repo.withoutRls().findAll();
|
|
577
|
+
const total = await repo.query().withoutRls().count();
|
|
578
|
+
|
|
579
|
+
// Multi-tenant scoping:
|
|
580
|
+
const tenantRepo = repo.forTenant('org_acme');
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### 12.3 Database Scoped Accessors
|
|
584
|
+
|
|
585
|
+
```javascript
|
|
586
|
+
// Scoped database instance:
|
|
587
|
+
const userDb = db.forUser(currentUser);
|
|
588
|
+
const docs = await userDb.getRepository('Document').findAll();
|
|
589
|
+
const drafts = await userDb.query('Document').where('status', 'draft').list();
|
|
590
|
+
|
|
591
|
+
// Ambient transaction scoping:
|
|
592
|
+
await db.transaction(async (trx) => {
|
|
593
|
+
const noteRepo = trx.forUser(currentUser).getRepository('Document');
|
|
594
|
+
await noteRepo.create({ title: 'Transaction Document' });
|
|
595
|
+
});
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### 12.4 Automatic Express Integration (`rlsPlugin`)
|
|
599
|
+
|
|
600
|
+
Register `rlsPlugin()` to automatically wrap `req.db.getRepository()` with `req.user` in API endpoints and SSR loaders:
|
|
601
|
+
|
|
602
|
+
```javascript
|
|
603
|
+
const { createApp, rlsPlugin } = require('webspresso');
|
|
604
|
+
|
|
605
|
+
const app = createApp({
|
|
606
|
+
plugins: [
|
|
607
|
+
rlsPlugin({
|
|
608
|
+
bypassRoles: ['admin', 'superadmin'], // default
|
|
609
|
+
getUser: (req) => req.user, // default
|
|
610
|
+
}),
|
|
611
|
+
],
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// Inside any API route (pages/api/documents.get.js):
|
|
615
|
+
module.exports = defineApi({
|
|
616
|
+
handler: async (req, res) => {
|
|
617
|
+
// req.db.getRepository('Document') is automatically scoped to req.user!
|
|
618
|
+
const docs = await req.db.getRepository('Document').findAll();
|
|
619
|
+
return res.json(docs);
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
|
|
471
625
|
|
|
472
626
|
|
package/.agents/PLUGINS.md
CHANGED
|
@@ -692,3 +692,31 @@ Zero-dependency on-demand plugin hydration wrapper that defers heavy plugin init
|
|
|
692
692
|
- Mounts a lightweight Express router proxy at `prefix`.
|
|
693
693
|
- Stubs placeholder services into `ServiceRegistry` and proxy helpers into Nunjucks context.
|
|
694
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.
|
|
695
|
+
|
|
696
|
+
### 2.21 `rlsPlugin` (`plugins/rls`)
|
|
697
|
+
Zero-dependency Dynamic Model Row-Level Security (RLS) plugin that bridges request authentication (`req.user`, `req.auth`, `req.tenantId`) to ORM repositories with automatic query scoping, role-based bypass, and route-level overrides.
|
|
698
|
+
- **Why**: Eliminates manual `where({ user_id: req.user.id })` boilerplate across all API endpoints, prevents accidental cross-tenant/cross-user data leakage, and provides declarative data access boundaries.
|
|
699
|
+
- **Options**:
|
|
700
|
+
- `bypassRoles`: Global roles that bypass RLS across all models (default: `['admin', 'superadmin']`). Set to `false` to disable.
|
|
701
|
+
- `getUser`: Custom extractor for current user from `req` (default: `req.user || req.adminUser || req.auth?.user`).
|
|
702
|
+
- `getTenantId`: Custom extractor for current tenant ID from `req` (default: `req.tenantId || req.user?.tenant_id`).
|
|
703
|
+
- `global`: Mount global middleware wrapping `req.db` (default: `true`).
|
|
704
|
+
- `enabled`: Toggle RLS plugin active state (default: `true`).
|
|
705
|
+
- **Usage**:
|
|
706
|
+
```javascript
|
|
707
|
+
const { createApp, rlsPlugin } = require('webspresso');
|
|
708
|
+
|
|
709
|
+
const app = createApp({
|
|
710
|
+
plugins: [
|
|
711
|
+
rlsPlugin({
|
|
712
|
+
bypassRoles: ['admin', 'superadmin'],
|
|
713
|
+
}),
|
|
714
|
+
],
|
|
715
|
+
});
|
|
716
|
+
```
|
|
717
|
+
- **How It Works**:
|
|
718
|
+
- Automatically wraps `req.db.getRepository()` and `req.db.query()` with the active `user` and `tenantId`.
|
|
719
|
+
- Admins bypass filters automatically unless model specifies `bypassRoles: false`.
|
|
720
|
+
- Endpoints can explicitly bypass RLS when needed via `req.db.getRepository('Model').withoutRls()`.
|
|
721
|
+
- Named middleware `middleware: ['rls']` available for selective route application.
|
|
722
|
+
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Enterprise Resilience, Circuit Breaker & Retry Engine (`core/resilience`)
|
|
2
|
+
|
|
3
|
+
Webspresso includes a zero-dependency, enterprise-grade resilience and fault tolerance engine built directly on native Node.js primitives (`events`, `perf_hooks`, `async_hooks`). It prevents cascading downstream outages, protects critical services, and recovers automatically from transient network spikes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🚦 Architectural Overview
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌───────────────────────────────────────┐
|
|
11
|
+
│ Incoming Request │
|
|
12
|
+
└───────────────────┬───────────────────┘
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
┌───────────────────────────────────────┐
|
|
16
|
+
│ Circuit Breaker (Fast Fail / OPEN) │
|
|
17
|
+
└───────┬───────────────────────▲───────┘
|
|
18
|
+
(Closed) │ │ (Trips on failure)
|
|
19
|
+
▼ │
|
|
20
|
+
┌───────────────────────────────┴───────┐
|
|
21
|
+
│ Retry Engine (Backoff + Jitter) │
|
|
22
|
+
└───────┬───────────────────────▲───────┘
|
|
23
|
+
(Attempt)│ │ (Transient Error)
|
|
24
|
+
▼ │
|
|
25
|
+
┌───────────────────────────────┴───────┐
|
|
26
|
+
│ Service Handler / Remote Upstream │
|
|
27
|
+
└───────────────────────────────────────┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
1. **State Machine (`CircuitBreaker`)**:
|
|
31
|
+
- `CLOSED`: Normal operation. Requests pass through. Failures are tracked in a rolling time window.
|
|
32
|
+
- `OPEN`: Downstream is degraded/unhealthy. Subsequent calls **fail fast immediately** with `CircuitBreakerOpenError` (HTTP 503) without hitting the downstream resource, or return a predefined `fallback`.
|
|
33
|
+
- `HALF_OPEN`: Trial recovery state entered automatically once `resetTimeout` elapses. A controlled number of probe requests (`successThreshold`) test the downstream. If they succeed, the breaker resets to `CLOSED`. If any probe fails, it trips back to `OPEN`.
|
|
34
|
+
|
|
35
|
+
2. **Retry with Exponential Backoff & Full Jitter (`retry`, `withRetry`)**:
|
|
36
|
+
- Automatically retries transient network errors (`ECONNRESET`, `ETIMEDOUT`, `ENOTFOUND`, 429 Too Many Requests, and 5xx Gateway errors).
|
|
37
|
+
- Skips client-side 4xx errors (e.g. 400 Bad Request, 401, 403, 404, 422) since retrying client errors produces identical failures.
|
|
38
|
+
- Full jitter prevents synchronization spikes (the "thundering herd" problem) against recovering third-party services.
|
|
39
|
+
- Respects `AbortSignal` for cancellation.
|
|
40
|
+
|
|
41
|
+
3. **Seamless Services Layer Integration**:
|
|
42
|
+
- Declare `circuitBreaker` and `retry` declaratively inside `defineService({ ... })`.
|
|
43
|
+
- Automatically protects service calls from SSR pages, API routes, or worker jobs.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 🛠️ Quick Start & Usage Examples
|
|
48
|
+
|
|
49
|
+
### 1. Declarative Service Protection (`defineService`)
|
|
50
|
+
|
|
51
|
+
```javascript
|
|
52
|
+
const { defineService } = require('webspresso');
|
|
53
|
+
|
|
54
|
+
module.exports = defineService({
|
|
55
|
+
// Automatic retry on transient network errors
|
|
56
|
+
retry: {
|
|
57
|
+
retries: 3,
|
|
58
|
+
delay: '100ms',
|
|
59
|
+
factor: 2,
|
|
60
|
+
jitter: true,
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Circuit breaker with fast-fail and graceful fallback
|
|
64
|
+
circuitBreaker: {
|
|
65
|
+
failureThreshold: 3, // Open after 3 consecutive failures
|
|
66
|
+
failureRateThreshold: 0.5, // Or if >= 50% fail in rolling window
|
|
67
|
+
rollingWindow: '10s', // 10-second sample window
|
|
68
|
+
resetTimeout: '15s', // Stay open for 15s before trial recovery
|
|
69
|
+
timeout: '3s', // Per-call execution timeout (ms or string)
|
|
70
|
+
fallback: (err, input) => {
|
|
71
|
+
// Graceful fallback returned when OPEN or when all retries fail
|
|
72
|
+
return { fromCache: true, id: input.id, data: null, degraded: true };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
schema: ({ z }) => ({
|
|
77
|
+
id: z.string(),
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
handler: async (input, ctx) => {
|
|
81
|
+
// Call external payment or inventory API
|
|
82
|
+
const response = await fetch(`https://api.external.com/items/${input.id}`);
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const err = new Error(`Upstream API responded with status ${response.status}`);
|
|
85
|
+
err.statusCode = response.status;
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
return await response.json();
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Standalone Circuit Breaker Usage
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
const { createCircuitBreaker } = require('webspresso');
|
|
97
|
+
|
|
98
|
+
const paymentBreaker = createCircuitBreaker({
|
|
99
|
+
name: 'stripe-gateway',
|
|
100
|
+
failureThreshold: 5,
|
|
101
|
+
resetTimeout: '30s',
|
|
102
|
+
timeout: '5s',
|
|
103
|
+
fallback: (err) => {
|
|
104
|
+
return { status: 'queued_offline', message: 'Payment queued for processing' };
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Execute async function wrapped by breaker
|
|
109
|
+
const result = await paymentBreaker.execute(async () => {
|
|
110
|
+
return await stripeClient.charges.create({ ... });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Or wrap a function
|
|
114
|
+
const safeCharge = paymentBreaker.wrap(chargeFunction);
|
|
115
|
+
await safeCharge(args);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Standalone Retry Engine (`retry`)
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
const { retry, isTransientError } = require('webspresso');
|
|
122
|
+
|
|
123
|
+
const data = await retry(
|
|
124
|
+
async (attempt) => {
|
|
125
|
+
return await fetchRemoteData();
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
retries: 3,
|
|
129
|
+
delay: 200, // Initial 200ms delay
|
|
130
|
+
factor: 2, // 200ms -> 400ms -> 800ms
|
|
131
|
+
jitter: true, // Randomized jitter
|
|
132
|
+
onRetry: ({ attempt, error, delayMs }) => {
|
|
133
|
+
console.warn(`Retry attempt ${attempt} after error: ${error.message} (waiting ${delayMs}ms)`);
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 4. Accessing Breakers & Retry from Route Handlers & Context
|
|
140
|
+
|
|
141
|
+
In API routes and SSR loaders:
|
|
142
|
+
|
|
143
|
+
```javascript
|
|
144
|
+
// Inside API route handler or SSR loader
|
|
145
|
+
export async function handler(req, res) {
|
|
146
|
+
// Named circuit breaker from central registry
|
|
147
|
+
const crmBreaker = req.breaker('hubspot-api', { failureThreshold: 3 });
|
|
148
|
+
|
|
149
|
+
// Retry engine available on req
|
|
150
|
+
const lead = await req.retry(async () => {
|
|
151
|
+
return await pushLeadToCrm(req.body);
|
|
152
|
+
}, { retries: 2 });
|
|
153
|
+
|
|
154
|
+
return res.json(lead);
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
In services handlers (`ctx`):
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
module.exports = defineService({
|
|
162
|
+
handler: async (input, ctx) => {
|
|
163
|
+
// Access circuit breaker
|
|
164
|
+
const breaker = ctx.breaker('custom-api');
|
|
165
|
+
|
|
166
|
+
// Access retry helper
|
|
167
|
+
return await ctx.retry(async (attempt) => {
|
|
168
|
+
return await queryDownstream();
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## ⚙️ Configuration Reference
|
|
177
|
+
|
|
178
|
+
### `CircuitBreakerOptions`
|
|
179
|
+
|
|
180
|
+
| Option | Type | Default | Description |
|
|
181
|
+
| :--- | :--- | :--- | :--- |
|
|
182
|
+
| `name` | `string` | `'default'` | Identifier for logging and registry lookup. |
|
|
183
|
+
| `failureThreshold` | `number` | `5` | Consecutive failures before opening circuit. |
|
|
184
|
+
| `failureRateThreshold` | `number` | `null` | Failure rate (0.01 - 1.0) in rolling window to open circuit (e.g. `0.5` = 50%). |
|
|
185
|
+
| `minimumRequests` | `number` | `5` | Minimum calls in rolling window before failure rate check activates. |
|
|
186
|
+
| `rollingWindow` | `number \| string` | `10000` (10s) | Rolling time window for metrics sample pruning. |
|
|
187
|
+
| `successThreshold` | `number` | `2` | Consecutive successes in `HALF_OPEN` state required to close circuit. |
|
|
188
|
+
| `resetTimeout` | `number \| string` | `10000` (10s) | Duration circuit stays `OPEN` before attempting `HALF_OPEN` probe. |
|
|
189
|
+
| `timeout` | `number \| string` | `0` (disabled) | Per-call execution timeout in milliseconds or duration string (`'2s'`). |
|
|
190
|
+
| `fallback` | `Function` | `null` | Fallback handler `(error, ...args) => any` called when `OPEN` or call fails. |
|
|
191
|
+
| `isFailure` | `Function` | `defaultIsFailure` | Predicate `(error) => boolean`. By default ignores 4xx client errors (except 429). |
|
|
192
|
+
| `onStateChange` | `Function` | `null` | Callback `(fromState, toState, breaker) => void`. |
|
|
193
|
+
|
|
194
|
+
### `RetryOptions`
|
|
195
|
+
|
|
196
|
+
| Option | Type | Default | Description |
|
|
197
|
+
| :--- | :--- | :--- | :--- |
|
|
198
|
+
| `retries` | `number` | `3` | Maximum retry attempts before giving up. |
|
|
199
|
+
| `delay` / `minTimeout` | `number \| string` | `100` | Initial delay in ms or duration string (`'100ms'`). |
|
|
200
|
+
| `maxTimeout` | `number \| string` | `10000` (10s) | Maximum capped delay for exponential backoff. |
|
|
201
|
+
| `factor` | `number` | `2` | Exponential backoff multiplier. |
|
|
202
|
+
| `jitter` | `boolean` | `true` | Apply full randomized jitter (`Math.random() * delay`). |
|
|
203
|
+
| `shouldRetry` | `Function` | `isTransientError` | Predicate `(error, attempt) => boolean`. Retries network/5xx/429 errors. |
|
|
204
|
+
| `onRetry` | `Function` | `null` | Hook `({ attempt, error, delayMs }) => void`. |
|
|
205
|
+
| `signal` | `AbortSignal` | `null` | Optional signal to cancel pending retry delays immediately. |
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 📊 Telemetry & Monitoring
|
|
210
|
+
|
|
211
|
+
Inspect health and failure metrics at any time:
|
|
212
|
+
|
|
213
|
+
```javascript
|
|
214
|
+
// Breaker instance stats
|
|
215
|
+
const stats = breaker.getStats();
|
|
216
|
+
// {
|
|
217
|
+
// name: 'stripe-gateway',
|
|
218
|
+
// state: 'CLOSED',
|
|
219
|
+
// totalCalls: 120,
|
|
220
|
+
// successfulCalls: 118,
|
|
221
|
+
// failedCalls: 2,
|
|
222
|
+
// rejectedCalls: 0,
|
|
223
|
+
// timedOutCalls: 0,
|
|
224
|
+
// fallbackCalls: 0,
|
|
225
|
+
// consecutiveFailures: 0,
|
|
226
|
+
// consecutiveSuccesses: 118,
|
|
227
|
+
// failureRate: 0.016,
|
|
228
|
+
// lastFailureTime: 1713430000000,
|
|
229
|
+
// lastSuccessTime: 1713430001000,
|
|
230
|
+
// lastStateChange: 1713429000000
|
|
231
|
+
// }
|
|
232
|
+
|
|
233
|
+
// Global registry stats across all breakers
|
|
234
|
+
const allStats = app.breakers.getStats();
|
|
235
|
+
```
|
package/AGENTS.md
CHANGED
|
@@ -18,6 +18,7 @@ When tasked with a feature or bugfix, **immediately consult the corresponding to
|
|
|
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
20
|
| **Decoupled Events & Signals** | [`.agents/EVENTS.md`](.agents/EVENTS.md) | `core/events/`, `listeners/` |
|
|
21
|
+
| **Fault Tolerance / Circuit Breaker / Retry** | [`.agents/RESILIENCE.md`](.agents/RESILIENCE.md) | `core/resilience/` |
|
|
21
22
|
| **API Signatures & Types Verification** | [`index.d.ts`](index.d.ts) | **Canonical Ground Truth** |
|
|
22
23
|
|
|
23
24
|
---
|
|
@@ -57,6 +58,11 @@ Every coding agent working on Webspresso **MUST** follow this 4-step execution d
|
|
|
57
58
|
- **Framework Exceptions & Central Error Boundary**: Built-in exception hierarchy (`WebspressoError`, `HttpError`, `ValidationError`, `SecurityError`, `RequestAbortedError`, `ConfigurationError`, `PluginError`, `RouteNotFoundError`) and custom handler hook (`app.setErrorHandler`). Async route handlers automatically propagate rejected promises. Production responses mask 500 stack traces and error details.
|
|
58
59
|
- **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.
|
|
59
60
|
- **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)`).
|
|
61
|
+
- **HTTP 103 Early Hints & Critical Asset Preloading**: Zero-dependency RFC 8288 / RFC 8297 Early Hints dispatcher configured via `createApp({ earlyHints: true })` or per-page `definePage({ earlyHints })`. Emits informational 103 status code with route CSS/JS preloads before async `load()` finishes to minimize TTFB, FCP and LCP, with safe fallback and `res.earlyHints(links)` / `ctx.earlyHints(links)` helpers.
|
|
62
|
+
- **Configurable Payload Size Limits & DoS Protection**: Zero-dependency body limit enforcement configured via `createApp({ limits: { json: '1mb', urlencoded: '1mb', raw: false, text: false, parameterLimit: 1000, captureRawBody: false } })` or shorthand `limits: '5mb'`, with automatic HTTP 413 `PayloadTooLargeError` and HTTP 400 `BadRequestError` mapping, route overrides (`limits.routes`), streaming bypass (`limits.exclude`), and raw body capture on `req.rawBody` for webhook signatures.
|
|
63
|
+
- **Native Cloud & Kubernetes Health & Readiness Probes**: Zero-dependency RFC-compliant liveness (`/healthz`), readiness (`/readyz`), and combined diagnostic (`/health`) endpoints configured via `createApp({ healthCheck: true })`. Includes non-blocking event loop lag monitoring (`perf_hooks.monitorEventLoopDelay`), database/queue connectivity tests, and automatic HTTP 503 draining integration with `ShutdownManager` when pods enter graceful termination.
|
|
64
|
+
- **High-Performance In-Memory LRU & Tagged Cache Engine**: Zero-dependency memory-bounded caching (`core/cache`) configured via `createApp({ cache: { maxEntries: 1000, maxMemoryMb: 50, defaultTtl: '15m' } })` or shorthand `cache: true|false`. Provides O(1) LRU eviction, byte budget constraints preventing OOM, leak-free tag invalidation (`TaggedLruCache`), stampede protection (`getOrSet`), and powers ORM query caching and Zod schema caching.
|
|
65
|
+
- **Enterprise Resilience & Fault Tolerance (Circuit Breaker & Retry Engine)**: Zero-dependency resilience system (`core/resilience`) featuring a three-state `CircuitBreaker` (`CLOSED`, `OPEN`, `HALF_OPEN`), rolling window failure rate tracking, per-call execution timeouts, graceful fallbacks, and an exponential backoff retry engine (`retry`, `withRetry`) with randomized jitter and transient error detection (`ECONNRESET`, 429, 5xx). Seamlessly integrated into the Services Layer (`defineService({ circuitBreaker, retry })`) and exposed via `app.breakers`, `app.getBreaker()`, `req.breaker()`, and `req.retry()`.
|
|
60
66
|
- **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
67
|
- **Cold Start & High-Performance Optimizations**:
|
|
62
68
|
- Unified single-pass route scanner (`discoverAppRoutes`).
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ cd my-app
|
|
|
22
22
|
npm run dev
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Open **`http://localhost:
|
|
25
|
+
Open **`http://localhost:7331`** in your browser.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -147,7 +147,7 @@ const { app } = createApp({
|
|
|
147
147
|
},
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
const PORT = process.env.PORT ||
|
|
150
|
+
const PORT = process.env.PORT || 7331;
|
|
151
151
|
app.listen(PORT, () => {
|
|
152
152
|
console.log(`Server running at http://localhost:${PORT}`);
|
|
153
153
|
});
|
|
@@ -56,13 +56,13 @@ function detectDatabase(cwd, pkg = {}) {
|
|
|
56
56
|
* Generate production-grade multi-stage Dockerfile
|
|
57
57
|
* @param {Object} options
|
|
58
58
|
* @param {string} [options.node='22-alpine']
|
|
59
|
-
* @param {number} [options.port=
|
|
59
|
+
* @param {number} [options.port=7331]
|
|
60
60
|
* @param {boolean} [options.hasBuildScript=false]
|
|
61
61
|
* @returns {string}
|
|
62
62
|
*/
|
|
63
63
|
function generateDockerfile(options = {}) {
|
|
64
64
|
const nodeImage = normalizeNodeVersion(options.node || '22-alpine');
|
|
65
|
-
const port = options.port ||
|
|
65
|
+
const port = options.port || 7331;
|
|
66
66
|
const isAlpine = nodeImage.includes('alpine');
|
|
67
67
|
|
|
68
68
|
return `# ==========================================
|
|
@@ -198,7 +198,7 @@ public/uploads/*
|
|
|
198
198
|
* @param {string} options.appName
|
|
199
199
|
* @param {'postgres'|'mysql'|'sqlite'|'none'} options.db
|
|
200
200
|
* @param {boolean} [options.redis=false]
|
|
201
|
-
* @param {number} [options.port=
|
|
201
|
+
* @param {number} [options.port=7331]
|
|
202
202
|
* @returns {string}
|
|
203
203
|
*/
|
|
204
204
|
function generateDockerCompose(options = {}) {
|
|
@@ -206,7 +206,7 @@ function generateDockerCompose(options = {}) {
|
|
|
206
206
|
appName = 'webspresso-app',
|
|
207
207
|
db = 'sqlite',
|
|
208
208
|
redis = false,
|
|
209
|
-
port =
|
|
209
|
+
port = 7331,
|
|
210
210
|
} = options;
|
|
211
211
|
|
|
212
212
|
let dbService = '';
|
|
@@ -338,7 +338,7 @@ function registerCommand(program) {
|
|
|
338
338
|
.option('-n, --node <version>', 'Node.js base image version (default: 22-alpine)', '22-alpine')
|
|
339
339
|
.option('-d, --db <type>', 'Database service to configure: postgres, mysql, sqlite, none (auto-detected by default)')
|
|
340
340
|
.option('-r, --redis', 'Include Redis service in docker-compose.yml', false)
|
|
341
|
-
.option('-p, --port <port>', 'Application container port (default:
|
|
341
|
+
.option('-p, --port <port>', 'Application container port (default: 7331)', '7331')
|
|
342
342
|
.option('-f, --force', 'Overwrite existing Dockerfile and docker-compose.yml without prompting', false)
|
|
343
343
|
.action(async (...actionArgs) => {
|
|
344
344
|
const cmd = actionArgs[actionArgs.length - 1];
|
|
@@ -363,7 +363,7 @@ function registerCommand(program) {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
const appName = pkg.name ? pkg.name.replace(/[^a-zA-Z0-9_-]/g, '-') : 'webspresso-app';
|
|
366
|
-
const port = parseInt(options.port || '
|
|
366
|
+
const port = parseInt(options.port || '7331', 10) || 7331;
|
|
367
367
|
const dbType = options.db || detectDatabase(cwd, pkg);
|
|
368
368
|
const isRedis = Boolean(options.redis);
|
|
369
369
|
const nodeVersion = options.node || '22-alpine';
|
package/bin/commands/dev.js
CHANGED
|
@@ -31,7 +31,7 @@ function registerCommand(program) {
|
|
|
31
31
|
program
|
|
32
32
|
.command('dev')
|
|
33
33
|
.description('Start development server')
|
|
34
|
-
.option('-p, --port <port>', 'Port number', '
|
|
34
|
+
.option('-p, --port <port>', 'Port number', '7331')
|
|
35
35
|
.option('--no-css', 'Skip CSS watch (if Tailwind is set up)')
|
|
36
36
|
.option('--no-js', 'Skip client JS watch (if esbuild / watch:js is set up)')
|
|
37
37
|
.action((options) => {
|
package/bin/commands/new.js
CHANGED
|
@@ -312,10 +312,10 @@ module.exports = { loadEnv };
|
|
|
312
312
|
|
|
313
313
|
const envSchema = z.object({
|
|
314
314
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
315
|
-
PORT: z.coerce.number().int().positive().default(
|
|
315
|
+
PORT: z.coerce.number().int().positive().default(7331),
|
|
316
316
|
DEFAULT_LOCALE: z.string().min(1).default('en'),
|
|
317
317
|
SUPPORTED_LOCALES: z.string().min(1).default('en,de'),
|
|
318
|
-
BASE_URL: z.string().url().default('http://localhost:
|
|
318
|
+
BASE_URL: z.string().url().default('http://localhost:7331'),
|
|
319
319
|
DATABASE_URL: z.string().optional(),
|
|
320
320
|
});
|
|
321
321
|
|
|
@@ -382,11 +382,11 @@ app.listen(env.PORT, () => {
|
|
|
382
382
|
|
|
383
383
|
// Create .env.example (see config/load-env.js for merge order)
|
|
384
384
|
let envExample = `# Copy to .env and adjust. Optional overrides: .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
|
|
385
|
-
PORT=
|
|
385
|
+
PORT=7331
|
|
386
386
|
NODE_ENV=development
|
|
387
387
|
DEFAULT_LOCALE=en
|
|
388
388
|
SUPPORTED_LOCALES=en,de
|
|
389
|
-
BASE_URL=http://localhost:
|
|
389
|
+
BASE_URL=http://localhost:7331
|
|
390
390
|
`;
|
|
391
391
|
|
|
392
392
|
if (useDatabase && databaseType) {
|
|
@@ -645,7 +645,7 @@ cp .env.example .env
|
|
|
645
645
|
npm run dev
|
|
646
646
|
\`\`\`
|
|
647
647
|
|
|
648
|
-
Visit http://localhost:
|
|
648
|
+
Visit http://localhost:7331
|
|
649
649
|
|
|
650
650
|
## Configuration
|
|
651
651
|
|
package/bin/commands/openapi.js
CHANGED
|
@@ -23,7 +23,7 @@ function registerCommand(program) {
|
|
|
23
23
|
.option('--title <title>', 'OpenAPI specification title override')
|
|
24
24
|
.option('--api-version <version>', 'OpenAPI specification version override')
|
|
25
25
|
.option('--description <desc>', 'OpenAPI specification description override')
|
|
26
|
-
.option('--server <url>', 'Default server URL in spec', 'http://localhost:
|
|
26
|
+
.option('--server <url>', 'Default server URL in spec', 'http://localhost:7331')
|
|
27
27
|
.option('--include-orm', 'Include ORM model schemas in components', false)
|
|
28
28
|
.option('--indent <spaces>', 'JSON indentation spaces', '2')
|
|
29
29
|
.option('-p, --print', 'Print specification JSON to stdout', false)
|
package/bin/commands/start.js
CHANGED
|
@@ -10,7 +10,7 @@ function registerCommand(program) {
|
|
|
10
10
|
program
|
|
11
11
|
.command('start')
|
|
12
12
|
.description('Start production server')
|
|
13
|
-
.option('-p, --port <port>', 'Port number', '
|
|
13
|
+
.option('-p, --port <port>', 'Port number', '7331')
|
|
14
14
|
.action((options) => {
|
|
15
15
|
if (!fs.existsSync('server.js')) {
|
|
16
16
|
console.error('❌ server.js not found! Make sure you are in a Webspresso project.');
|
package/bin/utils/project.js
CHANGED
|
@@ -56,7 +56,7 @@ function startDevServer(projectPath, useTailwind) {
|
|
|
56
56
|
stdio: 'inherit',
|
|
57
57
|
shell: true,
|
|
58
58
|
cwd: projectPath,
|
|
59
|
-
env: { ...process.env, PORT: process.env.PORT || '
|
|
59
|
+
env: { ...process.env, PORT: process.env.PORT || '7331', NODE_ENV: 'development' }
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
// Handle exit
|
|
@@ -77,7 +77,7 @@ function startDevServer(projectPath, useTailwind) {
|
|
|
77
77
|
stdio: 'inherit',
|
|
78
78
|
shell: true,
|
|
79
79
|
cwd: projectPath,
|
|
80
|
-
env: { ...process.env, PORT: process.env.PORT || '
|
|
80
|
+
env: { ...process.env, PORT: process.env.PORT || '7331', NODE_ENV: 'development' }
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
server.on('exit', (code) => {
|
package/core/auth/hash.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module core/auth/hash
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const { sha256, randomToken } = require('../utils/crypto');
|
|
8
|
+
|
|
7
9
|
let bcrypt = null;
|
|
8
10
|
let bcryptChecked = false;
|
|
9
11
|
function getBcrypt() {
|
|
@@ -96,8 +98,7 @@ function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
|
|
|
96
98
|
* @returns {string} Hex-encoded random token
|
|
97
99
|
*/
|
|
98
100
|
function generateToken(length = 32) {
|
|
99
|
-
|
|
100
|
-
return crypto.randomBytes(length).toString('hex');
|
|
101
|
+
return randomToken(length, 'hex');
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
/**
|
|
@@ -111,9 +112,9 @@ function generateToken(length = 32) {
|
|
|
111
112
|
* @returns {string} Hex-encoded SHA-256 digest
|
|
112
113
|
*/
|
|
113
114
|
function hashToken(token) {
|
|
114
|
-
|
|
115
|
+
if (!token) return '';
|
|
115
116
|
// CodeQL [js/insufficient-password-hash] False positive: token is a 256-bit random string, not a user password
|
|
116
|
-
return
|
|
117
|
+
return sha256(String(token));
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
module.exports = {
|