webspresso 0.0.103 → 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.
Files changed (165) hide show
  1. package/.agents/AGENTS.md +6 -0
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +1 -1
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/HEALTH.md +118 -0
  7. package/.agents/LIMITS.md +109 -0
  8. package/.agents/ORM.md +155 -1
  9. package/.agents/PLUGINS.md +28 -0
  10. package/.agents/RESILIENCE.md +235 -0
  11. package/AGENTS.md +6 -0
  12. package/README.md +2 -2
  13. package/bin/commands/add-docker.js +6 -6
  14. package/bin/commands/dev.js +4 -3
  15. package/bin/commands/new.js +5 -5
  16. package/bin/commands/openapi.js +1 -1
  17. package/bin/commands/start.js +1 -1
  18. package/bin/utils/orm-map-html.js +2 -8
  19. package/bin/utils/project.js +2 -2
  20. package/core/auth/hash.js +5 -4
  21. package/core/auth/jwt.js +6 -57
  22. package/core/auth/magic-link.js +4 -5
  23. package/core/auth/manager.js +3 -5
  24. package/core/auth/oauth.js +45 -30
  25. package/core/auth/totp.js +5 -16
  26. package/core/auth/user-agent.js +1 -21
  27. package/core/cache/byte-size.js +123 -0
  28. package/core/cache/index.js +21 -0
  29. package/core/cache/lru.js +477 -0
  30. package/core/cache/tagged-lru.js +346 -0
  31. package/core/commands/registry.js +6 -20
  32. package/core/content/renderer.js +1 -13
  33. package/core/early-hints/index.js +267 -0
  34. package/core/errors/domain.js +48 -0
  35. package/core/errors/http.js +31 -0
  36. package/core/errors/index.js +10 -0
  37. package/core/errors/middleware.js +2 -8
  38. package/core/errors/normalize.js +88 -2
  39. package/core/events/discovery.js +6 -24
  40. package/core/events/dispatcher.js +2 -7
  41. package/core/forms/form-builder.js +1 -9
  42. package/core/health/index.js +41 -0
  43. package/core/health/middleware.js +213 -0
  44. package/core/health/normalize.js +140 -0
  45. package/core/health/probes.js +203 -0
  46. package/core/limits/index.js +34 -0
  47. package/core/limits/middleware.js +317 -0
  48. package/core/limits/normalize.js +222 -0
  49. package/core/orm/batch.js +70 -0
  50. package/core/orm/cache/fingerprint.js +2 -2
  51. package/core/orm/cache/memory-provider.js +27 -110
  52. package/core/orm/index.js +76 -8
  53. package/core/orm/json-fields.js +22 -8
  54. package/core/orm/model.js +10 -0
  55. package/core/orm/query-builder.js +63 -9
  56. package/core/orm/query-filters.js +393 -0
  57. package/core/orm/repository.js +318 -19
  58. package/core/orm/schema-helpers.js +8 -10
  59. package/core/orm/scopes.js +321 -1
  60. package/core/orm/transaction.js +10 -2
  61. package/core/orm/utils.js +4 -86
  62. package/core/queue/adapters/database.js +4 -8
  63. package/core/queue/manager.js +55 -41
  64. package/core/resilience/circuit-breaker.js +391 -0
  65. package/core/resilience/errors.js +63 -0
  66. package/core/resilience/index.js +53 -0
  67. package/core/resilience/registry.js +97 -0
  68. package/core/resilience/retry.js +203 -0
  69. package/core/schema-cache.js +9 -2
  70. package/core/shutdown/http-adapter.js +4 -1
  71. package/core/shutdown/shutdown-manager.js +132 -31
  72. package/core/utils/array.js +12 -0
  73. package/core/utils/base64url.js +8 -0
  74. package/core/utils/crypto.js +191 -0
  75. package/core/utils/ensure-leading-slash.js +6 -0
  76. package/core/utils/escape-html.js +7 -0
  77. package/core/utils/escape-regexp.js +7 -0
  78. package/core/utils/fetch.js +121 -0
  79. package/core/utils/fs.js +144 -0
  80. package/core/utils/get-client-ip.js +6 -0
  81. package/core/utils/hmac.js +6 -0
  82. package/core/utils/html.js +72 -0
  83. package/core/utils/ip.js +86 -0
  84. package/core/utils/json.js +98 -0
  85. package/core/utils/object.js +106 -0
  86. package/core/utils/random.js +9 -0
  87. package/core/utils/regex.js +77 -0
  88. package/core/utils/safe-compare.js +3 -0
  89. package/core/utils/sha256.js +6 -0
  90. package/core/utils/string.js +87 -0
  91. package/core/utils/strip-trailing-slash.js +6 -0
  92. package/core/utils/time.js +96 -0
  93. package/core/utils/timeout.js +348 -0
  94. package/core/utils/url.js +106 -0
  95. package/core/utils/walk.js +7 -0
  96. package/index.d.ts +627 -2
  97. package/index.js +158 -0
  98. package/package.json +1 -2
  99. package/plugins/admin-panel/auth.js +15 -7
  100. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  101. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  102. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  103. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  104. package/plugins/admin-panel/core/api-extensions.js +3 -80
  105. package/plugins/admin-panel/index.js +1 -1
  106. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  107. package/plugins/admin-panel/modules/menu.js +1 -1
  108. package/plugins/api-key/crypto.js +3 -26
  109. package/plugins/api-key/scopes.js +5 -7
  110. package/plugins/audit-log/api-handlers.js +8 -7
  111. package/plugins/audit-log/middleware.js +11 -2
  112. package/plugins/audit-log/parse.js +3 -3
  113. package/plugins/basic-auth/index.js +2 -26
  114. package/plugins/cors.js +2 -2
  115. package/plugins/csrf/index.js +7 -23
  116. package/plugins/dashboard/app.js +2 -2
  117. package/plugins/dashboard/index.js +5 -5
  118. package/plugins/data-exchange/record-selection.js +2 -5
  119. package/plugins/email/api-handlers.js +5 -18
  120. package/plugins/email/auth-bridge.js +3 -2
  121. package/plugins/file-manager/admin-component.js +3 -1
  122. package/plugins/file-manager/core.js +34 -4
  123. package/plugins/health-check.js +2 -1
  124. package/plugins/index.js +2 -0
  125. package/plugins/lazy.js +5 -6
  126. package/plugins/mcp/auth.js +1 -13
  127. package/plugins/mcp/discovery.js +18 -17
  128. package/plugins/mcp/index.js +23 -1
  129. package/plugins/mcp/transports/sse.js +10 -1
  130. package/plugins/orm-cache-admin/admin-component.js +3 -2
  131. package/plugins/polar/src/api.js +11 -3
  132. package/plugins/polar/src/webhooks.js +7 -13
  133. package/plugins/rate-limit/index.js +3 -1
  134. package/plugins/recaptcha/helpers.js +1 -7
  135. package/plugins/recaptcha/verify.js +37 -23
  136. package/plugins/redirect/index.js +57 -7
  137. package/plugins/rest-resources/index.js +1 -207
  138. package/plugins/rls/index.js +170 -0
  139. package/plugins/schema-explorer/admin-component.js +4 -2
  140. package/plugins/schema-explorer.js +40 -29
  141. package/plugins/seo-checker/index.js +5 -2
  142. package/plugins/site-analytics/tracking.js +21 -18
  143. package/plugins/sitemap.js +21 -17
  144. package/plugins/swagger.js +13 -17
  145. package/plugins/upload/index.js +4 -3
  146. package/plugins/upload/orphan-pruner.js +4 -3
  147. package/plugins/webhooks/crypto.js +6 -28
  148. package/plugins/webhooks/dispatcher.js +14 -9
  149. package/plugins/webhooks/index.js +6 -0
  150. package/plugins/webhooks/matcher.js +28 -17
  151. package/plugins/webhooks/sender.js +40 -13
  152. package/src/app-context.js +41 -5
  153. package/src/dev/banner.js +6 -2
  154. package/src/dev-reload/index.js +47 -6
  155. package/src/file-router.js +1 -3
  156. package/src/helpers.js +180 -33
  157. package/src/modules/module-discovery.js +1 -9
  158. package/src/pages/page-loader.js +53 -1
  159. package/src/plugin-manager.js +90 -17
  160. package/src/server.js +137 -26
  161. package/src/services/discovery.js +19 -40
  162. package/src/services/executor.js +86 -25
  163. package/src/services/memoize.js +3 -25
  164. package/src/services/registry.js +6 -0
  165. 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
 
