webspresso 0.0.102 → 0.0.104

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 (91) hide show
  1. package/.agents/ORM.md +13 -4
  2. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  3. package/README.md +2 -2
  4. package/bin/commands/dev.js +3 -2
  5. package/bin/utils/orm-map-html.js +2 -8
  6. package/core/auth/jwt.js +12 -21
  7. package/core/auth/oauth.js +38 -14
  8. package/core/auth/totp.js +3 -15
  9. package/core/auth/user-agent.js +59 -60
  10. package/core/compression/compression-stream.js +2 -2
  11. package/core/compression/middleware.js +1 -1
  12. package/core/compression/negotiate.js +11 -4
  13. package/core/content/renderer.js +1 -13
  14. package/core/errors/middleware.js +2 -8
  15. package/core/events/dispatcher.js +56 -33
  16. package/core/forms/form-builder.js +1 -9
  17. package/core/openapi/orm-components.js +2 -3
  18. package/core/orm/cache/listeners.js +12 -2
  19. package/core/orm/index.js +5 -0
  20. package/core/orm/json-fields.js +7 -1
  21. package/core/orm/model.js +99 -8
  22. package/core/orm/query-builder.js +71 -25
  23. package/core/orm/query-filters.js +393 -0
  24. package/core/orm/repository.js +42 -17
  25. package/core/orm/schema-helpers.js +49 -0
  26. package/core/orm/scopes.js +40 -9
  27. package/core/orm/types.js +12 -1
  28. package/core/queue/manager.js +5 -0
  29. package/core/shutdown/shutdown-manager.js +16 -16
  30. package/core/utils/crypto.js +73 -0
  31. package/core/utils/escape-html.js +7 -0
  32. package/core/utils/fetch.js +120 -0
  33. package/core/utils/get-client-ip.js +6 -0
  34. package/core/utils/html.js +72 -0
  35. package/core/utils/ip.js +86 -0
  36. package/core/utils/safe-compare.js +3 -0
  37. package/core/validation/index.js +9 -3
  38. package/index.d.ts +33 -6
  39. package/index.js +10 -0
  40. package/package.json +2 -1
  41. package/plugins/admin-panel/auth.js +15 -7
  42. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
  43. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
  44. package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
  45. package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
  46. package/plugins/admin-panel/core/api-extensions.js +1 -75
  47. package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
  48. package/plugins/api-key/crypto.js +1 -24
  49. package/plugins/audit-log/api-handlers.js +11 -1
  50. package/plugins/audit-log/middleware.js +50 -6
  51. package/plugins/basic-auth/index.js +2 -26
  52. package/plugins/cors.js +2 -2
  53. package/plugins/csrf/index.js +3 -15
  54. package/plugins/email/service.js +32 -1
  55. package/plugins/file-manager/admin-component.js +3 -1
  56. package/plugins/file-manager/api-handlers.js +10 -17
  57. package/plugins/file-manager/core.js +34 -4
  58. package/plugins/file-manager/index.js +11 -9
  59. package/plugins/file-manager/security.js +18 -6
  60. package/plugins/mcp/auth.js +1 -13
  61. package/plugins/mcp/index.js +23 -1
  62. package/plugins/mcp/transports/sse.js +10 -1
  63. package/plugins/orm-cache-admin/admin-component.js +3 -2
  64. package/plugins/polar/src/api.js +9 -2
  65. package/plugins/polar/src/webhooks.js +3 -8
  66. package/plugins/recaptcha/helpers.js +1 -7
  67. package/plugins/recaptcha/verify.js +37 -23
  68. package/plugins/redirect/index.js +51 -3
  69. package/plugins/rest-resources/index.js +1 -207
  70. package/plugins/schema-explorer/admin-component.js +4 -2
  71. package/plugins/schema-explorer.js +40 -29
  72. package/plugins/seo-checker/index.js +5 -2
  73. package/plugins/site-analytics/bot-patterns.js +3 -0
  74. package/plugins/site-analytics/client-error-handler.js +3 -1
  75. package/plugins/site-analytics/index.js +10 -1
  76. package/plugins/site-analytics/tracking.js +265 -63
  77. package/plugins/sitemap.js +18 -15
  78. package/plugins/swagger.js +9 -14
  79. package/plugins/webhooks/crypto.js +1 -18
  80. package/plugins/webhooks/dispatcher.js +6 -1
  81. package/plugins/webhooks/matcher.js +25 -10
  82. package/plugins/webhooks/sender.js +32 -2
  83. package/src/api/api-loader.js +1 -1
  84. package/src/dev-reload/index.js +47 -6
  85. package/src/discovery/scan-routes.js +14 -4
  86. package/src/file-router.js +62 -6
  87. package/src/helpers.js +157 -32
  88. package/src/plugin-manager.js +34 -13
  89. package/src/routing/route-table.js +14 -4
  90. package/src/server.js +25 -9
  91. package/src/services/validator.js +57 -32
