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
@@ -4,9 +4,7 @@
4
4
  * @module plugins/webhooks/crypto
5
5
  */
6
6
 
7
- 'use strict';
8
-
9
- const crypto = require('crypto');
7
+ const { safeCompare, hmacSha256, randomId } = require('../../core/utils/crypto');
10
8
 
11
9
  /**
12
10
  * Generate a cryptographically secure webhook signing secret
@@ -16,27 +14,7 @@ const crypto = require('crypto');
16
14
  * @returns {string} E.g. 'whsec_e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934c'
17
15
  */
18
16
  function generateSecret(prefix = 'whsec_', byteLength = 24) {
19
- const bytes = crypto.randomBytes(byteLength).toString('hex');
20
- return `${prefix || ''}${bytes}`;
21
- }
22
-
23
- /**
24
- * Perform constant-time string comparison to prevent timing attacks
25
- *
26
- * @param {string} a
27
- * @param {string} b
28
- * @returns {boolean}
29
- */
30
- function safeCompare(a, b) {
31
- if (typeof a !== 'string' || typeof b !== 'string') {
32
- return false;
33
- }
34
- const bufA = Buffer.from(a, 'utf8');
35
- const bufB = Buffer.from(b, 'utf8');
36
- if (bufA.length !== bufB.length) {
37
- return false;
38
- }
39
- return crypto.timingSafeEqual(bufA, bufB);
17
+ return randomId(prefix, byteLength, 'hex');
40
18
  }
41
19
 
