webspresso 0.0.106 → 0.0.108

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 (118) hide show
  1. package/.agents/AGENTS.md +4 -1
  2. package/.agents/CACHE.md +1 -0
  3. package/.agents/CONTEXT.md +152 -0
  4. package/.agents/ORM.md +74 -0
  5. package/.agents/SHUTDOWN.md +8 -1
  6. package/.agents/skills/webspresso-framework/SKILL.md +2 -1
  7. package/.agents/skills/webspresso-orm-and-database/SKILL.md +9 -0
  8. package/.agents/skills/webspresso-routing-and-api/SKILL.md +18 -2
  9. package/.agents/skills/webspresso-services/SKILL.md +36 -3
  10. package/AGENTS.md +6 -3
  11. package/bin/webspresso.js +17 -7
  12. package/core/app.js +45 -11
  13. package/core/applySchema.js +3 -72
  14. package/core/auth/index.js +4 -1
  15. package/core/auth/jwt.js +85 -13
  16. package/core/auth/magic-link.js +23 -5
  17. package/core/auth/manager.js +7 -1
  18. package/core/auth/redis-session.js +62 -25
  19. package/core/auth/session-manager.js +28 -2
  20. package/core/auth/sessions.js +182 -52
  21. package/core/cache/lru.js +10 -0
  22. package/core/cache/tagged-lru.js +4 -0
  23. package/core/compileSchema.js +3 -68
  24. package/core/compression/compressible.js +53 -6
  25. package/core/compression/negotiate.js +31 -12
  26. package/core/console/eval.js +4 -1
  27. package/core/content/service.js +1 -0
  28. package/core/context/app-context.js +201 -0
  29. package/core/context/index.js +55 -0
  30. package/core/context/request.js +241 -0
  31. package/core/errors/domain.js +20 -2
  32. package/core/errors/http.js +1 -0
  33. package/core/events/dispatcher.js +144 -15
  34. package/core/events/signal.js +9 -0
  35. package/core/health/probes.js +12 -2
  36. package/core/http/hono-adapter.js +1 -1
  37. package/core/http/index.js +10 -1
  38. package/core/http/pipeline.js +519 -334
  39. package/core/index.js +36 -2
  40. package/core/mail/mailer.js +1 -1
  41. package/core/orm/eager-loader.js +170 -55
  42. package/core/orm/index.js +25 -47
  43. package/core/orm/json-fields.js +44 -13
  44. package/core/orm/model.js +18 -1
  45. package/core/orm/query-builder.js +33 -15
  46. package/core/orm/repository.js +59 -12
  47. package/core/orm/schema-helpers.js +59 -0
  48. package/core/orm/scopes.js +54 -4
  49. package/core/orm/transaction.js +109 -14
  50. package/core/plugins/index.js +8 -0
  51. package/core/plugins/plugin-manager.js +1098 -0
  52. package/core/queue/manager.js +55 -27
  53. package/core/rate-limit/stores/memory.js +23 -10
  54. package/core/resilience/circuit-breaker.js +34 -14
  55. package/core/schema-cache.js +2 -64
  56. package/core/services/builtins/auth.js +273 -0
  57. package/core/services/builtins/exchange.js +229 -0
  58. package/core/services/builtins/file-manager.js +178 -0
  59. package/core/services/builtins/mail.js +134 -0
  60. package/core/services/builtins/media.js +196 -0
  61. package/core/services/builtins/system.js +219 -0
  62. package/core/services/discovery.js +133 -0
  63. package/core/services/executor.js +629 -0
  64. package/core/services/index.js +114 -0
  65. package/core/services/loader.js +161 -0
  66. package/core/services/memoize.js +408 -0
  67. package/core/services/registry.js +731 -0
  68. package/core/services/validator.js +424 -0
  69. package/core/shutdown/shutdown-manager.js +194 -114
  70. package/core/ssr/stream.js +109 -16
  71. package/core/stream/text-stream.js +45 -4
  72. package/core/studio/inspector.js +9 -1
  73. package/core/studio/server.js +24 -8
  74. package/core/utils/index.js +110 -0
  75. package/core/utils/string.js +77 -16
  76. package/core/utils/timeout.js +4 -0
  77. package/core/validation/apply.js +74 -0
  78. package/core/validation/cache.js +66 -0
  79. package/core/validation/compiler.js +77 -0
  80. package/core/validation/index.js +40 -1
  81. package/index.d.ts +249 -12
  82. package/index.js +68 -20
  83. package/package.json +18 -8
  84. package/plugins/admin-panel/client/parts/07-model-list.js +5 -2
  85. package/plugins/admin-panel/index.js +24 -11
  86. package/plugins/admin-panel/modules/user-management.js +3 -2
  87. package/plugins/csrf/index.js +2 -1
  88. package/plugins/dashboard/index.js +27 -7
  89. package/src/app-context.js +4 -188
  90. package/src/dev/typegen.js +1 -1
  91. package/src/file-router.js +12 -3
  92. package/src/plugin-manager.js +5 -861
  93. package/src/server.js +30 -34
  94. package/src/services/builtins/auth.js +2 -273
  95. package/src/services/builtins/exchange.js +2 -229
  96. package/src/services/builtins/file-manager.js +2 -178
  97. package/src/services/builtins/mail.js +2 -134
  98. package/src/services/builtins/media.js +2 -196
  99. package/src/services/builtins/system.js +2 -219
  100. package/src/services/discovery.js +2 -125
  101. package/src/services/executor.js +2 -362
  102. package/src/services/index.js +4 -60
  103. package/src/services/loader.js +2 -0
  104. package/src/services/memoize.js +2 -234
  105. package/src/services/registry.js +2 -314
  106. package/src/services/validator.js +2 -273
  107. package/core/utils/array.js +0 -12
  108. package/core/utils/base64url.js +0 -8
  109. package/core/utils/ensure-leading-slash.js +0 -6
  110. package/core/utils/escape-html.js +0 -7
  111. package/core/utils/escape-regexp.js +0 -7
  112. package/core/utils/get-client-ip.js +0 -6
  113. package/core/utils/hmac.js +0 -6
  114. package/core/utils/random.js +0 -9
  115. package/core/utils/safe-compare.js +0 -3
  116. package/core/utils/sha256.js +0 -6
  117. package/core/utils/strip-trailing-slash.js +0 -6
  118. package/core/utils/walk.js +0 -7
