@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 +56 -0
- package/dist/application.d.ts +14 -1
- package/dist/application.js +56 -0
- package/dist/errors/http-exception.d.ts +1 -0
- package/dist/errors/http-exception.js +8 -0
- package/dist/exceptions/errors.module.d.ts +40 -0
- package/dist/exceptions/errors.module.js +46 -0
- package/dist/exceptions/exception-handler.d.ts +56 -0
- package/dist/exceptions/exception-handler.js +17 -0
- package/dist/exceptions/index.d.ts +6 -0
- package/dist/exceptions/index.js +3 -0
- package/dist/exceptions/reporter.d.ts +21 -0
- package/dist/exceptions/reporter.js +70 -0
- package/dist/factory/bootstrap.js +4 -2
- package/dist/http/handler-executor.js +35 -13
- package/dist/index.d.ts +5 -1
- package/dist/index.js +6 -1
- package/dist/live/live.engine.d.ts +7 -0
- package/dist/live/live.engine.js +26 -3
- package/dist/pipeline/index.d.ts +1 -1
- package/dist/pipeline/index.js +1 -1
- package/dist/pipeline/tokens.d.ts +24 -0
- package/dist/pipeline/tokens.js +20 -0
- package/dist/queue/queue.binding.js +9 -2
- package/dist/queue/queue.dispatch.js +9 -2
- package/dist/schedule-node/schedule.executor.js +8 -3
- package/dist/websocket/ws-dispatcher.js +31 -10
- package/dist/websocket/ws-exception.d.ts +11 -2
- package/dist/websocket/ws-exception.js +14 -4
- package/package.json +2 -1
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):
|
package/dist/application.d.ts
CHANGED
|
@@ -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
|
package/dist/application.js
CHANGED
|
@@ -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,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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
/**
|
package/dist/live/live.engine.js
CHANGED
|
@@ -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:
|
|
339
|
-
message:
|
|
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') {
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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>>;
|
package/dist/pipeline/tokens.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
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
|
-
/**
|
|
13
|
-
|
|
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
|
-
/**
|
|
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.
|
|
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
|
},
|