@asenajs/ergenecore 4.0.0 → 5.0.1

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.
@@ -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 ? this.createFastPathHandler(route) : this.createRouteHandler(route);
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
- return async (req) => {
876
- const context = new ErgenecoreContextWrapper(req, this.server);
877
- context.routePattern = route.path;
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
- const params = req.params;
884
- for (const key in params) {
885
- context.setValue(`param:${key}`, params[key]);
886
- }
972
+ context.setRouteParams(req.params);
887
973
  }
888
- // Execute handler directly
889
- const response = await route.handler(context);
890
- // Return response (check if already a Response object)
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
- // Wrap in Response with static headers
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 await this.respondToError(error, context);
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
- return async (req) => {
932
- // Create context wrapper outside try block so it's accessible in catch
933
- const context = new ErgenecoreContextWrapper(req, this.server);
934
- context.routePattern = route.path;
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
- const params = req.params;
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
- // Execute middleware chain with handler as onComplete callback
946
- // This ensures the handler runs INSIDE the middleware async context
947
- const result = await this.executeMiddlewares(context, middlewares, 0, async () => {
948
- // Execute validation
949
- if (route.validator) {
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
- // If middleware chain returned false (stopped), return 403
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
- // The context here already has params injected, so pass it through unchanged.
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, validator) {
1027
- // Iterate through all validator methods (body, query, param, header)
1028
- for (const key of VALIDATOR_METHODS) {
1029
- const validatorHandler = validator[key];
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.isValidationSchemaWithHook(validationSchema)
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 data = await this.extractValidationData(context, key);
1047
- const result = schema.safeParse(data);
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
- async extractValidationData(context, target) {
1217
+ createValidationExtractor(target) {
1079
1218
  switch (target) {
1080
1219
  case 'json':
1081
- return await context.getBody();
1082
- case 'body': {
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
- const url = new URL(context.req.url);
1089
- const queryObj = {};
1090
- url.searchParams.forEach((value, key) => {
1091
- queryObj[key] = value;
1092
- });
1093
- return queryObj;
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
- // Extract all params from context
1097
- const params = {};
1098
- // We stored params with 'param:' prefix in context
1099
- for (const [key, value] of context.values.entries()) {
1100
- if (key.startsWith('param:')) {
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
- return params;
1239
+ return params;
1240
+ };
1105
1241
  }
1106
1242
  case 'header': {
1107
- const headers = {};
1108
- context.req.headers.forEach((value, key) => {
1109
- headers[key.toLowerCase()] = value;
1110
- });
1111
- return headers;
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
- const contentType = context.req.headers.get('content-type') ?? '';
1115
- // hono parity: non-form content-type validates {} (a required field then fails),
1116
- // instead of feeding JSON into a form schema
1117
- if (!contentType.includes('multipart/form-data') &&
1118
- !contentType.includes('application/x-www-form-urlencoded')) {
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
- else if (Object.hasOwn(form, key)) {
1131
- form[key] = [form[key], value];
1132
- }
1133
- else {
1134
- form[key] = value;
1135
- }
1136
- });
1137
- return form;
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
- async serveStaticFile(req, context, staticServe, resolvedRoot, resolvedRootPrefix) {
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 url = new URL(req.url);
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(url.pathname);
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
- // 3. Build absolute file path
1189
- const filePath = path.join(staticServe.root, rewrittenPath);
1190
- // 4. Security: Resolve and validate path to prevent traversal attacks
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, url.pathname, req.method);
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
- // 8. Create Bun file response with automatic Content-Type detection
1230
- const fileResponse = new Response(file);
1231
- // 9. Build response headers starting with Bun's Content-Type
1232
- const finalHeaders = new Headers(fileResponse.headers);
1233
- // 10. Add cache validation headers (ETag and Last-Modified)
1234
- // ETag format: W/"<size>-<lastModified>" (weak validator)
1235
- const etag = `W/"${file.size}-${file.lastModified}"`;
1236
- finalHeaders.set('ETag', etag);
1237
- // Last-Modified: HTTP date format (RFC 7231)
1238
- const lastModified = new Date(file.lastModified).toUTCString();
1239
- finalHeaders.set('Last-Modified', lastModified);
1240
- // 11. Add custom headers (can override defaults)
1241
- const customHeaders = this.buildStaticFileHeaders(resolvedFilePath, staticServe.extra);
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
- // 12. Add default Cache-Control if not provided
1246
- // Using 'public, max-age=0' allows caching but requires revalidation
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
- // 13. Return response with all headers
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