bunsane 0.3.2 → 0.4.0

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 (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. package/tests/utils/test-context.ts +0 -149
@@ -0,0 +1,247 @@
1
+ import type { BaseComponent } from "../components";
2
+ import type ArcheType from "../ArcheType";
3
+ import type { EntityEvent, ComponentEvent, LifecycleEvent } from "../events/EntityLifecycleEvents";
4
+ import { getMetadataStorage } from "../metadata";
5
+
6
+ // Memoized constructor → typeId. Hook matching runs on every save event for
7
+ // every hook filter; instantiating components (`new compCtor()`) per check
8
+ // was O(hooks × filters) constructor calls per event.
9
+ const typeIdCache = new Map<Function, string>();
10
+ export function typeIdOfCtor(compCtor: new () => BaseComponent): string {
11
+ let id = typeIdCache.get(compCtor);
12
+ if (id === undefined) {
13
+ id = getMetadataStorage().getComponentId(compCtor.name);
14
+ typeIdCache.set(compCtor, id);
15
+ }
16
+ return id;
17
+ }
18
+
19
+ /**
20
+ * Hook callback function signature for entity events
21
+ */
22
+ export type EntityHookCallback<T extends EntityEvent = EntityEvent> = (event: T) => void;
23
+
24
+ /**
25
+ * Hook callback function signature for component events
26
+ */
27
+ export type ComponentHookCallback<T extends ComponentEvent = ComponentEvent> = (event: T) => void;
28
+
29
+ /**
30
+ * Hook callback function signature for any lifecycle event
31
+ */
32
+ export type LifecycleHookCallback = (event: LifecycleEvent) => void;
33
+
34
+ /**
35
+ * Component targeting configuration for hooks
36
+ */
37
+ export interface ComponentTargetConfig {
38
+ /** Component types that must be present on the entity for the hook to execute */
39
+ includeComponents?: (new () => BaseComponent)[];
40
+ /** Component types that must NOT be present on the entity for the hook to execute */
41
+ excludeComponents?: (new () => BaseComponent)[];
42
+ /** Whether to require ALL included components (AND) or ANY included component (OR) */
43
+ requireAllIncluded?: boolean;
44
+ /** Whether to require ALL excluded components to be absent (AND) or ANY excluded component to be absent (OR) */
45
+ requireAllExcluded?: boolean;
46
+ /** Archetype to match - entity must have exactly these component types */
47
+ archetype?: ArcheType;
48
+ /** Archetypes to match - entity must match ANY of these archetypes */
49
+ archetypes?: ArcheType[];
50
+ }
51
+
52
+ /**
53
+ * Hook registration options
54
+ */
55
+ export interface HookOptions {
56
+ /** Priority for hook execution order (higher numbers execute first) */
57
+ priority?: number;
58
+ /** Optional name for the hook for debugging */
59
+ name?: string;
60
+ /** Whether the hook should be executed asynchronously */
61
+ async?: boolean;
62
+ /** Filter function to conditionally execute the hook */
63
+ filter?: (event: LifecycleEvent) => boolean;
64
+ /** Maximum execution time in milliseconds (for timeout handling) */
65
+ timeout?: number;
66
+ /** Component targeting configuration for fine-grained hook execution */
67
+ componentTarget?: ComponentTargetConfig;
68
+ }
69
+
70
+ /**
71
+ * Registered hook information
72
+ */
73
+ export interface RegisteredHook {
74
+ callback: LifecycleHookCallback;
75
+ options: HookOptions;
76
+ id: string;
77
+ }
78
+
79
+ /**
80
+ * Hook execution metrics
81
+ */
82
+ export interface HookMetrics {
83
+ totalExecutions: number;
84
+ totalExecutionTime: number;
85
+ averageExecutionTime: number;
86
+ errorCount: number;
87
+ lastExecutionTime: number;
88
+ }
89
+
90
+ /**
91
+ * Registry state owned by the manager instance
92
+ */
93
+ export interface RegistryState {
94
+ hooks: Map<string, RegisteredHook[]>;
95
+ hookCounter: number;
96
+ }
97
+
98
+ /**
99
+ * Create initial registry state
100
+ */
101
+ export function createRegistryState(): RegistryState {
102
+ return {
103
+ hooks: new Map(),
104
+ hookCounter: 0
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Generate a unique hook ID
110
+ */
111
+ export function generateHookId(state: RegistryState): string {
112
+ return `hook_${++state.hookCounter}_${Date.now()}`;
113
+ }
114
+
115
+ /**
116
+ * Sort hooks by priority (higher priority first)
117
+ */
118
+ export function sortHooksByPriority(state: RegistryState, eventType: string): void {
119
+ const hooks = state.hooks.get(eventType);
120
+ if (hooks) {
121
+ hooks.sort((a, b) => (b.options.priority || 0) - (a.options.priority || 0));
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Register a hook for entity lifecycle events
127
+ */
128
+ export function registerEntityHook<T extends EntityEvent>(
129
+ state: RegistryState,
130
+ eventType: T['eventType'],
131
+ callback: EntityHookCallback<T>,
132
+ options: HookOptions
133
+ ): string {
134
+ const hookId = generateHookId(state);
135
+ const hook: RegisteredHook = {
136
+ callback: callback as LifecycleHookCallback,
137
+ options: { priority: 0, ...options },
138
+ id: hookId
139
+ };
140
+
141
+ if (!state.hooks.has(eventType)) {
142
+ state.hooks.set(eventType, []);
143
+ }
144
+
145
+ state.hooks.get(eventType)!.push(hook);
146
+ sortHooksByPriority(state, eventType);
147
+
148
+ return hookId;
149
+ }
150
+
151
+ /**
152
+ * Register a hook for component lifecycle events
153
+ */
154
+ export function registerComponentHook<T extends ComponentEvent>(
155
+ state: RegistryState,
156
+ eventType: T['eventType'],
157
+ callback: ComponentHookCallback<T>,
158
+ options: HookOptions
159
+ ): string {
160
+ const hookId = generateHookId(state);
161
+ const hook: RegisteredHook = {
162
+ callback: callback as LifecycleHookCallback,
163
+ options: { priority: 0, ...options },
164
+ id: hookId
165
+ };
166
+
167
+ if (!state.hooks.has(eventType)) {
168
+ state.hooks.set(eventType, []);
169
+ }
170
+
171
+ state.hooks.get(eventType)!.push(hook);
172
+ sortHooksByPriority(state, eventType);
173
+
174
+ return hookId;
175
+ }
176
+
177
+ /**
178
+ * Register a hook for all lifecycle events
179
+ */
180
+ export function registerLifecycleHook(
181
+ state: RegistryState,
182
+ callback: LifecycleHookCallback,
183
+ options: HookOptions
184
+ ): string {
185
+ const hookId = generateHookId(state);
186
+ const hook: RegisteredHook = {
187
+ callback,
188
+ options: { priority: 0, ...options },
189
+ id: hookId
190
+ };
191
+
192
+ // Register for all event types
193
+ const allEventTypes = [
194
+ "entity.created", "entity.updated", "entity.deleted",
195
+ "component.added", "component.updated", "component.removed"
196
+ ];
197
+
198
+ for (const eventType of allEventTypes) {
199
+ if (!state.hooks.has(eventType)) {
200
+ state.hooks.set(eventType, []);
201
+ }
202
+ state.hooks.get(eventType)!.push({ ...hook }); // Clone hook for each event type
203
+ }
204
+
205
+ return hookId;
206
+ }
207
+
208
+ /**
209
+ * Remove a hook by its ID
210
+ */
211
+ export function removeHook(state: RegistryState, hookId: string): boolean {
212
+ let removed = false;
213
+
214
+ for (const [eventType, hooks] of state.hooks.entries()) {
215
+ const initialLength = hooks.length;
216
+ state.hooks.set(eventType, hooks.filter(hook => hook.id !== hookId));
217
+
218
+ if (state.hooks.get(eventType)!.length < initialLength) {
219
+ removed = true;
220
+ }
221
+ }
222
+
223
+ return removed;
224
+ }
225
+
226
+ /**
227
+ * Get the number of registered hooks for an event type
228
+ */
229
+ export function getHookCount(state: RegistryState, eventType?: string): number {
230
+ if (eventType) {
231
+ return state.hooks.get(eventType)?.length || 0;
232
+ }
233
+
234
+ let total = 0;
235
+ for (const hooks of state.hooks.values()) {
236
+ total += hooks.length;
237
+ }
238
+ return total;
239
+ }
240
+
241
+ /**
242
+ * Clear all hooks
243
+ */
244
+ export function clearAllHooks(state: RegistryState): void {
245
+ state.hooks.clear();
246
+ state.hookCounter = 0;
247
+ }
@@ -20,6 +20,6 @@ export interface ComponentPropertyMetadata {
20
20
  export interface IndexedFieldMetadata {
21
21
  componentId: string;
22
22
  propertyKey: string;
23
- indexType: 'gin' | 'btree' | 'hash' | 'numeric';
23
+ indexType: 'gin' | 'btree' | 'hash' | 'numeric' | 'fulltext';
24
24
  isDateField: boolean;
25
25
  }
@@ -3,6 +3,11 @@ import { getMetadataStorage } from "./getMetadataStorage";
3
3
 
4
4
  export { getMetadataStorage } from "./getMetadataStorage";
5
5
 
6
+ // Cached after first call — metadata is fixed after startup so serialization
7
+ // cost is paid only once regardless of how many /studio navigations occur.
8
+ let _metadataCache: ReturnType<typeof getSerializedMetadataStorage> | undefined;
9
+ let _metadataScriptCache: string | undefined;
10
+
6
11
  function toFieldLabel(fieldName: string): string {
7
12
  let label = fieldName.replace(/_/g, ' ');
8
13
  label = label.split(' ').map(word => word === 'id' ? 'ID' : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
@@ -20,6 +25,8 @@ export function getSerializedMetadataStorage(): {
20
25
  }[]
21
26
  >;
22
27
  } {
28
+ if (_metadataCache) return _metadataCache;
29
+
23
30
  const storage = getMetadataStorage();
24
31
  const archeTypes: Record<string, any> = {};
25
32
 
@@ -34,11 +41,15 @@ export function getSerializedMetadataStorage(): {
34
41
  });
35
42
  });
36
43
 
37
- // console.log(archeTypes, 'archeTypes');
44
+ _metadataCache = { archeTypes };
45
+ return _metadataCache;
46
+ }
38
47
 
39
- return {
40
- archeTypes,
41
- };
48
+ /** Returns the pre-serialized `<script>` tag for studio injection. */
49
+ export function getMetadataScript(): string {
50
+ if (_metadataScriptCache) return _metadataScriptCache;
51
+ _metadataScriptCache = `<script>window.bunsaneMetadata = ${JSON.stringify(getSerializedMetadataStorage())};</script>`;
52
+ return _metadataScriptCache;
42
53
  }
43
54
 
44
55
  export function Enum() {
@@ -1,105 +1,102 @@
1
- import type { Middleware } from '../Middleware';
2
- import { logger as MainLogger } from '../Logger';
3
-
4
- const logger = MainLogger.child({ scope: 'RateLimit' });
5
-
6
- export type RateLimitOptions = {
7
- /** Maximum requests in the window. Default: 100 */
8
- max?: number;
9
- /** Window length in milliseconds. Default: 60_000 (1 min) */
10
- windowMs?: number;
11
- /** Only apply to paths matching this prefix list. Default: all */
12
- pathPrefixes?: string[];
13
- /** Extract client key (override default: X-Forwarded-For → remote). */
14
- keyExtractor?: (req: Request) => string;
15
- /** Response status for rejection. Default: 429 */
16
- status?: number;
17
- /** Trust X-Forwarded-For header. Default: false */
18
- trustProxy?: boolean;
19
- };
20
-
21
- type Bucket = {
22
- count: number;
23
- resetAt: number;
24
- };
25
-
26
- /**
27
- * In-memory token-bucket rate limiter. Per-instance only — for multi-instance
28
- * deployments use a shared Redis-backed limiter. Sweeps expired buckets on
29
- * each increment to keep memory bounded.
30
- */
31
- export function rateLimit(options: RateLimitOptions = {}): Middleware {
32
- const max = options.max ?? 100;
33
- const windowMs = options.windowMs ?? 60_000;
34
- const pathPrefixes = options.pathPrefixes;
35
- const status = options.status ?? 429;
36
- const trustProxy = options.trustProxy ?? false;
37
- const keyExtractor = options.keyExtractor ?? ((req: Request) => {
38
- if (trustProxy) {
39
- const xff = req.headers.get('x-forwarded-for');
40
- if (xff) return xff.split(',')[0]!.trim();
41
- }
42
- const realIp = req.headers.get('x-real-ip');
43
- if (realIp) return realIp;
44
- return 'anonymous';
45
- });
46
-
47
- const buckets = new Map<string, Bucket>();
48
- let lastSweep = Date.now();
49
-
50
- return async (req, next) => {
51
- if (pathPrefixes && pathPrefixes.length > 0) {
52
- const url = new URL(req.url);
53
- const match = pathPrefixes.some((p) => url.pathname.startsWith(p));
54
- if (!match) return next();
55
- }
56
-
57
- const now = Date.now();
58
- const key = keyExtractor(req);
59
-
60
- if (now - lastSweep > windowMs) {
61
- for (const [k, v] of buckets) {
62
- if (v.resetAt <= now) buckets.delete(k);
63
- }
64
- lastSweep = now;
65
- }
66
-
67
- let bucket = buckets.get(key);
68
- if (!bucket || bucket.resetAt <= now) {
69
- bucket = { count: 0, resetAt: now + windowMs };
70
- buckets.set(key, bucket);
71
- }
72
-
73
- bucket.count++;
74
- const remaining = Math.max(0, max - bucket.count);
75
- const retryAfterSec = Math.ceil((bucket.resetAt - now) / 1000);
76
-
77
- if (bucket.count > max) {
78
- logger.warn({ key, path: new URL(req.url).pathname, count: bucket.count, max }, 'rate limit exceeded');
79
- return new Response(
80
- JSON.stringify({ error: 'Too many requests', retryAfter: retryAfterSec }),
81
- {
82
- status,
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- 'Retry-After': String(retryAfterSec),
86
- 'X-RateLimit-Limit': String(max),
87
- 'X-RateLimit-Remaining': '0',
88
- 'X-RateLimit-Reset': String(Math.floor(bucket.resetAt / 1000)),
89
- },
90
- },
91
- );
92
- }
93
-
94
- const response = await next();
95
- const newHeaders = new Headers(response.headers);
96
- newHeaders.set('X-RateLimit-Limit', String(max));
97
- newHeaders.set('X-RateLimit-Remaining', String(remaining));
98
- newHeaders.set('X-RateLimit-Reset', String(Math.floor(bucket.resetAt / 1000)));
99
- return new Response(response.body, {
100
- status: response.status,
101
- statusText: response.statusText,
102
- headers: newHeaders,
103
- });
104
- };
105
- }
1
+ import type { Middleware } from '../Middleware';
2
+ import { logger as MainLogger } from '../Logger';
3
+ import { setResponseHeaders } from './headers';
4
+
5
+ const logger = MainLogger.child({ scope: 'RateLimit' });
6
+
7
+ export type RateLimitOptions = {
8
+ /** Maximum requests in the window. Default: 100 */
9
+ max?: number;
10
+ /** Window length in milliseconds. Default: 60_000 (1 min) */
11
+ windowMs?: number;
12
+ /** Only apply to paths matching this prefix list. Default: all */
13
+ pathPrefixes?: string[];
14
+ /** Extract client key (override default: X-Forwarded-For → remote). */
15
+ keyExtractor?: (req: Request) => string;
16
+ /** Response status for rejection. Default: 429 */
17
+ status?: number;
18
+ /** Trust X-Forwarded-For header. Default: false */
19
+ trustProxy?: boolean;
20
+ };
21
+
22
+ type Bucket = {
23
+ count: number;
24
+ resetAt: number;
25
+ };
26
+
27
+ /**
28
+ * In-memory token-bucket rate limiter. Per-instance only for multi-instance
29
+ * deployments use a shared Redis-backed limiter. Sweeps expired buckets on
30
+ * each increment to keep memory bounded.
31
+ */
32
+ export function rateLimit(options: RateLimitOptions = {}): Middleware {
33
+ const max = options.max ?? 100;
34
+ const windowMs = options.windowMs ?? 60_000;
35
+ const pathPrefixes = options.pathPrefixes;
36
+ const status = options.status ?? 429;
37
+ const trustProxy = options.trustProxy ?? false;
38
+ const keyExtractor = options.keyExtractor ?? ((req: Request) => {
39
+ if (trustProxy) {
40
+ const xff = req.headers.get('x-forwarded-for');
41
+ if (xff) return xff.split(',')[0]!.trim();
42
+ }
43
+ const realIp = req.headers.get('x-real-ip');
44
+ if (realIp) return realIp;
45
+ return 'anonymous';
46
+ });
47
+
48
+ const buckets = new Map<string, Bucket>();
49
+ let lastSweep = Date.now();
50
+
51
+ return async (req, next) => {
52
+ if (pathPrefixes && pathPrefixes.length > 0) {
53
+ const url = new URL(req.url);
54
+ const match = pathPrefixes.some((p) => url.pathname.startsWith(p));
55
+ if (!match) return next();
56
+ }
57
+
58
+ const now = Date.now();
59
+ const key = keyExtractor(req);
60
+
61
+ if (now - lastSweep > windowMs) {
62
+ for (const [k, v] of buckets) {
63
+ if (v.resetAt <= now) buckets.delete(k);
64
+ }
65
+ lastSweep = now;
66
+ }
67
+
68
+ let bucket = buckets.get(key);
69
+ if (!bucket || bucket.resetAt <= now) {
70
+ bucket = { count: 0, resetAt: now + windowMs };
71
+ buckets.set(key, bucket);
72
+ }
73
+
74
+ bucket.count++;
75
+ const remaining = Math.max(0, max - bucket.count);
76
+ const retryAfterSec = Math.ceil((bucket.resetAt - now) / 1000);
77
+
78
+ if (bucket.count > max) {
79
+ logger.warn({ key, path: new URL(req.url).pathname, count: bucket.count, max }, 'rate limit exceeded');
80
+ return new Response(
81
+ JSON.stringify({ error: 'Too many requests', retryAfter: retryAfterSec }),
82
+ {
83
+ status,
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Retry-After': String(retryAfterSec),
87
+ 'X-RateLimit-Limit': String(max),
88
+ 'X-RateLimit-Remaining': '0',
89
+ 'X-RateLimit-Reset': String(Math.floor(bucket.resetAt / 1000)),
90
+ },
91
+ },
92
+ );
93
+ }
94
+
95
+ const response = await next();
96
+ return setResponseHeaders(response, [
97
+ ['X-RateLimit-Limit', String(max)],
98
+ ['X-RateLimit-Remaining', String(remaining)],
99
+ ['X-RateLimit-Reset', String(Math.floor(bucket.resetAt / 1000))],
100
+ ]);
101
+ };
102
+ }
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
2
  import type { Middleware } from '../Middleware';
3
+ import { setResponseHeaders } from './headers';
3
4
 
4
5
  /**
5
6
  * AsyncLocalStorage to propagate requestId to any code running within a request.
@@ -24,15 +25,7 @@ export function requestId(): Middleware {
24
25
 
25
26
  return requestStore.run({ requestId: id }, async () => {
26
27
  const response = await next();
27
-
28
- const newHeaders = new Headers(response.headers);
29
- newHeaders.set('X-Request-Id', id);
30
-
31
- return new Response(response.body, {
32
- status: response.status,
33
- statusText: response.statusText,
34
- headers: newHeaders,
35
- });
28
+ return setResponseHeaders(response, [['X-Request-Id', id]]);
36
29
  });
37
30
  };
38
31
  }
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from '../Middleware';
2
+ import { setResponseHeaders } from './headers';
2
3
 
3
4
  export type SecurityHeadersOptions = {
4
5
  /** Enable HSTS header. Default: true in production */
@@ -47,16 +48,6 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
47
48
 
48
49
  return async (req, next) => {
49
50
  const response = await next();
50
-
51
- const newHeaders = new Headers(response.headers);
52
- for (const [key, value] of headersToSet) {
53
- newHeaders.set(key, value);
54
- }
55
-
56
- return new Response(response.body, {
57
- status: response.status,
58
- statusText: response.statusText,
59
- headers: newHeaders,
60
- });
51
+ return setResponseHeaders(response, headersToSet);
61
52
  };
62
53
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Attempt to mutate `response.headers` in-place.
3
+ * Constructed Responses (Yoga, REST handlers) allow mutation; only proxied
4
+ * fetch Responses guard their headers as immutable. Fall back to a single
5
+ * clone so the chain never needs more than one new Response.
6
+ */
7
+ export function setResponseHeaders(
8
+ response: Response,
9
+ headers: Iterable<[string, string]>,
10
+ ): Response {
11
+ try {
12
+ for (const [key, value] of headers) {
13
+ response.headers.set(key, value);
14
+ }
15
+ return response;
16
+ } catch {
17
+ // Immutable guard hit (e.g. proxied fetch Response) — clone once.
18
+ const cloned = new Headers(response.headers);
19
+ for (const [key, value] of headers) {
20
+ cloned.set(key, value);
21
+ }
22
+ return new Response(response.body, {
23
+ status: response.status,
24
+ statusText: response.statusText,
25
+ headers: cloned,
26
+ });
27
+ }
28
+ }