@@ -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:3000`** in your browser.
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 || 3000;
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=3000]
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 || 3000;
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=3000]
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 = 3000,
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: 3000)', '3000')
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 || '3000', 10) || 3000;
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';
@@ -13,8 +13,9 @@ const { spawn } = require('child_process');
13
13
  function buildWatchArgs() {
14
14
  const args = ['--watch'];
15
15
 
16
- // Add watch paths for common directories
17
- const watchPaths = ['pages', 'models', 'views'];
16
+ // Add watch paths for server-side code directories (services, models, config, pages)
17
+ // Note: views contains Nunjucks templates which are live-reloaded in-memory by devReloadHub
18
+ const watchPaths = ['pages', 'models', 'services', 'config'];
18
19
 
19
20
  for (const dir of watchPaths) {
20
21
  if (fs.existsSync(dir)) {
@@ -30,7 +31,7 @@ function registerCommand(program) {
30
31
  program
31
32
  .command('dev')
32
33
  .description('Start development server')
33
- .option('-p, --port <port>', 'Port number', '3000')
34
+ .option('-p, --port <port>', 'Port number', '7331')
34
35
  .option('--no-css', 'Skip CSS watch (if Tailwind is set up)')
35
36
  .option('--no-js', 'Skip client JS watch (if esbuild / watch:js is set up)')
36
37
  .action((options) => {
@@ -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(3000),
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:3000'),
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=3000
385
+ PORT=7331
386
386
  NODE_ENV=development
387
387
  DEFAULT_LOCALE=en
388
388
  SUPPORTED_LOCALES=en,de
389
- BASE_URL=http://localhost:3000
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:3000
648
+ Visit http://localhost:7331
649
649
 
650
650
  ## Configuration
651
651
 
@@ -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:3000')
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)
@@ -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', '3000')
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.');
@@ -4,6 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
+ const { escapeHtml } = require('../../core/utils/html');
7
8
 
8
9
  /**
9
10
  * @param {object} snapshot - { generatedAt, models }
@@ -661,14 +662,7 @@ function buildOrmMapHtml(snapshot, mermaidSource, meta = {}) {
661
662
  `;
662
663
  }
663
664
 
664
- function escapeHtml(s) {
665
- if (s == null) return '';
666
- return String(s)
667
- .replace(/&/g, '&amp;')
668
- .replace(/</g, '&lt;')
669
- .replace(/>/g, '&gt;')
670
- .replace(/"/g, '&quot;');
671
- }
665
+
672
666
 
673
667
  /**
674
668
  * Optional: read package name from cwd/package.json
@@ -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 || '3000', NODE_ENV: 'development' }
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 || '3000', NODE_ENV: 'development' }
80
+ env: { ...process.env, PORT: process.env.PORT || '7331', NODE_ENV: 'development' }
81
81
  });
82
82
 
83
83
  server.on('exit', (code) => {