42
20
  /**
@@ -59,12 +37,12 @@ function signPayload(payload, secret, options = {}) {
59
37
  : Math.floor(Date.now() / 1000);
60
38
 
61
39
  // 1. Direct payload HMAC-SHA256 (compatible with GitHub / standard webhooks)
62
- const hex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
40
+ const hex = hmacSha256(payloadStr, secret, 'hex');
63
41
  const signature = `sha256=${hex}`;
64
42
 
65
43
  // 2. Timestamped HMAC-SHA256 (compatible with Stripe / Svix / Slack)
66
44
  const toSign = `${timestamp}.${payloadStr}`;
67
- const timestampedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
45
+ const timestampedHex = hmacSha256(toSign, secret, 'hex');
68
46
  const timestampedSignature = `t=${timestamp},v1=${timestampedHex}`;
69
47
 
70
48
  return {
@@ -121,7 +99,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
121
99
  }
122
100
 
123
101
  const toSign = `${timestamp}.${payloadStr}`;
124
- const expectedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
102
+ const expectedHex = hmacSha256(toSign, secret, 'hex');
125
103
 
126
104
  return signatures.some((sig) => safeCompare(sig, expectedHex));
127
105
  }
@@ -132,7 +110,7 @@ function verifySignature(payload, signatureHeader, secret, options = {}) {
132
110
  receivedHex = receivedHex.slice(7);
133
111
  }
134
112
 
135
- const expectedHex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
113
+ const expectedHex = hmacSha256(payloadStr, secret, 'hex');
136
114
  return safeCompare(receivedHex, expectedHex);
137
115
  }
138
116
 
@@ -8,9 +8,11 @@
8
8
 
9
9
  const EventEmitter = require('events');
10
10
  const crypto = require('crypto');
11
+ const { randomTimestampedId } = require('../../core/utils/crypto');
11
12
  const { generateSecret } = require('./crypto');
12
13
  const { matchesAnyEvent, normalizeEvents } = require('./matcher');
13
14
  const { sendWebhook } = require('./sender');
15
+ const { safeJsonParse } = require('../../core/utils/json');
14
16
 
15
17
  /**
16
18
  * Generate a standard unique delivery identifier
@@ -20,7 +22,7 @@ function generateDeliveryId() {
20
22
  if (typeof crypto.randomUUID === 'function') {
21
23
  return `dlv_${crypto.randomUUID().replace(/-/g, '')}`;
22
24
  }
23
- return `dlv_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
25
+ return randomTimestampedId('dlv', 8);
24
26
  }
25
27
 
26
28
  class WebhookDispatcher extends EventEmitter {
@@ -47,7 +49,10 @@ class WebhookDispatcher extends EventEmitter {
47
49
  this.deliveriesTable = options.deliveriesTable || 'webhook_deliveries';
48
50
  this.maxAttempts = typeof options.maxAttempts === 'number' ? options.maxAttempts : 3;
49
51
  this.backoffBaseMs = typeof options.backoffBaseMs === 'number' ? options.backoffBaseMs : 1000;
50
- this.timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 10000;
52
+ const defaultWebhookTimeout = options.timeouts?.webhook || 10000;
53
+ this.timeoutMs = typeof options.timeoutMs === 'number'
54
+ ? options.timeoutMs
55
+ : (typeof options.timeout === 'number' ? options.timeout : defaultWebhookTimeout);
51
56
  this.wrapPayload = options.wrapPayload !== undefined ? options.wrapPayload : true;
52
57
  this.queue = options.queue || null;
53
58
  this.logger = options.logger || console;
@@ -461,9 +466,14 @@ class WebhookDispatcher extends EventEmitter {
461
466
  attempt: attempt + 1,
462
467
  autoRetry: true,
463
468
  }).catch((err) => {
464
- this.emit('error', err);
469
+ if (this.listenerCount('error') > 0) {
470
+ this.emit('error', err);
471
+ } else {
472
+ console.error('[webhooks] Delivery retry failed:', err?.message || err);
473
+ }
465
474
  });
466
475
  }, delayMs);
476
+ if (timer.unref) timer.unref();
467
477
  this._activeTimers.add(timer);
468
478
  }
469
479
  } else {
@@ -522,12 +532,7 @@ class WebhookDispatcher extends EventEmitter {
522
532
  throw new Error(`Target endpoint #${delivery.endpoint_id} no longer exists.`);
523
533
  }
524
534
 
525
- let parsedPayload = delivery.payload;
526
- try {
527
- parsedPayload = JSON.parse(delivery.payload);
528
- } catch (e) {
529
- // Keep string if not json
530
- }
535
+ const parsedPayload = safeJsonParse(delivery.payload, delivery.payload);
531
536
 
532
537
  const nextAttempt = (delivery.attempt || 1) + 1;
533
538
  return this.executeDelivery({
@@ -104,6 +104,12 @@ function webhooksPlugin(options = {}) {
104
104
  dispatcher.knex = db.knex || db;
105
105
  }
106
106
 
107
+ // Sync timeouts from app context if not explicitly passed
108
+ const timeouts = ctx?.timeouts || (ctx?.app && typeof ctx.app.get === 'function' ? ctx.app.get('timeouts') : null);
109
+ if (timeouts && timeouts.webhook && dispatcher.timeoutMs === 10000 && options.timeoutMs === undefined && options.timeout === undefined) {
110
+ dispatcher.timeoutMs = timeouts.webhook;
111
+ }
112
+
107
113
  // Attach dispatcher to app and request objects
108
114
  if (ctx.app) {
109
115
  ctx.app.webhooks = dispatcher;
@@ -6,6 +6,9 @@
6
6
 
7
7
  'use strict';
8
8
 
9
+ const { globToRegExp } = require('../../core/utils/regex');
10
+ const { safeJsonParse } = require('../../core/utils/json');
11
+
9
12
  /**
10
13
  * Normalize an event filter into an array of strings
11
14
  *
@@ -29,13 +32,9 @@ function normalizeEvents(events) {
29
32
 
30
33
  // If JSON array string like '["user.*", "order.created"]'
31
34
  if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
32
- try {
33
- const parsed = JSON.parse(trimmed);
34
- if (Array.isArray(parsed)) {
35
- return parsed.map((e) => (typeof e === 'string' ? e.trim() : '')).filter(Boolean);
36
- }
37
- } catch (err) {
38
- // Fall back to comma-separated
35
+ const parsed = safeJsonParse(trimmed, null);
36
+ if (Array.isArray(parsed)) {
37
+ return parsed.map((e) => (typeof e === 'string' ? e.trim() : '')).filter(Boolean);
39
38
  }
40
39
  }
41
40
 
@@ -48,6 +47,22 @@ function normalizeEvents(events) {
48
47
  return ['*'];
49
48
  }
50
49
 
50
+ const regexCache = new Map();
51
+ const MAX_REGEX_CACHE = 1000;
52
+
53
+ function getPatternRegex(pattern) {
54
+ let cached = regexCache.get(pattern);
55
+ if (!cached) {
56
+ if (regexCache.size >= MAX_REGEX_CACHE) {
57
+ const first = regexCache.keys().next().value;
58
+ if (first !== undefined) regexCache.delete(first);
59
+ }
60
+ cached = globToRegExp(pattern, { separator: null, flags: 'i' });
61
+ regexCache.set(pattern, cached);
62
+ }
63
+ return cached;
64
+ }
65
+
51
66
  /**
52
67
  * Test whether an event pattern matches an actual event name
53
68
  *
@@ -56,12 +71,15 @@ function normalizeEvents(events) {
56
71
  * @returns {boolean}
57
72
  */