@@ -32,7 +32,8 @@ const { signPayload } = require('./crypto');
32
32
  * timestamp: number
33
33
  * }>}
34
34
  */
35
- async function sendWebhook(options) {
35
+ async function sendWebhook(options = {}) {
36
+ const opts = options || {};
36
37
  const {
37
38
  url,
38
39
  event,
@@ -43,7 +44,7 @@ async function sendWebhook(options) {
43
44
  timeoutMs = 10000,
44
45
  wrapPayload = true,
45
46
  maxResponseBodyLength = 2048,
46
- } = options;
47
+ } = opts;
47
48
 
48
49
  if (!url || typeof url !== 'string') {
49
50
  return {
@@ -57,6 +58,32 @@ async function sendWebhook(options) {
57
58
  };
58
59
  }
59
60
 
61
+ let parsedUrl;
62
+ try {
63
+ parsedUrl = new URL(url);
64
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
65
+ return {
66
+ success: false,
67
+ statusCode: 0,
68
+ responseBody: '',
69
+ durationMs: 0,
70
+ error: `Invalid webhook URL protocol: "${parsedUrl.protocol}". Only http: and https: are supported.`,
71
+ deliveryId,
72
+ timestamp: Math.floor(Date.now() / 1000),
73
+ };
74
+ }
75
+ } catch (err) {
76
+ return {
77
+ success: false,
78
+ statusCode: 0,
79
+ responseBody: '',
80
+ durationMs: 0,
81
+ error: 'Invalid webhook URL: destination URL is malformed.',
82
+ deliveryId,
83
+ timestamp: Math.floor(Date.now() / 1000),
84
+ };
85
+ }
86
+
60
87
  const timestamp = Math.floor(Date.now() / 1000);
61
88
 
62
89
  // Construct request body
@@ -116,6 +143,9 @@ async function sendWebhook(options) {
116
143
  timeoutId = setTimeout(() => {
117
144
  controller.abort();
118
145
  }, timeoutMs);
146
+ if (timeoutId && timeoutId.unref) {
147
+ timeoutId.unref();
148
+ }
119
149
 
120
150
  const response = await fetch(url, {
121
151
  method: 'POST',
@@ -95,7 +95,7 @@ function createApiHandler(descriptor, context) {
95
95
  // 1. Reload module in development
96
96
  if (isDev) {
97
97
  try {
98
- const resolvedPath = require.resolve(descriptor.file);
98
+ const resolvedPath = descriptor._resolvedPath || (descriptor._resolvedPath = require.resolve(descriptor.file));
99
99
  if (require.cache[resolvedPath]) {
100
100
  delete require.cache[resolvedPath];
101
101
  cachedMiddlewares = null;
@@ -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
  }
@@ -94,6 +94,19 @@ function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
94
94
  return results;
95
95
  }
96
96
 
97
+ let isWebspressoPackageCache = null;
98
+ function checkIsWebspressoPackage() {
99
+ if (isWebspressoPackageCache === null) {
100
+ try {
101
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
102
+ isWebspressoPackageCache = Boolean(pkg && pkg.name === 'webspresso');
103
+ } catch (_) {
104
+ isWebspressoPackageCache = false;
105
+ }
106
+ }
107
+ return isWebspressoPackageCache;
108
+ }
109
+
97
110
  /**
98
111
  * Scans configured pages, api, and module directories
99
112
  * @param {Object} options
@@ -106,10 +119,7 @@ function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
106
119
  function scanRoutes(options = {}) {
107
120
  const rootDir = options.rootDir || (() => {
108
121
  if (process.env.NODE_ENV === 'test') {
109
- try {
110
- const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
111
- if (pkg.name === 'webspresso') return process.cwd();
112
- } catch (_) {}
122
+ if (checkIsWebspressoPackage()) return process.cwd();
113
123
  return path.resolve(__dirname, '../..');
114
124
  }
115
125
  return process.cwd();
@@ -190,6 +190,10 @@ const translatorCache = new Map();
190
190
 
191
191
  function clearTranslatorCache() {
192
192
  translatorCache.clear();
193
+ intlNumberFormatCache.clear();
194
+ intlDateTimeFormatCache.clear();
195
+ intlRelativeTimeFormatCache.clear();
196
+ filePathToRouteCache.clear();
193
197
  }
194
198
 
195
199
  // Cache for route configs in production
@@ -215,6 +219,48 @@ const methodFromFilenameCache = new Map();
215
219
  // Cache for translation param replacement regexes
216
220
  const paramRegexCache = new Map();
217
221
 
222
+ // Caches for file-to-route resolution and Intl formatters
223
+ const filePathToRouteCache = new Map();
224
+ const intlNumberFormatCache = new Map();
225
+ const intlDateTimeFormatCache = new Map();
226
+ const intlRelativeTimeFormatCache = new Map();
227
+
228
+ function getCachedNumberFormat(locale, opts) {
229
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
230
+ let fmt = intlNumberFormatCache.get(key);
231
+ if (!fmt) {
232
+ fmt = new Intl.NumberFormat(locale, opts);
233
+ if (intlNumberFormatCache.size < 500) {
234
+ intlNumberFormatCache.set(key, fmt);
235
+ }
236
+ }
237
+ return fmt;
238
+ }
239
+
240
+ function getCachedDateTimeFormat(locale, opts) {
241
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
242
+ let fmt = intlDateTimeFormatCache.get(key);
243
+ if (!fmt) {
244
+ fmt = new Intl.DateTimeFormat(locale, opts);
245
+ if (intlDateTimeFormatCache.size < 500) {
246
+ intlDateTimeFormatCache.set(key, fmt);
247
+ }
248
+ }
249
+ return fmt;
250
+ }
251
+
252
+ function getCachedRelativeTimeFormat(locale, opts) {
253
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
254
+ let fmt = intlRelativeTimeFormatCache.get(key);
255
+ if (!fmt) {
256
+ fmt = new Intl.RelativeTimeFormat(locale, opts);
257
+ if (intlRelativeTimeFormatCache.size < 500) {
258
+ intlRelativeTimeFormatCache.set(key, fmt);
259
+ }
260
+ }
261
+ return fmt;
262
+ }
263
+
218
264
  const MAX_LOCALE_LEN = 16;
219
265
 
220
266
  let cachedSupportedLocalesRaw = null;
@@ -404,6 +450,12 @@ function isApiRouteFile(file) {
404
450
  * @returns {string} Express route pattern
405
451
  */
406
452
  function filePathToRoute(filePath, ext) {
453
+ const cacheKey = filePath + '::' + ext;
454
+ const cached = filePathToRouteCache.get(cacheKey);
455
+ if (cached !== undefined) {
456
+ return cached;
457
+ }
458
+
407
459
  // Remove extension
408
460
  let route = filePath.replace(ext, '');
409
461
 
@@ -429,6 +481,10 @@ function filePathToRoute(filePath, ext) {
429
481
  route = '/' + route;
430
482
  }
431
483
 
484
+ if (filePathToRouteCache.size < 5000) {
485
+ filePathToRouteCache.set(cacheKey, route);
486
+ }
487
+
432
488
  return route;
433
489
  }
434
490
 
@@ -886,8 +942,8 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
886
942
  return value;
887
943
  }
888
944
 
889
- // Replace params like {{name}} in the translation
890
- if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
945
+ // Replace params like {{name}} in the translation (skip if no placeholders in template)
946
+ if (value.includes('{{') && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
891
947
  for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
892
948
  let regex = paramRegexCache.get(paramKey);
893
949
  if (regex === undefined) {
@@ -911,7 +967,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
911
967
  t.number = function formatNumber(num, numOpts) {
912
968
  if (num === null || num === undefined || Number.isNaN(Number(num))) return '';
913
969
  try {
914
- return new Intl.NumberFormat(locale, numOpts).format(Number(num));
970
+ return getCachedNumberFormat(locale, numOpts).format(Number(num));
915
971
  } catch {
916
972
  return String(num);
917
973
  }
@@ -921,7 +977,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
921
977
  t.currency = function formatCurrency(amount, currency = 'USD', currOpts = {}) {
922
978
  if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '';
923
979
  try {
924
- return new Intl.NumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
980
+ return getCachedNumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
925
981
  } catch {
926
982
  return `${amount} ${currency}`;
927
983
  }
@@ -933,7 +989,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
933
989
  try {
934
990
  const d = date instanceof Date ? date : new Date(date);
935
991
  if (Number.isNaN(d.getTime())) return '';
936
- return new Intl.DateTimeFormat(locale, dateOpts).format(d);
992
+ return getCachedDateTimeFormat(locale, dateOpts).format(d);
937
993
  } catch {
938
994
  return String(date);
939
995
  }
@@ -943,7 +999,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
943
999
  t.relativeTime = function formatRelativeTime(val, unit = 'day', relOpts = { numeric: 'auto' }) {
944
1000
  if (val === null || val === undefined || Number.isNaN(Number(val))) return '';
945
1001
  try {
946
- return new Intl.RelativeTimeFormat(locale, relOpts).format(Number(val), unit);
1002
+ return getCachedRelativeTimeFormat(locale, relOpts).format(Number(val), unit);
947
1003
  } catch {
948
1004
  return `${val} ${unit}`;
949
1005
  }
package/src/helpers.js CHANGED
@@ -21,6 +21,25 @@ dayjs.extend(customParseFormat);
21
21
  // Cache for isPath regex patterns
22
22
  const isPathRegexCache = new Map();
23
23
 
24
+ // Caches for manifest files and filesystem existence checks
25
+ const manifestFileCache = new Map();
26
+ const diskExistenceCache = new Map();
27
+
28
+ function cachedExistsSync(filePath) {
29
+ const cached = diskExistenceCache.get(filePath);
30
+ if (cached !== undefined) return cached;
31
+ let exists = false;
32
+ try {
33
+ exists = fs.existsSync(filePath);
34
+ } catch (_) {
35
+ exists = false;
36
+ }
37
+ if (diskExistenceCache.size < 2000) {
38
+ diskExistenceCache.set(filePath, exists);
39
+ }
40
+ return exists;
41
+ }
42
+
24
43
  /**
25
44
  * Asset Manager - handles asset paths, versioning, and manifest
26
45
  */
@@ -34,6 +53,12 @@ class AssetManager {
34
53
  this.version = options.version || null;
35
54
  this.prefix = options.prefix || '';
36
55
 
56
+ // Per-instance fast-path caches
57
+ this._resolveCache = new Map();
58
+ this._hasJsCache = new Map();
59
+ this._hasCssCache = new Map();
60
+ this._lookupIndex = null;
61
+
37
62
  // Auto-detect manifest if not explicitly provided
38
63
  if (!this.manifestPath) {
39
64
  const candidates = [
@@ -42,7 +67,7 @@ class AssetManager {
42
67
  path.join(this.publicDir, '.vite', 'manifest.json'),
43
68
  ];
44
69
  for (const candidate of candidates) {
45
- if (fs.existsSync(candidate)) {
70
+ if (cachedExistsSync(candidate)) {
46
71
  this.manifestPath = candidate;
47
72
  break;
48
73
  }
@@ -54,6 +79,25 @@ class AssetManager {
54
79
  this.loadManifest();
55
80
  }
56
81
  }
82
+
83
+ /**
84
+ * Pre-build lookup index for fast O(1) fallback matching
85
+ */
86
+ _buildLookupIndex() {
87
+ if (!this.manifest || typeof this.manifest !== 'object') {
88
+ this._lookupIndex = null;
89
+ return;
90
+ }
91
+ const idx = new Map();
92
+ for (const [key, val] of Object.entries(this.manifest)) {
93
+ idx.set(key, val);
94
+ const base = path.basename(key);
95
+ if (!idx.has(base)) {
96
+ idx.set(base, val);
97
+ }
98
+ }
99
+ this._lookupIndex = idx;
100
+ }
57
101
 
58
102
  /**
59
103
  * Load asset manifest file (Vite, Webpack, etc.)
@@ -62,12 +106,35 @@ class AssetManager {
62
106
  try {
63
107
  const fullPath = path.resolve(this.manifestPath);
64
108
  if (fs.existsSync(fullPath)) {
65
- const content = fs.readFileSync(fullPath, 'utf-8');
66
- this.manifest = JSON.parse(content);
109
+ try {
110
+ const stats = fs.statSync(fullPath);
111
+ const cached = manifestFileCache.get(fullPath);
112
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
113
+ this.manifest = cached.manifest;
114
+ this._lookupIndex = cached.lookupIndex;
115
+ return;
116
+ }
117
+ const content = fs.readFileSync(fullPath, 'utf-8');
118
+ this.manifest = JSON.parse(content);
119
+ this._buildLookupIndex();
120
+ manifestFileCache.set(fullPath, {
121
+ mtimeMs: stats.mtimeMs,
122
+ size: stats.size,
123
+ manifest: this.manifest,
124
+ lookupIndex: this._lookupIndex,
125
+ });
126
+ return;
127
+ } catch (_) {
128
+ const content = fs.readFileSync(fullPath, 'utf-8');
129
+ this.manifest = JSON.parse(content);
130
+ this._buildLookupIndex();
131
+ return;
132
+ }
67
133
  }
68
134
  } catch (err) {
69
135
  console.warn('Failed to load asset manifest:', err.message);
70
136
  this.manifest = null;
137
+ this._lookupIndex = null;
71
138
  }
72
139
  }
73
140
 
@@ -77,6 +144,9 @@ class AssetManager {
77
144
  * @returns {string}
78
145
  */
79
146
  resolve(assetPath) {
147
+ const cached = this._resolveCache.get(assetPath);
148
+ if (cached !== undefined) return cached;
149
+
80
150
  // Remove leading slash for manifest lookup
81
151
  const lookupPath = assetPath.replace(/^\//, '');
82
152
 
@@ -85,7 +155,12 @@ class AssetManager {
85
155
  // 1. Direct match with lookupPath or assetPath
86
156
  let entry = this.manifest[lookupPath] || this.manifest[assetPath];
87
157
 
88
- // 2. Flexible fallback: match by basename or trailing path
158
+ // 2. Fast lookup index match
159
+ if (!entry && this._lookupIndex) {
160
+ entry = this._lookupIndex.get(lookupPath) || this._lookupIndex.get(path.basename(lookupPath));
161
+ }
162
+
163
+ // 3. Flexible fallback: match by basename or trailing path
89
164
  if (!entry) {
90
165
  const baseName = path.basename(lookupPath);
91
166
  for (const [key, val] of Object.entries(this.manifest)) {
@@ -102,14 +177,20 @@ class AssetManager {
102
177
  }
103
178
 
104
179
  if (entry) {
180
+ let result;
105
181
  // Webpack string format (e.g. "/dist/app.xyz789.js")
106
182
  if (typeof entry === 'string') {
107
- return this.prefix + entry;
108
- }
109
- // Vite / esbuild metafile format (e.g. { file: "assets/app-xyz789.js" })
110
- if (entry.file) {
183
+ result = this.prefix + entry;
184
+ } else if (entry.file) {
185
+ // Vite / esbuild metafile format (e.g. { file: "assets/app-xyz789.js" })
111
186
  const file = entry.file.startsWith('/') ? entry.file : '/' + entry.file;
112
- return this.prefix ? (this.prefix + file).replace(/\/+/g, '/') : file;
187
+ result = this.prefix ? (this.prefix + file).replace(/\/+/g, '/') : file;
188
+ }
189
+ if (result !== undefined) {
190
+ if (this._resolveCache.size < 2000) {
191
+ this._resolveCache.set(assetPath, result);
192
+ }
193
+ return result;
113
194
  }
114
195
  }
115
196
  }
@@ -123,6 +204,9 @@ class AssetManager {
123
204
  finalPath += `${separator}v=${this.version}`;
124
205
  }
125
206
 
207
+ if (this._resolveCache.size < 2000) {
208
+ this._resolveCache.set(assetPath, finalPath);
209
+ }
126
210
  return finalPath;
127
211
  }
128
212
 
@@ -136,8 +220,11 @@ class AssetManager {
136
220
  /**
137
221
  * Generate CSS link tag
138
222
  */
139
- css(href, attributes = {}) {
223
+ css(href, attributes) {
140
224
  const resolvedHref = this.resolve(href);
225
+ if (!attributes || Object.keys(attributes).length === 0) {
226
+ return `<link rel="stylesheet" href="${this.escapeHtml(resolvedHref)}">`;
227
+ }
141
228
  const attrs = this.buildAttributes({
142
229
  rel: 'stylesheet',
143
230
  href: resolvedHref,
@@ -149,8 +236,11 @@ class AssetManager {
149
236
  /**
150
237
  * Generate JS script tag
151
238
  */
152
- js(src, attributes = {}) {
239
+ js(src, attributes) {
153
240
  const resolvedSrc = this.resolve(src);
241
+ if (!attributes || Object.keys(attributes).length === 0) {
242
+ return `<script src="${this.escapeHtml(resolvedSrc)}"></script>`;
243
+ }
154
244
  const attrs = this.buildAttributes({
155
245
  src: resolvedSrc,
156
246
  ...attributes
@@ -161,8 +251,11 @@ class AssetManager {
161
251
  /**
162
252
  * Generate image tag
163
253
  */
164
- img(src, alt = '', attributes = {}) {
254
+ img(src, alt = '', attributes) {
165
255
  const resolvedSrc = this.resolve(src);
256
+ if (!attributes || Object.keys(attributes).length === 0) {
257
+ return `<img src="${this.escapeHtml(resolvedSrc)}" alt="${this.escapeHtml(alt)}">`;
258
+ }
166
259
  const attrs = this.buildAttributes({
167
260
  src: resolvedSrc,
168
261
  alt,
@@ -325,6 +418,9 @@ class AssetManager {
325
418
  */
326
419
  hasPageJs(name) {
327
420
  if (!name || typeof name !== 'string') return false;
421
+ const cached = this._hasJsCache.get(name);
422
+ if (cached !== undefined) return cached;
423
+
328
424
  let clean = String(name).trim().replace(/^\/+/, '');
329
425
  if (clean.startsWith('pages/')) clean = clean.slice(6);
330
426
  if (clean.startsWith('dist/pages/')) clean = clean.slice(11);
@@ -343,6 +439,7 @@ class AssetManager {
343
439
  this.manifest['src/client/' + pagePath] ||
344
440
  this.manifest['client/' + pagePath]
345
441
  ) {
442
+ if (this._hasJsCache.size < 500) this._hasJsCache.set(name, true);
346
443
  return true;
347
444
  }
348
445
  }
@@ -369,13 +466,13 @@ class AssetManager {
369
466
  ];
370
467
 
371
468
  for (const p of diskChecks) {
372
- try {
373
- if (fs.existsSync(p)) {
374
- return true;
375
- }
376
- } catch (_) {}
469
+ if (cachedExistsSync(p)) {
470
+ if (this._hasJsCache.size < 500) this._hasJsCache.set(name, true);
471
+ return true;
472
+ }
377
473
  }
378
474
 
475
+ if (this._hasJsCache.size < 500) this._hasJsCache.set(name, false);
379
476
  return false;
380
477
  }
381
478
 
@@ -386,6 +483,9 @@ class AssetManager {
386
483
  */
387
484
  hasPageCss(name) {
388
485
  if (!name || typeof name !== 'string') return false;
486
+ const cached = this._hasCssCache.get(name);
487
+ if (cached !== undefined) return cached;
488
+
389
489
  let clean = String(name).trim().replace(/^\/+/, '');
390
490
  if (clean.startsWith('pages/')) clean = clean.slice(6);
391
491
  if (clean.startsWith('dist/pages/')) clean = clean.slice(11);
@@ -404,6 +504,7 @@ class AssetManager {
404
504
  this.manifest['src/client/' + pagePath] ||
405
505
  this.manifest['client/' + pagePath]
406
506
  ) {
507
+ if (this._hasCssCache.size < 500) this._hasCssCache.set(name, true);
407
508
  return true;
408
509
  }
409
510
  }
@@ -426,13 +527,13 @@ class AssetManager {
426
527
  ];
427
528
 
428
529
  for (const p of diskChecks) {
429
- try {
430
- if (fs.existsSync(p)) {
431
- return true;
432
- }
433
- } catch (_) {}
530
+ if (cachedExistsSync(p)) {
531
+ if (this._hasCssCache.size < 500) this._hasCssCache.set(name, true);
532
+ return true;
533
+ }
434
534
  }
435
535
 
536
+ if (this._hasCssCache.size < 500) this._hasCssCache.set(name, false);
436
537
  return false;
437
538
  }
438
539
 
@@ -507,22 +608,26 @@ class AssetManager {
507
608
  * Build HTML attributes string
508
609
  */
509
610
  buildAttributes(attrs) {
510
- return Object.entries(attrs)
511
- .filter(([k, v]) => v !== undefined && v !== null && v !== false && /^[a-zA-Z0-9_\-:]+$/.test(k))
512
- .map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`)
513
- .join(' ');
611
+ if (!attrs || typeof attrs !== 'object') return '';
612
+ let result = '';
613
+ const keys = Object.keys(attrs);
614
+ for (let i = 0; i < keys.length; i++) {
615
+ const k = keys[i];
616
+ const v = attrs[k];
617
+ if (v === undefined || v === null || v === false || !/^[a-zA-Z0-9_\-:]+$/.test(k)) {
618
+ continue;
619
+ }
620
+ if (result) result += ' ';
621
+ result += v === true ? k : `${k}="${this.escapeHtml(String(v))}"`;
622
+ }
623
+ return result;
514
624
  }
515
625
 
516
626
  /**
517
627
  * Escape HTML special characters
518
628
  */
519
629
  escapeHtml(str) {
520
- return str
521
- .replace(/&/g, '&amp;')
522
- .replace(/"/g, '&quot;')
523
- .replace(/'/g, '&#39;')
524
- .replace(/</g, '&lt;')
525
- .replace(/>/g, '&gt;');
630
+ return require('../core/utils/html').escapeHtml(str);
526
631
  }
527
632
  }
528
633
 
@@ -2091,6 +2196,26 @@ const utils = {
2091
2196
 
2092
2197
  pageAssets(nameOrAttrs, attrs) {
2093
2198
  return getAssetManager().pageAssets(nameOrAttrs, attrs);
2199
+ },
2200
+
2201
+ fetchWithTimeout(url, options) {
2202
+ return require('../core/utils/fetch').fetchWithTimeout(url, options);
2203
+ },
2204
+
2205
+ safeCompare(a, b) {
2206
+ return require('../core/utils/crypto').safeCompare(a, b);
2207
+ },
2208
+
2209
+ escapeHtml(value) {
2210
+ return require('../core/utils/html').escapeHtml(value);
2211
+ },
2212
+
2213
+ unescapeHtml(value) {
2214
+ return require('../core/utils/html').unescapeHtml(value);
2215
+ },
2216
+
2217
+ getClientIp(req, fallback) {
2218
+ return require('../core/utils/ip').getClientIp(req, fallback);
2094
2219
  }
2095
2220
  };
2096
2221