@carno.js/core 1.1.1 → 1.2.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.
- package/LICENSE +21 -21
- package/README.md +188 -188
- package/dist/Carno.js +45 -26
- package/dist/Carno.mjs +45 -26
- package/dist/bun/index.js +4 -4
- package/dist/bun/index.js.map +30 -29
- package/dist/compression/CompressionMiddleware.js +110 -0
- package/dist/compression/CompressionMiddleware.mjs +90 -0
- package/dist/index.js +3 -1
- package/dist/index.mjs +2 -0
- package/package.json +2 -2
- package/src/Carno.ts +728 -673
- package/src/DefaultRoutes.ts +34 -34
- package/src/cache/CacheDriver.ts +50 -50
- package/src/cache/CacheService.ts +139 -139
- package/src/cache/MemoryDriver.ts +104 -104
- package/src/cache/RedisDriver.ts +116 -116
- package/src/compiler/JITCompiler.ts +167 -167
- package/src/compression/CompressionMiddleware.ts +221 -0
- package/src/container/Container.ts +168 -168
- package/src/context/Context.ts +130 -130
- package/src/cors/CorsHandler.ts +145 -145
- package/src/decorators/Controller.ts +63 -63
- package/src/decorators/Inject.ts +16 -16
- package/src/decorators/Middleware.ts +22 -22
- package/src/decorators/Service.ts +18 -18
- package/src/decorators/methods.ts +58 -58
- package/src/decorators/params.ts +47 -47
- package/src/events/Lifecycle.ts +97 -97
- package/src/exceptions/HttpException.ts +99 -99
- package/src/index.ts +99 -95
- package/src/metadata.ts +46 -46
- package/src/middleware/CarnoMiddleware.ts +20 -14
- package/src/router/RadixRouter.ts +225 -225
- package/src/testing/TestHarness.ts +185 -185
- package/src/utils/Metadata.ts +43 -43
- package/src/utils/parseQuery.ts +161 -161
- package/src/validation/ValibotAdapter.ts +95 -95
- package/src/validation/ValidatorAdapter.ts +69 -69
- package/src/validation/ZodAdapter.ts +102 -102
- package/dist/Carno.d.js +0 -14
- package/dist/Carno.d.mjs +0 -1
- package/dist/DefaultRoutes.d.js +0 -13
- package/dist/DefaultRoutes.d.mjs +0 -0
- package/dist/cache/CacheDriver.d.js +0 -13
- package/dist/cache/CacheDriver.d.mjs +0 -0
- package/dist/cache/CacheService.d.js +0 -13
- package/dist/cache/CacheService.d.mjs +0 -0
- package/dist/cache/MemoryDriver.d.js +0 -13
- package/dist/cache/MemoryDriver.d.mjs +0 -0
- package/dist/cache/RedisDriver.d.js +0 -13
- package/dist/cache/RedisDriver.d.mjs +0 -0
- package/dist/compiler/JITCompiler.d.js +0 -13
- package/dist/compiler/JITCompiler.d.mjs +0 -0
- package/dist/container/Container.d.js +0 -13
- package/dist/container/Container.d.mjs +0 -0
- package/dist/context/Context.d.js +0 -13
- package/dist/context/Context.d.mjs +0 -0
- package/dist/cors/CorsHandler.d.js +0 -13
- package/dist/cors/CorsHandler.d.mjs +0 -0
- package/dist/decorators/Controller.d.js +0 -13
- package/dist/decorators/Controller.d.mjs +0 -0
- package/dist/decorators/Inject.d.js +0 -13
- package/dist/decorators/Inject.d.mjs +0 -0
- package/dist/decorators/Middleware.d.js +0 -13
- package/dist/decorators/Middleware.d.mjs +0 -0
- package/dist/decorators/Service.d.js +0 -13
- package/dist/decorators/Service.d.mjs +0 -0
- package/dist/decorators/methods.d.js +0 -13
- package/dist/decorators/methods.d.mjs +0 -0
- package/dist/decorators/params.d.js +0 -13
- package/dist/decorators/params.d.mjs +0 -0
- package/dist/events/Lifecycle.d.js +0 -13
- package/dist/events/Lifecycle.d.mjs +0 -0
- package/dist/exceptions/HttpException.d.js +0 -13
- package/dist/exceptions/HttpException.d.mjs +0 -0
- package/dist/index.d.js +0 -130
- package/dist/index.d.mjs +0 -78
- package/dist/metadata.d.js +0 -13
- package/dist/metadata.d.mjs +0 -0
- package/dist/middleware/CarnoMiddleware.d.js +0 -13
- package/dist/middleware/CarnoMiddleware.d.mjs +0 -0
- package/dist/router/RadixRouter.d.js +0 -13
- package/dist/router/RadixRouter.d.mjs +0 -0
- package/dist/testing/TestHarness.d.js +0 -13
- package/dist/testing/TestHarness.d.mjs +0 -0
- package/dist/utils/Metadata.d.js +0 -13
- package/dist/utils/Metadata.d.mjs +0 -0
- package/dist/utils/parseQuery.d.js +0 -13
- package/dist/utils/parseQuery.d.mjs +0 -0
- package/dist/validation/ValibotAdapter.d.js +0 -13
- package/dist/validation/ValibotAdapter.d.mjs +0 -0
- package/dist/validation/ValidatorAdapter.d.js +0 -13
- package/dist/validation/ValidatorAdapter.d.mjs +0 -0
- package/dist/validation/ZodAdapter.d.js +0 -13
- package/dist/validation/ZodAdapter.d.mjs +0 -0
- package/src/Carno.d.ts +0 -135
- package/src/DefaultRoutes.d.ts +0 -19
- package/src/cache/CacheDriver.d.ts +0 -43
- package/src/cache/CacheService.d.ts +0 -89
- package/src/cache/MemoryDriver.d.ts +0 -32
- package/src/cache/RedisDriver.d.ts +0 -34
- package/src/compiler/JITCompiler.d.ts +0 -36
- package/src/container/Container.d.ts +0 -38
- package/src/context/Context.d.ts +0 -36
- package/src/cors/CorsHandler.d.ts +0 -47
- package/src/decorators/Controller.d.ts +0 -13
- package/src/decorators/Inject.d.ts +0 -6
- package/src/decorators/Middleware.d.ts +0 -5
- package/src/decorators/Service.d.ts +0 -9
- package/src/decorators/methods.d.ts +0 -7
- package/src/decorators/params.d.ts +0 -13
- package/src/events/Lifecycle.d.ts +0 -54
- package/src/exceptions/HttpException.d.ts +0 -43
- package/src/index.d.ts +0 -42
- package/src/metadata.d.ts +0 -41
- package/src/middleware/CarnoMiddleware.d.ts +0 -12
- package/src/router/RadixRouter.d.ts +0 -19
- package/src/testing/TestHarness.d.ts +0 -71
- package/src/utils/Metadata.d.ts +0 -20
- package/src/utils/parseQuery.d.ts +0 -23
- package/src/validation/ValibotAdapter.d.ts +0 -30
- package/src/validation/ValidatorAdapter.d.ts +0 -54
- package/src/validation/ZodAdapter.d.ts +0 -35
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { Context } from '../context/Context';
|
|
2
|
+
import type { CarnoMiddleware, CarnoClosure } from '../middleware/CarnoMiddleware';
|
|
3
|
+
import { brotliCompressSync, constants } from 'zlib';
|
|
4
|
+
|
|
5
|
+
type GzipLevel = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
6
|
+
type BrotliQuality = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compression configuration.
|
|
10
|
+
*/
|
|
11
|
+
export interface CompressionConfig {
|
|
12
|
+
/**
|
|
13
|
+
* Minimum response size in bytes to trigger compression.
|
|
14
|
+
* Responses smaller than this are sent uncompressed.
|
|
15
|
+
* @default 1024
|
|
16
|
+
*/
|
|
17
|
+
threshold?: number;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Preferred encoding order. Client's Accept-Encoding is matched against this list.
|
|
21
|
+
* @default ['br', 'gzip']
|
|
22
|
+
*/
|
|
23
|
+
encodings?: ('br' | 'gzip' | 'deflate')[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Content-Type patterns that should be compressed.
|
|
27
|
+
* Matched using string includes (case-insensitive).
|
|
28
|
+
* @default ['text/', 'application/json', 'application/javascript', 'application/xml', 'image/svg+xml']
|
|
29
|
+
*/
|
|
30
|
+
compressibleTypes?: string[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Brotli compression quality (0-11). Higher = better compression, slower.
|
|
34
|
+
* @default 4
|
|
35
|
+
*/
|
|
36
|
+
brotliQuality?: BrotliQuality;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gzip compression level (-1 to 9). Higher = better compression, slower.
|
|
40
|
+
* @default 6
|
|
41
|
+
*/
|
|
42
|
+
gzipLevel?: GzipLevel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_THRESHOLD = 1024;
|
|
46
|
+
const DEFAULT_ENCODINGS: ('br' | 'gzip' | 'deflate')[] = ['br', 'gzip'];
|
|
47
|
+
const DEFAULT_COMPRESSIBLE_TYPES = [
|
|
48
|
+
'text/',
|
|
49
|
+
'application/json',
|
|
50
|
+
'application/javascript',
|
|
51
|
+
'application/xml',
|
|
52
|
+
'application/xhtml+xml',
|
|
53
|
+
'image/svg+xml',
|
|
54
|
+
];
|
|
55
|
+
const DEFAULT_BROTLI_QUALITY: BrotliQuality = 4;
|
|
56
|
+
const DEFAULT_GZIP_LEVEL: GzipLevel = 6;
|
|
57
|
+
|
|
58
|
+
type CompressFn = (data: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compression middleware for Carno.
|
|
62
|
+
*
|
|
63
|
+
* Supports **gzip**, **brotli** and **deflate** using Bun's native APIs
|
|
64
|
+
* (zero external dependencies).
|
|
65
|
+
*
|
|
66
|
+
* Does NOT touch the hot path — runs entirely as a middleware in the
|
|
67
|
+
* onion chain. Routes without middleware remain untouched.
|
|
68
|
+
*
|
|
69
|
+
* All configuration is resolved once at construction time (startup)
|
|
70
|
+
* so the per-request overhead is minimal: one header check +
|
|
71
|
+
* one compression call for eligible responses.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { Carno, CompressionMiddleware } from '@carno.js/core';
|
|
76
|
+
*
|
|
77
|
+
* const app = new Carno()
|
|
78
|
+
* .middlewares([new CompressionMiddleware()])
|
|
79
|
+
* .controllers([MyController]);
|
|
80
|
+
*
|
|
81
|
+
* app.listen(3000);
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* @example Custom configuration:
|
|
85
|
+
* ```ts
|
|
86
|
+
* new CompressionMiddleware({
|
|
87
|
+
* threshold: 512,
|
|
88
|
+
* encodings: ['gzip'],
|
|
89
|
+
* gzipLevel: 9,
|
|
90
|
+
* })
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export class CompressionMiddleware implements CarnoMiddleware {
|
|
94
|
+
private readonly threshold: number;
|
|
95
|
+
private readonly compressibleTypes: string[];
|
|
96
|
+
private readonly compressors: Map<string, CompressFn>;
|
|
97
|
+
private readonly encodingOrder: string[];
|
|
98
|
+
|
|
99
|
+
constructor(config?: CompressionConfig) {
|
|
100
|
+
this.threshold = config?.threshold ?? DEFAULT_THRESHOLD;
|
|
101
|
+
this.compressibleTypes = (config?.compressibleTypes ?? DEFAULT_COMPRESSIBLE_TYPES)
|
|
102
|
+
.map(t => t.toLowerCase());
|
|
103
|
+
|
|
104
|
+
const encodings = config?.encodings ?? DEFAULT_ENCODINGS;
|
|
105
|
+
this.encodingOrder = encodings;
|
|
106
|
+
|
|
107
|
+
const brotliQuality = config?.brotliQuality ?? DEFAULT_BROTLI_QUALITY;
|
|
108
|
+
const gzipLevel = config?.gzipLevel ?? DEFAULT_GZIP_LEVEL;
|
|
109
|
+
|
|
110
|
+
// Pre-build compressor functions at construction time (startup only)
|
|
111
|
+
this.compressors = new Map();
|
|
112
|
+
|
|
113
|
+
for (const enc of encodings) {
|
|
114
|
+
switch (enc) {
|
|
115
|
+
case 'br':
|
|
116
|
+
this.compressors.set('br', (data: Uint8Array<ArrayBuffer>) => {
|
|
117
|
+
const buf = brotliCompressSync(data, {
|
|
118
|
+
params: { [constants.BROTLI_PARAM_QUALITY]: brotliQuality },
|
|
119
|
+
});
|
|
120
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
121
|
+
});
|
|
122
|
+
break;
|
|
123
|
+
case 'gzip':
|
|
124
|
+
this.compressors.set('gzip', (data: Uint8Array<ArrayBuffer>) =>
|
|
125
|
+
Bun.gzipSync(data, { level: gzipLevel }),
|
|
126
|
+
);
|
|
127
|
+
break;
|
|
128
|
+
case 'deflate':
|
|
129
|
+
this.compressors.set('deflate', (data: Uint8Array<ArrayBuffer>) =>
|
|
130
|
+
Bun.deflateSync(data),
|
|
131
|
+
);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async handle(ctx: Context, next: CarnoClosure): Promise<Response | void> {
|
|
138
|
+
const response = await next();
|
|
139
|
+
|
|
140
|
+
// Fast-exit: no Accept-Encoding header
|
|
141
|
+
const acceptEncoding = ctx.req.headers.get('accept-encoding');
|
|
142
|
+
if (!acceptEncoding) {
|
|
143
|
+
return response;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Already encoded — skip
|
|
147
|
+
if (response.headers.get('content-encoding')) {
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Non-compressible content type — skip
|
|
152
|
+
const contentType = response.headers.get('content-type');
|
|
153
|
+
if (!contentType || !this.isCompressible(contentType)) {
|
|
154
|
+
return response;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Negotiate encoding
|
|
158
|
+
const encoding = this.negotiateEncoding(acceptEncoding);
|
|
159
|
+
if (!encoding) {
|
|
160
|
+
return response;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Read body bytes (consumes the response body)
|
|
164
|
+
const buffer = await response.arrayBuffer();
|
|
165
|
+
|
|
166
|
+
// Below threshold — reconstruct original response
|
|
167
|
+
if (buffer.byteLength < this.threshold) {
|
|
168
|
+
return new Response(buffer, {
|
|
169
|
+
status: response.status,
|
|
170
|
+
statusText: response.statusText,
|
|
171
|
+
headers: response.headers,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const compressor = this.compressors.get(encoding)!;
|
|
176
|
+
const bodyBytes = new Uint8Array(buffer);
|
|
177
|
+
const compressed = compressor(bodyBytes);
|
|
178
|
+
|
|
179
|
+
// If compressed is not smaller, return original uncompressed
|
|
180
|
+
if (compressed.byteLength >= buffer.byteLength) {
|
|
181
|
+
return new Response(buffer, {
|
|
182
|
+
status: response.status,
|
|
183
|
+
statusText: response.statusText,
|
|
184
|
+
headers: response.headers,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const headers = new Headers(response.headers);
|
|
189
|
+
headers.set('Content-Encoding', encoding);
|
|
190
|
+
headers.set('Content-Length', String(compressed.byteLength));
|
|
191
|
+
headers.set('Vary', this.buildVaryHeader(headers.get('Vary')));
|
|
192
|
+
|
|
193
|
+
return new Response(compressed, {
|
|
194
|
+
status: response.status,
|
|
195
|
+
statusText: response.statusText,
|
|
196
|
+
headers,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private isCompressible(contentType: string): boolean {
|
|
201
|
+
const lower = contentType.toLowerCase();
|
|
202
|
+
for (const pattern of this.compressibleTypes) {
|
|
203
|
+
if (lower.includes(pattern)) return true;
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private negotiateEncoding(acceptEncoding: string): string | null {
|
|
209
|
+
const lower = acceptEncoding.toLowerCase();
|
|
210
|
+
for (const encoding of this.encodingOrder) {
|
|
211
|
+
if (lower.includes(encoding)) return encoding;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private buildVaryHeader(existing: string | null): string {
|
|
217
|
+
if (!existing) return 'Accept-Encoding';
|
|
218
|
+
if (existing.toLowerCase().includes('accept-encoding')) return existing;
|
|
219
|
+
return `${existing}, Accept-Encoding`;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -1,168 +1,168 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lightweight DI Container for Turbo.
|
|
3
|
-
*
|
|
4
|
-
* Features:
|
|
5
|
-
* - Constructor injection via reflect-metadata
|
|
6
|
-
* - Singleton scope by default
|
|
7
|
-
* - Request scope support
|
|
8
|
-
* - Lazy instantiation
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export type Token<T = any> = new (...args: any[]) => T;
|
|
12
|
-
|
|
13
|
-
export enum Scope {
|
|
14
|
-
SINGLETON = 'singleton', // Always the same instance
|
|
15
|
-
REQUEST = 'request', // New instance per request
|
|
16
|
-
INSTANCE = 'instance' // New instance per dependency injection
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface ProviderConfig<T = any> {
|
|
20
|
-
token: Token<T>;
|
|
21
|
-
useClass?: Token<T>;
|
|
22
|
-
useValue?: T;
|
|
23
|
-
scope?: Scope;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ProviderEntry {
|
|
27
|
-
config: ProviderConfig;
|
|
28
|
-
instance: any | null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class Container {
|
|
32
|
-
private configs = new Map<Token, ProviderConfig>();
|
|
33
|
-
private instances = new Map<Token, any>();
|
|
34
|
-
private resolving = new Set<Token>();
|
|
35
|
-
|
|
36
|
-
register<T>(config: ProviderConfig<T> | Token<T>): this {
|
|
37
|
-
const normalized = this.normalizeConfig(config);
|
|
38
|
-
|
|
39
|
-
this.configs.set(normalized.token, normalized);
|
|
40
|
-
|
|
41
|
-
if (normalized.useValue !== undefined) {
|
|
42
|
-
this.instances.set(normalized.token, normalized.useValue);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
return this;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get<T>(token: Token<T>): T {
|
|
49
|
-
const cached = this.instances.get(token);
|
|
50
|
-
|
|
51
|
-
if (cached !== undefined) {
|
|
52
|
-
return cached;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const res = this.resolveInternal(token);
|
|
56
|
-
return res.instance;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
has(token: Token): boolean {
|
|
60
|
-
return this.configs.has(token);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolves a token to return instance and its effective scope.
|
|
65
|
-
*/
|
|
66
|
-
private resolveInternal<T>(token: Token<T>, requestLocals?: Map<Token, any>): { instance: T, scope: Scope } {
|
|
67
|
-
// 1. Check Request Cache
|
|
68
|
-
if (requestLocals?.has(token)) {
|
|
69
|
-
return { instance: requestLocals.get(token), scope: Scope.REQUEST };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 2. Check Singleton Cache
|
|
73
|
-
const cached = this.instances.get(token);
|
|
74
|
-
if (cached !== undefined) {
|
|
75
|
-
return { instance: cached, scope: Scope.SINGLETON };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const config = this.configs.get(token);
|
|
79
|
-
|
|
80
|
-
if (!config) {
|
|
81
|
-
throw new Error(`Provider not found: ${token.name}`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 3. Create Instance with Scope Bubbling
|
|
85
|
-
const creation = this.createInstance(config, requestLocals);
|
|
86
|
-
|
|
87
|
-
// 4. Cache based on Effective Scope
|
|
88
|
-
if (creation.scope === Scope.SINGLETON) {
|
|
89
|
-
this.instances.set(token, creation.instance);
|
|
90
|
-
} else if (creation.scope === Scope.REQUEST && requestLocals) {
|
|
91
|
-
requestLocals.set(token, creation.instance);
|
|
92
|
-
}
|
|
93
|
-
// INSTANCE scope is never cached
|
|
94
|
-
|
|
95
|
-
return creation;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
private createInstance(config: ProviderConfig, requestLocals?: Map<Token, any>): { instance: any, scope: Scope } {
|
|
99
|
-
const target = config.useClass ?? config.token;
|
|
100
|
-
|
|
101
|
-
if (this.resolving.has(target)) {
|
|
102
|
-
throw new Error(`Circular dependency detected: ${target.name}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
this.resolving.add(target);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const depsToken = this.getDependencies(target);
|
|
109
|
-
|
|
110
|
-
if (depsToken.length === 0) {
|
|
111
|
-
// No deps: Scope is as configured
|
|
112
|
-
return { instance: new target(), scope: config.scope || Scope.SINGLETON };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const args: any[] = [];
|
|
116
|
-
let effectiveScope = config.scope || Scope.SINGLETON;
|
|
117
|
-
|
|
118
|
-
for (const depToken of depsToken) {
|
|
119
|
-
const depResult = this.resolveInternal(depToken, requestLocals);
|
|
120
|
-
args.push(depResult.instance);
|
|
121
|
-
|
|
122
|
-
// Scope Bubbling Logic:
|
|
123
|
-
// If a dependency is REQUEST scoped, the parent MUST become REQUEST scoped (if it was Singleton)
|
|
124
|
-
// to avoid holding a stale reference to a request-bound instance.
|
|
125
|
-
if (depResult.scope === Scope.REQUEST && effectiveScope === Scope.SINGLETON) {
|
|
126
|
-
effectiveScope = Scope.REQUEST;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Note: INSTANCE scope dependencies do not force bubbling because they are transient and safe to hold (usually),
|
|
130
|
-
// unless semantic logic dictates otherwise. For now, strictly bubbling REQUEST scope.
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return { instance: new target(...args), scope: effectiveScope };
|
|
134
|
-
} finally {
|
|
135
|
-
this.resolving.delete(target);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
private getDependencies(target: Token): Token[] {
|
|
140
|
-
const types = Reflect.getMetadata('design:paramtypes', target) || [];
|
|
141
|
-
return types.filter((t: any) => t && typeof t === 'function' && !this.isPrimitive(t));
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private isPrimitive(type: any): boolean {
|
|
145
|
-
return type === String || type === Number || type === Boolean || type === Object || type === Array || type === Symbol;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
private normalizeConfig<T>(config: ProviderConfig<T> | Token<T>): ProviderConfig<T> {
|
|
149
|
-
if (typeof config === 'function') {
|
|
150
|
-
return {
|
|
151
|
-
token: config,
|
|
152
|
-
useClass: config,
|
|
153
|
-
scope: Scope.SINGLETON
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
...config,
|
|
159
|
-
useClass: config.useClass ?? config.token,
|
|
160
|
-
scope: config.scope ?? Scope.SINGLETON
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
clear(): void {
|
|
165
|
-
this.configs.clear();
|
|
166
|
-
this.instances.clear();
|
|
167
|
-
}
|
|
168
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight DI Container for Turbo.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Constructor injection via reflect-metadata
|
|
6
|
+
* - Singleton scope by default
|
|
7
|
+
* - Request scope support
|
|
8
|
+
* - Lazy instantiation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type Token<T = any> = new (...args: any[]) => T;
|
|
12
|
+
|
|
13
|
+
export enum Scope {
|
|
14
|
+
SINGLETON = 'singleton', // Always the same instance
|
|
15
|
+
REQUEST = 'request', // New instance per request
|
|
16
|
+
INSTANCE = 'instance' // New instance per dependency injection
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProviderConfig<T = any> {
|
|
20
|
+
token: Token<T>;
|
|
21
|
+
useClass?: Token<T>;
|
|
22
|
+
useValue?: T;
|
|
23
|
+
scope?: Scope;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ProviderEntry {
|
|
27
|
+
config: ProviderConfig;
|
|
28
|
+
instance: any | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Container {
|
|
32
|
+
private configs = new Map<Token, ProviderConfig>();
|
|
33
|
+
private instances = new Map<Token, any>();
|
|
34
|
+
private resolving = new Set<Token>();
|
|
35
|
+
|
|
36
|
+
register<T>(config: ProviderConfig<T> | Token<T>): this {
|
|
37
|
+
const normalized = this.normalizeConfig(config);
|
|
38
|
+
|
|
39
|
+
this.configs.set(normalized.token, normalized);
|
|
40
|
+
|
|
41
|
+
if (normalized.useValue !== undefined) {
|
|
42
|
+
this.instances.set(normalized.token, normalized.useValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get<T>(token: Token<T>): T {
|
|
49
|
+
const cached = this.instances.get(token);
|
|
50
|
+
|
|
51
|
+
if (cached !== undefined) {
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const res = this.resolveInternal(token);
|
|
56
|
+
return res.instance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
has(token: Token): boolean {
|
|
60
|
+
return this.configs.has(token);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves a token to return instance and its effective scope.
|
|
65
|
+
*/
|
|
66
|
+
private resolveInternal<T>(token: Token<T>, requestLocals?: Map<Token, any>): { instance: T, scope: Scope } {
|
|
67
|
+
// 1. Check Request Cache
|
|
68
|
+
if (requestLocals?.has(token)) {
|
|
69
|
+
return { instance: requestLocals.get(token), scope: Scope.REQUEST };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Check Singleton Cache
|
|
73
|
+
const cached = this.instances.get(token);
|
|
74
|
+
if (cached !== undefined) {
|
|
75
|
+
return { instance: cached, scope: Scope.SINGLETON };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const config = this.configs.get(token);
|
|
79
|
+
|
|
80
|
+
if (!config) {
|
|
81
|
+
throw new Error(`Provider not found: ${token.name}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Create Instance with Scope Bubbling
|
|
85
|
+
const creation = this.createInstance(config, requestLocals);
|
|
86
|
+
|
|
87
|
+
// 4. Cache based on Effective Scope
|
|
88
|
+
if (creation.scope === Scope.SINGLETON) {
|
|
89
|
+
this.instances.set(token, creation.instance);
|
|
90
|
+
} else if (creation.scope === Scope.REQUEST && requestLocals) {
|
|
91
|
+
requestLocals.set(token, creation.instance);
|
|
92
|
+
}
|
|
93
|
+
// INSTANCE scope is never cached
|
|
94
|
+
|
|
95
|
+
return creation;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private createInstance(config: ProviderConfig, requestLocals?: Map<Token, any>): { instance: any, scope: Scope } {
|
|
99
|
+
const target = config.useClass ?? config.token;
|
|
100
|
+
|
|
101
|
+
if (this.resolving.has(target)) {
|
|
102
|
+
throw new Error(`Circular dependency detected: ${target.name}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.resolving.add(target);
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const depsToken = this.getDependencies(target);
|
|
109
|
+
|
|
110
|
+
if (depsToken.length === 0) {
|
|
111
|
+
// No deps: Scope is as configured
|
|
112
|
+
return { instance: new target(), scope: config.scope || Scope.SINGLETON };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const args: any[] = [];
|
|
116
|
+
let effectiveScope = config.scope || Scope.SINGLETON;
|
|
117
|
+
|
|
118
|
+
for (const depToken of depsToken) {
|
|
119
|
+
const depResult = this.resolveInternal(depToken, requestLocals);
|
|
120
|
+
args.push(depResult.instance);
|
|
121
|
+
|
|
122
|
+
// Scope Bubbling Logic:
|
|
123
|
+
// If a dependency is REQUEST scoped, the parent MUST become REQUEST scoped (if it was Singleton)
|
|
124
|
+
// to avoid holding a stale reference to a request-bound instance.
|
|
125
|
+
if (depResult.scope === Scope.REQUEST && effectiveScope === Scope.SINGLETON) {
|
|
126
|
+
effectiveScope = Scope.REQUEST;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Note: INSTANCE scope dependencies do not force bubbling because they are transient and safe to hold (usually),
|
|
130
|
+
// unless semantic logic dictates otherwise. For now, strictly bubbling REQUEST scope.
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { instance: new target(...args), scope: effectiveScope };
|
|
134
|
+
} finally {
|
|
135
|
+
this.resolving.delete(target);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private getDependencies(target: Token): Token[] {
|
|
140
|
+
const types = Reflect.getMetadata('design:paramtypes', target) || [];
|
|
141
|
+
return types.filter((t: any) => t && typeof t === 'function' && !this.isPrimitive(t));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private isPrimitive(type: any): boolean {
|
|
145
|
+
return type === String || type === Number || type === Boolean || type === Object || type === Array || type === Symbol;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private normalizeConfig<T>(config: ProviderConfig<T> | Token<T>): ProviderConfig<T> {
|
|
149
|
+
if (typeof config === 'function') {
|
|
150
|
+
return {
|
|
151
|
+
token: config,
|
|
152
|
+
useClass: config,
|
|
153
|
+
scope: Scope.SINGLETON
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...config,
|
|
159
|
+
useClass: config.useClass ?? config.token,
|
|
160
|
+
scope: config.scope ?? Scope.SINGLETON
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
clear(): void {
|
|
165
|
+
this.configs.clear();
|
|
166
|
+
this.instances.clear();
|
|
167
|
+
}
|
|
168
|
+
}
|