@@ -102,29 +102,65 @@ function createKnexSessionTracker(db, options = {}) {
102
102
  };
103
103
  }
104
104
 
105
- const [id] = await knex(tableName).insert({
106
- session_id: sessionId,
107
- user_id: userId,
108
- ip_address: ip,
109
- user_agent: ua ? ua.slice(0, 1000) : null,
110
- browser,
111
- os,
112
- device,
113
- last_active_at: now,
114
- created_at: now,
115
- });
105
+ try {
106
+ const [id] = await knex(tableName).insert({
107
+ session_id: sessionId,
108
+ user_id: userId,
109
+ ip_address: ip,
110
+ user_agent: ua ? ua.slice(0, 1000) : null,
111
+ browser,
112
+ os,
113
+ device,
114
+ last_active_at: now,
115
+ created_at: now,
116
+ });
116
117
 
117
- return {
118
- id,
119
- session_id: sessionId,
120
- user_id: userId,
121
- ip_address: ip,
122
- browser,
123
- os,
124
- device,
125
- last_active_at: now,
126
- created_at: now,
127
- };
118
+ return {
119
+ id,
120
+ session_id: sessionId,
121
+ user_id: userId,
122
+ ip_address: ip,
123
+ browser,
124
+ os,
125
+ device,
126
+ last_active_at: now,
127
+ created_at: now,
128
+ };
129
+ } catch (insertErr) {
130
+ // Handle concurrent insert race condition gracefully across database engines
131
+ const isUniqueConstraint =
132
+ insertErr.code === 'SQLITE_CONSTRAINT' ||
133
+ insertErr.code === '23505' || // PostgreSQL
134
+ insertErr.code === 'ER_DUP_ENTRY' || // MySQL
135
+ insertErr.errno === 19 || // SQLite
136
+ insertErr.errno === 1062 || // MySQL
137
+ (typeof insertErr.message === 'string' && /unique|duplicate|constraint/i.test(insertErr.message));
138
+
139
+ if (!isUniqueConstraint) {
140
+ throw insertErr;
141
+ }
142
+
143
+ await knex(tableName).where({ session_id: sessionId }).update({
144
+ user_id: userId,
145
+ ip_address: ip,
146
+ user_agent: ua ? ua.slice(0, 1000) : null,
147
+ browser,
148
+ os,
149
+ device,
150
+ last_active_at: now,
151
+ });
152
+
153
+ const record = await knex(tableName).where({ session_id: sessionId }).first();
154
+ return record || {
155
+ session_id: sessionId,
156
+ user_id: userId,
157
+ ip_address: ip,
158
+ browser,
159
+ os,
160
+ device,
161
+ last_active_at: now,
162
+ };
163
+ }
128
164
  },
