@velajs/vela 1.3.0 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ A sanctioned per-request injectable lands as a framework primitive, and the metadata-store unification finally has its regression tests.
6
+
7
+ ### New
8
+
9
+ - **`REQUEST_CONTEXT` injectable.** A request-scoped primitive carrying a stable `id` (mirrored from inbound `x-request-id` if present, else `crypto.randomUUID()`), `receivedAt`, the raw `Request`, the Hono `Context`, and a typed `set/get/has` bag for cross-cutting metadata. Seeded by `RouteManager` into each per-request child container; resolves through `@Inject(REQUEST_CONTEXT)` from any request-scoped service. No `AsyncLocalStorage` — edge-runtime contract intact (verified live under workerd via `pnpm test:workers`).
10
+
11
+ ```ts
12
+ import { Inject, Injectable, Scope, REQUEST_CONTEXT } from '@velajs/vela';
13
+ import type { RequestContext } from '@velajs/vela';
14
+
15
+ @Injectable({ scope: Scope.REQUEST })
16
+ class TenantResolver {
17
+ constructor(@Inject(REQUEST_CONTEXT) private readonly ctx: RequestContext) {}
18
+ resolve() {
19
+ return this.ctx.hono.req.header('x-tenant') ?? 'default';
20
+ }
21
+ }
22
+ ```
23
+
24
+ ### Internal cleanup
25
+
26
+ - **Metadata stacking + funnel coverage** (audit #8 follow-up). New `metadata-stacking.test.ts` asserts `appendCustomHandlerMeta` is order-deterministic, `Reflect.defineMetadata` round-trips through `MetadataRegistry`'s typed slots, `MetadataRegistry.reset()` clears `classMeta`/`handlerMeta`, and class+handler `@SetMetadata` on the same key remain independent.
27
+ - **`NestModule.configure()` regression coverage** (audit #4 follow-up). New `configure-resolution.test.ts` asserts configure-time DI works (modules can constructor-inject providers from their own scope), synchronous errors thrown from `configure()` propagate, and unresolvable constructor deps fail loudly rather than silently skipping middleware setup.
28
+ - **Edge-safe contract documented** (audit #7 follow-up). README now states the contract explicitly: the main export is edge-safe and audited in CI by `src/__tests__/edge-runtime-audit.test.ts`; `@velajs/vela/schedule-node` is the one opt-in Node/Bun carve-out.
29
+ - **`RouteManager` split into focused units** (audit #9 follow-up). New files `argument-resolver.ts`, `handler-executor.ts`, `response-mapper.ts`, and `instantiate.ts` carry parameter extraction, the per-request orchestration closure, response/redirect mapping, and the container-aware factory. `RouteManager` is now focused on Hono route registration and path composition. Internal-only refactor — no public API change, no behavior change.
30
+ - **Stale `WeakMap` comment removed** from `MetadataRegistry.reset()` — the WeakMap fallback was retired in 1.1.0; the comment was documentation drift.
31
+ - **`Container.setRequestInstance(token, value)`** — public method to pre-seed the per-request cache. Used by `RouteManager` to populate `REQUEST_CONTEXT` before any handler resolution runs.
32
+
33
+ ## 1.3.0
34
+
5
35
  Module boundaries are enforced. NestJS-shape: a service cannot resolve dependencies from a module it didn't import. Bootstrap is consolidated into a single primitive, discovery failures are diagnostically routed, and a generic plugin composer is included.
6
36
 
7
37
  ### New
package/README.md CHANGED
@@ -73,6 +73,19 @@ Vela runs on any runtime that supports the Web Standards API:
73
73
 
74
74
  No Node.js-specific APIs (`node:fs`, `Buffer`, `process`) are used.
75
75
 
76
+ ### Edge-safe contract
77
+
78
+ The **main export** (`@velajs/vela`) is edge-safe by contract — no `node:*` imports, no `Buffer`, no `process`, no `setInterval`, no `Bun.serve`. This is enforced in CI by [`src/__tests__/edge-runtime-audit.test.ts`](src/__tests__/edge-runtime-audit.test.ts), which fails the build if any file under `src/` references a forbidden API.
79
+
80
+ One subpath, **`@velajs/vela/schedule-node`**, is an opt-in Node/Bun adapter for `setInterval`-based job execution. It uses runtime-specific APIs by design and is **excluded from the edge-runtime audit**. Edge runtimes (Cloudflare Workers, Deno Deploy, Vercel Edge) should not import it — use platform cron triggers instead (e.g., `@velajs/cloudflare` ≥ 0.2.0 dispatches `@Cron` jobs via the Workers `scheduled()` handler).
81
+
82
+ ```ts
83
+ // Node / Bun only — opt-in
84
+ import { ScheduleNodeModule } from '@velajs/vela/schedule-node';
85
+ ```
86
+
87
+ The other subpaths (`@velajs/vela/internal`, `@velajs/vela/streaming`) follow the main export's edge-safe contract.
88
+
76
89
  ## Dynamic modules
77
90
 
78
91
  Configurable modules use `forRoot` (sync) and `forRootAsync` (DI-resolved):
@@ -15,6 +15,7 @@ export declare class Container {
15
15
  private recordOrigin;
16
16
  registerScope(scope: ModuleScope): void;
17
17
  markGlobalToken(token: Token): void;
18
+ setRequestInstance(token: Token, value: unknown): void;
18
19
  getDiagnostics(): Diagnostics;
19
20
  resolve<T>(token: Token<T>, requestingModuleId?: string): T;
20
21
  resolveAll<T>(token: Token<T>, requestingModuleId?: string): T[];
@@ -86,6 +86,14 @@ export class Container {
86
86
  markGlobalToken(token) {
87
87
  this.globals.add(token);
88
88
  }
89
+ // Pre-seed the per-request cache. Only meaningful on a child container
90
+ // produced by createChild() — the root's requestInstances map is unused.
91
+ // Used by RouteManager to populate framework-provided request-scope
92
+ // values (REQUEST_CONTEXT) before any handler resolution runs, so the
93
+ // provider's factory never fires on the request path.
94
+ setRequestInstance(token, value) {
95
+ this.requestInstances.set(token, value);
96
+ }
89
97
  getDiagnostics() {
90
98
  return this.diagnostics;
91
99
  }
@@ -1,5 +1,7 @@
1
+ import { Scope } from "../constants.js";
1
2
  import { Container } from "../container/container.js";
2
3
  import { ModuleRef } from "../container/module-ref.js";
4
+ import { REQUEST_CONTEXT } from "../http/request-context.js";
3
5
  import { RouteManager } from "../http/route.manager.js";
4
6
  import { ModuleLoader } from "../module/module-loader.js";
5
7
  import { bindAppProviders } from "../pipeline/app-providers.js";
@@ -39,6 +41,18 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from
39
41
  ]){
40
42
  container.markGlobalToken(t);
41
43
  }
44
+ // REQUEST_CONTEXT is seeded into each request-scoped child by RouteManager
45
+ // before any handler resolves it (see route.manager.ts:getRequestContainer).
46
+ // The factory throws so misuse outside the request path surfaces immediately
47
+ // instead of materializing a phantom context.
48
+ container.register({
49
+ provide: REQUEST_CONTEXT,
50
+ scope: Scope.REQUEST,
51
+ useFactory: ()=>{
52
+ throw new Error('REQUEST_CONTEXT can only be resolved inside a request — ' + 'it is seeded by RouteManager when the request enters the pipeline.');
53
+ }
54
+ });
55
+ container.markGlobalToken(REQUEST_CONTEXT);
42
56
  const routeManager = new RouteManager(container, options);
43
57
  ComponentManager.init(container);
44
58
  const loader = new ModuleLoader(container, routeManager);
@@ -0,0 +1,10 @@
1
+ import type { Context } from 'hono';
2
+ import type { Container } from '../container/container';
3
+ import type { PipeTransform } from '../pipeline/types';
4
+ import type { ParamMetadata } from './types';
5
+ export declare class ArgumentResolver {
6
+ private readonly ipExtractor;
7
+ constructor(ipExtractor: (c: Context) => string | null);
8
+ extract(c: Context, paramMetadata: ParamMetadata[], pipes: PipeTransform[], requestContainer: Container, paramTypes?: unknown[]): Promise<unknown[]>;
9
+ private extractParam;
10
+ }
@@ -0,0 +1,89 @@
1
+ import { getCookie } from "hono/cookie";
2
+ import { ParamType } from "../constants.js";
3
+ import { instantiate } from "./instantiate.js";
4
+ const PARAM_EXTRACTORS = new Map([
5
+ [
6
+ ParamType.PARAM,
7
+ (c, p)=>p.name ? c.req.param(p.name) : c.req.param()
8
+ ],
9
+ [
10
+ ParamType.QUERY,
11
+ (c, p)=>p.name ? c.req.query(p.name) : c.req.query()
12
+ ],
13
+ [
14
+ ParamType.BODY,
15
+ async (c, p)=>{
16
+ let body;
17
+ try {
18
+ body = await c.req.json();
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ return p.name && body !== null && typeof body === 'object' ? body[p.name] : body;
23
+ }
24
+ ],
25
+ [
26
+ ParamType.HEADERS,
27
+ (c, p)=>p.name ? c.req.header(p.name) : c.req.header()
28
+ ],
29
+ [
30
+ ParamType.REQUEST,
31
+ (c)=>c
32
+ ],
33
+ [
34
+ ParamType.RESPONSE,
35
+ (c)=>c
36
+ ],
37
+ [
38
+ ParamType.COOKIE,
39
+ (c, p)=>p.name ? getCookie(c, p.name) : getCookie(c)
40
+ ],
41
+ [
42
+ ParamType.RAW_BODY,
43
+ async (c)=>new Uint8Array(await c.req.arrayBuffer())
44
+ ]
45
+ ]);
46
+ // Pulls handler arguments from the request, applies shared pipes (global +
47
+ // controller + method) and then per-param pipes. Empty paramMetadata returns
48
+ // `[c]` to match the old direct-Hono-handler shape — a back-compat behavior
49
+ // the framework's tests rely on.
50
+ export class ArgumentResolver {
51
+ ipExtractor;
52
+ constructor(ipExtractor){
53
+ this.ipExtractor = ipExtractor;
54
+ }
55
+ async extract(c, paramMetadata, pipes, requestContainer, paramTypes) {
56
+ if (paramMetadata.length === 0) {
57
+ return [
58
+ c
59
+ ];
60
+ }
61
+ const maxIndex = paramMetadata.at(-1).index;
62
+ const args = new Array(maxIndex + 1).fill(undefined);
63
+ for (const param of paramMetadata){
64
+ let value = await this.extractParam(c, param);
65
+ const metadata = {
66
+ type: param.type,
67
+ data: param.name,
68
+ metatype: paramTypes?.[param.index]
69
+ };
70
+ for (const pipe of pipes){
71
+ value = await pipe.transform(value, metadata);
72
+ }
73
+ if (param.pipes && param.pipes.length > 0) {
74
+ for (const paramPipe of param.pipes){
75
+ const pipeInstance = instantiate(paramPipe, requestContainer);
76
+ value = await pipeInstance.transform(value, metadata);
77
+ }
78
+ }
79
+ args[param.index] = value;
80
+ }
81
+ return args;
82
+ }
83
+ extractParam(c, param) {
84
+ if (param.type === ParamType.IP) return this.ipExtractor(c);
85
+ const extractor = PARAM_EXTRACTORS.get(param.type);
86
+ if (extractor) return extractor(c, param);
87
+ return param.factory ? param.factory(param.name, c) : undefined;
88
+ }
89
+ }
@@ -0,0 +1,20 @@
1
+ import type { Context } from 'hono';
2
+ import type { Container } from '../container/container';
3
+ import type { Token, Type } from '../container/types';
4
+ import type { CanActivate, ExceptionFilter, NestInterceptor, PipeTransform } from '../pipeline/types';
5
+ import type { FilterType, GuardType, InterceptorType, ParameterMetadata, PipeType } from '../registry/types';
6
+ import type { ArgumentResolver } from './argument-resolver';
7
+ import type { RouteMetadata } from './types';
8
+ export interface HandlerGlobals {
9
+ guards: Array<GuardType | Token<CanActivate>>;
10
+ pipes: Array<PipeType | Token<PipeTransform>>;
11
+ interceptors: Array<InterceptorType | Token<NestInterceptor>>;
12
+ filters: Array<FilterType | Token<ExceptionFilter>>;
13
+ }
14
+ export declare class HandlerExecutor {
15
+ private readonly argumentResolver;
16
+ private readonly getGlobals;
17
+ private readonly getRequestContainer;
18
+ constructor(argumentResolver: ArgumentResolver, getGlobals: () => HandlerGlobals, getRequestContainer: (c: Context) => Container);
19
+ create(route: RouteMetadata, controller: Type, allParamMetadata: Map<string | symbol, ParameterMetadata[]>): (c: Context) => Promise<Response>;
20
+ }
@@ -0,0 +1,108 @@
1
+ import { ForbiddenException, HttpException } from "../errors/http-exception.js";
2
+ import { ComponentManager } from "../pipeline/component.manager.js";
3
+ import { shouldFilterCatch } from "../pipeline/decorators.js";
4
+ import { getHttpCode, getRedirect, getResponseHeaders } from "./decorators.js";
5
+ import { buildExecutionContext } from "./execution-context.js";
6
+ import { instantiateMany } from "./instantiate.js";
7
+ import { applyResponseHeaders, mapRedirect, mapResponse } from "./response-mapper.js";
8
+ // Builds the per-request closure that runs guards, pipes, interceptors, the
9
+ // handler itself, and exception filters. Pure orchestration — Hono route
10
+ // registration stays in RouteManager. Globals are read via a callback so
11
+ // post-create additions (`useGlobalGuards`, etc.) propagate to subsequent
12
+ // requests without rebuilding routes.
13
+ export class HandlerExecutor {
14
+ argumentResolver;
15
+ getGlobals;
16
+ getRequestContainer;
17
+ constructor(argumentResolver, getGlobals, getRequestContainer){
18
+ this.argumentResolver = argumentResolver;
19
+ this.getGlobals = getGlobals;
20
+ this.getRequestContainer = getRequestContainer;
21
+ }
22
+ create(route, controller, allParamMetadata) {
23
+ const paramMetadata = (allParamMetadata.get(route.handlerName) || []).sort((a, b)=>a.index - b.index);
24
+ // Read param types once at build time for metatype population.
25
+ // Routed via Reflect so the polyfill funnels both src-level and dist-level
26
+ // consumers to the same registry (matters in tests that import from dist).
27
+ const paramTypes = Reflect.getMetadata('design:paramtypes', controller.prototype, route.handlerName);
28
+ const methodGuards = ComponentManager.getComponents('guard', controller, route.handlerName);
29
+ const methodPipes = ComponentManager.getComponents('pipe', controller, route.handlerName);
30
+ const methodInterceptors = ComponentManager.getComponents('interceptor', controller, route.handlerName);
31
+ // Filters: reverse order (handler → controller → global) — closest to handler runs first
32
+ const methodFilters = [
33
+ ...ComponentManager.getComponents('filter', controller, route.handlerName)
34
+ ].reverse();
35
+ const httpCode = getHttpCode(controller, route.handlerName);
36
+ const responseHeaders = getResponseHeaders(controller, route.handlerName);
37
+ const redirect = getRedirect(controller, route.handlerName);
38
+ return async (c)=>{
39
+ // Combine global + method at request time so post-create registrations propagate.
40
+ const requestContainer = this.getRequestContainer(c);
41
+ const globals = this.getGlobals();
42
+ const guards = [
43
+ ...instantiateMany(globals.guards, requestContainer),
44
+ ...instantiateMany(methodGuards, requestContainer)
45
+ ];
46
+ const pipes = [
47
+ ...instantiateMany(globals.pipes, requestContainer),
48
+ ...instantiateMany(methodPipes, requestContainer)
49
+ ];
50
+ const interceptors = [
51
+ ...instantiateMany(globals.interceptors, requestContainer),
52
+ ...instantiateMany(methodInterceptors, requestContainer)
53
+ ];
54
+ const filters = [
55
+ ...instantiateMany(methodFilters, requestContainer),
56
+ ...instantiateMany(globals.filters, requestContainer)
57
+ ];
58
+ const executionContext = buildExecutionContext(c, controller, route.handlerName);
59
+ try {
60
+ const instance = requestContainer.resolve(controller);
61
+ // 1. Extract args + run pipes (vela order: args before guards).
62
+ const args = await this.argumentResolver.extract(c, paramMetadata, pipes, requestContainer, paramTypes);
63
+ // 2. Guards (fail-fast).
64
+ for (const guard of guards){
65
+ const canActivate = await guard.canActivate(executionContext);
66
+ if (!canActivate) {
67
+ throw new ForbiddenException();
68
+ }
69
+ }
70
+ // 3. Get handler method.
71
+ const method = Reflect.get(instance, route.handlerName);
72
+ if (typeof method !== 'function') {
73
+ throw new Error(`Method ${String(route.handlerName)} not found on controller`);
74
+ }
75
+ // 4. Build core handler.
76
+ const coreHandler = async ()=>Reflect.apply(method, instance, args);
77
+ // 5. Run interceptor chain.
78
+ const result = await ComponentManager.runInterceptorChain(interceptors, executionContext, coreHandler);
79
+ if (redirect) {
80
+ return mapRedirect(c, result, redirect);
81
+ }
82
+ const response = mapResponse(c, result, httpCode);
83
+ applyResponseHeaders(response, responseHeaders);
84
+ return response;
85
+ } catch (error) {
86
+ for (const filter of filters){
87
+ if (shouldFilterCatch(filter, error)) {
88
+ try {
89
+ const filtered = await filter.catch(error, executionContext);
90
+ return mapResponse(c, filtered);
91
+ } catch {
92
+ break;
93
+ }
94
+ }
95
+ }
96
+ if (error instanceof HttpException) {
97
+ const response = error.getResponse();
98
+ const status = error.getStatus();
99
+ return c.json(response, status);
100
+ }
101
+ return c.json({
102
+ statusCode: 500,
103
+ message: 'Internal Server Error'
104
+ }, 500);
105
+ }
106
+ };
107
+ }
108
+ }
@@ -0,0 +1,4 @@
1
+ import type { Container } from '../container/container';
2
+ import type { Token, Type } from '../container/types';
3
+ export declare function instantiate<T>(classOrInstance: Type<T> | Token<T> | T, container: Container): T;
4
+ export declare function instantiateMany<T>(items: Array<Type<T> | Token<T> | T>, container: Container): T[];
@@ -0,0 +1,18 @@
1
+ // Resolve a class/token through the container if registered, otherwise treat
2
+ // the input as a plain instance. Used by RouteManager and HandlerExecutor to
3
+ // materialize middleware, guards, pipes, interceptors, and filters per request.
4
+ export function instantiate(classOrInstance, container) {
5
+ if (typeof classOrInstance === 'function') {
6
+ if (container.has(classOrInstance)) {
7
+ return container.resolve(classOrInstance);
8
+ }
9
+ return new classOrInstance();
10
+ }
11
+ if (container.has(classOrInstance)) {
12
+ return container.resolve(classOrInstance);
13
+ }
14
+ return classOrInstance;
15
+ }
16
+ export function instantiateMany(items, container) {
17
+ return items.map((item)=>instantiate(item, container));
18
+ }
@@ -0,0 +1,13 @@
1
+ import type { Context } from 'hono';
2
+ import { InjectionToken } from '../container/types';
3
+ export interface RequestContext {
4
+ readonly id: string;
5
+ readonly receivedAt: Date;
6
+ readonly request: Request;
7
+ readonly hono: Context;
8
+ set<T>(key: string | symbol, value: T): void;
9
+ get<T>(key: string | symbol): T | undefined;
10
+ has(key: string | symbol): boolean;
11
+ }
12
+ export declare const REQUEST_CONTEXT: InjectionToken<RequestContext>;
13
+ export declare function createRequestContext(c: Context): RequestContext;
@@ -0,0 +1,22 @@
1
+ import { InjectionToken } from "../container/types.js";
2
+ export const REQUEST_CONTEXT = new InjectionToken('vela.RequestContext');
3
+ export function createRequestContext(c) {
4
+ const bag = new Map();
5
+ const inboundId = c.req.raw.headers.get('x-request-id');
6
+ const id = inboundId && inboundId.length > 0 ? inboundId : crypto.randomUUID();
7
+ return {
8
+ id,
9
+ receivedAt: new Date(),
10
+ request: c.req.raw,
11
+ hono: c,
12
+ set (key, value) {
13
+ bag.set(key, value);
14
+ },
15
+ get (key) {
16
+ return bag.get(key);
17
+ },
18
+ has (key) {
19
+ return bag.has(key);
20
+ }
21
+ };
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { Context } from 'hono';
2
+ export interface ResponseRedirect {
3
+ url: string;
4
+ statusCode: number;
5
+ }
6
+ export declare function mapResponse(c: Context, result: unknown, statusCode?: number): Response;
7
+ export declare function mapRedirect(c: Context, result: unknown, redirect: ResponseRedirect): Response;
8
+ export declare function applyResponseHeaders(response: Response, headers: Iterable<readonly [string, string]>): void;
@@ -0,0 +1,42 @@
1
+ // A handler returning `{ url, statusCode? }` may override the @Redirect-decorator
2
+ // destination — kept identical to the previous inline behavior.
3
+ function parseRedirectResult(result) {
4
+ if (typeof result !== 'object' || result === null) return undefined;
5
+ if (!('url' in result)) return undefined;
6
+ const obj = result;
7
+ if (typeof obj.url !== 'string') return undefined;
8
+ return {
9
+ url: obj.url,
10
+ ...typeof obj.statusCode === 'number' ? {
11
+ statusCode: obj.statusCode
12
+ } : {}
13
+ };
14
+ }
15
+ // Maps a controller return value to a Hono Response. `null`/`undefined` → 204
16
+ // (empty body); strings → text; anything else → JSON. A pre-built Response
17
+ // passes through unchanged.
18
+ export function mapResponse(c, result, statusCode) {
19
+ if (result instanceof Response) {
20
+ return result;
21
+ }
22
+ if (result === null || result === undefined) {
23
+ return c.body(null, statusCode ?? 204);
24
+ }
25
+ if (typeof result === 'string') {
26
+ return c.text(result, statusCode ?? 200);
27
+ }
28
+ return c.json(result, statusCode ?? 200);
29
+ }
30
+ // Honors @Redirect on the handler. If the handler returned `{ url, statusCode? }`,
31
+ // those override the decorator's defaults; otherwise the decorator wins.
32
+ export function mapRedirect(c, result, redirect) {
33
+ const overrides = parseRedirectResult(result);
34
+ const finalUrl = overrides?.url ?? redirect.url;
35
+ const finalStatus = overrides?.statusCode ?? redirect.statusCode;
36
+ return c.redirect(finalUrl, finalStatus);
37
+ }
38
+ export function applyResponseHeaders(response, headers) {
39
+ for (const [name, value] of headers){
40
+ response.headers.set(name, value);
41
+ }
42
+ }
@@ -13,7 +13,6 @@ export interface RouteManagerOptions {
13
13
  export declare class RouteManager {
14
14
  private container;
15
15
  private static readonly METHOD_REGISTRAR;
16
- private static readonly PARAM_EXTRACTORS;
17
16
  private controllers;
18
17
  private globalMiddleware;
19
18
  private globalPipes;
@@ -22,7 +21,7 @@ export declare class RouteManager {
22
21
  private globalFilters;
23
22
  private globalPrefix;
24
23
  private consumerMiddlewareDefinitions;
25
- private readonly ipExtractor;
24
+ private readonly handlerExecutor;
26
25
  constructor(container: Container, options?: RouteManagerOptions);
27
26
  registerConsumerMiddleware(definitions: MiddlewareRouteDefinition[]): this;
28
27
  setGlobalPrefix(prefix: string): this;
@@ -36,17 +35,10 @@ export declare class RouteManager {
36
35
  useGlobalInterceptorTokens(...interceptorTokens: Array<Token<NestInterceptor>>): this;
37
36
  useGlobalFilters(...filters: FilterType[]): this;
38
37
  useGlobalFilterTokens(...filterTokens: Array<Token<ExceptionFilter>>): this;
39
- private instantiate;
40
- private instantiateMany;
41
38
  private getMiddlewarePriority;
42
39
  private getRequestContainer;
43
40
  registerController(controller: Type): this;
44
41
  build(): Promise<Hono>;
45
- private createExecutionContext;
46
- private createHandler;
47
- private extractArguments;
48
- private extractParam;
49
- private createResponse;
50
42
  private registerRoute;
51
43
  private buildVersionedPaths;
52
44
  private compilePathMatcher;
@@ -1,27 +1,14 @@
1
1
  import { Hono } from "hono";
2
- import { getCookie } from "hono/cookie";
3
- import { HttpMethod, ParamType } from "../constants.js";
2
+ import { HttpMethod } from "../constants.js";
4
3
  import { getMetadata } from "../metadata.js";
5
4
  import { joinPaths } from "../registry/paths.js";
6
- import { buildExecutionContext } from "./execution-context.js";
7
- import { ForbiddenException, HttpException } from "../errors/http-exception.js";
5
+ import { ArgumentResolver } from "./argument-resolver.js";
6
+ import { HandlerExecutor } from "./handler-executor.js";
7
+ import { instantiate, instantiateMany } from "./instantiate.js";
8
+ import { REQUEST_CONTEXT, createRequestContext } from "./request-context.js";
8
9
  import { ComponentManager } from "../pipeline/component.manager.js";
9
- import { shouldFilterCatch } from "../pipeline/decorators.js";
10
10
  import { MetadataRegistry } from "../registry/metadata.registry.js";
11
- import { getHttpCode, getRedirect, getResponseHeaders } from "./decorators.js";
12
11
  const defaultGetClientIp = (c)=>c.req.raw.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.raw.headers.get('x-real-ip') ?? null;
13
- function parseRedirectResult(result) {
14
- if (typeof result !== 'object' || result === null) return undefined;
15
- if (!('url' in result)) return undefined;
16
- const obj = result;
17
- if (typeof obj.url !== 'string') return undefined;
18
- return {
19
- url: obj.url,
20
- ...typeof obj.statusCode === 'number' ? {
21
- statusCode: obj.statusCode
22
- } : {}
23
- };
24
- }
25
12
  export class RouteManager {
26
13
  container;
27
14
  static METHOD_REGISTRAR = new Map([
@@ -58,48 +45,6 @@ export class RouteManager {
58
45
  (app, p, h)=>app.all(p, h)
59
46
  ]
60
47
  ]);
61
- static PARAM_EXTRACTORS = new Map([
62
- [
63
- ParamType.PARAM,
64
- (c, p)=>p.name ? c.req.param(p.name) : c.req.param()
65
- ],
66
- [
67
- ParamType.QUERY,
68
- (c, p)=>p.name ? c.req.query(p.name) : c.req.query()
69
- ],
70
- [
71
- ParamType.BODY,
72
- async (c, p)=>{
73
- let body;
74
- try {
75
- body = await c.req.json();
76
- } catch {
77
- return undefined;
78
- }
79
- return p.name && body !== null && typeof body === 'object' ? body[p.name] : body;
80
- }
81
- ],
82
- [
83
- ParamType.HEADERS,
84
- (c, p)=>p.name ? c.req.header(p.name) : c.req.header()
85
- ],
86
- [
87
- ParamType.REQUEST,
88
- (c)=>c
89
- ],
90
- [
91
- ParamType.RESPONSE,
92
- (c)=>c
93
- ],
94
- [
95
- ParamType.COOKIE,
96
- (c, p)=>p.name ? getCookie(c, p.name) : getCookie(c)
97
- ],
98
- [
99
- ParamType.RAW_BODY,
100
- async (c)=>new Uint8Array(await c.req.arrayBuffer())
101
- ]
102
- ]);
103
48
  controllers = [];
104
49
  globalMiddleware = [];
105
50
  globalPipes = [];
@@ -108,10 +53,16 @@ export class RouteManager {
108
53
  globalFilters = [];
109
54
  globalPrefix = '';
110
55
  consumerMiddlewareDefinitions = [];
111
- ipExtractor;
56
+ handlerExecutor;
112
57
  constructor(container, options = {}){
113
58
  this.container = container;
114
- this.ipExtractor = options.getClientIp ?? defaultGetClientIp;
59
+ const argumentResolver = new ArgumentResolver(options.getClientIp ?? defaultGetClientIp);
60
+ this.handlerExecutor = new HandlerExecutor(argumentResolver, ()=>({
61
+ guards: this.globalGuards,
62
+ pipes: this.globalPipes,
63
+ interceptors: this.globalInterceptors,
64
+ filters: this.globalFilters
65
+ }), (c)=>this.getRequestContainer(c));
115
66
  }
116
67
  registerConsumerMiddleware(definitions) {
117
68
  this.consumerMiddlewareDefinitions.push(...definitions);
@@ -161,21 +112,6 @@ export class RouteManager {
161
112
  this.globalFilters.push(...filterTokens);
162
113
  return this;
163
114
  }
164
- instantiate(classOrInstance, container) {
165
- if (typeof classOrInstance === 'function') {
166
- if (container.has(classOrInstance)) {
167
- return container.resolve(classOrInstance);
168
- }
169
- return new classOrInstance();
170
- }
171
- if (container.has(classOrInstance)) {
172
- return container.resolve(classOrInstance);
173
- }
174
- return classOrInstance;
175
- }
176
- instantiateMany(items, container) {
177
- return items.map((item)=>this.instantiate(item, container));
178
- }
179
115
  getMiddlewarePriority(entry) {
180
116
  if (entry == null) return 0;
181
117
  if (typeof entry === 'function') {
@@ -188,7 +124,7 @@ export class RouteManager {
188
124
  if (typeof inst.constructor?.priority === 'number') return inst.constructor.priority;
189
125
  }
190
126
  try {
191
- const resolved = this.instantiate(entry, this.container);
127
+ const resolved = instantiate(entry, this.container);
192
128
  if (resolved && typeof resolved === 'object') {
193
129
  const p = resolved.priority;
194
130
  if (typeof p === 'number') return p;
@@ -206,6 +142,7 @@ export class RouteManager {
206
142
  return existing;
207
143
  }
208
144
  const child = this.container.createChild();
145
+ child.setRequestInstance(REQUEST_CONTEXT, createRequestContext(c));
209
146
  c.set('container', child);
210
147
  return child;
211
148
  }
@@ -235,21 +172,18 @@ export class RouteManager {
235
172
  index,
236
173
  priority: this.getMiddlewarePriority(entry)
237
174
  })).sort((a, b)=>a.priority - b.priority || a.index - b.index);
238
- // Register global middleware on the Hono app
239
175
  for (const { entry } of sortedGlobal){
240
176
  app.use('*', (c, next)=>{
241
177
  const requestContainer = this.getRequestContainer(c);
242
- const resolved = this.instantiate(entry, requestContainer);
178
+ const resolved = instantiate(entry, requestContainer);
243
179
  return resolved.use(c, next);
244
180
  });
245
181
  }
246
- // Sort consumer middleware with the same stable-by-index rule.
247
182
  const sortedConsumer = this.consumerMiddlewareDefinitions.map((def, index)=>({
248
183
  def,
249
184
  index,
250
185
  priority: def.priority ?? 0
251
186
  })).sort((a, b)=>a.priority - b.priority || a.index - b.index);
252
- // Register MiddlewareConsumer-configured middleware
253
187
  for (const { def } of sortedConsumer){
254
188
  const matchRoute = this.compileRouteMatcher(def.routes);
255
189
  const matchExclude = this.compileRouteMatcher(def.excludes);
@@ -260,30 +194,27 @@ export class RouteManager {
260
194
  const requestContainer = this.getRequestContainer(c);
261
195
  const runChain = (index)=>{
262
196
  if (index >= def.middleware.length) return next();
263
- const instance = this.instantiate(def.middleware[index], requestContainer);
197
+ const instance = instantiate(def.middleware[index], requestContainer);
264
198
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
265
199
  return instance.use(c, ()=>runChain(index + 1)).then(()=>{});
266
200
  };
267
201
  return runChain(0);
268
202
  });
269
203
  }
270
- // First pass: register all custom routes (must come before CRUD /:id routes)
204
+ // First pass: register all custom routes (must come before CRUD /:id routes).
271
205
  for (const { controller, metadata, routes } of this.controllers){
272
206
  if (routes.length > 0) {
273
207
  const allParamMetadata = MetadataRegistry.getParameters(controller);
274
208
  for (const route of routes){
275
- // Determine effective version: route-level overrides controller-level
276
209
  const effectiveVersion = route.version ?? metadata.version;
277
- // Build versioned paths
278
210
  const versionedPaths = this.buildVersionedPaths(this.globalPrefix, metadata.prefix, route.path, effectiveVersion);
279
- // Register per-route middleware as Hono middleware before the handler
280
211
  const middlewareItems = ComponentManager.getComponents('middleware', controller, route.handlerName);
281
- const handler = this.createHandler(route, controller, allParamMetadata);
212
+ const handler = this.handlerExecutor.create(route, controller, allParamMetadata);
282
213
  for (const fullPath of versionedPaths){
283
214
  for (const middlewareItem of middlewareItems){
284
215
  app.use(fullPath, (c, next)=>{
285
216
  const requestContainer = this.getRequestContainer(c);
286
- const resolved = this.instantiate(middlewareItem, requestContainer);
217
+ const resolved = instantiate(middlewareItem, requestContainer);
287
218
  return resolved.use(c, next);
288
219
  });
289
220
  }
@@ -292,7 +223,7 @@ export class RouteManager {
292
223
  }
293
224
  }
294
225
  }
295
- // Second pass: mount CRUD sub-apps (/:id routes registered last)
226
+ // Second pass: mount CRUD sub-apps (/:id routes registered last).
296
227
  for (const { controller, metadata } of this.controllers){
297
228
  const crudConfig = getMetadata('vela:crud', controller);
298
229
  if (crudConfig) {
@@ -301,7 +232,7 @@ export class RouteManager {
301
232
  const { buildCrudRoutes } = await import(pkg);
302
233
  await buildCrudRoutes(app, controller, metadata.prefix, crudConfig, {
303
234
  globalPrefix: this.globalPrefix,
304
- globalGuards: this.instantiateMany(this.globalGuards, this.container),
235
+ globalGuards: instantiateMany(this.globalGuards, this.container),
305
236
  joinPaths
306
237
  });
307
238
  } catch (err) {
@@ -313,155 +244,6 @@ export class RouteManager {
313
244
  }
314
245
  return app;
315
246
  }
316
- createExecutionContext(c, controller, route) {
317
- return buildExecutionContext(c, controller, route.handlerName);
318
- }
319
- createHandler(route, controller, allParamMetadata) {
320
- const paramMetadata = (allParamMetadata.get(route.handlerName) || []).sort((a, b)=>a.index - b.index);
321
- // Read param types once at build time for metatype population.
322
- // Routed via Reflect so the polyfill funnels both src-level and dist-level
323
- // consumers to the same registry (matters in tests that import from dist).
324
- const paramTypes = Reflect.getMetadata('design:paramtypes', controller.prototype, route.handlerName);
325
- // Collect controller + method level components at build time
326
- const methodGuards = ComponentManager.getComponents('guard', controller, route.handlerName);
327
- const methodPipes = ComponentManager.getComponents('pipe', controller, route.handlerName);
328
- const methodInterceptors = ComponentManager.getComponents('interceptor', controller, route.handlerName);
329
- // Filters: reverse order (handler → controller → global) — closest to handler runs first
330
- const methodFilters = [
331
- ...ComponentManager.getComponents('filter', controller, route.handlerName)
332
- ].reverse();
333
- // Read response decorators at build time
334
- const httpCode = getHttpCode(controller, route.handlerName);
335
- const responseHeaders = getResponseHeaders(controller, route.handlerName);
336
- const redirect = getRedirect(controller, route.handlerName);
337
- return async (c)=>{
338
- // Combine global + method at request time (allows post-create registration)
339
- const requestContainer = this.getRequestContainer(c);
340
- const guards = [
341
- ...this.instantiateMany(this.globalGuards, requestContainer),
342
- ...this.instantiateMany(methodGuards, requestContainer)
343
- ];
344
- const pipes = [
345
- ...this.instantiateMany(this.globalPipes, requestContainer),
346
- ...this.instantiateMany(methodPipes, requestContainer)
347
- ];
348
- const interceptors = [
349
- ...this.instantiateMany(this.globalInterceptors, requestContainer),
350
- ...this.instantiateMany(methodInterceptors, requestContainer)
351
- ];
352
- const filters = [
353
- ...this.instantiateMany(methodFilters, requestContainer),
354
- ...this.instantiateMany(this.globalFilters, requestContainer)
355
- ];
356
- const executionContext = this.createExecutionContext(c, controller, route);
357
- try {
358
- const instance = requestContainer.resolve(controller);
359
- // 1. Extract args + run pipes
360
- const args = await this.extractArguments(c, paramMetadata, pipes, requestContainer, paramTypes);
361
- // 2. Guards (fail-fast)
362
- for (const guard of guards){
363
- const canActivate = await guard.canActivate(executionContext);
364
- if (!canActivate) {
365
- throw new ForbiddenException();
366
- }
367
- }
368
- // 3. Get handler method
369
- const method = Reflect.get(instance, route.handlerName);
370
- if (typeof method !== 'function') {
371
- throw new Error(`Method ${String(route.handlerName)} not found on controller`);
372
- }
373
- // 4. Build core handler
374
- const coreHandler = async ()=>Reflect.apply(method, instance, args);
375
- // 5. Run interceptor chain
376
- const result = await ComponentManager.runInterceptorChain(interceptors, executionContext, coreHandler);
377
- // Handle @Redirect
378
- if (redirect) {
379
- const overrides = parseRedirectResult(result);
380
- const finalUrl = overrides?.url ?? redirect.url;
381
- const finalStatus = overrides?.statusCode ?? redirect.statusCode;
382
- return c.redirect(finalUrl, finalStatus);
383
- }
384
- // Build response with @HttpCode and @Header support
385
- const response = this.createResponse(c, result, httpCode);
386
- // Apply @Header decorators
387
- for (const [headerName, headerValue] of responseHeaders){
388
- response.headers.set(headerName, headerValue);
389
- }
390
- return response;
391
- } catch (error) {
392
- // Run exception filters
393
- for (const filter of filters){
394
- if (shouldFilterCatch(filter, error)) {
395
- try {
396
- const result = await filter.catch(error, executionContext);
397
- return this.createResponse(c, result);
398
- } catch {
399
- break;
400
- }
401
- }
402
- }
403
- // Default HttpException handling
404
- if (error instanceof HttpException) {
405
- const response = error.getResponse();
406
- const status = error.getStatus();
407
- return c.json(response, status);
408
- }
409
- // Default 500
410
- return c.json({
411
- statusCode: 500,
412
- message: 'Internal Server Error'
413
- }, 500);
414
- }
415
- };
416
- }
417
- async extractArguments(c, paramMetadata, pipes, requestContainer, paramTypes) {
418
- if (paramMetadata.length === 0) {
419
- return [
420
- c
421
- ];
422
- }
423
- const maxIndex = paramMetadata.at(-1).index;
424
- const args = new Array(maxIndex + 1).fill(undefined);
425
- for (const param of paramMetadata){
426
- let value = await this.extractParam(c, param);
427
- const metadata = {
428
- type: param.type,
429
- data: param.name,
430
- metatype: paramTypes?.[param.index]
431
- };
432
- // Run shared pipes (global + controller + method)
433
- for (const pipe of pipes){
434
- value = await pipe.transform(value, metadata);
435
- }
436
- // Run param-level pipes
437
- if (param.pipes && param.pipes.length > 0) {
438
- for (const paramPipe of param.pipes){
439
- const pipeInstance = this.instantiate(paramPipe, requestContainer);
440
- value = await pipeInstance.transform(value, metadata);
441
- }
442
- }
443
- args[param.index] = value;
444
- }
445
- return args;
446
- }
447
- extractParam(c, param) {
448
- if (param.type === ParamType.IP) return this.ipExtractor(c);
449
- const extractor = RouteManager.PARAM_EXTRACTORS.get(param.type);
450
- if (extractor) return extractor(c, param);
451
- return param.factory ? param.factory(param.name, c) : undefined;
452
- }
453
- createResponse(c, result, statusCode) {
454
- if (result instanceof Response) {
455
- return result;
456
- }
457
- if (result === null || result === undefined) {
458
- return c.body(null, statusCode ?? 204);
459
- }
460
- if (typeof result === 'string') {
461
- return c.text(result, statusCode ?? 200);
462
- }
463
- return c.json(result, statusCode ?? 200);
464
- }
465
247
  registerRoute(app, method, path, handler) {
466
248
  const normalizedPath = path || '/';
467
249
  const registrar = RouteManager.METHOD_REGISTRAR.get(method);
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, fo
10
10
  export type { Type, Token, InjectableOptions, ProviderOptions, ModuleScope, ContainerOptions, Diagnostics, } from './container/index';
11
11
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from './constants';
12
12
  export { Controller, Version, Get, Post, Put, Patch, Delete, Options, Head, All, Sse, Param, Query, Body, Headers, Req, Res, Ip, Cookie, Cookies, RawBody, HttpCode, Header, Redirect, createParamDecorator, applyDecorators, } from './http/index';
13
+ export { REQUEST_CONTEXT } from './http/request-context';
14
+ export type { RequestContext } from './http/request-context';
13
15
  export { Logger, LogLevel } from './services/index';
14
16
  export type { LoggerService, ContextProvider, Writer, LoggerLevelName } from './services/index';
15
17
  export { ConfigModule, ConfigService, CONFIG_OPTIONS } from './config/index';
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@ export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, fo
12
12
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from "./constants.js";
13
13
  // HTTP Decorators
14
14
  export { Controller, Version, Get, Post, Put, Patch, Delete, Options, Head, All, Sse, Param, Query, Body, Headers, Req, Res, Ip, Cookie, Cookies, RawBody, HttpCode, Header, Redirect, createParamDecorator, applyDecorators } from "./http/index.js";
15
+ // Request-scoped context primitive
16
+ export { REQUEST_CONTEXT } from "./http/request-context.js";
15
17
  // Services
16
18
  export { Logger, LogLevel } from "./services/index.js";
17
19
  // Config
@@ -265,6 +265,5 @@ export class MetadataRegistry {
265
265
  this.handlerComponents[type].clear();
266
266
  }
267
267
  this.globalComponents = emptyComponentStore();
268
- // reflectMeta is a WeakMap — entries die with their targets.
269
268
  }
270
269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",