bazis 0.100.6 → 0.100.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/core/app/index.ts +1 -0
- package/core/app/messages.ts +44 -0
- package/core/app/runApp.ts +20 -0
- package/core/di/errors/InvalidProviderError.ts +3 -2
- package/core/di/errors/ScopedServiceFromRootError.ts +2 -1
- package/core/di/internal/GraphValidator.ts +6 -2
- package/core/di/module/createContainer.ts +18 -1
- package/core/http/Binding/ParameterBinder.ts +3 -3
- package/core/http/Binding/convert.ts +2 -2
- package/core/http/Binding/modelBinder.ts +17 -18
- package/core/http/Binding/modelValidator.ts +12 -4
- package/core/http/Errors/HttpError.ts +42 -20
- package/core/http/Errors/messages.ts +158 -0
- package/core/http/HttpContext/HttpContext.ts +3 -3
- package/core/http/HttpServer.ts +21 -9
- package/core/http/Middleware/errorHandler.ts +39 -13
- package/core/http/index.ts +1 -0
- package/core/http/options.ts +7 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/Bazis.ts +9 -1
- package/core/kernel/LifecycleCoordinator.ts +10 -1
- package/core/kernel/config/defineConfig.ts +5 -1
- package/core/kernel/startupFailure.ts +4 -2
- package/library/http-client/HttpClient.ts +7 -0
- package/library/http-client/configKeys.ts +36 -0
- package/library/validation/MessageRegistry.ts +39 -10
- package/library/validation/README.md +1 -1
- package/library/validation/RuleEngine.ts +1 -1
- package/library/validation/SPEC.md +7 -1
- package/library/validation/ValidationError.ts +4 -0
- package/library/validation/index.ts +1 -1
- package/library/validation/modelValidatorAdapter.ts +5 -6
- package/package.json +1 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** Placeholder values of a message template: `{name}` -> `params.name`. */
|
|
2
|
+
export type MessageParams = Readonly<Record<string, unknown>>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turns a message key and its parameters into text in the application
|
|
6
|
+
* language; `undefined` keeps the English text the error was created with.
|
|
7
|
+
* `runApp` builds it from its `language` and `messages` options.
|
|
8
|
+
*/
|
|
9
|
+
export type Localize = (key: string, params: MessageParams) => string | undefined;
|
|
10
|
+
|
|
11
|
+
/** A message key and parameters carried by an error, rendered at the response. */
|
|
12
|
+
export interface LocalizedMessage {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
readonly params?: MessageParams;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Keys of the texts the HTTP layer writes itself. */
|
|
18
|
+
export type HttpMessageKey =
|
|
19
|
+
| "badRequest"
|
|
20
|
+
| "unauthorized"
|
|
21
|
+
| "forbidden"
|
|
22
|
+
| "notFound"
|
|
23
|
+
| "methodNotAllowed"
|
|
24
|
+
| "conflict"
|
|
25
|
+
| "gone"
|
|
26
|
+
| "preconditionFailed"
|
|
27
|
+
| "payloadTooLarge"
|
|
28
|
+
| "unsupportedMediaType"
|
|
29
|
+
| "unprocessableContent"
|
|
30
|
+
| "locked"
|
|
31
|
+
| "tooManyRequests"
|
|
32
|
+
| "clientError"
|
|
33
|
+
| "internalServerError"
|
|
34
|
+
| "badGateway"
|
|
35
|
+
| "serviceUnavailable"
|
|
36
|
+
| "gatewayTimeout"
|
|
37
|
+
| "serverError"
|
|
38
|
+
| "malformedPath"
|
|
39
|
+
| "unsupportedApiVersion"
|
|
40
|
+
| "malformedJson"
|
|
41
|
+
| "malformedForm"
|
|
42
|
+
| "bodyNotObject"
|
|
43
|
+
| "bodyTooDeep"
|
|
44
|
+
| "bodyTooComplex"
|
|
45
|
+
| "bodyCyclic"
|
|
46
|
+
| "missingParameter"
|
|
47
|
+
| "invalidParameter"
|
|
48
|
+
| "invalidListQuery";
|
|
49
|
+
|
|
50
|
+
export const EN_HTTP_MESSAGES: Readonly<Record<HttpMessageKey, string>> = Object.freeze({
|
|
51
|
+
badRequest: "Bad Request",
|
|
52
|
+
unauthorized: "Unauthorized",
|
|
53
|
+
forbidden: "Forbidden",
|
|
54
|
+
notFound: "Not Found",
|
|
55
|
+
methodNotAllowed: "Method Not Allowed",
|
|
56
|
+
conflict: "Conflict",
|
|
57
|
+
gone: "Gone",
|
|
58
|
+
preconditionFailed: "Precondition Failed",
|
|
59
|
+
payloadTooLarge: "Payload Too Large",
|
|
60
|
+
unsupportedMediaType: "Unsupported Media Type: expected {expected}",
|
|
61
|
+
unprocessableContent: "Unprocessable Content",
|
|
62
|
+
locked: "Locked",
|
|
63
|
+
tooManyRequests: "Too Many Requests",
|
|
64
|
+
clientError: "Client Error",
|
|
65
|
+
internalServerError: "Internal Server Error",
|
|
66
|
+
badGateway: "Bad Gateway",
|
|
67
|
+
serviceUnavailable: "Service Unavailable",
|
|
68
|
+
gatewayTimeout: "Gateway Timeout",
|
|
69
|
+
serverError: "Server Error",
|
|
70
|
+
malformedPath: "Malformed request path",
|
|
71
|
+
unsupportedApiVersion: "Unsupported API version",
|
|
72
|
+
malformedJson: "Malformed JSON in request body",
|
|
73
|
+
malformedForm: "Malformed form data in request body",
|
|
74
|
+
bodyNotObject: "Request body must be a JSON object",
|
|
75
|
+
bodyTooDeep: "Request body is too deeply nested",
|
|
76
|
+
bodyTooComplex: "Request body is too complex",
|
|
77
|
+
bodyCyclic: "Request body must be acyclic JSON",
|
|
78
|
+
missingParameter: 'Missing required {kind} parameter "{name}"',
|
|
79
|
+
invalidParameter: 'Parameter "{name}" must be of type {type}, got: "{value}"',
|
|
80
|
+
invalidListQuery: "Invalid list query",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export const RU_HTTP_MESSAGES: Readonly<Record<HttpMessageKey, string>> = Object.freeze({
|
|
84
|
+
badRequest: "Некорректный запрос",
|
|
85
|
+
unauthorized: "Требуется аутентификация",
|
|
86
|
+
forbidden: "Доступ запрещён",
|
|
87
|
+
notFound: "Не найдено",
|
|
88
|
+
methodNotAllowed: "Метод не поддерживается",
|
|
89
|
+
conflict: "Конфликт",
|
|
90
|
+
gone: "Ресурс удалён",
|
|
91
|
+
preconditionFailed: "Предусловие не выполнено",
|
|
92
|
+
payloadTooLarge: "Слишком большой запрос",
|
|
93
|
+
unsupportedMediaType: "Неподдерживаемый тип содержимого: ожидается {expected}",
|
|
94
|
+
unprocessableContent: "Невозможно обработать данные",
|
|
95
|
+
locked: "Ресурс заблокирован",
|
|
96
|
+
tooManyRequests: "Слишком много запросов",
|
|
97
|
+
clientError: "Ошибка в запросе",
|
|
98
|
+
internalServerError: "Внутренняя ошибка сервера",
|
|
99
|
+
badGateway: "Ошибка внешнего сервиса",
|
|
100
|
+
serviceUnavailable: "Сервис недоступен",
|
|
101
|
+
gatewayTimeout: "Внешний сервис не ответил вовремя",
|
|
102
|
+
serverError: "Ошибка сервера",
|
|
103
|
+
malformedPath: "Некорректный путь запроса",
|
|
104
|
+
unsupportedApiVersion: "Неподдерживаемая версия API",
|
|
105
|
+
malformedJson: "Тело запроса — некорректный JSON",
|
|
106
|
+
malformedForm: "Некорректные данные формы в теле запроса",
|
|
107
|
+
bodyNotObject: "Тело запроса должно быть JSON-объектом",
|
|
108
|
+
bodyTooDeep: "Слишком глубокая вложенность в теле запроса",
|
|
109
|
+
bodyTooComplex: "Тело запроса слишком сложное",
|
|
110
|
+
bodyCyclic: "Тело запроса не должно содержать циклов",
|
|
111
|
+
missingParameter: 'Не передан обязательный параметр "{name}"',
|
|
112
|
+
invalidParameter: 'Параметр "{name}": ожидается {type}, получено: "{value}"',
|
|
113
|
+
invalidListQuery: "Некорректные параметры списка",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** Reason-phrase keys of the statuses the error handler writes for unexpected errors. */
|
|
117
|
+
export const STATUS_MESSAGE_KEYS: Readonly<Record<number, HttpMessageKey>> = Object.freeze({
|
|
118
|
+
400: "badRequest", 401: "unauthorized", 403: "forbidden", 404: "notFound", 405: "methodNotAllowed",
|
|
119
|
+
409: "conflict", 410: "gone", 412: "preconditionFailed", 413: "payloadTooLarge", 415: "unsupportedMediaType",
|
|
120
|
+
422: "unprocessableContent", 423: "locked", 429: "tooManyRequests", 500: "internalServerError",
|
|
121
|
+
502: "badGateway", 503: "serviceUnavailable", 504: "gatewayTimeout",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Parameters whose value is a type name, translated through `typeName.<name>` keys. */
|
|
125
|
+
const TYPE_PARAMS = new Set(["expected", "actual", "type"]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Substitutes `{name}` placeholders. Unknown placeholders stay as they are.
|
|
129
|
+
* With `lookup`, a type name in `expected`/`actual`/`type` is replaced by the
|
|
130
|
+
* `typeName.<name>` text when the catalog has one ("number" -> "число").
|
|
131
|
+
*/
|
|
132
|
+
export function formatMessage(template: string, params: MessageParams, lookup?: (key: string) => string | undefined): string {
|
|
133
|
+
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => {
|
|
134
|
+
if (!Object.prototype.hasOwnProperty.call(params, name) || params[name] === undefined) return placeholder;
|
|
135
|
+
const value = params[name];
|
|
136
|
+
if (lookup !== undefined && TYPE_PARAMS.has(name) && typeof value === "string") {
|
|
137
|
+
const typeName = lookup(`typeName.${value}`);
|
|
138
|
+
if (typeName !== undefined) return typeName;
|
|
139
|
+
}
|
|
140
|
+
return stringifyParam(value);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function stringifyParam(value: unknown): string {
|
|
145
|
+
if (typeof value === "object" && value !== null && !(value instanceof RegExp)) {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.stringify(value);
|
|
148
|
+
} catch {
|
|
149
|
+
return String(value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return String(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** English text of a framework message, used when the app sets no language. */
|
|
156
|
+
export function englishHttpMessage(key: HttpMessageKey, params: MessageParams = {}): string {
|
|
157
|
+
return formatMessage(EN_HTTP_MESSAGES[key], params);
|
|
158
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ServiceScope } from "../../di";
|
|
2
|
-
import {
|
|
2
|
+
import { PayloadTooLargeError, frameworkBadRequest } from "../Errors/HttpError";
|
|
3
3
|
import { ResponseBuilder } from "./ResponseBuilder";
|
|
4
4
|
import type { ModelValidator } from "../Binding/modelValidator";
|
|
5
5
|
|
|
@@ -75,7 +75,7 @@ export class HttpContext {
|
|
|
75
75
|
try {
|
|
76
76
|
return JSON.parse(text) as T;
|
|
77
77
|
} catch {
|
|
78
|
-
throw
|
|
78
|
+
throw frameworkBadRequest("malformedJson");
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
81
|
return this.bodyPromise as Promise<T>;
|
|
@@ -94,7 +94,7 @@ export class HttpContext {
|
|
|
94
94
|
if (error instanceof PayloadTooLargeError) {
|
|
95
95
|
throw error;
|
|
96
96
|
}
|
|
97
|
-
throw
|
|
97
|
+
throw frameworkBadRequest("malformedForm");
|
|
98
98
|
}) as ReturnType<Request["formData"]>;
|
|
99
99
|
}
|
|
100
100
|
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOr
|
|
|
22
22
|
import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
|
|
23
23
|
import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
|
|
24
24
|
import { HttpSetupError } from "./Errors/HttpError";
|
|
25
|
+
import { EN_HTTP_MESSAGES, formatMessage, type HttpMessageKey, type MessageParams } from "./Errors/messages";
|
|
25
26
|
import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
|
|
26
27
|
|
|
27
28
|
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
@@ -166,6 +167,7 @@ export class HttpServer implements HostedService {
|
|
|
166
167
|
errorHandler({
|
|
167
168
|
exposeDetails,
|
|
168
169
|
...(errorLogger ? { logger: errorLogger } : {}),
|
|
170
|
+
...(this.options.localize ? { localize: this.options.localize } : {}),
|
|
169
171
|
...this.options.errorHandler,
|
|
170
172
|
onUnexpectedError: this.collectErrorHook(),
|
|
171
173
|
}),
|
|
@@ -384,13 +386,23 @@ export class HttpServer implements HostedService {
|
|
|
384
386
|
return request.method === "HEAD" ? stripBody(response) : response;
|
|
385
387
|
}
|
|
386
388
|
|
|
389
|
+
/** A response text the server writes itself, in the application language. */
|
|
390
|
+
private text(key: HttpMessageKey, params: MessageParams = {}): string {
|
|
391
|
+
const english = formatMessage(EN_HTTP_MESSAGES[key], params);
|
|
392
|
+
try {
|
|
393
|
+
return this.options.localize?.(key, params) ?? english;
|
|
394
|
+
} catch {
|
|
395
|
+
return english;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
387
399
|
private transportError(error: unknown): Response {
|
|
388
400
|
try {
|
|
389
401
|
console.error("[http] transport failed:", redactSensitive(error));
|
|
390
402
|
} catch {
|
|
391
403
|
// Best-effort diagnostics only.
|
|
392
404
|
}
|
|
393
|
-
const response = json(500, { error: "
|
|
405
|
+
const response = json(500, { error: this.text("internalServerError") });
|
|
394
406
|
if (this.options.securityHeaders !== false) {
|
|
395
407
|
applySecurityHeaders(response, this.options.securityHeaders ?? {});
|
|
396
408
|
}
|
|
@@ -434,9 +446,9 @@ export class HttpServer implements HostedService {
|
|
|
434
446
|
if (!segments) {
|
|
435
447
|
if (this.maxBodyBytes !== undefined && exceedsBodyLimit(request, this.maxBodyBytes)) {
|
|
436
448
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
437
|
-
json(413, { error: "
|
|
449
|
+
json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: this.maxBodyBytes } }));
|
|
438
450
|
}
|
|
439
|
-
return this.runShortCircuit(request, url, clientIp, () => json(400, { error: "
|
|
451
|
+
return this.runShortCircuit(request, url, clientIp, () => json(400, { error: this.text("malformedPath") }));
|
|
440
452
|
}
|
|
441
453
|
|
|
442
454
|
const match = this.router!.match(request.method, segments, this.readVersion(request, url));
|
|
@@ -445,18 +457,18 @@ export class HttpServer implements HostedService {
|
|
|
445
457
|
: this.maxBodyBytes;
|
|
446
458
|
if (effectiveMaxBodyBytes !== undefined && exceedsBodyLimit(request, effectiveMaxBodyBytes)) {
|
|
447
459
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
448
|
-
json(413, { error: "
|
|
460
|
+
json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: effectiveMaxBodyBytes } }));
|
|
449
461
|
}
|
|
450
462
|
if (match.kind === "not-found") {
|
|
451
|
-
return this.runShortCircuit(request, url, clientIp, () => json(404, { error: "
|
|
463
|
+
return this.runShortCircuit(request, url, clientIp, () => json(404, { error: this.text("notFound") }));
|
|
452
464
|
}
|
|
453
465
|
if (match.kind === "method-not-allowed") {
|
|
454
466
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
455
|
-
json(405, { error: "
|
|
467
|
+
json(405, { error: this.text("methodNotAllowed") }, { allow: match.allow.join(", ") }));
|
|
456
468
|
}
|
|
457
469
|
if (match.kind === "unsupported-version") {
|
|
458
470
|
return this.runShortCircuit(request, url, clientIp, () =>
|
|
459
|
-
json(400, { error: "
|
|
471
|
+
json(400, { error: this.text("unsupportedApiVersion"), details: { supported: match.supported } }));
|
|
460
472
|
}
|
|
461
473
|
|
|
462
474
|
const scope = this.provider.createScope();
|
|
@@ -475,7 +487,7 @@ export class HttpServer implements HostedService {
|
|
|
475
487
|
await runPipeline(match.action.chain, ctx);
|
|
476
488
|
// The error boundary always sets a response on failure; an empty
|
|
477
489
|
// response here means no middleware produced one.
|
|
478
|
-
const response = this.prepareClientResponse(ctx.response ?? json(404, { error: "
|
|
490
|
+
const response = this.prepareClientResponse(ctx.response ?? json(404, { error: this.text("notFound") }), request);
|
|
479
491
|
if (request.method === "HEAD") return stripBody(response);
|
|
480
492
|
if (!response.body) return response;
|
|
481
493
|
this.trackResponse(holdResponseScope(response, request.signal, () =>
|
|
@@ -520,7 +532,7 @@ export class HttpServer implements HostedService {
|
|
|
520
532
|
let scopeTransferred = false;
|
|
521
533
|
try {
|
|
522
534
|
await runPipeline([...this.shortCircuitChain, terminal], ctx);
|
|
523
|
-
const response = this.prepareClientResponse(ctx.response ?? json(500, { error: "
|
|
535
|
+
const response = this.prepareClientResponse(ctx.response ?? json(500, { error: this.text("internalServerError") }), request);
|
|
524
536
|
if (request.method === "HEAD") return stripBody(response);
|
|
525
537
|
if (!response.body) return response;
|
|
526
538
|
this.trackResponse(holdResponseScope(response, request.signal, () =>
|
|
@@ -2,7 +2,8 @@ import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
|
|
|
2
2
|
import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
|
|
3
3
|
import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
|
|
4
4
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
5
|
-
import { HttpError } from "../Errors/HttpError";
|
|
5
|
+
import { HttpError, ModelValidationError } from "../Errors/HttpError";
|
|
6
|
+
import { EN_HTTP_MESSAGES, STATUS_MESSAGE_KEYS, type HttpMessageKey, type Localize } from "../Errors/messages";
|
|
6
7
|
import { upstreamFailure } from "../Errors/upstreamFailure";
|
|
7
8
|
import type { HttpMiddleware } from "./types";
|
|
8
9
|
|
|
@@ -33,6 +34,13 @@ export interface ErrorHandlerOptions {
|
|
|
33
34
|
* passes the application `LOGGER`; without either, `console.error` is used.
|
|
34
35
|
*/
|
|
35
36
|
logger?: Logger;
|
|
37
|
+
/**
|
|
38
|
+
* Renders framework texts (`Not Found`, validation messages, …) in the
|
|
39
|
+
* application language; `runApp` builds it from `language` and `messages`.
|
|
40
|
+
* Without it the texts stay English. Messages an application passes to an
|
|
41
|
+
* error itself are never changed.
|
|
42
|
+
*/
|
|
43
|
+
localize?: Localize;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
|
|
@@ -103,6 +111,7 @@ function safelyReportUnexpected(
|
|
|
103
111
|
*/
|
|
104
112
|
export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
|
|
105
113
|
const log = resolveErrorLog(options);
|
|
114
|
+
const text = textRenderer(options.localize);
|
|
106
115
|
return async (ctx, next) => {
|
|
107
116
|
try {
|
|
108
117
|
await next();
|
|
@@ -125,14 +134,23 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
|
|
|
125
134
|
if (headers !== undefined) {
|
|
126
135
|
extra.push(...Object.entries(headers));
|
|
127
136
|
}
|
|
137
|
+
const message = error.localized === undefined ? error.message : text(error.localized.key, error.localized.params, error.message);
|
|
138
|
+
const details = error instanceof ModelValidationError
|
|
139
|
+
? error.errors.map((issue) => ({
|
|
140
|
+
property: issue.property,
|
|
141
|
+
message: issue.params === undefined || issue.code === undefined ? issue.message : text(issue.code, issue.params, issue.message),
|
|
142
|
+
code: issue.code,
|
|
143
|
+
}))
|
|
144
|
+
: error.details;
|
|
128
145
|
ctx.response = safeJsonResponse(
|
|
129
146
|
error.status,
|
|
130
|
-
|
|
147
|
+
details === undefined ? { error: message } : { error: message, details },
|
|
131
148
|
extra,
|
|
132
149
|
);
|
|
133
150
|
return;
|
|
134
151
|
}
|
|
135
|
-
const [status,
|
|
152
|
+
const [status, key] = unexpectedStatus(error, options.status);
|
|
153
|
+
const title = text(key, {}, EN_HTTP_MESSAGES[key]);
|
|
136
154
|
if (status >= 500) safelyReportUnexpected(options, log, ctx, error);
|
|
137
155
|
ctx.response = options.exposeDetails
|
|
138
156
|
? safeJsonResponse(status, {
|
|
@@ -154,19 +172,27 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
|
|
|
154
172
|
* and stays 500, like everything else. Upstream details never reach the
|
|
155
173
|
* client; the log keeps the full error.
|
|
156
174
|
*/
|
|
157
|
-
|
|
158
|
-
400: "Bad Request", 404: "Not Found", 409: "Conflict", 410: "Gone", 412: "Precondition Failed", 422: "Unprocessable Content",
|
|
159
|
-
423: "Locked", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number | undefined): readonly [number, string] {
|
|
175
|
+
function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number | undefined): readonly [number, HttpMessageKey] {
|
|
163
176
|
if (error instanceof HttpClientError && error.code !== undefined) {
|
|
164
|
-
return error.code === HttpErrorCode.Timeout ? [504, "
|
|
177
|
+
return error.code === HttpErrorCode.Timeout ? [504, "gatewayTimeout"] : [502, "badGateway"];
|
|
165
178
|
}
|
|
166
179
|
const upstream = upstreamFailure(error);
|
|
167
|
-
if (upstream !== undefined) return upstream === "timeout" ? [504, "
|
|
180
|
+
if (upstream !== undefined) return upstream === "timeout" ? [504, "gatewayTimeout"] : [502, "badGateway"];
|
|
168
181
|
let mapped: number | undefined;
|
|
169
182
|
try { mapped = mapper?.(error); } catch { mapped = undefined; }
|
|
170
|
-
if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped,
|
|
171
|
-
return [500, "
|
|
183
|
+
if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, STATUS_MESSAGE_KEYS[mapped] ?? (mapped >= 500 ? "serverError" : "clientError")];
|
|
184
|
+
return [500, "internalServerError"];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Text of a message key in the application language, or the English fallback. */
|
|
188
|
+
function textRenderer(localize: Localize | undefined): (key: string, params: Readonly<Record<string, unknown>> | undefined, fallback: string) => string {
|
|
189
|
+
if (localize === undefined) return (_key, _params, fallback) => fallback;
|
|
190
|
+
return (key, params, fallback) => {
|
|
191
|
+
try {
|
|
192
|
+
return localize(key, params ?? {}) ?? fallback;
|
|
193
|
+
} catch {
|
|
194
|
+
// A broken catalog must not turn an error response into a 500.
|
|
195
|
+
return fallback;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
172
198
|
}
|
package/core/http/index.ts
CHANGED
|
@@ -141,4 +141,5 @@ export {
|
|
|
141
141
|
UnauthorizedError,
|
|
142
142
|
UnsupportedMediaTypeError,
|
|
143
143
|
} from "./Errors/HttpError";
|
|
144
|
+
export { EN_HTTP_MESSAGES, RU_HTTP_MESSAGES, type HttpMessageKey, type Localize, type LocalizedMessage } from "./Errors/messages";
|
|
144
145
|
export { markUpstreamFailure, upstreamFailure, type UpstreamFailure } from "./Errors/upstreamFailure";
|
package/core/http/options.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Class } from "../di";
|
|
|
2
2
|
import type { AccessLogOptions } from "./Middleware/accessLog";
|
|
3
3
|
import type { CorsOptions } from "./Middleware/cors";
|
|
4
4
|
import type { ErrorHandlerOptions } from "./Middleware/errorHandler";
|
|
5
|
+
import type { Localize } from "./Errors/messages";
|
|
5
6
|
import type { SecurityHeadersOptions } from "./Middleware/securityHeaders";
|
|
6
7
|
import type { HttpMiddleware } from "./Middleware/types";
|
|
7
8
|
import type { ActionMeta, ControllerMeta } from "./Decorators/metadata";
|
|
@@ -58,6 +59,12 @@ export interface HttpModuleOptions {
|
|
|
58
59
|
readonly inspectableRedirects?: boolean;
|
|
59
60
|
/** Request-model validator captured by this HTTP server instance. */
|
|
60
61
|
readonly validator?: ModelValidator;
|
|
62
|
+
/**
|
|
63
|
+
* Renders framework response texts in the application language. `runApp`
|
|
64
|
+
* sets it from its `language` and `messages` options; without it the
|
|
65
|
+
* texts are English.
|
|
66
|
+
*/
|
|
67
|
+
readonly localize?: Localize;
|
|
61
68
|
/**
|
|
62
69
|
* Modules providing the controllers' dependencies. Controllers live inside
|
|
63
70
|
* the returned HttpModule, so their dependencies must be visible to it:
|
|
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
|
|
|
232
232
|
if (method === "account/updated") this.modelCache = undefined;
|
|
233
233
|
}, () => { this.clearLogin(); this.modelCache = undefined; });
|
|
234
234
|
const initialized = await client.request("initialize", {
|
|
235
|
-
clientInfo: { name: "bazis", title: "Bazis", version: "0.100.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.100.8" }, capabilities: { experimentalApi: true },
|
|
236
236
|
}, 15_000, signal);
|
|
237
237
|
if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
|
|
238
238
|
client.notify("initialized");
|
package/core/kernel/Bazis.ts
CHANGED
|
@@ -69,9 +69,17 @@ export class Bazis {
|
|
|
69
69
|
} catch (error) {
|
|
70
70
|
stopWaiting?.();
|
|
71
71
|
if (error instanceof ShutdownTimeoutError) {
|
|
72
|
+
// A failed start whose cleanup is stuck: report why the start failed
|
|
73
|
+
// first, otherwise only the cleanup timeout would be visible.
|
|
74
|
+
const startFailure = error.cause;
|
|
75
|
+
if (startFailure !== undefined) {
|
|
76
|
+
if (screen !== undefined) screen.failed(startFailure, kernel);
|
|
77
|
+
else console.error(`[bazis] application failed: ${formatStartupFailure(startFailure)}`);
|
|
78
|
+
}
|
|
72
79
|
// Graceful shutdown is stuck: hanging handles would keep the process
|
|
73
80
|
// alive forever, so this is the one place a hard exit is correct.
|
|
74
|
-
|
|
81
|
+
const text = startFailure === undefined ? error.message : `Cleanup after the failed start did not finish within ${error.timeoutMs}ms.`;
|
|
82
|
+
console.error(`[bazis] ${redactSensitiveText(text)} Forcing exit.`);
|
|
75
83
|
process.exit(1);
|
|
76
84
|
}
|
|
77
85
|
// Configuration problems surfacing at start (e.g. options fail-fast)
|
|
@@ -97,7 +97,16 @@ export class LifecycleCoordinator {
|
|
|
97
97
|
|
|
98
98
|
try {
|
|
99
99
|
for (let index = 0; index < this.hooks.length; index += 1) {
|
|
100
|
-
|
|
100
|
+
const hook = this.hooks[index] as LifecycleHook;
|
|
101
|
+
if (hook.onInit === undefined) continue;
|
|
102
|
+
// A hung onInit is named by the startup timeout, like a hosted service.
|
|
103
|
+
const name = `hook ${hook.constructor?.name ?? "LifecycleHook"}.onInit`;
|
|
104
|
+
this.pending.add(name);
|
|
105
|
+
try {
|
|
106
|
+
await this.invokeHook(() => hook.onInit?.(), signal);
|
|
107
|
+
} finally {
|
|
108
|
+
this.pending.delete(name);
|
|
109
|
+
}
|
|
101
110
|
}
|
|
102
111
|
|
|
103
112
|
const phases = groupByPhase(hosted);
|
|
@@ -184,7 +184,11 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
|
|
|
184
184
|
const priority = Math.max(...candidates.map(candidate => candidate.priority));
|
|
185
185
|
const winners = candidates.filter(candidate => candidate.priority === priority);
|
|
186
186
|
const selected = winners[0];
|
|
187
|
-
if (winners.some(candidate => candidate.raw !== selected?.raw))
|
|
187
|
+
if (winners.some(candidate => candidate.raw !== selected?.raw)) {
|
|
188
|
+
// Names only: the values may be secrets.
|
|
189
|
+
const variables = [...new Set(winners.map(candidate => candidate.name))];
|
|
190
|
+
issue(key, "conflict", `conflicting environment aliases: ${variables.join(" and ")} are set to different values; set only one`);
|
|
191
|
+
}
|
|
188
192
|
const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
|
|
189
193
|
let value: ConfigValueType | undefined;
|
|
190
194
|
if (type === "secret") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { inspect } from "node:util";
|
|
2
2
|
import { redactSensitive, redactSensitiveText } from "../../library/redaction";
|
|
3
|
-
import { StartupFailuresError } from "./errors";
|
|
3
|
+
import { ShutdownTimeoutError, StartupFailuresError, StartupTimeoutError } from "./errors";
|
|
4
4
|
|
|
5
5
|
const MAX_CAUSES = 5;
|
|
6
6
|
/** Properties already printed in the header, the frames or the cause chain. */
|
|
@@ -32,7 +32,9 @@ export function formatStartupFailure(error: unknown): string {
|
|
|
32
32
|
// Services that failed together: the list above is the report; the
|
|
33
33
|
// aggregate's own frames are the kernel's, not the application's.
|
|
34
34
|
if (!(current instanceof StartupFailuresError)) lines.push(...(current.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line)));
|
|
35
|
-
|
|
35
|
+
// Timeouts already say their limit and what was pending in the message.
|
|
36
|
+
const restated = current instanceof StartupTimeoutError || current instanceof ShutdownTimeoutError;
|
|
37
|
+
const details = restated ? {} : Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
|
|
36
38
|
if (Object.keys(details).length > 0) lines.push(inspect(redactSensitive(details), INSPECT));
|
|
37
39
|
current = current.cause;
|
|
38
40
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { HttpClientError, HttpErrorCode } from "./errors";
|
|
2
|
+
import { assertKnownOptions } from "./configKeys";
|
|
2
3
|
import { InspectableRedirectProtocol as redirectProtocol } from "./redirectProtocol";
|
|
3
4
|
import { InterceptorManager } from "./interceptors";
|
|
4
5
|
import { trackDownload } from "./progress";
|
|
@@ -85,6 +86,7 @@ export class HttpClient {
|
|
|
85
86
|
};
|
|
86
87
|
|
|
87
88
|
public constructor(defaults: RequestConfig = {}) {
|
|
89
|
+
assertKnownOptions(defaults, "the client defaults");
|
|
88
90
|
this.defaults = defaults;
|
|
89
91
|
this.interceptors = {
|
|
90
92
|
request: new InterceptorManager<ResolvedRequestConfig>(),
|
|
@@ -106,6 +108,11 @@ export class HttpClient {
|
|
|
106
108
|
public request<T = unknown>(configOrUrl: RequestConfig | string, maybeConfig: RequestConfig = {}): Promise<HttpResponse<T>> {
|
|
107
109
|
const config: RequestConfig =
|
|
108
110
|
typeof configOrUrl === "string" ? { ...maybeConfig, url: configOrUrl } : configOrUrl;
|
|
111
|
+
try {
|
|
112
|
+
assertKnownOptions(config, "the request config");
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return Promise.reject(error);
|
|
115
|
+
}
|
|
109
116
|
const merged = this.mergeConfig(this.defaults, config);
|
|
110
117
|
|
|
111
118
|
// axios ordering: request interceptors run last-registered-first.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { HttpClientError } from "./errors";
|
|
2
|
+
import type { RequestConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every option of {@link RequestConfig}, checked so a misspelled one is not
|
|
6
|
+
* silently ignored. A record, not a list: a new option fails typecheck until
|
|
7
|
+
* it is added here.
|
|
8
|
+
*/
|
|
9
|
+
const OPTIONS: Readonly<Record<keyof RequestConfig, true>> = {
|
|
10
|
+
url: true, method: true, baseUrl: true, headers: true, params: true, paramsSerializer: true, data: true,
|
|
11
|
+
transformRequest: true, transformResponse: true, onDownloadProgress: true, timeoutMs: true, maxResponseBytes: true,
|
|
12
|
+
signal: true, responseType: true, withCredentials: true, credentials: true, auth: true,
|
|
13
|
+
allowCrossOriginCredentials: true, validateStatus: true, retry: true, propagateCorrelation: true,
|
|
14
|
+
correlationHeaders: true, fetch: true, inspectableRedirects: true, redirect: true, maxRedirects: true, cache: true,
|
|
15
|
+
mode: true, keepalive: true, meta: true,
|
|
16
|
+
};
|
|
17
|
+
const KNOWN: ReadonlySet<string> = new Set(Object.keys(OPTIONS));
|
|
18
|
+
|
|
19
|
+
/** axios names with a different spelling here. */
|
|
20
|
+
const AXIOS_NAMES: Readonly<Record<string, keyof RequestConfig>> = {
|
|
21
|
+
timeout: "timeoutMs",
|
|
22
|
+
maxContentLength: "maxResponseBytes",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rejects an option the client does not know. Without it `baseURL` (the axios
|
|
27
|
+
* spelling) was dropped, and every call failed with ERR_NETWORK
|
|
28
|
+
* "fetch() URL is invalid".
|
|
29
|
+
*/
|
|
30
|
+
export function assertKnownOptions(config: object, where: string): void {
|
|
31
|
+
for (const key of Object.keys(config)) {
|
|
32
|
+
if (KNOWN.has(key)) continue;
|
|
33
|
+
const suggestion = AXIOS_NAMES[key] ?? [...KNOWN].find((known) => known.toLowerCase() === key.toLowerCase());
|
|
34
|
+
throw new HttpClientError(`Unknown HttpClient option "${key}" in ${where}${suggestion === undefined ? "" : `; did you mean "${suggestion}"?`}`);
|
|
35
|
+
}
|
|
36
|
+
}
|