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.
Files changed (132) 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 +1 -1
  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/project.js +2 -2
  19. package/core/auth/hash.js +5 -4
  20. package/core/auth/jwt.js +5 -54
  21. package/core/auth/magic-link.js +4 -5
  22. package/core/auth/manager.js +3 -5
  23. package/core/auth/oauth.js +8 -17
  24. package/core/auth/totp.js +2 -1
  25. package/core/cache/byte-size.js +123 -0
  26. package/core/cache/index.js +21 -0
  27. package/core/cache/lru.js +477 -0
  28. package/core/cache/tagged-lru.js +346 -0
  29. package/core/commands/registry.js +6 -20
  30. package/core/early-hints/index.js +267 -0
  31. package/core/errors/domain.js +48 -0
  32. package/core/errors/http.js +31 -0
  33. package/core/errors/index.js +10 -0
  34. package/core/errors/normalize.js +88 -2
  35. package/core/events/discovery.js +6 -24
  36. package/core/events/dispatcher.js +2 -7
  37. package/core/health/index.js +41 -0
  38. package/core/health/middleware.js +213 -0
  39. package/core/health/normalize.js +140 -0
  40. package/core/health/probes.js +203 -0
  41. package/core/limits/index.js +34 -0
  42. package/core/limits/middleware.js +317 -0
  43. package/core/limits/normalize.js +222 -0
  44. package/core/orm/batch.js +70 -0
  45. package/core/orm/cache/fingerprint.js +2 -2
  46. package/core/orm/cache/memory-provider.js +27 -110
  47. package/core/orm/index.js +71 -8
  48. package/core/orm/json-fields.js +22 -8
  49. package/core/orm/model.js +10 -0
  50. package/core/orm/query-builder.js +63 -9
  51. package/core/orm/repository.js +318 -19
  52. package/core/orm/schema-helpers.js +8 -10
  53. package/core/orm/scopes.js +321 -1
  54. package/core/orm/transaction.js +10 -2
  55. package/core/orm/utils.js +4 -86
  56. package/core/queue/adapters/database.js +4 -8
  57. package/core/queue/manager.js +55 -41
  58. package/core/resilience/circuit-breaker.js +391 -0
  59. package/core/resilience/errors.js +63 -0
  60. package/core/resilience/index.js +53 -0
  61. package/core/resilience/registry.js +97 -0
  62. package/core/resilience/retry.js +203 -0
  63. package/core/schema-cache.js +9 -2
  64. package/core/shutdown/http-adapter.js +4 -1
  65. package/core/shutdown/shutdown-manager.js +120 -19
  66. package/core/utils/array.js +12 -0
  67. package/core/utils/base64url.js +8 -0
  68. package/core/utils/crypto.js +118 -0
  69. package/core/utils/ensure-leading-slash.js +6 -0
  70. package/core/utils/escape-regexp.js +7 -0
  71. package/core/utils/fetch.js +6 -5
  72. package/core/utils/fs.js +144 -0
  73. package/core/utils/hmac.js +6 -0
  74. package/core/utils/json.js +98 -0
  75. package/core/utils/object.js +106 -0
  76. package/core/utils/random.js +9 -0
  77. package/core/utils/regex.js +77 -0
  78. package/core/utils/sha256.js +6 -0
  79. package/core/utils/string.js +87 -0
  80. package/core/utils/strip-trailing-slash.js +6 -0
  81. package/core/utils/time.js +96 -0
  82. package/core/utils/timeout.js +348 -0
  83. package/core/utils/url.js +106 -0
  84. package/core/utils/walk.js +7 -0
  85. package/index.d.ts +616 -1
  86. package/index.js +148 -0
  87. package/package.json +1 -2
  88. package/plugins/admin-panel/core/api-extensions.js +2 -5
  89. package/plugins/admin-panel/index.js +1 -1
  90. package/plugins/admin-panel/modules/menu.js +1 -1
  91. package/plugins/api-key/crypto.js +3 -3
  92. package/plugins/api-key/scopes.js +5 -7
  93. package/plugins/audit-log/api-handlers.js +6 -15
  94. package/plugins/audit-log/parse.js +3 -3
  95. package/plugins/csrf/index.js +5 -9
  96. package/plugins/dashboard/app.js +2 -2
  97. package/plugins/dashboard/index.js +5 -5
  98. package/plugins/data-exchange/record-selection.js +2 -5
  99. package/plugins/email/api-handlers.js +5 -18
  100. package/plugins/email/auth-bridge.js +3 -2
  101. package/plugins/health-check.js +2 -1
  102. package/plugins/index.js +2 -0
  103. package/plugins/lazy.js +5 -6
  104. package/plugins/mcp/discovery.js +18 -17
  105. package/plugins/polar/src/api.js +2 -1
  106. package/plugins/polar/src/webhooks.js +5 -6
  107. package/plugins/rate-limit/index.js +3 -1
  108. package/plugins/redirect/index.js +9 -7
  109. package/plugins/rls/index.js +170 -0
  110. package/plugins/site-analytics/tracking.js +2 -2
  111. package/plugins/sitemap.js +3 -2
  112. package/plugins/swagger.js +4 -3
  113. package/plugins/upload/index.js +4 -3
  114. package/plugins/upload/orphan-pruner.js +4 -3
  115. package/plugins/webhooks/crypto.js +6 -11
  116. package/plugins/webhooks/dispatcher.js +8 -8
  117. package/plugins/webhooks/index.js +6 -0
  118. package/plugins/webhooks/matcher.js +7 -11
  119. package/plugins/webhooks/sender.js +8 -11
  120. package/src/app-context.js +41 -5
  121. package/src/dev/banner.js +6 -2
  122. package/src/file-router.js +1 -3
  123. package/src/helpers.js +159 -24
  124. package/src/modules/module-discovery.js +1 -9
  125. package/src/pages/page-loader.js +53 -1
  126. package/src/plugin-manager.js +90 -17
  127. package/src/server.js +136 -25
  128. package/src/services/discovery.js +19 -40
  129. package/src/services/executor.js +86 -25
  130. package/src/services/memoize.js +3 -25
  131. package/src/services/registry.js +6 -0
  132. package/src/template/precompile.js +8 -19
