webspresso 0.0.104 → 0.0.105
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +1 -1
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +8 -17
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +71 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +120 -19
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +616 -1
- package/index.js +148 -0
- package/package.json +1 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/csrf/index.js +5 -9
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +9 -7
- package/plugins/rls/index.js +170 -0
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +4 -3
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/file-router.js +1 -3
- package/src/helpers.js +159 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +136 -25
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- package/src/template/precompile.js +8 -19
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
};
|
package/core/errors/domain.js
CHANGED
|
@@ -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
|
+
|