@carno.js/core 1.1.2 → 1.3.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/dist/Carno.js +44 -5
- package/dist/Carno.mjs +44 -5
- package/dist/bun/index.js +4 -4
- package/dist/bun/index.js.map +6 -5
- 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 +76 -3
- package/src/compression/CompressionMiddleware.ts +221 -0
- package/src/index.ts +4 -0
- package/src/middleware/CarnoMiddleware.ts +8 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: !0 });
|
|
8
|
+
}, __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from == "object" || typeof from == "function")
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
!__hasOwnProp.call(to, key) && key !== except && __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
12
|
+
return to;
|
|
13
|
+
};
|
|
14
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: !0 }), mod);
|
|
15
|
+
var CompressionMiddleware_exports = {};
|
|
16
|
+
__export(CompressionMiddleware_exports, {
|
|
17
|
+
CompressionMiddleware: () => CompressionMiddleware
|
|
18
|
+
});
|
|
19
|
+
module.exports = __toCommonJS(CompressionMiddleware_exports);
|
|
20
|
+
var import_zlib = require("zlib");
|
|
21
|
+
const DEFAULT_THRESHOLD = 1024, DEFAULT_ENCODINGS = ["br", "gzip"], DEFAULT_COMPRESSIBLE_TYPES = [
|
|
22
|
+
"text/",
|
|
23
|
+
"application/json",
|
|
24
|
+
"application/javascript",
|
|
25
|
+
"application/xml",
|
|
26
|
+
"application/xhtml+xml",
|
|
27
|
+
"image/svg+xml"
|
|
28
|
+
], DEFAULT_BROTLI_QUALITY = 4, DEFAULT_GZIP_LEVEL = 6;
|
|
29
|
+
class CompressionMiddleware {
|
|
30
|
+
constructor(config) {
|
|
31
|
+
this.threshold = config?.threshold ?? DEFAULT_THRESHOLD, this.compressibleTypes = (config?.compressibleTypes ?? DEFAULT_COMPRESSIBLE_TYPES).map((t) => t.toLowerCase());
|
|
32
|
+
const encodings = config?.encodings ?? DEFAULT_ENCODINGS;
|
|
33
|
+
this.encodingOrder = encodings;
|
|
34
|
+
const brotliQuality = config?.brotliQuality ?? DEFAULT_BROTLI_QUALITY, gzipLevel = config?.gzipLevel ?? DEFAULT_GZIP_LEVEL;
|
|
35
|
+
this.compressors = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const enc of encodings)
|
|
37
|
+
switch (enc) {
|
|
38
|
+
case "br":
|
|
39
|
+
this.compressors.set("br", (data) => {
|
|
40
|
+
const buf = (0, import_zlib.brotliCompressSync)(data, {
|
|
41
|
+
params: { [import_zlib.constants.BROTLI_PARAM_QUALITY]: brotliQuality }
|
|
42
|
+
});
|
|
43
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
44
|
+
});
|
|
45
|
+
break;
|
|
46
|
+
case "gzip":
|
|
47
|
+
this.compressors.set(
|
|
48
|
+
"gzip",
|
|
49
|
+
(data) => Bun.gzipSync(data, { level: gzipLevel })
|
|
50
|
+
);
|
|
51
|
+
break;
|
|
52
|
+
case "deflate":
|
|
53
|
+
this.compressors.set(
|
|
54
|
+
"deflate",
|
|
55
|
+
(data) => Bun.deflateSync(data)
|
|
56
|
+
);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async handle(ctx, next) {
|
|
61
|
+
const response = await next(), acceptEncoding = ctx.req.headers.get("accept-encoding");
|
|
62
|
+
if (!acceptEncoding || response.headers.get("content-encoding"))
|
|
63
|
+
return response;
|
|
64
|
+
const contentType = response.headers.get("content-type");
|
|
65
|
+
if (!contentType || !this.isCompressible(contentType))
|
|
66
|
+
return response;
|
|
67
|
+
const encoding = this.negotiateEncoding(acceptEncoding);
|
|
68
|
+
if (!encoding)
|
|
69
|
+
return response;
|
|
70
|
+
const buffer = await response.arrayBuffer();
|
|
71
|
+
if (buffer.byteLength < this.threshold)
|
|
72
|
+
return new Response(buffer, {
|
|
73
|
+
status: response.status,
|
|
74
|
+
statusText: response.statusText,
|
|
75
|
+
headers: response.headers
|
|
76
|
+
});
|
|
77
|
+
const compressor = this.compressors.get(encoding), bodyBytes = new Uint8Array(buffer), compressed = compressor(bodyBytes);
|
|
78
|
+
if (compressed.byteLength >= buffer.byteLength)
|
|
79
|
+
return new Response(buffer, {
|
|
80
|
+
status: response.status,
|
|
81
|
+
statusText: response.statusText,
|
|
82
|
+
headers: response.headers
|
|
83
|
+
});
|
|
84
|
+
const headers = new Headers(response.headers);
|
|
85
|
+
return headers.set("Content-Encoding", encoding), headers.set("Content-Length", String(compressed.byteLength)), headers.set("Vary", this.buildVaryHeader(headers.get("Vary"))), new Response(compressed, {
|
|
86
|
+
status: response.status,
|
|
87
|
+
statusText: response.statusText,
|
|
88
|
+
headers
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
isCompressible(contentType) {
|
|
92
|
+
const lower = contentType.toLowerCase();
|
|
93
|
+
for (const pattern of this.compressibleTypes)
|
|
94
|
+
if (lower.includes(pattern)) return !0;
|
|
95
|
+
return !1;
|
|
96
|
+
}
|
|
97
|
+
negotiateEncoding(acceptEncoding) {
|
|
98
|
+
const lower = acceptEncoding.toLowerCase();
|
|
99
|
+
for (const encoding of this.encodingOrder)
|
|
100
|
+
if (lower.includes(encoding)) return encoding;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
buildVaryHeader(existing) {
|
|
104
|
+
return existing ? existing.toLowerCase().includes("accept-encoding") ? existing : `${existing}, Accept-Encoding` : "Accept-Encoding";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
108
|
+
0 && (module.exports = {
|
|
109
|
+
CompressionMiddleware
|
|
110
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { brotliCompressSync, constants } from "zlib";
|
|
2
|
+
const DEFAULT_THRESHOLD = 1024, DEFAULT_ENCODINGS = ["br", "gzip"], DEFAULT_COMPRESSIBLE_TYPES = [
|
|
3
|
+
"text/",
|
|
4
|
+
"application/json",
|
|
5
|
+
"application/javascript",
|
|
6
|
+
"application/xml",
|
|
7
|
+
"application/xhtml+xml",
|
|
8
|
+
"image/svg+xml"
|
|
9
|
+
], DEFAULT_BROTLI_QUALITY = 4, DEFAULT_GZIP_LEVEL = 6;
|
|
10
|
+
class CompressionMiddleware {
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this.threshold = config?.threshold ?? DEFAULT_THRESHOLD, this.compressibleTypes = (config?.compressibleTypes ?? DEFAULT_COMPRESSIBLE_TYPES).map((t) => t.toLowerCase());
|
|
13
|
+
const encodings = config?.encodings ?? DEFAULT_ENCODINGS;
|
|
14
|
+
this.encodingOrder = encodings;
|
|
15
|
+
const brotliQuality = config?.brotliQuality ?? DEFAULT_BROTLI_QUALITY, gzipLevel = config?.gzipLevel ?? DEFAULT_GZIP_LEVEL;
|
|
16
|
+
this.compressors = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const enc of encodings)
|
|
18
|
+
switch (enc) {
|
|
19
|
+
case "br":
|
|
20
|
+
this.compressors.set("br", (data) => {
|
|
21
|
+
const buf = brotliCompressSync(data, {
|
|
22
|
+
params: { [constants.BROTLI_PARAM_QUALITY]: brotliQuality }
|
|
23
|
+
});
|
|
24
|
+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
25
|
+
});
|
|
26
|
+
break;
|
|
27
|
+
case "gzip":
|
|
28
|
+
this.compressors.set(
|
|
29
|
+
"gzip",
|
|
30
|
+
(data) => Bun.gzipSync(data, { level: gzipLevel })
|
|
31
|
+
);
|
|
32
|
+
break;
|
|
33
|
+
case "deflate":
|
|
34
|
+
this.compressors.set(
|
|
35
|
+
"deflate",
|
|
36
|
+
(data) => Bun.deflateSync(data)
|
|
37
|
+
);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async handle(ctx, next) {
|
|
42
|
+
const response = await next(), acceptEncoding = ctx.req.headers.get("accept-encoding");
|
|
43
|
+
if (!acceptEncoding || response.headers.get("content-encoding"))
|
|
44
|
+
return response;
|
|
45
|
+
const contentType = response.headers.get("content-type");
|
|
46
|
+
if (!contentType || !this.isCompressible(contentType))
|
|
47
|
+
return response;
|
|
48
|
+
const encoding = this.negotiateEncoding(acceptEncoding);
|
|
49
|
+
if (!encoding)
|
|
50
|
+
return response;
|
|
51
|
+
const buffer = await response.arrayBuffer();
|
|
52
|
+
if (buffer.byteLength < this.threshold)
|
|
53
|
+
return new Response(buffer, {
|
|
54
|
+
status: response.status,
|
|
55
|
+
statusText: response.statusText,
|
|
56
|
+
headers: response.headers
|
|
57
|
+
});
|
|
58
|
+
const compressor = this.compressors.get(encoding), bodyBytes = new Uint8Array(buffer), compressed = compressor(bodyBytes);
|
|
59
|
+
if (compressed.byteLength >= buffer.byteLength)
|
|
60
|
+
return new Response(buffer, {
|
|
61
|
+
status: response.status,
|
|
62
|
+
statusText: response.statusText,
|
|
63
|
+
headers: response.headers
|
|
64
|
+
});
|
|
65
|
+
const headers = new Headers(response.headers);
|
|
66
|
+
return headers.set("Content-Encoding", encoding), headers.set("Content-Length", String(compressed.byteLength)), headers.set("Vary", this.buildVaryHeader(headers.get("Vary"))), new Response(compressed, {
|
|
67
|
+
status: response.status,
|
|
68
|
+
statusText: response.statusText,
|
|
69
|
+
headers
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
isCompressible(contentType) {
|
|
73
|
+
const lower = contentType.toLowerCase();
|
|
74
|
+
for (const pattern of this.compressibleTypes)
|
|
75
|
+
if (lower.includes(pattern)) return !0;
|
|
76
|
+
return !1;
|
|
77
|
+
}
|
|
78
|
+
negotiateEncoding(acceptEncoding) {
|
|
79
|
+
const lower = acceptEncoding.toLowerCase();
|
|
80
|
+
for (const encoding of this.encodingOrder)
|
|
81
|
+
if (lower.includes(encoding)) return encoding;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
buildVaryHeader(existing) {
|
|
85
|
+
return existing ? existing.toLowerCase().includes("accept-encoding") ? existing : `${existing}, Accept-Encoding` : "Accept-Encoding";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export {
|
|
89
|
+
CompressionMiddleware
|
|
90
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ __export(index_exports, {
|
|
|
18
18
|
Body: () => import_params.Body,
|
|
19
19
|
CacheService: () => import_CacheService.CacheService,
|
|
20
20
|
Carno: () => import_Carno.Carno,
|
|
21
|
+
CompressionMiddleware: () => import_CompressionMiddleware.CompressionMiddleware,
|
|
21
22
|
ConflictException: () => import_HttpException.ConflictException,
|
|
22
23
|
Container: () => import_Container.Container,
|
|
23
24
|
Context: () => import_Context.Context,
|
|
@@ -70,13 +71,14 @@ __export(index_exports, {
|
|
|
70
71
|
withTestApp: () => import_TestHarness.withTestApp
|
|
71
72
|
});
|
|
72
73
|
module.exports = __toCommonJS(index_exports);
|
|
73
|
-
var import_reflect_metadata = require("reflect-metadata"), import_Carno = require('./Carno.js'), import_Context = require('./context/Context.js'), import_Controller = require('./decorators/Controller.js'), import_methods = require('./decorators/methods.js'), import_params = require('./decorators/params.js'), import_Middleware = require('./decorators/Middleware.js'), import_Service = require('./decorators/Service.js'), import_Inject = require('./decorators/Inject.js'), import_Container = require('./container/Container.js'), import_RadixRouter = require('./router/RadixRouter.js'), import_CorsHandler = require('./cors/CorsHandler.js'), import_ValidatorAdapter = require('./validation/ValidatorAdapter.js'), import_ZodAdapter = require('./validation/ZodAdapter.js'), import_ValibotAdapter = require('./validation/ValibotAdapter.js'), import_HttpException = require('./exceptions/HttpException.js'), import_Lifecycle = require('./events/Lifecycle.js'), import_CacheService = require('./cache/CacheService.js'), import_MemoryDriver = require('./cache/MemoryDriver.js'), import_RedisDriver = require('./cache/RedisDriver.js'), import_TestHarness = require('./testing/TestHarness.js'), import_Metadata = require('./utils/Metadata.js');
|
|
74
|
+
var import_reflect_metadata = require("reflect-metadata"), import_Carno = require('./Carno.js'), import_Context = require('./context/Context.js'), import_Controller = require('./decorators/Controller.js'), import_methods = require('./decorators/methods.js'), import_params = require('./decorators/params.js'), import_Middleware = require('./decorators/Middleware.js'), import_Service = require('./decorators/Service.js'), import_Inject = require('./decorators/Inject.js'), import_Container = require('./container/Container.js'), import_RadixRouter = require('./router/RadixRouter.js'), import_CorsHandler = require('./cors/CorsHandler.js'), import_CompressionMiddleware = require('./compression/CompressionMiddleware.js'), import_ValidatorAdapter = require('./validation/ValidatorAdapter.js'), import_ZodAdapter = require('./validation/ZodAdapter.js'), import_ValibotAdapter = require('./validation/ValibotAdapter.js'), import_HttpException = require('./exceptions/HttpException.js'), import_Lifecycle = require('./events/Lifecycle.js'), import_CacheService = require('./cache/CacheService.js'), import_MemoryDriver = require('./cache/MemoryDriver.js'), import_RedisDriver = require('./cache/RedisDriver.js'), import_TestHarness = require('./testing/TestHarness.js'), import_Metadata = require('./utils/Metadata.js');
|
|
74
75
|
// Annotate the CommonJS export names for ESM import in node:
|
|
75
76
|
0 && (module.exports = {
|
|
76
77
|
BadRequestException,
|
|
77
78
|
Body,
|
|
78
79
|
CacheService,
|
|
79
80
|
Carno,
|
|
81
|
+
CompressionMiddleware,
|
|
80
82
|
ConflictException,
|
|
81
83
|
Container,
|
|
82
84
|
Context,
|
package/dist/index.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { Inject } from "./decorators/Inject.mjs";
|
|
|
10
10
|
import { Container, Scope } from "./container/Container.mjs";
|
|
11
11
|
import { RadixRouter } from "./router/RadixRouter.mjs";
|
|
12
12
|
import { CorsHandler } from "./cors/CorsHandler.mjs";
|
|
13
|
+
import { CompressionMiddleware } from "./compression/CompressionMiddleware.mjs";
|
|
13
14
|
import { Schema, getSchema, VALIDATION_SCHEMA } from "./validation/ValidatorAdapter.mjs";
|
|
14
15
|
import { ZodAdapter, ValidationException } from "./validation/ZodAdapter.mjs";
|
|
15
16
|
import { ValibotAdapter } from "./validation/ValibotAdapter.mjs";
|
|
@@ -42,6 +43,7 @@ export {
|
|
|
42
43
|
Body,
|
|
43
44
|
CacheService,
|
|
44
45
|
Carno,
|
|
46
|
+
CompressionMiddleware,
|
|
45
47
|
ConflictException,
|
|
46
48
|
Container,
|
|
47
49
|
Context,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carno.js/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Ultra-fast HTTP framework with aggressive AOT/JIT compilation",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -21,5 +21,5 @@
|
|
|
21
21
|
"esbuild-fix-imports-plugin": "^1.0.23",
|
|
22
22
|
"tsup": "^8.5.1"
|
|
23
23
|
},
|
|
24
|
-
"gitHead": "
|
|
24
|
+
"gitHead": "5e5c0a508a88a08d5af99081777a48e2dae539fe"
|
|
25
25
|
}
|
package/src/Carno.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type MiddlewareHandler = (ctx: Context) => Response | void | Promise<Resp
|
|
|
23
23
|
|
|
24
24
|
export type MiddlewareClass = new (...args: any[]) => CarnoMiddleware;
|
|
25
25
|
|
|
26
|
-
export type MiddlewareEntry = MiddlewareHandler | MiddlewareClass;
|
|
26
|
+
export type MiddlewareEntry = MiddlewareHandler | MiddlewareClass | CarnoMiddleware;
|
|
27
27
|
|
|
28
28
|
type ResolvedMiddleware =
|
|
29
29
|
| { kind: 'function'; handler: MiddlewareHandler }
|
|
@@ -84,6 +84,10 @@ export class Carno {
|
|
|
84
84
|
private validator: ValidatorAdapter | null = null;
|
|
85
85
|
private server: any;
|
|
86
86
|
|
|
87
|
+
// WebSocket support
|
|
88
|
+
_wsHandlerBuilder: ((container: Container) => any) | null = null;
|
|
89
|
+
_wsUpgradePaths: Set<string> = new Set();
|
|
90
|
+
|
|
87
91
|
// Cached lifecycle event flags - checked once at startup
|
|
88
92
|
private hasInitHooks = false;
|
|
89
93
|
private hasBootHooks = false;
|
|
@@ -117,7 +121,7 @@ export class Carno {
|
|
|
117
121
|
|
|
118
122
|
/**
|
|
119
123
|
* Use a Carno plugin.
|
|
120
|
-
* Imports controllers, services, middlewares, and
|
|
124
|
+
* Imports controllers, services, middlewares, routes, and WebSocket config from another Carno instance.
|
|
121
125
|
*/
|
|
122
126
|
use(plugin: Carno): this {
|
|
123
127
|
// Import controllers from plugin
|
|
@@ -165,9 +169,36 @@ export class Carno {
|
|
|
165
169
|
}
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
// Import WebSocket handler builder and upgrade paths
|
|
173
|
+
if (plugin._wsHandlerBuilder) {
|
|
174
|
+
this._wsHandlerBuilder = plugin._wsHandlerBuilder;
|
|
175
|
+
for (const path of plugin._wsUpgradePaths) {
|
|
176
|
+
this._wsUpgradePaths.add(path);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Register a WebSocket handler builder and the upgrade paths.
|
|
185
|
+
* Used internally by @carno.js/websocket plugin.
|
|
186
|
+
*/
|
|
187
|
+
wsHandler(builder: (container: Container) => any, upgradePaths: string[]): this {
|
|
188
|
+
this._wsHandlerBuilder = builder;
|
|
189
|
+
for (const path of upgradePaths) {
|
|
190
|
+
this._wsUpgradePaths.add(path);
|
|
191
|
+
}
|
|
168
192
|
return this;
|
|
169
193
|
}
|
|
170
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Returns the underlying Bun server instance (available after listen()).
|
|
197
|
+
*/
|
|
198
|
+
getServer(): any {
|
|
199
|
+
return this.server;
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
private findServiceInPlugin(plugin: Carno, exported: any): any | undefined {
|
|
172
203
|
return plugin._services.find(
|
|
173
204
|
s => this.getServiceToken(s) === this.getServiceToken(exported)
|
|
@@ -284,6 +315,32 @@ export class Carno {
|
|
|
284
315
|
}
|
|
285
316
|
};
|
|
286
317
|
|
|
318
|
+
// Wire in WebSocket support if a handler builder was registered
|
|
319
|
+
if (this._wsHandlerBuilder && this._wsUpgradePaths.size > 0) {
|
|
320
|
+
config.websocket = this._wsHandlerBuilder(this.container);
|
|
321
|
+
|
|
322
|
+
const upgradePaths = this._wsUpgradePaths;
|
|
323
|
+
const fallback = this.handleNotFound.bind(this);
|
|
324
|
+
|
|
325
|
+
config.fetch = (req: Request, server: any) => {
|
|
326
|
+
const pathname = new URL(req.url).pathname;
|
|
327
|
+
|
|
328
|
+
if (upgradePaths.has(pathname)) {
|
|
329
|
+
const upgraded = server.upgrade(req, {
|
|
330
|
+
data: {
|
|
331
|
+
id: crypto.randomUUID(),
|
|
332
|
+
namespace: pathname,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
if (upgraded) return;
|
|
337
|
+
return new Response('WebSocket upgrade failed', { status: 400 });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return fallback(req);
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
287
344
|
this.server = Bun.serve(config);
|
|
288
345
|
|
|
289
346
|
// Execute BOOT hooks after server is ready
|
|
@@ -313,6 +370,12 @@ export class Carno {
|
|
|
313
370
|
useValue: this.container
|
|
314
371
|
});
|
|
315
372
|
|
|
373
|
+
// Register this Carno instance so services can inject it (e.g. RoomManager)
|
|
374
|
+
this.container.register({
|
|
375
|
+
token: Carno,
|
|
376
|
+
useValue: this
|
|
377
|
+
});
|
|
378
|
+
|
|
316
379
|
// Always register CacheService (Memory by default)
|
|
317
380
|
const cacheConfig = typeof this.config.cache === 'object' ? this.config.cache : {};
|
|
318
381
|
this.container.register({
|
|
@@ -539,6 +602,11 @@ export class Carno {
|
|
|
539
602
|
return { kind: 'class', instance };
|
|
540
603
|
}
|
|
541
604
|
|
|
605
|
+
// Pre-built instance with handle method
|
|
606
|
+
if (typeof middleware === 'object' && middleware !== null && 'handle' in middleware) {
|
|
607
|
+
return { kind: 'class', instance: middleware as CarnoMiddleware };
|
|
608
|
+
}
|
|
609
|
+
|
|
542
610
|
// Already a function
|
|
543
611
|
return { kind: 'function', handler: middleware as MiddlewareHandler };
|
|
544
612
|
}
|
|
@@ -572,9 +640,14 @@ export class Carno {
|
|
|
572
640
|
} else {
|
|
573
641
|
chain = async (ctx: Context) => {
|
|
574
642
|
let response: Response | undefined;
|
|
575
|
-
await mw.instance.handle(ctx, async () => {
|
|
643
|
+
const result = await mw.instance.handle(ctx, async () => {
|
|
576
644
|
response = await nextLayer(ctx);
|
|
645
|
+
return response;
|
|
577
646
|
});
|
|
647
|
+
// If middleware returned a Response, use it (enables response transformation)
|
|
648
|
+
if (result instanceof Response) {
|
|
649
|
+
return result;
|
|
650
|
+
}
|
|
578
651
|
return response ?? new Response(null, { status: 200 });
|
|
579
652
|
};
|
|
580
653
|
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -51,6 +51,10 @@ export type { RouteMatch } from './router/RadixRouter';
|
|
|
51
51
|
export { CorsHandler } from './cors/CorsHandler';
|
|
52
52
|
export type { CorsConfig, CorsOrigin } from './cors/CorsHandler';
|
|
53
53
|
|
|
54
|
+
// Compression
|
|
55
|
+
export { CompressionMiddleware } from './compression/CompressionMiddleware';
|
|
56
|
+
export type { CompressionConfig } from './compression/CompressionMiddleware';
|
|
57
|
+
|
|
54
58
|
// Validation
|
|
55
59
|
export type { ValidatorAdapter, ValidationResult, ValidationError, ValidationConfig } from './validation/ValidatorAdapter';
|
|
56
60
|
export { Schema, getSchema, VALIDATION_SCHEMA } from './validation/ValidatorAdapter';
|
|
@@ -2,13 +2,19 @@ import type { Context } from '../context/Context';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Closure function to call the next middleware in the chain.
|
|
5
|
+
* Returns the Response produced by the next layer, allowing
|
|
6
|
+
* middlewares to inspect or transform it.
|
|
5
7
|
*/
|
|
6
|
-
export type CarnoClosure = () =>
|
|
8
|
+
export type CarnoClosure = () => Promise<Response>;
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Interface for onion-style middleware.
|
|
10
12
|
* Middleware must call next() to continue the chain.
|
|
13
|
+
*
|
|
14
|
+
* Returning a Response from handle() replaces the response
|
|
15
|
+
* produced by the downstream chain (useful for response transformers
|
|
16
|
+
* like compression).
|
|
11
17
|
*/
|
|
12
18
|
export interface CarnoMiddleware {
|
|
13
|
-
handle(ctx: Context, next: CarnoClosure): void | Promise<void>;
|
|
19
|
+
handle(ctx: Context, next: CarnoClosure): void | Response | Promise<void | Response>;
|
|
14
20
|
}
|