@velajs/vela 1.18.0 → 1.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0 (2026-07-11)
4
+
5
+ Exception-handler layer (roadmap phase 3): a single branded error concept, one
6
+ wire-redaction seam, and a Laravel-style reporting contract layered above the
7
+ NestJS-style exception filters. Design spec:
8
+ `docs/superpowers/specs/2026-07-11-errors-package-and-exception-layer-design.md`.
9
+
10
+ ### Added
11
+
12
+ - **`@velajs/errors` dependency** — the new sibling package vela now builds on:
13
+ the branded `VelaError` (own+enumerable fields so it rides any wire codec /
14
+ `structuredClone` / DO-RPC prop copy), error catalogs
15
+ (`defineErrorCatalog`/`composeCatalogs`/`CORE_CATALOG`), and the single
16
+ `toErrorBody` wire-redaction seam (unbranded or `internal`-coded errors never
17
+ echo their message). Its core surface is re-exported from `@velajs/vela` so
18
+ app authors need one import to throw branded errors, author handlers, and
19
+ define/compose catalogs.
20
+ - **`ExceptionHandler` contract + `resolveErrorReporter`** — an optional
21
+ application-wide handler (`report` / `dontReport` / `context` / `render`)
22
+ consulted at every transport edge (HTTP, WS, live, queue, schedule). Reporting
23
+ is fire-and-forget and fully contained: a broken or throwing handler (or
24
+ `dontReport` matcher) can never mask the original error.
25
+ - **`APP_EXCEPTION_HANDLER` / `ERROR_CATALOG` tokens** — provide the handler and
26
+ the composed catalog via module providers.
27
+ - **`ErrorsModule.forRoot({ catalogs?, handler? })`** — composes
28
+ `[CORE_CATALOG, ...catalogs]` (eager; a duplicate code across catalogs fails
29
+ fast with `duplicate error code`) into `ERROR_CATALOG`, and — when given —
30
+ registers `APP_EXCEPTION_HANDLER` (`useClass` for a handler class, `useValue`
31
+ for a handler object).
32
+ - **`app.useGlobalExceptionHandler(handler)`** — the imperative sibling of
33
+ `ErrorsModule.forRoot({ handler })`; registers `APP_EXCEPTION_HANDLER` on the
34
+ root container, taking effect on the next request without a rebuild.
35
+
36
+ ### Changed
37
+
38
+ - **BREAKING (sanctioned wire break): the HTTP error body is now the canonical
39
+ error object.** Uncaught controller/handler errors render as
40
+ `{ error: { code, message, hint?, docsUrl?, details? } }` with the code's
41
+ catalog status, replacing the prior `{ statusCode, message }` shape.
42
+ `HttpException` object responses still ship verbatim (crud-envelope compat).
43
+ Error reporting is now **report-first**: the reporter runs before any
44
+ exception filter, so a filter claiming an error can no longer make it
45
+ invisible to logging/Sentry. A new `app.onError` fallback funnels raw
46
+ hono-middleware throws (which previously bypassed the filter tier entirely)
47
+ through the same report + redaction path; hono's own `HTTPException`
48
+ responses are still honored verbatim. Per the compat policy, shipped under a
49
+ minor with no deprecation shim.
50
+
51
+ ### Fixed
52
+
53
+ - **Live engine raw-message leak** — initial-subscribe resolver errors were sent
54
+ to the browser as raw `err.message` (`src/live/live.engine.ts`); they now flow
55
+ through `toErrorBody`, so unbranded/internal errors are redacted like every
56
+ other edge. The WS exception frame and the report-then-rethrow queue/schedule
57
+ dispatch paths were aligned to the same report-first + redacted-body invariant.
58
+
3
59
  ## 1.15.0 (2026-07-04)
4
60
 
5
61
  Introspection seams for `@velajs/cli` (roadmap phase 3, CLI introspection):
@@ -1,6 +1,7 @@
1
1
  import type { Hono } from 'hono';
2
2
  import type { Container } from './container/container';
3
- import type { Token } from './container/types';
3
+ import type { Token, Type } from './container/types';
4
+ import type { ExceptionHandler } from './exceptions/exception-handler';
4
5
  import { EntrypointRegistry } from './entrypoint/entrypoint.registry';
5
6
  import type { RouteDescription, RouteManager } from './http/route.manager';
6
7
  import type { MountOpenApiOptions } from './openapi/types';
