@asenajs/ergenecore 3.2.1 → 5.0.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/README.md +12 -5
- package/dist/lib/Ergenecore.d.ts +12 -1
- package/dist/lib/Ergenecore.js +320 -173
- package/dist/lib/Ergenecore.js.map +1 -1
- package/dist/lib/ErgenecoreContextWrapper.d.ts +45 -12
- package/dist/lib/ErgenecoreContextWrapper.js +200 -54
- package/dist/lib/ErgenecoreContextWrapper.js.map +1 -1
- package/dist/lib/defaults/index.d.ts +1 -0
- package/dist/lib/defaults/index.js +1 -0
- package/dist/lib/defaults/index.js.map +1 -1
- package/dist/lib/defineContext.d.ts +8 -0
- package/dist/lib/defineContext.js +10 -0
- package/dist/lib/defineContext.js.map +1 -0
- package/dist/lib/index.d.ts +8 -1
- package/dist/lib/index.js +3 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/middlewares/CorsMiddleware.d.ts +2 -2
- package/dist/lib/middlewares/CorsMiddleware.js +5 -9
- package/dist/lib/middlewares/CorsMiddleware.js.map +1 -1
- package/dist/lib/middlewares/RateLimiterMiddleware.d.ts +73 -3
- package/dist/lib/middlewares/RateLimiterMiddleware.js +95 -7
- package/dist/lib/middlewares/RateLimiterMiddleware.js.map +1 -1
- package/dist/lib/middlewares/WebSocketOriginMiddleware.d.ts +82 -0
- package/dist/lib/middlewares/WebSocketOriginMiddleware.js +81 -0
- package/dist/lib/middlewares/WebSocketOriginMiddleware.js.map +1 -0
- package/dist/lib/stream/SSEStreamWriter.js +22 -13
- package/dist/lib/stream/SSEStreamWriter.js.map +1 -1
- package/dist/lib/utils/staticFileReader.d.ts +27 -0
- package/dist/lib/utils/staticFileReader.js +172 -0
- package/dist/lib/utils/staticFileReader.js.map +1 -0
- package/package.json +11 -7
package/dist/lib/Ergenecore.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AsenaAdapter, isHttpException, VALIDATOR_METHODS, } from '@asenajs/asena/adapter';
|
|
1
|
+
import { AsenaAdapter, isHttpException, VALIDATOR_METHODS, ASENA_CONTEXT_CONTRACT, assertContextAdapter, assertRouteContract, prepareContextValidation, } from '@asenajs/asena/adapter';
|
|
2
2
|
import { blue, green, red, yellow } from '@asenajs/asena/logger';
|
|
3
3
|
import { ErgenecoreWebsocketAdapter } from './ErgenecoreWebsocketAdapter';
|
|
4
4
|
import { ErgenecoreContextWrapper } from './ErgenecoreContextWrapper';
|
|
@@ -6,6 +6,7 @@ import * as Bun from 'bun';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { HttpException, MiddlewareResponseError, ValidationError } from './errors';
|
|
8
8
|
import { shouldApplyMiddleware } from '@asenajs/asena/utils';
|
|
9
|
+
import { regularFileStats, StaticFileReader, staticFileHeaders } from './utils/staticFileReader';
|
|
9
10
|
/**
|
|
10
11
|
* Static response headers for performance
|
|
11
12
|
*
|
|
@@ -18,6 +19,62 @@ const STATIC_JSON_HEADERS = Object.freeze({ 'Content-Type': 'application/json' }
|
|
|
18
19
|
*/
|
|
19
20
|
const INTERNAL_SERVER_ERROR = JSON.stringify({ error: 'Internal Server Error' });
|
|
20
21
|
const NOT_FOUND = JSON.stringify({ error: 'Not Found' });
|
|
22
|
+
const JSON_HEADERS = new Headers(STATIC_JSON_HEADERS);
|
|
23
|
+
function isThenable(value) {
|
|
24
|
+
return (value instanceof Promise ||
|
|
25
|
+
(value !== null &&
|
|
26
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
27
|
+
typeof value.then === 'function'));
|
|
28
|
+
}
|
|
29
|
+
const JSON_INIT = { headers: JSON_HEADERS };
|
|
30
|
+
function toHandlerResponse(response) {
|
|
31
|
+
if (response instanceof Response) {
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
return new Response(JSON.stringify(response), JSON_INIT);
|
|
35
|
+
}
|
|
36
|
+
function resolveStaticPath(root, rewrittenPath) {
|
|
37
|
+
// 3. Build absolute file path
|
|
38
|
+
const filePath = path.join(root, rewrittenPath);
|
|
39
|
+
// 4. Security: Resolve and validate path to prevent traversal attacks
|
|
40
|
+
return path.sep !== '/' || filePath.charCodeAt(0) !== 47
|
|
41
|
+
? path.resolve(filePath)
|
|
42
|
+
: filePath.length > 1 && filePath.endsWith('/')
|
|
43
|
+
? filePath.slice(0, -1)
|
|
44
|
+
: filePath;
|
|
45
|
+
}
|
|
46
|
+
function isNormalizedRelativePath(value) {
|
|
47
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
let segmentStart = value.charCodeAt(0) === 47 ? 1 : 0;
|
|
51
|
+
for (let i = segmentStart; i <= value.length; i++) {
|
|
52
|
+
if (i < value.length && value.charCodeAt(i) !== 47) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const length = i - segmentStart;
|
|
56
|
+
if (length === 0 ||
|
|
57
|
+
(value.charCodeAt(segmentStart) === 46 && (length === 1 || (length === 2 && value.charCodeAt(i - 1) === 46)))) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
segmentStart = i + 1;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
function requestPathname(url) {
|
|
65
|
+
const authority = url.indexOf('//');
|
|
66
|
+
const start = authority === -1 ? -1 : url.indexOf('/', authority + 2);
|
|
67
|
+
if (start === -1) {
|
|
68
|
+
return new URL(url).pathname;
|
|
69
|
+
}
|
|
70
|
+
for (let i = start; i < url.length; i++) {
|
|
71
|
+
const code = url.charCodeAt(i);
|
|
72
|
+
if (code === 63 || code === 35) {
|
|
73
|
+
return url.slice(start, i);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return start === 0 ? url : url.slice(start);
|
|
77
|
+
}
|
|
21
78
|
/**
|
|
22
79
|
* CoreAdapter - Native Bun adapter for Asenajs
|
|
23
80
|
*
|
|
@@ -66,6 +123,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
66
123
|
* Handler for requests that matched no route
|
|
67
124
|
*/
|
|
68
125
|
notFoundHandler;
|
|
126
|
+
staticFileReader = new StaticFileReader();
|
|
69
127
|
/**
|
|
70
128
|
* Whether the adapter logs errors before handing them to the application.
|
|
71
129
|
* Set false when your own handler already logs with a correlation id.
|
|
@@ -194,6 +252,20 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
194
252
|
* ```
|
|
195
253
|
*/
|
|
196
254
|
registerRoute(params) {
|
|
255
|
+
const label = `${params.method} ${params.path}`;
|
|
256
|
+
if (params.contextContract && !params.contextClass) {
|
|
257
|
+
throw new Error(`${label}: contextContract requires contextClass`);
|
|
258
|
+
}
|
|
259
|
+
if (params.contextClass &&
|
|
260
|
+
params.contextContract !== undefined &&
|
|
261
|
+
params.contextContract !== params.contextClass[ASENA_CONTEXT_CONTRACT]) {
|
|
262
|
+
throw new Error(`${label}: contextContract does not match contextClass`);
|
|
263
|
+
}
|
|
264
|
+
if (params.validatorCache !== undefined && typeof params.validatorCache !== 'boolean') {
|
|
265
|
+
throw new Error(`${label}: validatorCache must be a boolean`);
|
|
266
|
+
}
|
|
267
|
+
assertRouteContract(params, params.contextClass, label);
|
|
268
|
+
assertContextAdapter(params.contextClass, 'ergenecore');
|
|
197
269
|
this.routeQueue.push(params);
|
|
198
270
|
}
|
|
199
271
|
/**
|
|
@@ -220,7 +292,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
220
292
|
const serverHostname = this._hostname;
|
|
221
293
|
if (!this.routesBuilt) {
|
|
222
294
|
// 1. Build HTTP routes
|
|
223
|
-
const httpRoutes = this.buildBunRoutes();
|
|
295
|
+
const httpRoutes = await this.buildBunRoutes();
|
|
224
296
|
// 2. Build WebSocket routes
|
|
225
297
|
const wsRoutes = this.buildWebSocketRoutes();
|
|
226
298
|
// 3. Check for path collisions
|
|
@@ -291,6 +363,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
291
363
|
}
|
|
292
364
|
}
|
|
293
365
|
finally {
|
|
366
|
+
this.staticFileReader.close();
|
|
294
367
|
// In a `finally`, and swallowed: `AsenaServer.runStop()` awaits this call unguarded, so an
|
|
295
368
|
// error escaping here would strand everything queued behind it - the components' @OnStop
|
|
296
369
|
// hooks, the microservice transports, ulak. A WebSocket layer that cannot let go of a
|
|
@@ -354,7 +427,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
354
427
|
*
|
|
355
428
|
* @returns Bun router object
|
|
356
429
|
*/
|
|
357
|
-
buildBunRoutes() {
|
|
430
|
+
async buildBunRoutes() {
|
|
358
431
|
const routes = {};
|
|
359
432
|
// Group routes by exact path. There used to be an outer grouping by *base* path too, but
|
|
360
433
|
// its only product was a `commonMiddlewares` array that createRouteHandler ignored - and
|
|
@@ -380,6 +453,11 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
380
453
|
}
|
|
381
454
|
for (const route of pathRoutes) {
|
|
382
455
|
const method = route.method.toUpperCase();
|
|
456
|
+
const contextClass = route.contextClass;
|
|
457
|
+
const validateContract = contextClass
|
|
458
|
+
? prepareContextValidation(route.contextContract ?? contextClass[ASENA_CONTEXT_CONTRACT])
|
|
459
|
+
: undefined;
|
|
460
|
+
const validationSteps = route.validator ? await this.prepareValidationSteps(route) : [];
|
|
383
461
|
// Fast Path Optimization with Pattern Matching
|
|
384
462
|
// ✅ Check if this route has any applicable global middlewares
|
|
385
463
|
const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
|
|
@@ -387,9 +465,12 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
387
465
|
const isSimpleRoute = applicableGlobalMiddlewares.length === 0 &&
|
|
388
466
|
(!route.middlewares || route.middlewares.length === 0) &&
|
|
389
467
|
!route.validator &&
|
|
468
|
+
!validateContract &&
|
|
390
469
|
!route.staticServe;
|
|
391
470
|
// Create handler based on route complexity
|
|
392
|
-
const handler = isSimpleRoute
|
|
471
|
+
const handler = isSimpleRoute
|
|
472
|
+
? this.createFastPathHandler(route)
|
|
473
|
+
: this.createRouteHandler(route, validateContract, validationSteps);
|
|
393
474
|
// Register handler to both path variants for trailing slash support
|
|
394
475
|
if (method === 'ALL') {
|
|
395
476
|
// Bun native: direct handler function matches all HTTP methods
|
|
@@ -872,33 +953,32 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
872
953
|
// - and once both were routed through `respondToError` the two bodies were identical. It was
|
|
873
954
|
// also the only place the adapter read `errorHandler` at *build* time rather than per request,
|
|
874
955
|
// so a handler registered after start() would have selected the wrong closure.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
956
|
+
const settle = (pending, context) => Promise.resolve(pending).then(toHandlerResponse, (error) => this.respondToError(error, context));
|
|
957
|
+
const hasParams = route.path.includes(':');
|
|
958
|
+
const ContextClass = (route.contextClass ?? ErgenecoreContextWrapper);
|
|
959
|
+
// Not `async`: a synchronous handler's Response goes back to Bun without a promise.
|
|
960
|
+
return (req) => {
|
|
961
|
+
let context;
|
|
962
|
+
try {
|
|
963
|
+
context = new ContextClass(req, this.server, route.path);
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
return this.respondToError(error, new ErgenecoreContextWrapper(req, this.server, route.path));
|
|
967
|
+
}
|
|
878
968
|
try {
|
|
879
|
-
// Inject Bun's native route params if present
|
|
880
969
|
// @ts-expect-error - Bun adds params to Request
|
|
881
|
-
if (req.params) {
|
|
970
|
+
if (hasParams && req.params) {
|
|
882
971
|
// @ts-expect-error - Bun adds params to Request
|
|
883
|
-
|
|
884
|
-
for (const key in params) {
|
|
885
|
-
context.setValue(`param:${key}`, params[key]);
|
|
886
|
-
}
|
|
972
|
+
context.setRouteParams(req.params);
|
|
887
973
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
if (response instanceof Response) {
|
|
892
|
-
return response;
|
|
974
|
+
const result = route.handler(context);
|
|
975
|
+
if (isThenable(result)) {
|
|
976
|
+
return settle(result, context);
|
|
893
977
|
}
|
|
894
|
-
|
|
895
|
-
return new Response(JSON.stringify(response), {
|
|
896
|
-
status: 200,
|
|
897
|
-
headers: STATIC_JSON_HEADERS,
|
|
898
|
-
});
|
|
978
|
+
return toHandlerResponse(result);
|
|
899
979
|
}
|
|
900
980
|
catch (error) {
|
|
901
|
-
return
|
|
981
|
+
return this.respondToError(error, context);
|
|
902
982
|
}
|
|
903
983
|
};
|
|
904
984
|
}
|
|
@@ -914,12 +994,13 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
914
994
|
* @param route - Route parameters
|
|
915
995
|
* @returns Bun-compatible route handler
|
|
916
996
|
*/
|
|
917
|
-
createRouteHandler(route) {
|
|
997
|
+
createRouteHandler(route, validateContract, validationSteps = []) {
|
|
918
998
|
// ✅ Filter global middlewares by path pattern (ONCE during route building)
|
|
919
999
|
// This happens at server startup, NOT on every request → zero runtime overhead
|
|
920
1000
|
const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
|
|
921
1001
|
// Combine global + route middlewares into a single chain
|
|
922
1002
|
const middlewares = [...applicableGlobalMiddlewares, ...(route.middlewares || [])];
|
|
1003
|
+
const hasMiddlewares = middlewares.length > 0;
|
|
923
1004
|
// Resolved root for static file serving, computed once at startup instead of per request
|
|
924
1005
|
const resolvedStaticRoot = route.staticServe ? path.resolve(route.staticServe.root) : '';
|
|
925
1006
|
// The containment test below is a prefix comparison, so it needs the separator baked in.
|
|
@@ -928,65 +1009,91 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
928
1009
|
const resolvedStaticRootPrefix = resolvedStaticRoot.endsWith(path.sep)
|
|
929
1010
|
? resolvedStaticRoot
|
|
930
1011
|
: `${resolvedStaticRoot}${path.sep}`;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1012
|
+
const joinsVerbatim = path.sep === '/' && typeof route.staticServe?.root === 'string' && path.isAbsolute(route.staticServe.root);
|
|
1013
|
+
const hasParams = route.path.includes(':');
|
|
1014
|
+
const ContextClass = (route.contextClass ?? ErgenecoreContextWrapper);
|
|
1015
|
+
const serveRoute = (req, context) => {
|
|
1016
|
+
if (route.staticServe) {
|
|
1017
|
+
const staticResponse = this.serveStaticFile(req, context, route.staticServe, resolvedStaticRoot, resolvedStaticRootPrefix, joinsVerbatim);
|
|
1018
|
+
if (isThenable(staticResponse)) {
|
|
1019
|
+
return staticResponse.then((response) => response ?? this.runRouteHandler(route, context));
|
|
1020
|
+
}
|
|
1021
|
+
return staticResponse;
|
|
1022
|
+
}
|
|
1023
|
+
return this.runRouteHandler(route, context);
|
|
1024
|
+
};
|
|
1025
|
+
const runRoute = validateContract || validationSteps.length > 0
|
|
1026
|
+
? async (req, context) => {
|
|
1027
|
+
if (validateContract)
|
|
1028
|
+
await validateContract(context);
|
|
1029
|
+
if (validationSteps.length > 0) {
|
|
1030
|
+
const validationResult = await this.validateRequest(context, validationSteps);
|
|
1031
|
+
if (validationResult)
|
|
1032
|
+
return validationResult;
|
|
1033
|
+
}
|
|
1034
|
+
return serveRoute(req, context);
|
|
1035
|
+
}
|
|
1036
|
+
: serveRoute;
|
|
1037
|
+
return (req) => {
|
|
1038
|
+
let context;
|
|
1039
|
+
try {
|
|
1040
|
+
context = new ContextClass(req, this.server, route.path);
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
return this.respondToError(error, new ErgenecoreContextWrapper(req, this.server, route.path));
|
|
1044
|
+
}
|
|
935
1045
|
// Inject Bun's native route params
|
|
936
1046
|
// @ts-expect-error - Bun adds params to Request
|
|
937
|
-
if (req.params) {
|
|
1047
|
+
if (hasParams && req.params) {
|
|
938
1048
|
// @ts-expect-error - Bun adds params to Request
|
|
939
|
-
|
|
940
|
-
for (const key in params) {
|
|
941
|
-
context.setValue(`param:${key}`, params[key]);
|
|
942
|
-
}
|
|
1049
|
+
context.setRouteParams(req.params);
|
|
943
1050
|
}
|
|
944
1051
|
try {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const validationResult = await this.validateRequest(context, route.validator);
|
|
951
|
-
if (validationResult)
|
|
952
|
-
return validationResult;
|
|
953
|
-
}
|
|
954
|
-
// Handle static file serving
|
|
955
|
-
if (route.staticServe) {
|
|
956
|
-
const staticResponse = await this.serveStaticFile(req, context, route.staticServe, resolvedStaticRoot, resolvedStaticRootPrefix);
|
|
957
|
-
if (staticResponse)
|
|
958
|
-
return staticResponse;
|
|
959
|
-
}
|
|
960
|
-
// Execute route handler
|
|
961
|
-
const response = await route.handler(context);
|
|
962
|
-
// If handler returns Response, return it directly
|
|
963
|
-
if (response instanceof Response) {
|
|
964
|
-
return response;
|
|
965
|
-
}
|
|
966
|
-
// Otherwise, wrap in Response
|
|
967
|
-
return new Response(JSON.stringify(response), {
|
|
968
|
-
status: 200,
|
|
969
|
-
headers: STATIC_JSON_HEADERS,
|
|
970
|
-
});
|
|
971
|
-
});
|
|
972
|
-
// Single choke point so middleware headers reach handlers that answer without the
|
|
973
|
-
// wrapper. Idempotent for `ctx.send()`, which merged them already.
|
|
974
|
-
if (result instanceof Response) {
|
|
975
|
-
return context.applyMiddlewareHeaders(result);
|
|
1052
|
+
const result = hasMiddlewares
|
|
1053
|
+
? this.executeMiddlewares(context, middlewares, 0, () => runRoute(req, context))
|
|
1054
|
+
: runRoute(req, context);
|
|
1055
|
+
if (isThenable(result)) {
|
|
1056
|
+
return this.settleRoute(context, result);
|
|
976
1057
|
}
|
|
977
|
-
|
|
978
|
-
if (result === false) {
|
|
979
|
-
return context.applyMiddlewareHeaders(new Response('Forbidden', { status: 403 }));
|
|
980
|
-
}
|
|
981
|
-
// Default: result is true (should not happen with onComplete, but just in case)
|
|
982
|
-
return context.applyMiddlewareHeaders(new Response(null, { status: 204 }));
|
|
1058
|
+
return this.finishRoute(context, result);
|
|
983
1059
|
}
|
|
984
1060
|
catch (error) {
|
|
985
|
-
|
|
986
|
-
return context.applyMiddlewareHeaders(await this.respondToError(error, context));
|
|
1061
|
+
return this.failRoute(context, error);
|
|
987
1062
|
}
|
|
988
1063
|
};
|
|
989
1064
|
}
|
|
1065
|
+
runRouteHandler(route, context) {
|
|
1066
|
+
const response = route.handler(context);
|
|
1067
|
+
if (isThenable(response)) {
|
|
1068
|
+
return Promise.resolve(response).then(toHandlerResponse);
|
|
1069
|
+
}
|
|
1070
|
+
return toHandlerResponse(response);
|
|
1071
|
+
}
|
|
1072
|
+
async settleRoute(context, pending) {
|
|
1073
|
+
try {
|
|
1074
|
+
return this.finishRoute(context, await pending);
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
return await this.failRoute(context, error);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
finishRoute(context, result) {
|
|
1081
|
+
// Single choke point so middleware headers reach handlers that answer without the
|
|
1082
|
+
// wrapper. Idempotent for `ctx.send()`, which merged them already.
|
|
1083
|
+
if (result instanceof Response) {
|
|
1084
|
+
return context.applyMiddlewareHeaders(result);
|
|
1085
|
+
}
|
|
1086
|
+
// If middleware chain returned false (stopped), return 403
|
|
1087
|
+
if (result === false) {
|
|
1088
|
+
return context.applyMiddlewareHeaders(new Response('Forbidden', { status: 403 }));
|
|
1089
|
+
}
|
|
1090
|
+
// Default: result is true (should not happen with onComplete, but just in case)
|
|
1091
|
+
return context.applyMiddlewareHeaders(new Response(null, { status: 204 }));
|
|
1092
|
+
}
|
|
1093
|
+
async failRoute(context, error) {
|
|
1094
|
+
// The context here already has params injected, so pass it through unchanged.
|
|
1095
|
+
return context.applyMiddlewareHeaders(await this.respondToError(error, context));
|
|
1096
|
+
}
|
|
990
1097
|
/**
|
|
991
1098
|
* Type guard to check if validation schema has hook format
|
|
992
1099
|
*
|
|
@@ -1008,6 +1115,46 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1008
1115
|
isValidationSchemaWithHook(schema) {
|
|
1009
1116
|
return typeof schema === 'object' && schema !== null && 'schema' in schema;
|
|
1010
1117
|
}
|
|
1118
|
+
/** Prepare each legacy validator target once for this route, preserving method order. */
|
|
1119
|
+
async prepareValidationSteps(route) {
|
|
1120
|
+
const steps = [];
|
|
1121
|
+
for (const target of VALIDATOR_METHODS) {
|
|
1122
|
+
const handler = route.validator[target];
|
|
1123
|
+
if (!handler || typeof handler.handle !== 'function')
|
|
1124
|
+
continue;
|
|
1125
|
+
const step = {
|
|
1126
|
+
target,
|
|
1127
|
+
handle: () => handler.handle(),
|
|
1128
|
+
extract: this.createValidationExtractor(target),
|
|
1129
|
+
cachedResolved: route.validatorCache !== false,
|
|
1130
|
+
};
|
|
1131
|
+
if (route.validatorCache !== false) {
|
|
1132
|
+
try {
|
|
1133
|
+
const value = await step.handle();
|
|
1134
|
+
if (value)
|
|
1135
|
+
step.prepared = this.unwrapValidationSchema(value);
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
throw new Error(`Failed to prepare validator for ${route.method.toUpperCase()} ${route.path} (${target})`, {
|
|
1139
|
+
cause: error,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
steps.push(step);
|
|
1144
|
+
}
|
|
1145
|
+
return steps;
|
|
1146
|
+
}
|
|
1147
|
+
unwrapValidationSchema(value) {
|
|
1148
|
+
const schema = this.isValidationSchemaWithHook(value) ? value.schema : value;
|
|
1149
|
+
const hook = this.isValidationSchemaWithHook(value) ? (value.hook ?? null) : null;
|
|
1150
|
+
if (!schema || typeof schema.safeParseAsync !== 'function') {
|
|
1151
|
+
throw new TypeError('Validator factory must return a Zod schema with safeParseAsync()');
|
|
1152
|
+
}
|
|
1153
|
+
if (hook !== null && typeof hook !== 'function') {
|
|
1154
|
+
throw new TypeError('Validator hook must be a function');
|
|
1155
|
+
}
|
|
1156
|
+
return { schema, hook };
|
|
1157
|
+
}
|
|
1011
1158
|
/**
|
|
1012
1159
|
* Validates request data using Zod schemas
|
|
1013
1160
|
*
|
|
@@ -1023,28 +1170,20 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1023
1170
|
* @param validator - Validator instance with schema definitions
|
|
1024
1171
|
* @returns Response if validation fails, null if passes
|
|
1025
1172
|
*/
|
|
1026
|
-
async validateRequest(context,
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const
|
|
1030
|
-
// Skip if validator not defined for this target
|
|
1031
|
-
if (!validatorHandler || typeof validatorHandler.handle !== 'function') {
|
|
1032
|
-
continue;
|
|
1033
|
-
}
|
|
1034
|
-
// Get validation schema
|
|
1035
|
-
const validationSchema = await validatorHandler.handle();
|
|
1173
|
+
async validateRequest(context, steps) {
|
|
1174
|
+
for (const { target: key, handle, extract, prepared, cachedResolved } of steps) {
|
|
1175
|
+
const handled = cachedResolved ? undefined : handle();
|
|
1176
|
+
const validationSchema = isThenable(handled) ? await handled : handled;
|
|
1036
1177
|
// Skip if no validation schema provided
|
|
1037
|
-
if (!validationSchema) {
|
|
1178
|
+
if ((!cachedResolved && !validationSchema) || (cachedResolved && !prepared)) {
|
|
1038
1179
|
continue;
|
|
1039
1180
|
}
|
|
1040
1181
|
// Use type guard to discriminate between formats
|
|
1041
|
-
const schema = this.
|
|
1042
|
-
? validationSchema.schema
|
|
1043
|
-
: validationSchema;
|
|
1044
|
-
const hook = this.isValidationSchemaWithHook(validationSchema) ? (validationSchema.hook ?? null) : null;
|
|
1182
|
+
const { schema, hook } = prepared ?? this.unwrapValidationSchema(validationSchema);
|
|
1045
1183
|
// Extract data to validate
|
|
1046
|
-
const
|
|
1047
|
-
const
|
|
1184
|
+
const extracted = extract(context);
|
|
1185
|
+
const data = isThenable(extracted) ? await extracted : extracted;
|
|
1186
|
+
const result = await schema.safeParseAsync(data);
|
|
1048
1187
|
if (result.success) {
|
|
1049
1188
|
// Body targets only: z.object() strips unknown keys and the raw form parse is
|
|
1050
1189
|
// last-value-wins, so without the write-back the handler reads a shape the schema
|
|
@@ -1075,69 +1214,64 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1075
1214
|
* @param target - Validation target (body, query, param, header, form)
|
|
1076
1215
|
* @returns Data to be validated
|
|
1077
1216
|
*/
|
|
1078
|
-
|
|
1217
|
+
createValidationExtractor(target) {
|
|
1079
1218
|
switch (target) {
|
|
1080
1219
|
case 'json':
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
// 'json' is the correct Asena validator method name
|
|
1084
|
-
// 'body' is kept for backwards compatibility
|
|
1085
|
-
return await context.getBody();
|
|
1086
|
-
}
|
|
1220
|
+
case 'body':
|
|
1221
|
+
return (context) => context.getBody();
|
|
1087
1222
|
case 'query': {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1223
|
+
return (context) => {
|
|
1224
|
+
const url = new URL(context.req.url);
|
|
1225
|
+
const queryObj = {};
|
|
1226
|
+
url.searchParams.forEach((value, key) => {
|
|
1227
|
+
queryObj[key] = value;
|
|
1228
|
+
});
|
|
1229
|
+
return queryObj;
|
|
1230
|
+
};
|
|
1094
1231
|
}
|
|
1095
1232
|
case 'param': {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
params[key.replace('param:', '')] = value;
|
|
1233
|
+
return (context) => {
|
|
1234
|
+
const params = {};
|
|
1235
|
+
for (const [key, value] of context.values.entries()) {
|
|
1236
|
+
if (key.startsWith('param:'))
|
|
1237
|
+
params[key.replace('param:', '')] = value;
|
|
1102
1238
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1239
|
+
return params;
|
|
1240
|
+
};
|
|
1105
1241
|
}
|
|
1106
1242
|
case 'header': {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
headers
|
|
1110
|
-
|
|
1111
|
-
|
|
1243
|
+
return (context) => {
|
|
1244
|
+
const headers = {};
|
|
1245
|
+
context.req.headers.forEach((value, key) => {
|
|
1246
|
+
headers[key.toLowerCase()] = value;
|
|
1247
|
+
});
|
|
1248
|
+
return headers;
|
|
1249
|
+
};
|
|
1112
1250
|
}
|
|
1113
1251
|
case 'form': {
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
return {};
|
|
1120
|
-
}
|
|
1121
|
-
const formData = await context.getFormData();
|
|
1122
|
-
const form = Object.create(null);
|
|
1123
|
-
formData.forEach((value, key) => {
|
|
1124
|
-
if (key.endsWith('[]')) {
|
|
1125
|
-
(form[key] ??= []).push(value);
|
|
1126
|
-
}
|
|
1127
|
-
else if (Array.isArray(form[key])) {
|
|
1128
|
-
form[key].push(value);
|
|
1252
|
+
return async (context) => {
|
|
1253
|
+
const contentType = context.req.headers.get('content-type') ?? '';
|
|
1254
|
+
if (!contentType.includes('multipart/form-data') &&
|
|
1255
|
+
!contentType.includes('application/x-www-form-urlencoded')) {
|
|
1256
|
+
return {};
|
|
1129
1257
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1258
|
+
const formData = await context.getFormData();
|
|
1259
|
+
const form = Object.create(null);
|
|
1260
|
+
formData.forEach((value, key) => {
|
|
1261
|
+
if (key.endsWith('[]'))
|
|
1262
|
+
(form[key] ??= []).push(value);
|
|
1263
|
+
else if (Array.isArray(form[key]))
|
|
1264
|
+
form[key].push(value);
|
|
1265
|
+
else if (Object.hasOwn(form, key))
|
|
1266
|
+
form[key] = [form[key], value];
|
|
1267
|
+
else
|
|
1268
|
+
form[key] = value;
|
|
1269
|
+
});
|
|
1270
|
+
return form;
|
|
1271
|
+
};
|
|
1138
1272
|
}
|
|
1139
1273
|
default:
|
|
1140
|
-
return {};
|
|
1274
|
+
return () => ({});
|
|
1141
1275
|
}
|
|
1142
1276
|
}
|
|
1143
1277
|
/**
|
|
@@ -1165,30 +1299,29 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1165
1299
|
* });
|
|
1166
1300
|
* ```
|
|
1167
1301
|
*/
|
|
1168
|
-
|
|
1302
|
+
serveStaticFile(req, context, staticServe, resolvedRoot, resolvedRootPrefix, joinsVerbatim) {
|
|
1169
1303
|
// Deliberately no try/catch. Anything thrown here - a rewriteRequestPath that raises, a
|
|
1170
1304
|
// path.resolve or Bun.file failure - travels up to createRouteHandler's catch and through
|
|
1171
1305
|
// `respondToError`, like every other failure. Answering it here meant the body was
|
|
1172
1306
|
// `{"error": error.message}`, and a message thrown out of the filesystem layer is
|
|
1173
1307
|
// specifically a deployment path; and the application's `onError` never saw it at all.
|
|
1174
1308
|
// 1. Extract request path from URL
|
|
1175
|
-
const
|
|
1309
|
+
const pathname = requestPathname(req.url);
|
|
1176
1310
|
// A malformed escape - `%zz`, a lone `%` - makes decodeURIComponent throw a URIError, which
|
|
1177
1311
|
// reached respondToError as an unbranded error: a 500, an error-level log with a stack, and
|
|
1178
1312
|
// the application's onError handed a request the client had simply written wrong.
|
|
1179
1313
|
let requestPath;
|
|
1180
1314
|
try {
|
|
1181
|
-
requestPath = decodeURIComponent(
|
|
1315
|
+
requestPath = pathname.includes('%') ? decodeURIComponent(pathname) : pathname;
|
|
1182
1316
|
}
|
|
1183
1317
|
catch {
|
|
1184
1318
|
throw new HttpException(400, { error: 'Bad Request' });
|
|
1185
1319
|
}
|
|
1186
1320
|
// 2. Apply path rewriting if provided
|
|
1187
1321
|
const rewrittenPath = staticServe.rewriteRequestPath ? staticServe.rewriteRequestPath(requestPath) : requestPath;
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
const resolvedFilePath = path.resolve(filePath);
|
|
1322
|
+
const resolvedFilePath = joinsVerbatim && isNormalizedRelativePath(rewrittenPath)
|
|
1323
|
+
? resolvedRootPrefix + (rewrittenPath.charCodeAt(0) === 47 ? rewrittenPath.slice(1) : rewrittenPath)
|
|
1324
|
+
: resolveStaticPath(staticServe.root, rewrittenPath);
|
|
1192
1325
|
// Check if resolved file path is within root directory. The separator is what makes this a
|
|
1193
1326
|
// containment test rather than a string-prefix one: without it a root of `/srv/assets`
|
|
1194
1327
|
// accepted `/srv/assets-private/…`, reachable through `..%2F` in the request path. The
|
|
@@ -1198,6 +1331,29 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1198
1331
|
this.logger.warn(`Path traversal attempt detected: ${requestPath} -> ${resolvedFilePath}`);
|
|
1199
1332
|
return new Response('Forbidden', { status: 403 });
|
|
1200
1333
|
}
|
|
1334
|
+
const stats = regularFileStats(resolvedFilePath);
|
|
1335
|
+
if (stats !== undefined && !staticServe.onFound) {
|
|
1336
|
+
return this.statFileResponse(resolvedFilePath, stats, staticServe.extra, req);
|
|
1337
|
+
}
|
|
1338
|
+
return this.serveResolvedStaticFile(req, context, staticServe, pathname, rewrittenPath, resolvedFilePath, stats);
|
|
1339
|
+
}
|
|
1340
|
+
statFileResponse(filePath, stats, extra, req) {
|
|
1341
|
+
if (!extra) {
|
|
1342
|
+
return this.staticFileReader.respond(filePath, stats, !req.headers.has('range'));
|
|
1343
|
+
}
|
|
1344
|
+
const read = this.staticFileReader.read(filePath, stats, !req.headers.has('range'));
|
|
1345
|
+
const headers = this.withStaticExtras(staticFileHeaders(read.type, read.etag, read.lastModified), filePath, extra);
|
|
1346
|
+
return new Response(read.bytes ?? Bun.file(filePath), { status: 200, headers });
|
|
1347
|
+
}
|
|
1348
|
+
async serveResolvedStaticFile(req, context, staticServe, pathname, rewrittenPath, resolvedFilePath, stats) {
|
|
1349
|
+
if (stats !== undefined) {
|
|
1350
|
+
await staticServe.onFound.handler(rewrittenPath, context);
|
|
1351
|
+
if (staticServe.onFound.override) {
|
|
1352
|
+
return null;
|
|
1353
|
+
}
|
|
1354
|
+
return this.statFileResponse(resolvedFilePath, stats, staticServe.extra, req);
|
|
1355
|
+
}
|
|
1356
|
+
this.staticFileReader.release(resolvedFilePath);
|
|
1201
1357
|
// 5. Create Bun.file() instance
|
|
1202
1358
|
const file = Bun.file(resolvedFilePath);
|
|
1203
1359
|
// 5. Check if file exists
|
|
@@ -1216,7 +1372,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1216
1372
|
// `text/plain` 404 and never consult the config hook - so the same app produced a
|
|
1217
1373
|
// different 404 body here than on the hono adapter, where serveStatic calls next()
|
|
1218
1374
|
// and the request lands in app.notFound.
|
|
1219
|
-
return await this.respondToUnmatched(context,
|
|
1375
|
+
return await this.respondToUnmatched(context, pathname, req.method);
|
|
1220
1376
|
}
|
|
1221
1377
|
// 7. File found → trigger onFound hook
|
|
1222
1378
|
if (staticServe.onFound) {
|
|
@@ -1226,33 +1382,24 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1226
1382
|
return null;
|
|
1227
1383
|
}
|
|
1228
1384
|
}
|
|
1229
|
-
|
|
1230
|
-
const
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
for (const [key, value] of Object.entries(customHeaders)) {
|
|
1243
|
-
finalHeaders.set(key, value);
|
|
1385
|
+
const lastModifiedMs = file.lastModified;
|
|
1386
|
+
const finalHeaders = staticFileHeaders(file.type, `W/"${file.size}-${lastModifiedMs}"`, new Date(lastModifiedMs).toUTCString());
|
|
1387
|
+
return new Response(file, {
|
|
1388
|
+
status: 200,
|
|
1389
|
+
headers: this.withStaticExtras(finalHeaders, resolvedFilePath, staticServe.extra),
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
withStaticExtras(headers, filePath, extras) {
|
|
1393
|
+
if (extras) {
|
|
1394
|
+
const customHeaders = this.buildStaticFileHeaders(filePath, extras);
|
|
1395
|
+
for (const key in customHeaders) {
|
|
1396
|
+
headers.set(key, customHeaders[key]);
|
|
1397
|
+
}
|
|
1244
1398
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
if (!finalHeaders.has('Cache-Control')) {
|
|
1248
|
-
finalHeaders.set('Cache-Control', 'public, max-age=0');
|
|
1399
|
+
if (!headers.has('Cache-Control')) {
|
|
1400
|
+
headers.set('Cache-Control', 'public, max-age=0');
|
|
1249
1401
|
}
|
|
1250
|
-
|
|
1251
|
-
// This preserves Bun's zero-copy file serving while adding cache headers
|
|
1252
|
-
return new Response(fileResponse.body, {
|
|
1253
|
-
status: fileResponse.status,
|
|
1254
|
-
headers: finalHeaders,
|
|
1255
|
-
});
|
|
1402
|
+
return headers;
|
|
1256
1403
|
}
|
|
1257
1404
|
/**
|
|
1258
1405
|
* Builds response headers for static file serving
|