129
165
 
130
166
  /**
@@ -415,24 +451,92 @@ function createKvSessionTracker(kv, options = {}) {
415
451
  return record;
416
452
  },
417
453
 
418
- listUserSessions: async (userId) => {
419
- // If list is supported by KV
420
- if (typeof kv.list === 'function') {
421
- const listRes = await kv.list({ prefix });
422
- const keys = listRes.keys || [];
423
- const results = [];
424
- for (const k of keys) {
425
- const item = await kvGet(k.name);
426
- if (item && String(item.user_id) === String(userId)) {
427
- results.push(item);
428
- }
454
+ /**
455
+ * Touch session to update last_active_at and refresh expiration
456
+ * @param {string} sessionId
457
+ * @returns {Promise<boolean>}
458
+ */
459
+ touch: async (sessionId) => {
460
+ if (!sessionId) return false;
461
+ const record = await kvGet(`${prefix}${sessionId}`);
462
+ if (!record) return false;
463
+ record.last_active_at = new Date().toISOString();
464
+ await kvPut(`${prefix}${sessionId}`, record, defaultTtl);
465
+ return true;
466
+ },
467
+
468
+ /**
469
+ * Get session record by session_id
470
+ * @param {string} sessionId
471
+ * @returns {Promise<Object|null>}
472
+ */
473
+ get: async (sessionId) => {
474
+ if (!sessionId) return null;
475
+ return (await kvGet(`${prefix}${sessionId}`)) || null;
476
+ },
477
+
478
+ /**
479
+ * List all active sessions for a user, marking current session
480
+ * @param {string|number} userId
481
+ * @param {string} [currentSessionId]
482
+ * @returns {Promise<Array<Object>>}
483
+ */
484
+ list: async (userId, currentSessionId = null) => {
485
+ if (!userId) return [];
486
+ if (typeof kv.list !== 'function') return [];
487
+ const listRes = await kv.list({ prefix });
488
+ const keys = listRes.keys || [];
489
+ const items = await Promise.all(
490
+ keys.map((k) => kvGet(k.name).catch(() => null))
491
+ );
492
+ const results = [];
493
+ for (const item of items) {
494
+ if (item && String(item.user_id) === String(userId)) {
495
+ results.push({
496
+ ...item,
497
+ is_current: Boolean(currentSessionId && item.session_id === currentSessionId),
498
+ });
429
499
  }
430
- return results;
431
500
  }
432
- return [];
501
+ return results.sort((a, b) => new Date(b.last_active_at).getTime() - new Date(a.last_active_at).getTime());
502
+ },
503
+
504
+ /**
505
+ * Legacy alias for list()
506
+ * @param {string|number} userId
507
+ * @returns {Promise<Array<Object>>}
508
+ */
509
+ listUserSessions: async (userId) => {
510
+ if (!userId || typeof kv.list !== 'function') return [];
511
+ const listRes = await kv.list({ prefix });
512
+ const keys = listRes.keys || [];
513
+ const items = await Promise.all(
514
+ keys.map((k) => kvGet(k.name).catch(() => null))
515
+ );
516
+ return items.filter((item) => item && String(item.user_id) === String(userId));
517
+ },
518
+
519
+ /**
520
+ * Check if a session exists and belongs to the given user
521
+ * @param {string} sessionId
522
+ * @param {string|number} [userId]
523
+ * @returns {Promise<boolean>}
524
+ */
525
+ isSessionValid: async (sessionId, userId = null) => {
526
+ if (!sessionId) return false;
527
+ const record = await kvGet(`${prefix}${sessionId}`);
528
+ if (!record) return false;
529
+ if (userId && String(record.user_id) !== String(userId)) return false;
530
+ return true;
433
531
  },
434
532
 