@@ -0,0 +1,346 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso - Tagged LRU Cache
5
+ * Combines zero-allocation O(1) LRU eviction, byte budget constraints, and
6
+ * bidirectional tag-based invalidation for ORM query caching and full-app caching.
7
+ * Guaranteed zero dangling Set memory leaks on LRU evictions.
8
+ * @module core/cache/tagged-lru
9
+ */
10
+
11
+ const { LruCache } = require('./lru');
12
+
13
+ /**
14
+ * Normalizes input tags into clean string array
15
+ * @param {string|string[]|Set<string>} tags
16
+ * @returns {string[]}
17
+ */
18
+ function normalizeTags(tags) {
19
+ if (!tags) return [];
20
+ if (typeof tags === 'string') return [tags.trim()];
21
+ if (tags instanceof Set) return Array.from(tags).map(String);
22
+ if (Array.isArray(tags)) return tags.map(String).filter(Boolean);
23
+ return [];
24
+ }
25
+
26
+ class TaggedLruCache {
27
+ /**
28
+ * @param {import('./lru').LruCacheOptions} [options]
29
+ */
30
+ constructor(options = {}) {
31
+ /** @type {Map<string, Set<string>>} */
32
+ this.tagToKeys = new Map();
33
+
34
+ /** @type {Map<string, Set<string>>} */
35
+ this.keyToTags = new Map();
36
+
37
+ const userOnEvict = options.onEvict;
38
+
39
+ // Connect internal LRU eviction directly to tag cleanup
40
+ this.lru = new LruCache({
41
+ ...options,
42
+ onEvict: (key, value, reason) => {
43
+ this._removeKeyFromTags(key);
44
+ if (typeof userOnEvict === 'function') {
45
+ userOnEvict(key, value, reason);
46
+ }
47
+ },
48
+ });
49
+ }
50
+
51
+ get size() {
52
+ return this.lru.size;
53
+ }
54
+
55
+ get currentBytes() {
56
+ return this.lru.currentBytes;
57
+ }
58
+
59
+ get maxBytes() {
60
+ return this.lru.maxBytes;
61
+ }
62
+
63
+ get maxEntries() {
64
+ return this.lru.maxEntries;
65
+ }
66
+
67
+ get hits() {
68
+ return this.lru.hits;
69
+ }
70
+
71
+ get misses() {
72
+ return this.lru.misses;
73
+ }
74
+
75
+ get hitRate() {
76
+ return this.lru.hitRate;
77
+ }
78
+
79
+ get evictions() {
80
+ return this.lru.evictions;
81
+ }
82
+
83
+ get expirations() {
84
+ return this.lru.expirations;
85
+ }
86
+
87
+ /**
88
+ * Clean up all tag mappings for a key (called on eviction, deletion, or tag rewrite)
89
+ * @private
90
+ */
91
+ _removeKeyFromTags(key) {
92
+ const tags = this.keyToTags.get(key);
93
+ if (!tags) return;
94
+
95
+ for (const tag of tags) {
96
+ const set = this.tagToKeys.get(tag);
97
+ if (set) {
98
+ set.delete(key);
99
+ if (set.size === 0) {
100
+ this.tagToKeys.delete(tag);
101
+ }
102
+ }
103
+ }
104
+
105
+ this.keyToTags.delete(key);
106
+ }
107
+
108
+ /**
109
+ * Associate tags with a key
110
+ * @private
111
+ */
112
+ _addKeyToTags(key, tags) {
113
+ if (!tags || tags.length === 0) return;
114
+
115
+ const tagSet = new Set(tags);
116
+ this.keyToTags.set(key, tagSet);
117
+
118
+ for (const tag of tagSet) {
119
+ let keySet = this.tagToKeys.get(tag);
120
+ if (!keySet) {
121
+ keySet = new Set();
122
+ this.tagToKeys.set(tag, keySet);
123
+ }
124
+ keySet.add(key);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Retrieve item by key
130
+ */
131
+ get(key) {
132
+ return this.lru.get(key);
133
+ }
134
+
135
+ /**
136
+ * Peek item without updating LRU order
137
+ */
138
+ peek(key) {
139
+ return this.lru.peek(key);
140
+ }
141
+
142
+ /**
143
+ * Check existence
144
+ */
145
+ has(key) {
146
+ return this.lru.has(key);
147
+ }
148
+
149
+ /**
150
+ * Store item with optional tags, TTL, and size
151
+ *
152
+ * @param {string} key
153
+ * @param {*} value
154
+ * @param {Object} [options]
155
+ * @param {string|string[]} [options.tags]
156
+ * @param {string|number} [options.ttl]
157
+ * @param {number} [options.ttlMs]
158
+ * @param {number} [options.size]
159
+ * @returns {this}
160
+ */
161
+ set(key, value, options = {}) {
162
+ const tags = normalizeTags(options.tags);
163
+
164
+ // If key already had tags, clean them up first before assigning new tags
165
+ if (this.keyToTags.has(key)) {
166
+ this._removeKeyFromTags(key);
167
+ }
168
+
169
+ this._addKeyToTags(key, tags);
170
+ this.lru.set(key, value, options);
171
+
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Delete single key
177
+ */
178
+ delete(key) {
179
+ this._removeKeyFromTags(key);
180
+ return this.lru.delete(key);
181
+ }
182
+
183
+ /**
184
+ * Alias for delete
185
+ */
186
+ del(key) {
187
+ return this.delete(key);
188
+ }
189
+
190
+ /**
191
+ * Invalidate all keys matching a specific tag
192
+ *
193
+ * @param {string} tag
194
+ * @returns {number} Count of invalidated keys
195
+ */
196
+ invalidateTag(tag) {
197
+ const keySet = this.tagToKeys.get(String(tag));
198
+ if (!keySet || keySet.size === 0) return 0;
199
+
200
+ // Copy keys to array to avoid mutating set during iteration
201
+ const keys = Array.from(keySet);
202
+ for (const key of keys) {
203
+ this.lru.delete(key);
204
+ }
205
+
206
+ this.tagToKeys.delete(String(tag));
207
+ return keys.length;
208
+ }
209
+
210
+ /**
211
+ * Invalidate multiple tags in a single operation
212
+ *
213
+ * @param {string[]|string} tags
214
+ * @returns {number} Total count of invalidated keys
215
+ */
216
+ invalidateTags(tags) {
217
+ const normalized = normalizeTags(tags);
218
+ if (normalized.length === 0) return 0;
219
+
220
+ const toDelete = new Set();
221
+ for (const t of normalized) {
222
+ const keys = this.tagToKeys.get(t);
223
+ if (keys) {
224
+ for (const k of keys) {
225
+ toDelete.add(k);
226
+ }
227
+ }
228
+ }
229
+
230
+ for (const k of toDelete) {
231
+ this.lru.delete(k);
232
+ }
233
+
234
+ return toDelete.size;
235
+ }
236
+
237
+ /**
238
+ * Check if a tag exists and has active keys
239
+ * @param {string} tag
240
+ * @returns {boolean}
241
+ */
242
+ hasTag(tag) {
243
+ const keys = this.tagToKeys.get(String(tag));
244
+ return Boolean(keys && keys.size > 0);
245
+ }
246
+
247
+ /**
248
+ * Get all active keys for a tag
249
+ * @param {string} tag
250
+ * @returns {string[]}
251
+ */
252
+ getKeysForTag(tag) {
253
+ const keys = this.tagToKeys.get(String(tag));
254
+ return keys ? Array.from(keys) : [];
255
+ }
256
+
257
+ /**
258
+ * Purge everything
259
+ */
260
+ clear() {
261
+ this.tagToKeys.clear();
262
+ this.keyToTags.clear();
263
+ this.lru.clear();
264
+ return this;
265
+ }
266
+
267
+ /**
268
+ * Actively prune expired entries
269
+ */
270
+ pruneExpired() {
271
+ return this.lru.pruneExpired();
272
+ }
273
+
274
+ /**
275
+ * Thundering herd stampede protection with tagging support
276
+ */
277
+ async getOrSet(key, asyncFactory, options = {}) {
278
+ const cached = this.get(key);
279
+ if (cached !== undefined) {
280
+ return cached;
281
+ }
282
+
283
+ return this.lru.getOrSet(key, asyncFactory, options);
284
+ }
285
+
286
+ /**
287
+ * Returns stats compatible with both LruCache and ORM CacheProvider
288
+ */
289
+ getSizeStats() {
290
+ return {
291
+ entries: this.lru.size,
292
+ tags: this.tagToKeys.size,
293
+ currentBytes: this.lru.currentBytes,
294
+ maxBytes: this.lru.maxBytes,
295
+ maxEntries: this.lru.maxEntries,
296
+ hits: this.lru.hits,
297
+ misses: this.lru.misses,
298
+ hitRate: this.lru.hitRate,
299
+ evictions: this.lru.evictions,
300
+ expirations: this.lru.expirations,
301
+ };
302
+ }
303
+
304
+ getStats() {
305
+ return this.getSizeStats();
306
+ }
307
+
308
+ resetStats() {
309
+ this.lru.resetStats();
310
+ }
311
+
312
+ keys() {
313
+ return this.lru.keys();
314
+ }
315
+
316
+ values() {
317
+ return this.lru.values();
318
+ }
319
+
320
+ entries() {
321
+ return this.lru.entries();
322
+ }
323
+
324
+ [Symbol.iterator]() {
325
+ return this.lru.entries();
326
+ }
327
+
328
+ forEach(callback, thisArg) {
329
+ this.lru.forEach(callback, thisArg);
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Factory helper
335
+ * @param {import('./lru').LruCacheOptions} [options]
336
+ * @returns {TaggedLruCache}
337
+ */
338
+ function createTaggedLruCache(options) {
339
+ return new TaggedLruCache(options);
340
+ }
341
+
342
+ module.exports = {
343
+ TaggedLruCache,
344
+ createTaggedLruCache,
345
+ normalizeTags,
346
+ };
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { defineCommand } = require('./define-command');
12
+ const { walkDirectory } = require('../utils/fs');
12
13
 
13
14
  /**
14
15
  * Recursively find command files in directory
@@ -16,26 +17,11 @@ const { defineCommand } = require('./define-command');
16
17
  * @returns {string[]}
17
18
  */
18
19
  function findCommandFiles(dir) {
19
- if (!fs.existsSync(dir)) return [];
20
- const entries = fs.readdirSync(dir, { withFileTypes: true });
21
- const files = [];
22
-
23
- for (const entry of entries) {
24
- const fullPath = path.join(dir, entry.name);
25
- if (entry.isDirectory()) {
26
- files.push(...findCommandFiles(fullPath));
27
- } else if (
28
- entry.isFile() &&
29
- entry.name.endsWith('.js') &&
30
- !entry.name.startsWith('_') &&
31
- !entry.name.endsWith('.test.js') &&
32
- !entry.name.endsWith('.spec.js')
33
- ) {
34
- files.push(fullPath);
35
- }
36
- }
37
-
38
- return files;
20
+ return walkDirectory(dir, {
21
+ extensions: ['.js'],
22
+ ignorePrefixes: ['_', '.'],
23
+ ignoreTests: true,
24
+ });
39
25
  }
40
26
 
41
27
  /**
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso HTTP 103 Early Hints Module
5
+ * Zero-dependency RFC 8288 / RFC 8297 compliant Early Hints dispatcher.
6
+ * Preloads critical assets (CSS, JS, Fonts) before SSR rendering finishes to optimize TTFB, FCP and LCP.
7
+ *
8
+ * @module core/early-hints
9
+ */
10
+
11
+ const EXTENSION_AS_MAP = {
12
+ '.css': 'style',
13
+ '.js': 'script',
14
+ '.mjs': 'script',
15
+ '.cjs': 'script',
16
+ '.woff2': 'font',
17
+ '.woff': 'font',
18
+ '.ttf': 'font',
19
+ '.otf': 'font',
20
+ '.png': 'image',
21
+ '.jpg': 'image',
22
+ '.jpeg': 'image',
23
+ '.webp': 'image',
24
+ '.avif': 'image',
25
+ '.svg': 'image',
26
+ '.gif': 'image',
27
+ '.ico': 'image',
28
+ '.json': 'fetch',
29
+ '.mp4': 'video',
30
+ '.webm': 'video',
31
+ '.mp3': 'audio',
32
+ '.wav': 'audio',
33
+ '.ogg': 'audio',
34
+ };
35
+
36
+ /**
37
+ * Infers the resource `as` type based on file extension
38
+ * @param {string} url - Resource URL or path
39
+ * @returns {string|null}
40
+ */
41
+ function inferAsType(url) {
42
+ if (typeof url !== 'string' || !url) return null;
43
+ const cleanUrl = url.split('?')[0].split('#')[0];
44
+ const lastDot = cleanUrl.lastIndexOf('.');
45
+ if (lastDot === -1) return null;
46
+ const ext = cleanUrl.slice(lastDot).toLowerCase();
47
+ return EXTENSION_AS_MAP[ext] || null;
48
+ }
49
+
50
+ /**
51
+ * Formats a single link candidate into an RFC 8288 Link header string
52
+ * @param {string|Object} item - URL string or link options object
53
+ * @returns {string|null}
54
+ */
55
+ function formatLink(item) {
56
+ if (!item) return null;
57
+
58
+ // Already a serialized RFC 8288 Link header
59
+ if (typeof item === 'string') {
60
+ const trimmed = item.trim();
61
+ if (!trimmed) return null;
62
+ if (trimmed.startsWith('<') && trimmed.includes('>')) {
63
+ return trimmed;
64
+ }
65
+ const deducedAs = inferAsType(trimmed);
66
+ const parts = [`<${trimmed}>`, 'rel=preload'];
67
+ if (deducedAs) {
68
+ parts.push(`as=${deducedAs}`);
69
+ if (deducedAs === 'font') {
70
+ parts.push('crossorigin');
71
+ }
72
+ }
73
+ return parts.join('; ');
74
+ }
75
+
76
+ if (typeof item === 'object') {
77
+ const rawUrl = item.url || item.href;
78
+ if (!rawUrl || typeof rawUrl !== 'string') return null;
79
+ const url = rawUrl.trim();
80
+ if (!url) return null;
81
+
82
+ const rel = item.rel || 'preload';
83
+ const parts = [`<${url}>`, `rel=${rel}`];
84
+
85
+ const asType = item.as || (rel === 'preload' ? inferAsType(url) : null);
86
+ if (asType) {
87
+ parts.push(`as=${asType}`);
88
+ }
89
+
90
+ const co = item.crossOrigin !== undefined ? item.crossOrigin : item.crossorigin;
91
+ if (co === true || co === '' || co === 'anonymous') {
92
+ parts.push('crossorigin');
93
+ } else if (co === 'use-credentials') {
94
+ parts.push('crossorigin=use-credentials');
95
+ } else if (asType === 'font' && rel === 'preload' && co === undefined) {
96
+ // Browsers require CORS for font preloads regardless of origin
97
+ parts.push('crossorigin');
98
+ }
99
+
100
+ if (item.type) {
101
+ parts.push(`type="${item.type}"`);
102
+ }
103
+
104
+ if (item.media) {
105
+ parts.push(`media="${item.media}"`);
106
+ }
107
+
108
+ const fetchPriority = item.fetchPriority || item.fetchpriority;
109
+ if (fetchPriority) {
110
+ parts.push(`fetchpriority=${fetchPriority}`);
111
+ }
112
+
113
+ const noPush = item.noPush !== undefined ? item.noPush : item.nopush;
114
+ if (noPush) {
115
+ parts.push('nopush');
116
+ }
117
+
118
+ return parts.join('; ');
119
+ }
120
+
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Normalizes one or more link items into an array of RFC 8288 Link header strings
126
+ * @param {string|Object|Array<string|Object>} links
127
+ * @returns {string[]}
128
+ */
129
+ function formatLinkHeader(links) {
130
+ if (!links) return [];
131
+ const list = Array.isArray(links) ? links : [links];
132
+ const out = [];
133
+
134
+ for (const item of list) {
135
+ if (!item) continue;
136
+ if (typeof item === 'string' && item.includes(',') && !item.startsWith('<')) {
137
+ // Comma-separated list of simple URLs
138
+ const splitUrls = item.split(',').map((s) => s.trim()).filter(Boolean);
139
+ for (const u of splitUrls) {
140
+ const formatted = formatLink(u);
141
+ if (formatted) out.push(formatted);
142
+ }
143
+ continue;
144
+ }
145
+ const formatted = formatLink(item);
146
+ if (formatted) {
147
+ out.push(formatted);
148
+ }
149
+ }
150
+
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Safely sends HTTP 103 Early Hints before the final response is generated.
156
+ * Handles Node.js runtime variations, headersSent state, and socket closure gracefully.
157
+ *
158
+ * @param {import('http').ServerResponse} res - Node HTTP ServerResponse
159
+ * @param {string|Object|Array<string|Object>} hints - Links or hints map
160
+ * @param {Function} [callback] - Optional completion callback
161
+ * @returns {boolean} Whether Early Hints were dispatched
162
+ */
163
+ function sendEarlyHints(res, hints, callback) {
164
+ const cb = typeof callback === 'function' ? callback : null;
165
+
166
+ if (!res || typeof res.writeEarlyHints !== 'function') {
167
+ if (cb) process.nextTick(cb);
168
+ return false;
169
+ }
170
+
171
+ if (res.headersSent || res.writableEnded || res.destroyed) {
172
+ if (cb) process.nextTick(cb);
173
+ return false;
174
+ }
175
+
176
+ let payload = null;
177
+
178
+ if (Array.isArray(hints) || typeof hints === 'string') {
179
+ const formattedLinks = formatLinkHeader(hints);
180
+ if (formattedLinks.length === 0) {
181
+ if (cb) process.nextTick(cb);
182
+ return false;
183
+ }
184
+ payload = { link: formattedLinks };
185
+ } else if (typeof hints === 'object' && hints !== null) {
186
+ if (hints.url || hints.href) {
187
+ const formatted = formatLink(hints);
188
+ if (!formatted) {
189
+ if (cb) process.nextTick(cb);
190
+ return false;
191
+ }
192
+ payload = { link: [formatted] };
193
+ } else {
194
+ payload = { ...hints };
195
+ const rawLink = payload.link || payload.Link;
196
+ if (rawLink) {
197
+ const formatted = formatLinkHeader(rawLink);
198
+ if (formatted.length > 0) {
199
+ payload.link = formatted;
200
+ if (payload.Link) delete payload.Link;
201
+ }
202
+ }
203
+ }
204
+ } else {
205
+ if (cb) process.nextTick(cb);
206
+ return false;
207
+ }
208
+
209
+ try {
210
+ res.writeEarlyHints(payload, (err) => {
211
+ if (cb) cb(err || null);
212
+ });
213
+ return true;
214
+ } catch (err) {
215
+ if (cb) cb(err);
216
+ return false;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Creates an Express middleware for HTTP 103 Early Hints
222
+ * Attaches `res.earlyHints(links, cb)` to every response and optionally sends default hints on GET/HEAD requests.
223
+ *
224
+ * @param {boolean|Object} [options={}] - Early hints configuration
225
+ * @param {boolean} [options.enabled=true] - Whether early hints are enabled
226
+ * @param {string[]|Function} [options.links] - Default preload links for all GET/HEAD requests
227
+ * @returns {Function} Express middleware
228
+ */
229
+ function createEarlyHintsMiddleware(options = {}) {
230
+ const opts = typeof options === 'boolean' ? { enabled: options } : (options || {});
231
+ const enabled = opts.enabled !== false;
232
+ const defaultLinks = opts.links || null;
233
+
234
+ return function earlyHintsMiddleware(req, res, next) {
235
+ // Attach res.earlyHints helper (available regardless of whether global auto hints are active)
236
+ if (!res.earlyHints) {
237
+ res.earlyHints = function earlyHintsHelper(links, cb) {
238
+ return sendEarlyHints(res, links, cb);
239
+ };
240
+ }
241
+
242
+ if (enabled && defaultLinks && (req.method === 'GET' || req.method === 'HEAD')) {
243
+ try {
244
+ const linksToSend = typeof defaultLinks === 'function' ? defaultLinks(req) : defaultLinks;
245
+ if (linksToSend) {
246
+ sendEarlyHints(res, linksToSend);
247
+ }
248
+ } catch (err) {
249
+ // Early hints must never break normal request flow
250
+ if (process.env.NODE_ENV === 'development') {
251
+ console.warn('[EarlyHints] Failed to evaluate default links:', err.message);
252
+ }
253
+ }
254
+ }
255
+
256
+ next();
257
+ };
258
+ }
259
+
260
+ module.exports = {
261
+ EXTENSION_AS_MAP,
262
+ inferAsType,
263
+ formatLink,
264
+ formatLinkHeader,
265
+ sendEarlyHints,
266
+ createEarlyHintsMiddleware,
267
+ };
@@ -180,6 +180,51 @@ class RouteGenerationError extends RouterError {
180
180
  }
181
181
  }
182
182
 
183
+ /**
184
+ * Circuit Breaker Open Error
185
+ * Thrown when an invocation is rejected because the circuit is in the OPEN state.
186
+ * Maps to HTTP 503 Service Unavailable.
187
+ */
188
+ class CircuitBreakerOpenError extends HttpError {
189
+ /**
190
+ * @param {string} [breakerName='default']
191
+ * @param {number} [resetTimeoutMs=0]
192
+ * @param {Object} [options]
193
+ */
194
+ constructor(breakerName = 'default', resetTimeoutMs = 0, options = {}) {
195
+ super(503, `Circuit breaker '${breakerName}' is OPEN and failing fast`, {
196
+ code: 'CIRCUIT_BREAKER_OPEN',
197
+ expose: true,
198
+ ...options,
199
+ });
200
+ this.breakerName = breakerName;
201
+ this.resetTimeoutMs = resetTimeoutMs;
202
+ this.state = 'OPEN';
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Circuit Breaker Timeout Error
208
+ * Thrown when execution inside a circuit breaker exceeds timeout.
209
+ * Maps to HTTP 504 Gateway Timeout.
210
+ */
211
+ class CircuitBreakerTimeoutError extends HttpError {
212
+ /**
213
+ * @param {string} [breakerName='default']
214
+ * @param {number} [timeoutMs=0]
215
+ * @param {Object} [options]
216
+ */
217
+ constructor(breakerName = 'default', timeoutMs = 0, options = {}) {
218
+ super(504, `Execution in circuit breaker '${breakerName}' timed out after ${timeoutMs}ms`, {
219
+ code: 'CIRCUIT_BREAKER_TIMEOUT',
220
+ expose: true,
221
+ ...options,
222
+ });
223
+ this.breakerName = breakerName;
224
+ this.timeoutMs = timeoutMs;
225
+ }
226
+ }
227
+
183
228
  module.exports = {
184
229
  ValidationError,
185
230
  ConfigurationError,
@@ -190,4 +235,7 @@ module.exports = {
190
235
  RouterError,
191
236
  RouteNotFoundError,
192
237
  RouteGenerationError,
238
+ CircuitBreakerOpenError,
239
+ CircuitBreakerTimeoutError,
193
240
  };
241
+