@@ -38,6 +39,18 @@ export declare class VelaApplication {
38
39
  useGlobalGuards(...guards: GuardType[]): this;
39
40
  useGlobalInterceptors(...interceptors: InterceptorType[]): this;
40
41
  useGlobalFilters(...filters: FilterType[]): this;
42
+ /**
43
+ * Register the application-wide {@link ExceptionHandler} imperatively — the
44
+ * sibling of `ErrorsModule.forRoot({ handler })`. A class is constructed
45
+ * through DI (`useClass`); a plain handler object is registered verbatim
46
+ * (`useValue`). Consumed by `resolveErrorReporter` at every transport edge,
47
+ * so it takes effect on the next request without a rebuild.
48
+ *
49
+ * ```ts
50
+ * app.useGlobalExceptionHandler({ report: (e) => sentry.capture(e) });
51
+ * ```
52
+ */
53
+ useGlobalExceptionHandler(handler: Type<ExceptionHandler> | ExceptionHandler): this;
41
54
  /**
42
55
  * Serve a pre-built OpenAPI document and one or more interactive doc UIs
43
56
  * (Swagger UI, Scalar, ReDoc) on the underlying Hono app, mirroring the
@@ -1,3 +1,7 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+ import { toErrorBody } from "@velajs/errors";
3
+ import { APP_EXCEPTION_HANDLER } from "./pipeline/tokens.js";
4
+ import { resolveErrorReporter } from "./exceptions/reporter.js";
1
5
  import { DiscoveryService } from "./discovery/discovery.service.js";
2
6
  import { EntrypointRegistry } from "./entrypoint/entrypoint.registry.js";
3
7
  import { LazyModuleManager } from "./module/lazy-modules.js";
@@ -36,6 +40,38 @@ export class VelaApplication {
36
40
  }
37
41
  /** Pre-build routes (handles async CRUD imports). Called by VelaFactory. */ async initRoutes() {
38
42
  this.honoApp = await this.routeManager.build();
43
+ // Last line of defense for the HTTP edge. HandlerExecutor's catch tail
44
+ // only sees controller-handler errors; a raw Hono middleware (or any
45
+ // hono-level throw that bypasses route.manager's wrapMiddlewareWithFilters)
46
+ // would otherwise reach Hono's default error handler unreported and
47
+ // unredacted. `onError` funnels it through the same report-first + canonical
48
+ // redacted body path every other edge uses.
49
+ this.honoApp.onError((err, c)=>{
50
+ const reporter = resolveErrorReporter(this.container);
51
+ // Hono's own HTTPException (e.g. `bodyLimit`'s 413) carries a deliberate,
52
+ // author-intended client response — honor it exactly as Hono's default
53
+ // error handler would, without treating it as a server fault to redact.
54
+ // But a 5xx HTTPException is still a server fault: every other edge reports
55
+ // before returning, so report status>=500 here too (leaving the verbatim
56
+ // response — and its redaction/status — untouched).
57
+ if (err instanceof HTTPException) {
58
+ if (err.status >= 500) {
59
+ reporter.report(err, {
60
+ edge: 'hono',
61
+ source: `${c.req.method} ${c.req.path}`
62
+ });
63
+ }
64
+ return err.getResponse();
65
+ }
66
+ reporter.report(err, {
67
+ edge: 'hono',
68
+ source: `${c.req.method} ${c.req.path}`
69
+ });
70
+ const { body, status } = toErrorBody(err, {
71
+ catalog: reporter.catalog
72
+ });
73
+ return c.json(body, status);
74
+ });
39
75
  }
40
76
  getApp() {
41
77
  if (!this.honoApp) {
@@ -93,6 +129,26 @@ export class VelaApplication {
93
129
  return this;
94
130
  }
95
131
  /**
132
+ * Register the application-wide {@link ExceptionHandler} imperatively — the
133
+ * sibling of `ErrorsModule.forRoot({ handler })`. A class is constructed
134
+ * through DI (`useClass`); a plain handler object is registered verbatim
135
+ * (`useValue`). Consumed by `resolveErrorReporter` at every transport edge,
136
+ * so it takes effect on the next request without a rebuild.
137
+ *
138
+ * ```ts
139
+ * app.useGlobalExceptionHandler({ report: (e) => sentry.capture(e) });
140
+ * ```
141
+ */ useGlobalExceptionHandler(handler) {
142
+ this.container.register(typeof handler === 'function' ? {
143
+ provide: APP_EXCEPTION_HANDLER,
144
+ useClass: handler
145
+ } : {
146
+ provide: APP_EXCEPTION_HANDLER,
147
+ useValue: handler
148
+ });
149
+ return this;
150
+ }
151
+ /**
96
152
  * Serve a pre-built OpenAPI document and one or more interactive doc UIs
97
153
  * (Swagger UI, Scalar, ReDoc) on the underlying Hono app, mirroring the
98
154
  * hono-crud docs convention. Edge-safe: each UI is a tiny self-contained
@@ -5,6 +5,7 @@ export declare class HttpException extends Error {
5
5
  constructor(response: ExceptionResponse, statusCode: number);
6
6
  getStatus(): number;
7
7
  getResponse(): Record<string, unknown>;
8
+ getRawResponse(): ExceptionResponse;
8
9
  }
9
10
  export declare class BadRequestException extends HttpException {
10
11
  constructor(message?: ExceptionResponse);
@@ -21,6 +21,14 @@ export class HttpException extends Error {
21
21
  }
22
22
  return this._response;
23
23
  }
24
+ // The constructor's original argument, untransformed — a string message or a
25
+ // caller-supplied object. The canonical HTTP edge branches on this: string
26
+ // responses become `{ error: { code, message } }`, object responses ship
27
+ // verbatim (crud envelope compat). Distinct from `getResponse()`, which wraps
28
+ // strings in the legacy `{ statusCode, message }` shape.
29
+ getRawResponse() {
30
+ return this._response;
31
+ }
24
32
  }
25
33
  // 4xx
26
34
  export class BadRequestException extends HttpException {
@@ -0,0 +1,40 @@
1
+ import { type Catalog } from '@velajs/errors';
2
+ import type { Type } from '../container/types';
3
+ import type { ExceptionHandler } from './exception-handler';
4
+ /**
5
+ * Options for {@link ErrorsModule.forRoot}. Both fields are optional — an empty
6
+ * `forRoot()` provides just the core catalog (`CORE_CATALOG`) and no custom
7
+ * handler, which is the framework default anyway.
8
+ */
9
+ export interface ErrorsModuleOptions {
10
+ /**
11
+ * App-specific catalogs composed **onto** `CORE_CATALOG` via
12
+ * {@link composeCatalogs}. Composition is eager (at `forRoot` call time), so a
13
+ * duplicate code across two catalogs fails fast with `duplicate error code`.
14
+ */
15
+ catalogs?: Catalog<string>[];
16
+ /**
17
+ * The application-wide {@link ExceptionHandler}. A class is wired with
18
+ * `useClass` (constructed through DI); a plain handler object with `useValue`.
19
+ */
20
+ handler?: Type<ExceptionHandler> | ExceptionHandler;
21
+ }
22
+ declare const ConfigurableModuleClass: import("..").ConfigurableModuleClassType<ErrorsModuleOptions, "forRoot", "create", {
23
+ isGlobal?: boolean;
24
+ }>;
25
+ /**
26
+ * Registers the application-wide error surface: the composed
27
+ * {@link ERROR_CATALOG} that every transport edge redacts against, and an
28
+ * optional {@link APP_EXCEPTION_HANDLER}. The imperative sibling is
29
+ * `app.useGlobalExceptionHandler(handler)`.
30
+ *
31
+ * ```ts
32
+ * @Module({
33
+ * imports: [ErrorsModule.forRoot({ catalogs: [ordersCatalog], handler: SentryHandler })],
34
+ * })
35
+ * class AppModule {}
36
+ * ```
37
+ */
38
+ export declare class ErrorsModule extends ConfigurableModuleClass {
39
+ }
40
+ export {};
@@ -0,0 +1,46 @@
1
+ import { CORE_CATALOG, composeCatalogs } from "@velajs/errors";
2
+ import { defineModule } from "../module/define-module.js";
3
+ import { APP_EXCEPTION_HANDLER, ERROR_CATALOG } from "../pipeline/tokens.js";
4
+ /** Lower a `handler` option to an {@link APP_EXCEPTION_HANDLER} provider. */ const handlerProvider = (handler)=>typeof handler === 'function' ? {
5
+ provide: APP_EXCEPTION_HANDLER,
6
+ useClass: handler
7
+ } : {
8
+ provide: APP_EXCEPTION_HANDLER,
9
+ useValue: handler
10
+ };
11
+ const { ConfigurableModuleClass } = defineModule({
12
+ name: 'Errors',
13
+ setup: ({ options })=>{
14
+ const providers = [
15
+ {
16
+ provide: ERROR_CATALOG,
17
+ useValue: composeCatalogs(CORE_CATALOG, ...options.catalogs ?? [])
18
+ }
19
+ ];
20
+ const exports = [
21
+ ERROR_CATALOG
22
+ ];
23
+ if (options.handler) {
24
+ providers.push(handlerProvider(options.handler));
25
+ exports.push(APP_EXCEPTION_HANDLER);
26
+ }
27
+ return {
28
+ providers,
29
+ exports
30
+ };
31
+ }
32
+ });
33
+ /**
34
+ * Registers the application-wide error surface: the composed
35
+ * {@link ERROR_CATALOG} that every transport edge redacts against, and an
36
+ * optional {@link APP_EXCEPTION_HANDLER}. The imperative sibling is
37
+ * `app.useGlobalExceptionHandler(handler)`.
38
+ *
39
+ * ```ts
40
+ * @Module({
41
+ * imports: [ErrorsModule.forRoot({ catalogs: [ordersCatalog], handler: SentryHandler })],
42
+ * })
43
+ * class AppModule {}
44
+ * ```
45
+ */ export class ErrorsModule extends ConfigurableModuleClass {
46
+ }
@@ -0,0 +1,56 @@
1
+ import { type ErrorBodyResult } from '@velajs/errors';
2
+ /**
3
+ * A predicate for classifying an error, used by {@link ExceptionHandler.dontReport}.
4
+ *
5
+ * Three shapes are accepted:
6
+ * - `string` — a Vela error code; matches when the error is a branded
7
+ * `VelaError` whose `code` equals the string.
8
+ * - a predicate `(error: unknown) => boolean` — **must be an arrow function**.
9
+ * A class and a predicate are both `typeof 'function'`; {@link matchesAny}
10
+ * discriminates on `prototype`, which arrow functions do not have.
11
+ * - an error class constructor — matches via `instanceof`.
12
+ *
13
+ * Bound functions (e.g. `SomeClass.bind(null)`) also lack `.prototype`, so they
14
+ * are treated as predicates and invoked — never as class matchers. Since a bound
15
+ * constructor cannot be called without `new`, using one here throws; pass the
16
+ * unbound class instead.
17
+ */
18
+ export type ErrorMatcher = string | ((error: unknown) => boolean) | (new (...args: never[]) => Error);
19
+ /**
20
+ * Ambient context handed to every reporting/rendering hook. `edge` names the
21
+ * transport that caught the error; open-ended extra keys (via the index
22
+ * signature) let a handler's `context()` enrich the payload.
23
+ */
24
+ export interface ErrorReportContext {
25
+ edge: 'http' | 'ws' | 'live' | 'queue' | 'schedule' | 'hono';
26
+ /** e.g. `'CatsController.findAll'` or a query name. */
27
+ source?: string;
28
+ /** e.g. `'exception filter threw'`. */
29
+ note?: string;
30
+ [key: string]: unknown;
31
+ }
32
+ /**
33
+ * The application-wide exception handler contract. Provide an implementation
34
+ * under {@link APP_EXCEPTION_HANDLER} to customize how errors are reported and
35
+ * rendered. Every member is optional — an empty object is a valid handler.
36
+ */
37
+ export interface ExceptionHandler {
38
+ /** Fire-and-forget reporting (logging, Sentry, …). May be async. */
39
+ report?(error: unknown, ctx: ErrorReportContext): void | Promise<void>;
40
+ /** Errors matching any of these are never reported. */
41
+ dontReport?: ErrorMatcher[];
42
+ /** Extra fields merged into the report context. Must be side-effect free. */
43
+ context?(error: unknown, ctx: ErrorReportContext): Record<string, unknown>;
44
+ /** Override the client-bound response for an error. */
45
+ render?(error: unknown, ctx: unknown): Response | ErrorBodyResult | undefined;
46
+ }
47
+ /**
48
+ * True when `error` matches any of `matchers`. A string matches a branded
49
+ * {@link VelaError} by `code`; an arrow-function predicate is called; anything
50
+ * else with a `prototype` is treated as an error class and matched via
51
+ * `instanceof`. Predicates therefore MUST be arrow functions — a regular
52
+ * `function` expression has a `prototype` and would be mistaken for a class.
53
+ * Bound functions (e.g. `SomeClass.bind(null)`) lack `.prototype` too, so they
54
+ * are invoked as predicates — don't pass a bound class as a class matcher.
55
+ */
56
+ export declare const matchesAny: (matchers: ErrorMatcher[] | undefined, error: unknown) => boolean;
@@ -0,0 +1,17 @@
1
+ import { isVelaError } from "@velajs/errors";
2
+ /**
3
+ * True when `error` matches any of `matchers`. A string matches a branded
4
+ * {@link VelaError} by `code`; an arrow-function predicate is called; anything
5
+ * else with a `prototype` is treated as an error class and matched via
6
+ * `instanceof`. Predicates therefore MUST be arrow functions — a regular
7
+ * `function` expression has a `prototype` and would be mistaken for a class.
8
+ * Bound functions (e.g. `SomeClass.bind(null)`) lack `.prototype` too, so they
9
+ * are invoked as predicates — don't pass a bound class as a class matcher.
10
+ */ export const matchesAny = (matchers, error)=>{
11
+ if (!matchers?.length) return false;
12
+ return matchers.some((m)=>{
13
+ if (typeof m === 'string') return isVelaError(error) && error.code === m;
14
+ if (typeof m === 'function' && !m.prototype) return m(error);
15
+ return error instanceof m;
16
+ });
17
+ };
@@ -0,0 +1,6 @@
1
+ export { matchesAny } from './exception-handler';
2
+ export type { ErrorMatcher, ErrorReportContext, ExceptionHandler } from './exception-handler';
3
+ export { ErrorsModule } from './errors.module';
4
+ export type { ErrorsModuleOptions } from './errors.module';
5
+ export { resolveErrorReporter } from './reporter';
6
+ export type { ErrorReporter } from './reporter';
@@ -0,0 +1,3 @@
1
+ export { matchesAny } from "./exception-handler.js";
2
+ export { ErrorsModule } from "./errors.module.js";
3
+ export { resolveErrorReporter } from "./reporter.js";
@@ -0,0 +1,21 @@
1
+ import { type Catalog, type ErrorBodyResult } from '@velajs/errors';
2
+ import type { Container } from '../container/container';
3
+ import { type ErrorReportContext } from './exception-handler';
4
+ /**
5
+ * The resolved reporting facade every transport edge (HTTP, WS, live, queue,
6
+ * schedule) uses. `report` is fire-and-forget and fully contained — a broken
7
+ * or throwing handler can never mask the original error. `render` gives a
8
+ * handler the chance to override the client-bound response.
9
+ */
10
+ export interface ErrorReporter {
11
+ catalog: Catalog<string>;
12
+ report(error: unknown, ctx: ErrorReportContext): void;
13
+ render(error: unknown, executionCtx: unknown): Response | ErrorBodyResult | undefined;
14
+ }
15
+ /**
16
+ * Build an {@link ErrorReporter} from a container. Reads the optional
17
+ * {@link APP_EXCEPTION_HANDLER} and {@link ERROR_CATALOG} providers; with
18
+ * neither registered the reporter logs (unless diagnostics is `'silent'`) and
19
+ * exposes the core catalog.
20
+ */
21
+ export declare const resolveErrorReporter: (container: Container) => ErrorReporter;
@@ -0,0 +1,70 @@
1
+ import { CORE_CATALOG, isVelaError } from "@velajs/errors";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { HttpException } from "../errors/http-exception.js";
4
+ import { APP_EXCEPTION_HANDLER, ERROR_CATALOG } from "../pipeline/tokens.js";
5
+ import { matchesAny } from "./exception-handler.js";
6
+ /**
7
+ * Build an {@link ErrorReporter} from a container. Reads the optional
8
+ * {@link APP_EXCEPTION_HANDLER} and {@link ERROR_CATALOG} providers; with
9
+ * neither registered the reporter logs (unless diagnostics is `'silent'`) and
10
+ * exposes the core catalog.
11
+ */ export const resolveErrorReporter = (container)=>{
12
+ const handler = container.has(APP_EXCEPTION_HANDLER) ? container.resolve(APP_EXCEPTION_HANDLER) : undefined;
13
+ const catalog = container.has(ERROR_CATALOG) ? container.resolve(ERROR_CATALOG) : CORE_CATALOG;
14
+ return {
15
+ catalog,
16
+ report (error, ctx) {
17
+ let suppressed = false;
18
+ try {
19
+ suppressed = matchesAny(handler?.dontReport, error);
20
+ } catch {
21
+ // A broken matcher must never mask the original error — treat as no match.
22
+ }
23
+ if (suppressed) return;
24
+ const merged = handler?.context ? {
25
+ ...ctx,
26
+ ...safeContext(handler, error, ctx)
27
+ } : ctx;
28
+ if (handler?.report) {
29
+ try {
30
+ void Promise.resolve(handler.report(error, merged)).catch(()=>{});
31
+ } catch {
32
+ // A broken reporter must never mask the original error.
33
+ }
34
+ return;
35
+ }
36
+ if (container.getDiagnostics() !== 'silent') {
37
+ const status = clientFaultStatus(error);
38
+ if (status !== undefined && status >= 400 && status < 500) return; // client fault — not server-error log noise
39
+ console.error(`[vela] ${merged.edge} error${merged.source ? ` in ${merged.source}` : ''}${merged.note ? ` (${merged.note})` : ''}:`, error);
40
+ }
41
+ },
42
+ render (error, executionCtx) {
43
+ try {
44
+ return handler?.render?.(error, executionCtx);
45
+ } catch {
46
+ return undefined; // broken render hook falls through to default render
47
+ }
48
+ }
49
+ };
50
+ };
51
+ /**
52
+ * The HTTP status of a client-fault (4xx) error, read across the three shapes a
53
+ * caught error can take: a branded {@link VelaError} (`.status`), vela's own
54
+ * {@link HttpException} (`getStatus()`), or hono's {@link HTTPException}
55
+ * (`.status`). `undefined` for anything else — including raw/unbranded errors,
56
+ * which must always be logged. Only the DEFAULT console reporter uses this to
57
+ * mute client faults; a custom `handler.report` still receives everything.
58
+ */ const clientFaultStatus = (error)=>{
59
+ if (isVelaError(error)) return error.status;
60
+ if (error instanceof HttpException) return error.getStatus();
61
+ if (error instanceof HTTPException) return error.status; // hono
62
+ return undefined;
63
+ };
64
+ const safeContext = (handler, error, ctx)=>{
65
+ try {
66
+ return handler.context?.(error, ctx) ?? {};
67
+ } catch {
68
+ return {};
69
+ }
70
+ };
@@ -8,7 +8,7 @@ import { UrlGeneratorService } from "../http/url/url-generator.service.js";
8
8
  import { SignedUrlGuard } from "../http/url/signed-url.guard.js";
9
9
  import { ModuleLoader } from "../module/module-loader.js";
10
10
  import { bindAppProviders } from "../pipeline/app-providers.js";
11
- import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from "../pipeline/tokens.js";
11
+ import { APP_EXCEPTION_HANDLER, APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE, ERROR_CATALOG } from "../pipeline/tokens.js";
12
12
  /**
13
13
  * Wire the DI graph for `rootModule` and prepare the route manager — without
14
14
  * running lifecycle hooks or building the Hono app. The single primitive
@@ -48,7 +48,9 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_MIDDLEWARE, APP_PIPE } from
48
48
  APP_PIPE,
49
49
  APP_INTERCEPTOR,
50
50
  APP_FILTER,
51
- APP_MIDDLEWARE
51
+ APP_MIDDLEWARE,
52
+ APP_EXCEPTION_HANDLER,
53
+ ERROR_CATALOG
52
54
  ]){
53
55
  container.markGlobalToken(t);
54
56
  }
@@ -1,4 +1,6 @@
1
+ import { STATUS_TO_CODE, toErrorBody } from "@velajs/errors";
1
2
  import { HttpException } from "../errors/http-exception.js";
3
+ import { resolveErrorReporter } from "../exceptions/reporter.js";
2
4
  import { ComponentManager } from "../pipeline/component.manager.js";
3
5
  import { shouldFilterCatch } from "../pipeline/decorators.js";
4
6
  import { PipelineRunner } from "../pipeline/pipeline-runner.js";
@@ -80,31 +82,51 @@ export class HandlerExecutor {
80
82
  applyResponseHeaders(response, responseHeaders);
81
83
  return response;
82
84
  } catch (error) {
85
+ const reporter = resolveErrorReporter(requestContainer);
86
+ const source = `${controller.name}.${String(route.handlerName)}`;
87
+ // Report FIRST, always — rendering (filters included) is a separate
88
+ // concern; a filter claiming the error must not make it invisible.
89
+ reporter.report(error, {
90
+ edge: 'http',
91
+ source
92
+ });
83
93
  for (const filter of filters){
84
94
  if (shouldFilterCatch(filter, error)) {
85
95
  try {
86
96
  const filtered = await filter.catch(error, executionContext);
87
97
  return mapResponse(c, filtered);
88
- } catch {
98
+ } catch (filterError) {
99
+ // A broken filter is itself a bug worth logs — then fall through
100
+ // to the default render instead of silently dying here.
101
+ reporter.report(filterError, {
102
+ edge: 'http',
103
+ source,
104
+ note: 'exception filter threw'
105
+ });
89
106
  break;
90
107
  }
91
108
  }
92
109
  }
110
+ const rendered = reporter.render(error, executionContext);
111
+ if (rendered instanceof Response) return rendered;
112
+ if (rendered) return c.json(rendered.body, rendered.status);
93
113
  if (error instanceof HttpException) {
94
- const response = error.getResponse();
95
114
  const status = error.getStatus();
96
- return c.json(response, status);
97
- }
98
- // An unknown error reaching here means no filter claimed it — never
99
- // swallow it silently: the response is a generic 500, but the cause
100
- // must land in the logs (diagnostics 'silent' opts out).
101
- if (requestContainer.getDiagnostics() !== 'silent') {
102
- console.error(`[vela] unhandled error in ${controller.name}.${String(route.handlerName)}:`, error);
115
+ const raw = error.getRawResponse() ?? error.message;
116
+ if (typeof raw === 'string') {
117
+ return c.json({
118
+ error: {
119
+ code: STATUS_TO_CODE[status] ?? 'internal',
120
+ message: raw
121
+ }
122
+ }, status);
123
+ }
124
+ return c.json(raw, status); // object responses ship verbatim (crud envelope compat)
103
125
  }
104
- return c.json({
105
- statusCode: 500,
106
- message: 'Internal Server Error'
107
- }, 500);
126
+ const { body, status } = toErrorBody(error, {
127
+ catalog: reporter.catalog
128
+ });
129
+ return c.json(body, status);
108
130
  }
109
131
  };
110
132
  }
package/dist/index.d.ts CHANGED
@@ -51,13 +51,17 @@ export type { AdapterContext, RuntimeAdapter } from './factory/adapter';
51
51
  export type { VelaCreateOptions } from './factory';
52
52
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN, } from './plugin/plugin';
53
53
  export type { Plugin } from './plugin/plugin';
54
- export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, } from './pipeline/index';
54
+ export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, APP_EXCEPTION_HANDLER, ERROR_CATALOG, } from './pipeline/index';
55
55
  export type { PipelineRunOptions, ResolvedComponentMap } from './pipeline/index';
56
56
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, ReflectableDecorator, CreateDecoratorOptions, } from './pipeline/index';
57
57
  export type { Constructor, MiddlewareType, GuardType, PipeType, InterceptorType, FilterType, } from './registry/index';
58
58
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe, } from './pipeline/index';
59
59
  export type { ParseUUIDPipeOptions, ParseArrayPipeOptions } from './pipeline/index';
60
60
  export { HttpException, BadRequestException, UnauthorizedException, ForbiddenException, NotFoundException, MethodNotAllowedException, NotAcceptableException, RequestTimeoutException, ConflictException, GoneException, PayloadTooLargeException, UnsupportedMediaTypeException, UnprocessableEntityException, TooManyRequestsException, InternalServerErrorException, NotImplementedException, BadGatewayException, ServiceUnavailableException, GatewayTimeoutException, } from './errors/index';
61
+ export { ErrorsModule, matchesAny, resolveErrorReporter } from './exceptions/index';
62
+ export type { ErrorMatcher, ErrorReportContext, ErrorsModuleOptions, ExceptionHandler, ErrorReporter, } from './exceptions/index';
63
+ export { VelaError, isVelaError, defineErrorCatalog, composeCatalogs, toErrorBody, CORE_CATALOG, } from '@velajs/errors';
64
+ export type { Catalog, ErrorBodyResult, ErrorCatalogEntry, VelaErrorOptions } from '@velajs/errors';
61
65
  export type { OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, OnApplicationShutdown, BeforeApplicationShutdown, } from './lifecycle/index';
62
66
  export { MetadataRegistry } from './registry/metadata.registry';
63
67
  export { createZodDto, ValidationPipe } from './validation/index';
package/dist/index.js CHANGED
@@ -53,11 +53,16 @@ export { registerRouteContributor, getRouteContributors } from "./http/route-con
53
53
  // Plugin manifest + composer
54
54
  export { definePlugin, composePlugins, PluginRegistry, PluginRootModule, PLUGIN_REGISTRY_TOKEN } from "./plugin/plugin.js";
55
55
  // Pipeline Decorators
56
- export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from "./pipeline/index.js";
56
+ export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, SetMetadata, Reflector, PipelineRunner, getCatchTypes, shouldFilterCatch, resolveScopedComponents, APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, APP_EXCEPTION_HANDLER, ERROR_CATALOG } from "./pipeline/index.js";
57
57
  // Built-in Pipes
58
58
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe } from "./pipeline/index.js";
59
59
  // Errors
60
60
  export { HttpException, BadRequestException, UnauthorizedException, ForbiddenException, NotFoundException, MethodNotAllowedException, NotAcceptableException, RequestTimeoutException, ConflictException, GoneException, PayloadTooLargeException, UnsupportedMediaTypeException, UnprocessableEntityException, TooManyRequestsException, InternalServerErrorException, NotImplementedException, BadGatewayException, ServiceUnavailableException, GatewayTimeoutException } from "./errors/index.js";
61
+ // Exception handling — the ExceptionHandler contract + shared error reporter.
62
+ // Re-exports the core @velajs/errors surface so app authors need one import to
63
+ // author handlers, throw branded errors, and define/compose catalogs.
64
+ export { ErrorsModule, matchesAny, resolveErrorReporter } from "./exceptions/index.js";
65
+ export { VelaError, isVelaError, defineErrorCatalog, composeCatalogs, toErrorBody, CORE_CATALOG } from "@velajs/errors";
61
66
  // MetadataRegistry — the central decoration store. Test setup typically
62
67
  // uses `MetadataRegistry.clear()` between cases. Internal primitives like
63
68
  // RouteManager/ModuleLoader/ComponentManager live only at @velajs/vela/internal.
@@ -66,6 +66,13 @@ export declare class LiveEngine implements OnApplicationBootstrap, ContributesEn
66
66
  private runSubscribeGuards;
67
67
  private scheduleDrain;
68
68
  private drainLoop;
69
+ /**
70
+ * Map a resolver failure to its live error-frame code. A branded
71
+ * {@link VelaError} carries an HTTP-ish status that projects onto the live
72
+ * vocabulary (403 → forbidden, 400/422 → bad_args); everything else — and
73
+ * every unbranded error — is `internal` (the client sees a redacted message).
74
+ */
75
+ private static liveFrameCode;
69
76
  private push;
70
77
  private runQuery;
71
78
  /**
@@ -13,7 +13,7 @@ function _ts_param(paramIndex, decorator) {
13
13
  };
14
14
  }
15
15
  import { LIVE_ERROR_CODES, LIVE_PROTOCOL, encodeLiveEnvelope, isClientLiveFrame } from "@velajs/live-protocol";
16
- import { Container, DiscoveryService, Inject, Injectable, Optional, PipelineRunner, ReservedWsEvent, buildEntrypointExecutionContext, resolveScopedComponents, runInEntrypointScope } from "../index.js";
16
+ import { Container, DiscoveryService, Inject, Injectable, Optional, PipelineRunner, ReservedWsEvent, buildEntrypointExecutionContext, isVelaError, resolveErrorReporter, resolveScopedComponents, runInEntrypointScope, toErrorBody } from "../index.js";
17
17
  import { getLiveQueries } from "./live.decorators.js";
18
18
  import { encodeSubscriptionUpdate } from "./live.delta.js";
19
19
  import { LIVE_CURSOR_LOG, LIVE_DRIVER, LIVE_MODULE_OPTIONS, LIVE_RESOLVER_METADATA } from "./live.tokens.js";
@@ -308,6 +308,17 @@ export class LiveEngine {
308
308
  });
309
309
  }
310
310
  }
311
+ /**
312
+ * Map a resolver failure to its live error-frame code. A branded
313
+ * {@link VelaError} carries an HTTP-ish status that projects onto the live
314
+ * vocabulary (403 → forbidden, 400/422 → bad_args); everything else — and
315
+ * every unbranded error — is `internal` (the client sees a redacted message).
316
+ */ static liveFrameCode(err) {
317
+ if (!isVelaError(err)) return LIVE_ERROR_CODES.INTERNAL;
318
+ if (err.status === 403) return LIVE_ERROR_CODES.FORBIDDEN;
319
+ if (err.status === 400 || err.status === 422) return LIVE_ERROR_CODES.BAD_ARGS;
320
+ return LIVE_ERROR_CODES.INTERNAL;
321
+ }
311
322
  async push(conn, record, stamp, { initial }) {
312
323
  // Outbound expiry enforcement — the only place expiry CAN be enforced for
313
324
  // a passive subscriber.
@@ -331,12 +342,24 @@ export class LiveEngine {
331
342
  json = JSON.stringify(result);
332
343
  } catch (err) {
333
344
  if (initial) {
345
+ // Close the leak: an initial-subscribe resolver failure is REDACTED
346
+ // through `toErrorBody` (the single wire-redaction seam) — an unbranded
347
+ // error's raw message never reaches the browser, it surfaces only via
348
+ // the reporter. Branded VelaErrors keep their client-facing message.
334
349
  conn.subs.delete(record.sub);
350
+ const reporter = resolveErrorReporter(this.container);
351
+ reporter.report(err, {
352
+ edge: 'live',
353
+ source: record.query
354
+ });
355
+ const safe = toErrorBody(err, {
356
+ catalog: reporter.catalog
357
+ });
335
358
  this.sendFrame(conn.client, {
336
359
  t: 'error',
337
360
  sub: record.sub,
338
- code: LIVE_ERROR_CODES.INTERNAL,
339
- message: err instanceof Error ? err.message : 'live query failed',
361
+ code: LiveEngine.liveFrameCode(err),
362
+ message: safe.body.error.message,
340
363
  fatal: true
341
364
  });
342
365
  } else if (this.container.getDiagnostics() !== 'silent') {
@@ -6,7 +6,7 @@ export type { ResolvedComponentMap } from './scoped-components';
6
6
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, getCatchTypes, shouldFilterCatch, } from './decorators';
7
7
  export { SetMetadata, Reflector } from './reflector';
8
8
  export type { ReflectableDecorator, CreateDecoratorOptions } from './reflector';
9
- export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from './tokens';
9
+ export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, APP_EXCEPTION_HANDLER, ERROR_CATALOG, } from './tokens';
10
10
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe, } from './pipes';
11
11
  export type { ParseUUIDPipeOptions, ParseArrayPipeOptions } from './pipes';
12
12
  export type { HttpArgumentsHost, ExecutionContext, CanActivate, CallHandler, NestInterceptor, NestMiddleware, PipeTransform, ExceptionFilter, ArgumentMetadata, } from './types';
@@ -3,5 +3,5 @@ export { PipelineRunner } from "./pipeline-runner.js";
3
3
  export { resolveScopedComponents } from "./scoped-components.js";
4
4
  export { UseMiddleware, UseGuards, UsePipes, UseInterceptors, UseFilters, Catch, getCatchTypes, shouldFilterCatch } from "./decorators.js";
5
5
  export { SetMetadata, Reflector } from "./reflector.js";
6
- export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE } from "./tokens.js";
6
+ export { APP_GUARD, APP_PIPE, APP_INTERCEPTOR, APP_FILTER, APP_MIDDLEWARE, APP_EXCEPTION_HANDLER, ERROR_CATALOG } from "./tokens.js";
7
7
  export { ParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe, RequiredPipe, ZodValidationPipe } from "./pipes.js";
@@ -1,4 +1,6 @@
1
+ import type { Catalog } from '@velajs/errors';
1
2
  import { InjectionToken } from '../container/types';
3
+ import type { ExceptionHandler } from '../exceptions/exception-handler';
2
4
  import type { CanActivate, ExceptionFilter, NestInterceptor, NestMiddleware, PipeTransform } from './types';
3
5
  /**
4
6
  * Register global guards via module providers instead of app.useGlobalGuards().
@@ -31,3 +33,25 @@ export declare const APP_FILTER: InjectionToken<ExceptionFilter<unknown>>;
31
33
  * Register global middleware via module providers.
32
34
  */
33
35
  export declare const APP_MIDDLEWARE: InjectionToken<NestMiddleware>;
36
+ /**
37
+ * Register the application-wide exception handler via module providers.
38
+ * Consumed by the shared error reporter (`resolveErrorReporter`) at every
39
+ * transport edge to customize how errors are reported and rendered.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * @Module({
44
+ * providers: [
45
+ * { provide: APP_EXCEPTION_HANDLER, useValue: { report: (e) => sentry.capture(e) } },
46
+ * ],
47
+ * })
48
+ * class AppModule {}
49
+ * ```
50
+ */
51
+ export declare const APP_EXCEPTION_HANDLER: InjectionToken<ExceptionHandler>;
52
+ /**
53
+ * Provide the composed error catalog (`composeCatalogs(CORE_CATALOG, …)`)
54
+ * consulted when rendering wire-bound error bodies. Defaults to the core
55
+ * catalog when unset.
56
+ */
57
+ export declare const ERROR_CATALOG: InjectionToken<Catalog<string>>;
@@ -25,3 +25,23 @@ import { InjectionToken } from "../container/types.js";
25
25
  /**
26
26
  * Register global middleware via module providers.
27
27
  */ export const APP_MIDDLEWARE = new InjectionToken('APP_MIDDLEWARE');
28
+ /**
29
+ * Register the application-wide exception handler via module providers.
30
+ * Consumed by the shared error reporter (`resolveErrorReporter`) at every
31
+ * transport edge to customize how errors are reported and rendered.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * @Module({
36
+ * providers: [
37
+ * { provide: APP_EXCEPTION_HANDLER, useValue: { report: (e) => sentry.capture(e) } },
38
+ * ],
39
+ * })
40
+ * class AppModule {}
41
+ * ```
42
+ */ export const APP_EXCEPTION_HANDLER = new InjectionToken('APP_EXCEPTION_HANDLER');
43
+ /**
44
+ * Provide the composed error catalog (`composeCatalogs(CORE_CATALOG, …)`)
45
+ * consulted when rendering wire-bound error bodies. Defaults to the core
46
+ * catalog when unset.
47
+ */ export const ERROR_CATALOG = new InjectionToken('ERROR_CATALOG');
@@ -1,4 +1,4 @@
1
- import { EntrypointRegistry } from "../index.js";
1
+ import { EntrypointRegistry, resolveErrorReporter } from "../index.js";
2
2
  import { dispatchJobToEntries } from "./queue.dispatch.js";
3
3
  import { PROCESSOR_METADATA, queueToken } from "./queue.tokens.js";
4
4
  /**
@@ -48,6 +48,13 @@ import { PROCESSOR_METADATA, queueToken } from "./queue.tokens.js";
48
48
  if (mode === 'throw') {
49
49
  throw error instanceof Error ? error : new Error(String(error));
50
50
  }
51
- console.error(`[vela] unhandled error processing queue job '${job.name}' on '${job.queue}':`, error);
51
+ // Fire-and-forget (inline `immediate`) deliveries have no awaiter to rethrow
52
+ // into — route their unclaimed errors to the exception handler instead of a
53
+ // bare console.error.
54
+ resolveErrorReporter(this.container).report(error, {
55
+ edge: 'queue',
56
+ source: `${job.queue}/${job.name}`,
57
+ note: 'inline driver'
58
+ });
52
59
  }
53
60
  }
@@ -1,4 +1,4 @@
1
- import { buildEntrypointExecutionContext, PipelineRunner, resolveScopedComponents, runInEntrypointScope, shouldFilterCatch } from "../index.js";
1
+ import { buildEntrypointExecutionContext, PipelineRunner, resolveErrorReporter, resolveScopedComponents, runInEntrypointScope, shouldFilterCatch } from "../index.js";
2
2
  import { getProcessHandlers } from "./queue.decorators.js";
3
3
  const warnedDuplicates = new WeakSet();
4
4
  function selectHandler(container, processorClass, handlers, jobName) {
@@ -90,13 +90,20 @@ async function dispatchToProcessor(container, processorClass, job) {
90
90
  });
91
91
  return true;
92
92
  } catch (error) {
93
+ // Report BEFORE the filter loop and BEFORE any rethrow — every dispatch
94
+ // error reaches the exception handler, whether a scoped filter claims it
95
+ // or it rethrows to preserve platform retry semantics.
96
+ resolveErrorReporter(scope).report(error, {
97
+ edge: 'queue',
98
+ source: `${processorClass.name}.${String(handler.methodName)}`
99
+ });
93
100
  for (const filter of filters){
94
101
  if (shouldFilterCatch(filter, error)) {
95
102
  await filter.catch(error, context);
96
103
  return true;
97
104
  }
98
105
  }
99
- throw error;
106
+ throw error; // unclaimed → platform retry semantics stay intact
100
107
  }
101
108
  });
102
109
  }
@@ -14,6 +14,7 @@ function _ts_param(paramIndex, decorator) {
14
14
  }
15
15
  import { Inject, Injectable } from "../container/index.js";
16
16
  import { Container } from "../container/container.js";
17
+ import { resolveErrorReporter } from "../exceptions/reporter.js";
17
18
  import { parseCron } from "../schedule/cron-matcher.js";
18
19
  import { ScheduleRegistry } from "../schedule/schedule.registry.js";
19
20
  export class ScheduleExecutor {
@@ -70,13 +71,17 @@ export class ScheduleExecutor {
70
71
  await method.call(instance);
71
72
  }
72
73
  } catch (err) {
74
+ // Report BEFORE the rethrow below — the exception handler sees every
75
+ // scheduled-job error, and its default reporter logs it (unless silent),
76
+ // replacing the previous bare console.warn.
77
+ resolveErrorReporter(this.container).report(err, {
78
+ edge: 'schedule',
79
+ source: methodName
80
+ });
73
81
  // Runtime job error — keep the scheduler running by default. Users
74
82
  // can opt into rethrowing by setting diagnostics: 'throw'.
75
83
  const mode = this.container.getDiagnostics();
76
84
  if (mode === 'throw') throw err;
77
- if (mode === 'log') {
78
- console.warn(`[vela] scheduled job ${methodName} failed:`, err);
79
- }
80
85
  }
81
86
  }
82
87
  getMatcher(expression) {
@@ -15,6 +15,7 @@ function _ts_param(paramIndex, decorator) {
15
15
  import { Container } from "../container/container.js";
16
16
  import { Inject, Injectable, Optional } from "../container/decorators.js";
17
17
  import { DiscoveryService } from "../discovery/discovery.service.js";
18
+ import { resolveErrorReporter } from "../exceptions/reporter.js";
18
19
  import { instantiateMany } from "../http/instantiate.js";
19
20
  import { RouteManager } from "../http/route.manager.js";
20
21
  import { ComponentManager } from "../pipeline/component.manager.js";
@@ -127,9 +128,15 @@ export class WsDispatcher {
127
128
  }
128
129
  }
129
130
  async handleError(path, _client, err) {
130
- if (this.container.getDiagnostics() !== 'silent') {
131
- console.warn(`[vela] websocket error on gateway ${path}:`, err);
132
- }
131
+ // Connection-level and reserved-frame (`$…`) throws land here. Route them
132
+ // through the shared reporter so a custom APP_EXCEPTION_HANDLER observes
133
+ // them too — the default reporter console.errors and honors `'silent'`.
134
+ // No client frame is sent for this path, by design: reserved frames own
135
+ // their own client-facing responses, and there is no envelope id to reply to.
136
+ resolveErrorReporter(this.container).report(err, {
137
+ edge: 'ws',
138
+ source: path ? `reserved-frame ${path}` : 'reserved-frame'
139
+ });
133
140
  }
134
141
  async dispatchMessage(path, client, raw) {
135
142
  const entry = this.gateways.get(path);
@@ -189,7 +196,15 @@ export class WsDispatcher {
189
196
  });
190
197
  this.reply(client, message, result);
191
198
  } catch (error) {
192
- await this.runFilters(client, message, error, filters, ctx);
199
+ const reporter = resolveErrorReporter(this.container);
200
+ const source = `${entry.gatewayClass.name}.${handler.methodName}`;
201
+ // Report FIRST, always — before any exception filter can claim (and thus
202
+ // hide) the error. Rendering is a separate concern (mirrors HandlerExecutor).
203
+ reporter.report(error, {
204
+ edge: 'ws',
205
+ source
206
+ });
207
+ await this.runFilters(client, message, error, filters, ctx, reporter, source);
193
208
  }
194
209
  }
195
210
  /**
@@ -207,7 +222,7 @@ export class WsDispatcher {
207
222
  try {
208
223
  for (const guard of guards){
209
224
  if (!await guard.canActivate(ctx)) {
210
- const frame = toErrorFrame(new WsException('Forbidden'));
225
+ const frame = toErrorFrame(new WsException('Forbidden'), resolveErrorReporter(this.container).catalog);
211
226
  this.trySend(client, frame.event, frame.data, message.id);
212
227
  return;
213
228
  }
@@ -225,7 +240,7 @@ export class WsDispatcher {
225
240
  this.trySend(client, message.event, result, message.id);
226
241
  }
227
242
  }
228
- async runFilters(client, message, error, filters, ctx) {
243
+ async runFilters(client, message, error, filters, ctx, reporter, source) {
229
244
  for (const filter of filters){
230
245
  if (shouldFilterCatch(filter, error)) {
231
246
  try {
@@ -233,15 +248,21 @@ export class WsDispatcher {
233
248
  if (isWsResponse(handled)) {
234
249
  this.trySend(client, handled.event, handled.data, message.id);
235
250
  }
236
- } catch {
237
- // A throwing filter falls back to the default error frame (mirrors HandlerExecutor).
238
- const frame = toErrorFrame(error);
251
+ } catch (filterError) {
252
+ // A throwing filter is itself a bug worth reporting then fall back
253
+ // to the default redacted frame (mirrors HandlerExecutor).
254
+ reporter.report(filterError, {
255
+ edge: 'ws',
256
+ source,
257
+ note: 'exception filter threw'
258
+ });
259
+ const frame = toErrorFrame(error, reporter.catalog);
239
260
  this.trySend(client, frame.event, frame.data, message.id);
240
261
  }
241
262
  return;
242
263
  }
243
264
  }
244
- const errorFrame = toErrorFrame(error);
265
+ const errorFrame = toErrorFrame(error, reporter.catalog);
245
266
  this.trySend(client, errorFrame.event, errorFrame.data, message.id);
246
267
  }
247
268
  // Outbound sends are best-effort: the socket may have closed mid-dispatch.
@@ -1,3 +1,4 @@
1
+ import { type Catalog } from '@velajs/errors';
1
2
  /**
2
3
  * WebSocket analogue of `HttpException`. Thrown from guards/pipes/handlers to
3
4
  * signal a client-facing error; the dispatcher serializes it to an
@@ -9,8 +10,16 @@ export declare class WsException extends Error {
9
10
  constructor(err: string | Record<string, unknown>);
10
11
  getError(): string | Record<string, unknown>;
11
12
  }
12
- /** Default serialization of an uncaught error into the outbound exception frame. */
13
- export declare function toErrorFrame(error: unknown): {
13
+ /**
14
+ * Default serialization of an uncaught error into the outbound exception frame.
15
+ *
16
+ * A `WsException` ships its own payload verbatim (string → `{ message }`,
17
+ * object → as-is). Every other error routes through `toErrorBody` — the single
18
+ * wire-redaction seam shared with the HTTP edge — so unbranded/internal errors
19
+ * are redacted to their catalog title and only branded `VelaError`s echo a
20
+ * client-safe `{ code, message, ... }`.
21
+ */
22
+ export declare function toErrorFrame(error: unknown, catalog?: Catalog<string>): {
14
23
  event: 'exception';
15
24
  data: unknown;
16
25
  };
@@ -1,3 +1,4 @@
1
+ import { toErrorBody } from "@velajs/errors";
1
2
  /**
2
3
  * WebSocket analogue of `HttpException`. Thrown from guards/pipes/handlers to
3
4
  * signal a client-facing error; the dispatcher serializes it to an
@@ -13,7 +14,15 @@
13
14
  return this.err;
14
15
  }
15
16
  }
16
- /** Default serialization of an uncaught error into the outbound exception frame. */ export function toErrorFrame(error) {
17
+ /**
18
+ * Default serialization of an uncaught error into the outbound exception frame.
19
+ *
20
+ * A `WsException` ships its own payload verbatim (string → `{ message }`,
21
+ * object → as-is). Every other error routes through `toErrorBody` — the single
22
+ * wire-redaction seam shared with the HTTP edge — so unbranded/internal errors
23
+ * are redacted to their catalog title and only branded `VelaError`s echo a
24
+ * client-safe `{ code, message, ... }`.
25
+ */ export function toErrorFrame(error, catalog) {
17
26
  if (error instanceof WsException) {
18
27
  const e = error.getError();
19
28
  return {
@@ -23,10 +32,11 @@
23
32
  } : e
24
33
  };
25
34
  }
35
+ const { body } = toErrorBody(error, {
36
+ catalog
37
+ });
26
38
  return {
27
39
  event: 'exception',
28
- data: {
29
- message: 'Internal server error'
30
- }
40
+ data: body.error
31
41
  };
32
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,6 +88,7 @@
88
88
  "node": ">=20"
89
89
  },
90
90
  "dependencies": {
91
+ "@velajs/errors": "^1.0.0",
91
92
  "@velajs/live-protocol": "^1.0.0",
92
93
  "hono": "^4.12.26"
93
94
  },