435
- validate: async (sessionId, userId) => {
533
+ /**
534
+ * Legacy alias for isSessionValid()
535
+ * @param {string} sessionId
536
+ * @param {string|number} [userId]
537
+ * @returns {Promise<boolean>}
538
+ */
539
+ validate: async (sessionId, userId = null) => {
436
540
  if (!sessionId) return false;
437
541
  const record = await kvGet(`${prefix}${sessionId}`);
438
542
  if (!record) return false;
@@ -440,38 +544,64 @@ function createKvSessionTracker(kv, options = {}) {
440
544
  return true;
441
545
  },
442
546
 
547
+ /**
548
+ * Revoke a single session
549
+ * @param {string} sessionId
550
+ * @returns {Promise<boolean>}
551
+ */
443
552
  revoke: async (sessionId) => {
444
553
  if (!sessionId) return false;
445
554
  await kvDelete(`${prefix}${sessionId}`);
446
555
  return true;
447
556
  },
448
557
 
558
+ /**
559
+ * Revoke all other sessions for user, keeping the current session active
560
+ * @param {string|number} userId
561
+ * @param {string} currentSessionId
562
+ * @returns {Promise<number>} Number of revoked sessions
563
+ */
449
564
  revokeOthers: async (userId, currentSessionId) => {
450
- if (typeof kv.list !== 'function') return 0;
565
+ if (!userId || typeof kv.list !== 'function') return 0;
451
566
  const listRes = await kv.list({ prefix });
452
- let count = 0;
453
- for (const k of listRes.keys || []) {
454
- const item = await kvGet(k.name);
455
- if (item && String(item.user_id) === String(userId) && item.session_id !== currentSessionId) {
456
- await kvDelete(k.name);
457
- count++;
567
+ const keys = listRes.keys || [];
568
+ const items = await Promise.all(
569
+ keys.map((k) => kvGet(k.name).then((item) => ({ key: k.name, item })).catch(() => null))
570
+ );
571
+ const toDelete = [];
572
+ for (const entry of items) {
573
+ if (entry?.item && String(entry.item.user_id) === String(userId) && entry.item.session_id !== currentSessionId) {
574
+ toDelete.push(entry.key);
458
575
  }
459
576
  }
460
- return count;
577
+ if (toDelete.length > 0) {
578
+ await Promise.all(toDelete.map((k) => kvDelete(k)));
579
+ }
580
+ return toDelete.length;
461
581
  },
462
582
 
583
+ /**
584
+ * Revoke all sessions for a user (force logout from all devices)
585
+ * @param {string|number} userId
586
+ * @returns {Promise<number>} Number of revoked sessions
587
+ */
463
588
  revokeAll: async (userId) => {
464
- if (typeof kv.list !== 'function') return 0;
589
+ if (!userId || typeof kv.list !== 'function') return 0;
465
590
  const listRes = await kv.list({ prefix });
466
- let count = 0;
467
- for (const k of listRes.keys || []) {
468
- const item = await kvGet(k.name);
469
- if (item && String(item.user_id) === String(userId)) {
470
- await kvDelete(k.name);
471
- count++;
591
+ const keys = listRes.keys || [];
592
+ const items = await Promise.all(
593
+ keys.map((k) => kvGet(k.name).then((item) => ({ key: k.name, item })).catch(() => null))
594
+ );
595
+ const toDelete = [];
596
+ for (const entry of items) {
597
+ if (entry?.item && String(entry.item.user_id) === String(userId)) {
598
+ toDelete.push(entry.key);
472
599
  }
473
600
  }
474
- return count;
601
+ if (toDelete.length > 0) {
602
+ await Promise.all(toDelete.map((k) => kvDelete(k)));
603
+ }
604
+ return toDelete.length;
475
605
  },
476
606
 
477
607
  purgeExpired: async () => {
package/core/cache/lru.js CHANGED
@@ -47,6 +47,7 @@ function safeClone(value) {
47
47
  * @property {string|number} [defaultTtl=0] - Default TTL in ms or string duration ('5m', '1h', 0 = no expiry)
48
48
  * @property {function(*, string): number} [sizeCalculation] - Custom byte sizer function
49
49
  * @property {boolean} [clone=false] - Whether to deep-clone items on read/write
50
+ * @property {boolean} [trackBytes=false] - Whether to track approximate bytes when maxBytes is 0
50
51
  * @property {function(string, *, 'evict'|'expire'|'delete'|'clear'): void} [onEvict] - Eviction callback
51
52
  */
52
53
 
@@ -71,6 +72,11 @@ class LruCache {
71
72
  this.clone = options.clone === true;
72
73
  this.onEvict = typeof options.onEvict === 'function' ? options.onEvict : null;
73
74
 
75
+ // Track bytes only when explicit memory limit exists, custom sizer provided, or trackBytes is explicitly requested
76
+ this.trackBytes = options.trackBytes !== undefined
77
+ ? options.trackBytes === true
78
+ : (this.maxBytes > 0 || typeof options.sizeCalculation === 'function');
79
+
74
80
  /** @type {Map<string, { value: *, size: number, expiresAt: number }>} */
75
81
  this._store = new Map();
76
82
 
@@ -115,6 +121,10 @@ class LruCache {
115
121
  if (this.sizeCalculation) {
116
122
  return Math.max(0, this.sizeCalculation(value, key));
117
123
  }
124
+ // Fast path: if byte tracking is disabled, avoid expensive recursive object graph traversal
125
+ if (!this.trackBytes) {
126
+ return 0;
127
+ }
118
128
  return Math.max(0, approximateByteSize(value));
119
129
  }
120
130
 
@@ -60,6 +60,10 @@ class TaggedLruCache {
60
60
  return this.lru.maxBytes;
61
61
  }
62
62
 
63
+ get trackBytes() {
64
+ return this.lru.trackBytes;
65
+ }
66
+
63
67
  get maxEntries() {
64
68
  return this.lru.maxEntries;
65
69
  }
@@ -1,69 +1,4 @@
1
- /**
2
- * Schema Compiler
3
- * Compiles schema definitions from API files
4
- */
5
-
6
- const { z: zForApi } = require('./validation');
7
- const schemaCache = require('./schema-cache');
8
-
9
- /**
10
- * Compile schema from an API module
11
- * @param {string} filePath - Absolute file path to API module
12
- * @param {Object} apiModule - The loaded API module
13
- * @returns {Object|null} Compiled schema object or null if no schema
14
- */
15
- function compileSchema(filePath, apiModule) {
16
- // Return cached schema if exists
17
- if (schemaCache.has(filePath)) {
18
- return schemaCache.get(filePath);
19
- }
20
-
21
- // Check if module exports schema
22
- const schemaFn = apiModule.schema;
23
-
24
- // Schema is optional
25
- if (schemaFn === undefined) {
26
- schemaCache.set(filePath, null);
27
- return null;
28
- }
29
-
30
- // Schema must be a function
31
- if (typeof schemaFn !== 'function') {
32
- throw new Error(`Schema in ${filePath} must be a function`);
33
- }
34
-
35
- // Call schema function with { z } (extended with z.nanoid, same as generateNanoid / zdb.nanoid)
36
- const compiled = schemaFn({ z: zForApi });
37
-
38
- // Validate compiled schema structure
39
- if (compiled !== null && typeof compiled !== 'object') {
40
- throw new Error(`Schema function in ${filePath} must return an object or null`);
41
- }
42
-
43
- // Cache and return
44
- schemaCache.set(filePath, compiled);
45
- return compiled;
46
- }
47
-
48
- /**
49
- * Clear schema cache for a file (for hot-reload)
50
- * @param {string} filePath - Absolute file path
51
- */
52
- function invalidateSchema(filePath) {
53
- schemaCache.del(filePath);
54
- }
55
-
56
- /**
57
- * Clear all cached schemas
58
- */
59
- function clearAllSchemas() {
60
- schemaCache.clear();
61
- }
62
-
63
- module.exports = {
64
- compileSchema,
65
- invalidateSchema,
66
- clearAllSchemas
67
- };
68
-
1
+ 'use strict';
69
2
 
3
+ // Backward-compatibility shim
4
+ module.exports = require('./validation/compiler');
@@ -11,7 +11,56 @@ const COMPRESSIBLE_TYPE_REGEX = /^(?:text\/[a-z0-9.+_-]+|application\/(?:javascr
11
11
  const INCOMPRESSIBLE_TYPE_REGEX = /^(?:text\/event-stream|image\/(?:jpeg|png|gif|webp|avif|heic|heif)|video\/[a-z0-9.+_-]+|audio\/[a-z0-9.+_-]+|application\/(?:zip|gzip|x-gzip|x-bzip2|x-compress|x-7z-compressed|x-rar-compressed|pdf|wasm|octet-stream))$/i;
12
12
 
13
13
  const compressibleCache = new Map();
14
- const MAX_MIME_CACHE_SIZE = 256;
14
+ const MAX_MIME_CACHE_SIZE = 512;
15
+
16
+ // Pre-fill standard common web & API MIME types for zero-lookup warmup
17
+ const PREFILLED_MIMES = [
18
+ ['text/html', true],
19
+ ['text/html; charset=utf-8', true],
20
+ ['text/plain', true],
21
+ ['text/plain; charset=utf-8', true],
22
+ ['text/css', true],
23
+ ['text/css; charset=utf-8', true],
24
+ ['text/javascript', true],
25
+ ['text/javascript; charset=utf-8', true],
26
+ ['application/javascript', true],
27
+ ['application/javascript; charset=utf-8', true],
28
+ ['application/json', true],
29
+ ['application/json; charset=utf-8', true],
30
+ ['application/ld+json', true],
31
+ ['application/manifest+json', true],
32
+ ['application/graphql+json', true],
33
+ ['application/geo+json', true],
34
+ ['application/xml', true],
35
+ ['application/atom+xml', true],
36
+ ['application/rss+xml', true],
37
+ ['application/xhtml+xml', true],
38
+ ['image/svg+xml', true],
39
+ ['text/event-stream', false],
40
+ ['image/png', false],
41
+ ['image/jpeg', false],
42
+ ['image/webp', false],
43
+ ['image/gif', false],
44
+ ['image/avif', false],
45
+ ['application/pdf', false],
46
+ ['application/zip', false],
47
+ ['application/gzip', false],
48
+ ['application/octet-stream', false],
49
+ ['video/mp4', false],
50
+ ['audio/mpeg', false],
51
+ ];
52
+
53
+ for (const [mime, val] of PREFILLED_MIMES) {
54
+ compressibleCache.set(mime, val);
55
+ }
56
+
57
+ function setCompressibleCache(key, value) {
58
+ if (compressibleCache.size >= MAX_MIME_CACHE_SIZE) {
59
+ const firstKey = compressibleCache.keys().next().value;
60
+ compressibleCache.delete(firstKey);
61
+ }
62
+ compressibleCache.set(key, value);
63
+ }
15
64
 
16
65
  /**
17
66
  * Determine if a Content-Type is compressible
@@ -33,13 +82,13 @@ function isCompressible(contentType) {
33
82
  const mime = (semiIndex !== -1 ? contentType.slice(0, semiIndex) : contentType).trim().toLowerCase();
34
83
 
35
84
  if (!mime) {
36
- if (compressibleCache.size < MAX_MIME_CACHE_SIZE) compressibleCache.set(contentType, false);
85
+ setCompressibleCache(contentType, false);
37
86
  return false;
38
87
  }
39
88
 
40
89
  // Fast negative check
41
90
  if (INCOMPRESSIBLE_TYPE_REGEX.test(mime)) {
42
- if (compressibleCache.size < MAX_MIME_CACHE_SIZE) compressibleCache.set(contentType, false);
91
+ setCompressibleCache(contentType, false);
43
92
  return false;
44
93
  }
45
94
 
@@ -51,9 +100,7 @@ function isCompressible(contentType) {
51
100
  result = true;
52
101
  }
53
102
 
54
- if (compressibleCache.size < MAX_MIME_CACHE_SIZE) {
55
- compressibleCache.set(contentType, result);
56
- }
103
+ setCompressibleCache(contentType, result);
57
104
 
58
105
  return result;
59
106
  }
@@ -71,9 +71,29 @@ function parseAcceptEncoding(header) {
71
71
 
72
72
  const DEFAULT_SUPPORTED_ENCODINGS = Object.freeze(getDefaultSupportedEncodings());
73
73
  const DEFAULT_SUPPORTED_STRING = DEFAULT_SUPPORTED_ENCODINGS.join(',');
74
+ const DEFAULT_PREFERRED_ENCODING = DEFAULT_SUPPORTED_ENCODINGS[0] || 'identity';
74
75
 
75
76
  const selectEncodingCache = new Map();
76
- const MAX_ENCODING_CACHE_SIZE = 128;
77
+ const MAX_ENCODING_CACHE_SIZE = 256;
78
+
79
+ function setSelectEncodingCache(key, value) {
80
+ if (selectEncodingCache.size >= MAX_ENCODING_CACHE_SIZE) {
81
+ const firstKey = selectEncodingCache.keys().next().value;
82
+ selectEncodingCache.delete(firstKey);
83
+ }
84
+ selectEncodingCache.set(key, value);
85
+ }
86
+
87
+ // Pre-fill common browser patterns for default configuration
88
+ if (DEFAULT_SUPPORTED_ENCODINGS.includes('br')) {
89
+ selectEncodingCache.set('gzip, deflate, br', 'br');
90
+ selectEncodingCache.set('gzip, deflate, br, zstd', 'br');
91
+ selectEncodingCache.set('br;q=1.0, gzip;q=0.8, *;q=0.1', 'br');
92
+ }
93
+ selectEncodingCache.set('gzip, deflate', 'gzip');
94
+ selectEncodingCache.set('gzip', 'gzip');
95
+ selectEncodingCache.set('*', DEFAULT_PREFERRED_ENCODING);
96
+ selectEncodingCache.set('identity', 'identity');
77
97
 
78
98
  /**
79
99
  * Select the best compression encoding given client header and server supported list
@@ -82,14 +102,17 @@ const MAX_ENCODING_CACHE_SIZE = 128;
82
102
  * @returns {string|null} The selected encoding ('br', 'gzip', 'deflate', 'identity', or null)
83
103
  */
84
104
  function selectEncoding(acceptEncoding, supportedEncodings) {
85
- if (!acceptEncoding) {
105
+ if (!acceptEncoding || typeof acceptEncoding !== 'string') {
106
+ return 'identity';
107
+ }
108
+
109
+ const raw = acceptEncoding.trim();
110
+ if (!raw) {
86
111
  return 'identity';
87
112
  }
88
113
 
89
114
  const isDefault = !supportedEncodings || !Array.isArray(supportedEncodings) || supportedEncodings.length === 0;
90
- const cacheKey = isDefault
91
- ? `${acceptEncoding}|${DEFAULT_SUPPORTED_STRING}`
92
- : `${acceptEncoding}|${supportedEncodings.join(',')}`;
115
+ const cacheKey = isDefault ? raw : `${raw}|${supportedEncodings.join(',')}`;
93
116
 
94
117
  const cached = selectEncodingCache.get(cacheKey);
95
118
  if (cached !== undefined) {
@@ -100,11 +123,9 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
100
123
  ? DEFAULT_SUPPORTED_ENCODINGS
101
124
  : supportedEncodings.map(e => e.toLowerCase()).filter(e => e !== 'br' || supportsBrotli());
102
125
 
103
- const clientPreferences = parseAcceptEncoding(acceptEncoding);
126
+ const clientPreferences = parseAcceptEncoding(raw);
104
127
  if (clientPreferences.length === 0) {
105
- if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
106
- selectEncodingCache.set(cacheKey, 'identity');
107
- }
128
+ setSelectEncodingCache(cacheKey, 'identity');
108
129
  return 'identity';
109
130
  }
110
131
 
@@ -157,9 +178,7 @@ function selectEncoding(acceptEncoding, supportedEncodings) {
157
178
  result = wildcardQ > 0 ? 'identity' : null;
158
179
  }
159
180
 
160
- if (selectEncodingCache.size < MAX_ENCODING_CACHE_SIZE) {
161
- selectEncodingCache.set(cacheKey, result);
162
- }
181
+ setSelectEncodingCache(cacheKey, result);
163
182
 
164
183
  return result;
165
184
  }
@@ -15,7 +15,10 @@ function wrapBlock(code) {
15
15
  const lastLine = lines[lines.length - 1].trim();
16
16
  const isStatementKeyword = /^(return|const|let|var|throw|if|while|for|function|class|try|switch|import|export)\b/.test(lastLine);
17
17
  if (!isStatementKeyword && lastLine.length > 0) {
18
- const expr = lastLine.replace(/;+$/, '');
18
+ let expr = lastLine;
19
+ while (expr.endsWith(';')) {
20
+ expr = expr.slice(0, -1);
21
+ }
19
22
  lines[lines.length - 1] = `return (${expr});`;
20
23
  }
21
24
  return lines.join('\n');
@@ -400,6 +400,7 @@ function createContentService(db, options = {}) {
400
400
  createEntry,
401
401
  updateEntry,
402
402
  deleteEntry,
403
+ invalidateCache: () => cache.invalidateAll(),
403
404
  };
404
405
  }
405
406