58
73
  function matchEvent(pattern, eventName) {
59
- if (!pattern || !eventName) {
74
+ if (typeof pattern !== 'string' || typeof eventName !== 'string') {
60
75
  return false;
61
76
  }
62
77
 
63
78
  const p = pattern.trim();
64
79
  const e = eventName.trim();
80
+ if (!p || !e) {
81
+ return false;
82
+ }
65
83
 
66
84
  // 1. Wildcard matches all
67
85
  if (p === '*') {
@@ -73,15 +91,8 @@ function matchEvent(pattern, eventName) {
73
91
  return true;
74
92
  }
75
93
 
76
- // 3. Glob / Wildcard conversion
77
- // Convert 'user.*' to regular expression '^user\.[^.]+$' or '^user\..+$'
78
- // Support prefix, middle, or suffix wildcards safely
79
- const escaped = p
80
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
81
- .replace(/\*/g, '.*');
82
-
83
- const regex = new RegExp(`^${escaped}$`, 'i');
84
- return regex.test(e);
94
+ // 3. Glob / Wildcard conversion with bounded cache
95
+ return getPatternRegex(p).test(e);
85
96
  }
86
97
 
87
98
  /**
@@ -8,6 +8,8 @@
8
8
 
9
9
  const crypto = require('crypto');
10
10
  const { signPayload } = require('./crypto');
11
+ const { safeJsonParse } = require('../../core/utils/json');
12
+ const { parseDurationMs } = require('../../core/utils/time');
11
13
 
12
14
  /**
13
15
  * Send a webhook HTTP request to a target endpoint
@@ -32,7 +34,8 @@ const { signPayload } = require('./crypto');
32
34
  * timestamp: number
33
35
  * }>}
34
36
  */
35
- async function sendWebhook(options) {
37
+ async function sendWebhook(options = {}) {
38
+ const opts = options || {};
36
39
  const {
37
40
  url,
38
41
  event,
@@ -40,10 +43,11 @@ async function sendWebhook(options) {
40
43
  secret,
41
44
  deliveryId = crypto.randomUUID ? crypto.randomUUID() : `dlv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
42
45
  headers: customHeaders = {},
43
- timeoutMs = 10000,
44
46
  wrapPayload = true,
45
47
  maxResponseBodyLength = 2048,
46
- } = options;
48
+ } = opts;
49
+
50
+ const timeoutMs = parseDurationMs(opts.timeout !== undefined ? opts.timeout : (opts.timeoutMs !== undefined ? opts.timeoutMs : 10000), 10000);
47
51
 
48
52
  if (!url || typeof url !== 'string') {
49
53
  return {
@@ -57,6 +61,32 @@ async function sendWebhook(options) {
57
61
  };
58
62
  }
59
63
 
64
+ let parsedUrl;
65
+ try {
66
+ parsedUrl = new URL(url);
67
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
68
+ return {
69
+ success: false,
70
+ statusCode: 0,
71
+ responseBody: '',
72
+ durationMs: 0,
73
+ error: `Invalid webhook URL protocol: "${parsedUrl.protocol}". Only http: and https: are supported.`,
74
+ deliveryId,
75
+ timestamp: Math.floor(Date.now() / 1000),
76
+ };
77
+ }
78
+ } catch (err) {
79
+ return {
80
+ success: false,
81
+ statusCode: 0,
82
+ responseBody: '',
83
+ durationMs: 0,
84
+ error: 'Invalid webhook URL: destination URL is malformed.',
85
+ deliveryId,
86
+ timestamp: Math.floor(Date.now() / 1000),
87
+ };
88
+ }
89
+
60
90
  const timestamp = Math.floor(Date.now() / 1000);
61
91
 
62
92
  // Construct request body
@@ -83,16 +113,10 @@ async function sendWebhook(options) {
83
113
  }
84
114
 
85
115
  // Parse custom headers if stored as JSON string
86
- let parsedCustomHeaders = {};
87
- if (typeof customHeaders === 'string') {
88
- try {
89
- parsedCustomHeaders = JSON.parse(customHeaders);
90
- } catch (e) {
91
- parsedCustomHeaders = {};
92
- }
93
- } else if (typeof customHeaders === 'object' && customHeaders !== null) {
94
- parsedCustomHeaders = customHeaders;
95
- }
116
+ const parsedHeadersObj = safeJsonParse(customHeaders, {});
117
+ const parsedCustomHeaders = (parsedHeadersObj && typeof parsedHeadersObj === 'object' && !Array.isArray(parsedHeadersObj))
118
+ ? parsedHeadersObj
119
+ : {};
96
120
 
97
121
  const requestHeaders = {
98
122
  'content-type': 'application/json',
@@ -116,6 +140,9 @@ async function sendWebhook(options) {
116
140
  timeoutId = setTimeout(() => {
117
141
  controller.abort();
118
142
  }, timeoutMs);
143
+ if (timeoutId && timeoutId.unref) {
144
+ timeoutId.unref();
145
+ }
119
146
 
120
147
  const response = await fetch(url, {
121
148
  method: 'POST',
@@ -3,18 +3,22 @@
3
3
  * @module src/app-context
4
4
  */
5
5
 
6
- /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null, events: object|null, mailer: object|null }} */
6
+ const { circuitBreakerRegistry, retry } = require('../core/resilience');
7
+
8
+ /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null, events: object|null, mailer: object|null, cache: object|null, breakers: object|null }} */
7
9
  let context = {
8
10
  db: null,
9
11
  shutdownManager: null,
10
12
  serviceRegistry: null,
11
13
  events: null,
12
14
  mailer: null,
15
+ cache: null,
16
+ breakers: circuitBreakerRegistry,
13
17
  };
14
18
 
15
19
  /**
16
20
  * Merge into the current context (typically called once from createApp).
17
- * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null, events?: object|null, mailer?: object|null }} partial
21
+ * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null, events?: object|null, mailer?: object|null, cache?: object|null }} partial
18
22
  */
19
23
  function setAppContext(partial) {
20
24
  context = { ...context, ...partial };
@@ -99,7 +103,7 @@ function hasEvents() {
99
103
  * Clear context (e.g. between tests).
100
104
  */
101
105
  function resetAppContext() {
102
- context = { db: null, shutdownManager: null, serviceRegistry: null, events: null, mailer: null };
106
+ context = { db: null, shutdownManager: null, serviceRegistry: null, events: null, mailer: null, cache: null };
103
107
  }
104
108
 
105
109
  /**
@@ -117,7 +121,21 @@ function hasMailer() {
117
121
  }
118
122
 
119
123
  /**
120
- * Express middleware: sets `req.db`, `req.service`, `req.events`, and `req.mailer` from registered app context.
124
+ * @returns {object|null}
125
+ */
126
+ function getCache() {
127
+ return context.cache;
128
+ }
129
+
130
+ /**
131
+ * @returns {boolean}
132
+ */
133
+ function hasCache() {
134
+ return context.cache != null;
135
+ }
136
+
137
+ /**
138
+ * Express middleware: sets `req.db`, `req.service`, `req.events`, `req.mailer`, and `req.cache` from registered app context.
121
139
  * File-based `pages/api/*` routes attach this automatically; use in `setupRoutes`
122
140
  * for manually registered handlers that need `req.db` and `req.service`.
123
141
  * @type {import('express').RequestHandler}
@@ -132,9 +150,25 @@ function attachDbMiddleware(req, res, next) {
132
150
  if (context.mailer != null) {
133
151
  req.mailer = context.mailer;
134
152
  }
153
+ if (context.cache != null) {
154
+ req.cache = context.cache;
155
+ }
156
+ req.breakers = context.breakers || circuitBreakerRegistry;
157
+ req.breaker = (name, opts) => (context.breakers || circuitBreakerRegistry).getBreaker(name, opts);
158
+ req.retry = retry;
135
159
  if (context.serviceRegistry != null) {
136
160
  req.service = (name, input, opts) =>
137
- context.serviceRegistry.call(name, input, req.context || { req, res, db: context.db, events: context.events, mailer: context.mailer }, opts);
161
+ context.serviceRegistry.call(name, input, req.context || {
162
+ req,
163
+ res,
164
+ db: context.db,
165
+ events: context.events,
166
+ mailer: context.mailer,
167
+ cache: context.cache,
168
+ breakers: req.breakers,
169
+ breaker: req.breaker,
170
+ retry,
171
+ }, opts);
138
172
  }
139
173
  next();
140
174
  }
@@ -152,6 +186,8 @@ module.exports = {
152
186
  hasEvents,
153
187
  getMailer,
154
188
  hasMailer,
189
+ getCache,
190
+ hasCache,
155
191
  resetAppContext,
156
192
  attachDbMiddleware,
157
193
  };
package/src/dev/banner.js CHANGED
@@ -94,7 +94,7 @@ function collectBannerSummary(context = {}) {
94
94
  db,
95
95
  serviceRegistry,
96
96
  pluginManager,
97
- port = 3000,
97
+ port = 7331,
98
98
  host = 'localhost',
99
99
  } = context;
100
100
 
@@ -141,9 +141,13 @@ function collectBannerSummary(context = {}) {
141
141
  // 5. Plugins
142
142
  let pluginsList = [];
143
143
  let hasAdmin = false;
144
- if (pluginManager && pluginManager.plugins instanceof Map) {
144
+ if (context.hasAdmin !== undefined) {
145
+ hasAdmin = Boolean(context.hasAdmin);
146
+ } else if (pluginManager && pluginManager.plugins instanceof Map) {
145
147
  pluginsList = Array.from(pluginManager.plugins.keys());
146
148
  hasAdmin = pluginManager.plugins.has('adminPanel') || pluginManager.plugins.has('admin');
149
+ } else {
150
+ hasAdmin = true;
147
151
  }
148
152
 
149
153
  // URLs
@@ -15,6 +15,8 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
15
15
 
16
16
  var es = null;
17
17
  var reconnectTimer = null;
18
+ var wasConnected = false;
19
+ var reconnectDelay = 200;
18
20
 
19
21
  function reloadCss(targetFile) {
20
22
  var links = document.querySelectorAll('link[rel="stylesheet"]');
@@ -166,6 +168,36 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
166
168
  document.addEventListener('keydown', onKeyDown);
167
169
  }
168
170
 
171
+ function safeReload(reason) {
172
+ clearOverlay();
173
+ var reasonMsg = reason ? ' (' + reason + ')' : '';
174
+ console.log('[Webspresso] 🔄 Reloading page' + reasonMsg + '...');
175
+
176
+ // Ping server with lightweight HEAD request to make sure it is ready before full reload.
177
+ // This prevents browser "This site can't be reached" (ERR_CONNECTION_REFUSED) screens
178
+ // when the dev server is in the middle of restarting.
179
+ function pingAndReload() {
180
+ if (typeof window.fetch !== 'function') {
181
+ window.location.reload();
182
+ return;
183
+ }
184
+
185
+ fetch(window.location.href, { method: 'HEAD', cache: 'no-store' })
186
+ .then(function(res) {
187
+ if (res.ok || res.status < 500) {
188
+ window.location.reload();
189
+ } else {
190
+ setTimeout(pingAndReload, 150);
191
+ }
192
+ })
193
+ .catch(function() {
194
+ setTimeout(pingAndReload, 150);
195
+ });
196
+ }
197
+
198
+ setTimeout(pingAndReload, 30);
199
+ }
200
+
169
201
  function connect() {
170
202
  if (es) {
171
203
  try { es.close(); } catch (_) {}
@@ -173,6 +205,16 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
173
205
 
174
206
  es = new window.EventSource('${SSE_ENDPOINT_PATH}');
175
207
 
208
+ es.addEventListener('open', function() {
209
+ reconnectDelay = 200;
210
+ if (wasConnected) {
211
+ console.log('[Webspresso] 🔄 Dev server reconnected, reloading page...');
212
+ safeReload('server reconnected');
213
+ return;
214
+ }
215
+ wasConnected = true;
216
+ });
217
+
176
218
  es.addEventListener('reload', function(e) {
177
219
  try {
178
220
  var data = JSON.parse(e.data);
@@ -180,10 +222,7 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
180
222
  clearOverlay();
181
223
  reloadCss(data.file);
182
224
  } else if (data.type === 'full') {
183
- clearOverlay();
184
- var reasonMsg = data.reason ? ' (' + data.reason + ')' : '';
185
- console.log('[Webspresso] 🔄 Reloading page' + reasonMsg + '...');
186
- window.location.reload();
225
+ safeReload(data.reason);
187
226
  } else if (data.type === 'error') {
188
227
  showOverlay(data);
189
228
  } else if (data.type === 'clear-error') {
@@ -195,12 +234,14 @@ const CLIENT_RUNTIME_SCRIPT = `(function() {
195
234
  });
196
235
 
197
236
  es.onerror = function() {
198
- es.close();
237
+ try { es.close(); } catch (_) {}
199
238
  if (!reconnectTimer) {
239
+ var delay = reconnectDelay;
240
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 1500);
200
241
  reconnectTimer = setTimeout(function() {
201
242
  reconnectTimer = null;
202
243
  connect();
203
- }, 2000);
244
+ }, delay);
204
245
  }
205
246
  };
206
247
  }
@@ -32,6 +32,7 @@ const {
32
32
  resolveNamedMiddleware,
33
33
  resolveMiddlewares,
34
34
  } = require('./routing/middleware-resolver');
35
+ const { escapeRegExp } = require('../core/utils/regex');
35
36
 
36
37
  const NOOP = () => {};
37
38
  const EMPTY_OBJECT = Object.freeze({});
@@ -372,9 +373,6 @@ function rewriteDynamicRouteMarkers(route) {
372
373
  return out;
373
374
  }
374
375
 
375
- function escapeRegExp(s) {
376
- return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
377
- }
378
376
 
379
377
  /**
380
378
  * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'.