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.
Files changed (34) hide show
  1. package/README.md +12 -7
  2. package/core/app/index.ts +1 -0
  3. package/core/app/messages.ts +44 -0
  4. package/core/app/runApp.ts +20 -0
  5. package/core/di/errors/InvalidProviderError.ts +3 -2
  6. package/core/di/errors/ScopedServiceFromRootError.ts +2 -1
  7. package/core/di/internal/GraphValidator.ts +6 -2
  8. package/core/di/module/createContainer.ts +18 -1
  9. package/core/http/Binding/ParameterBinder.ts +3 -3
  10. package/core/http/Binding/convert.ts +2 -2
  11. package/core/http/Binding/modelBinder.ts +17 -18
  12. package/core/http/Binding/modelValidator.ts +12 -4
  13. package/core/http/Errors/HttpError.ts +42 -20
  14. package/core/http/Errors/messages.ts +158 -0
  15. package/core/http/HttpContext/HttpContext.ts +3 -3
  16. package/core/http/HttpServer.ts +21 -9
  17. package/core/http/Middleware/errorHandler.ts +39 -13
  18. package/core/http/index.ts +1 -0
  19. package/core/http/options.ts +7 -0
  20. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  21. package/core/kernel/Bazis.ts +9 -1
  22. package/core/kernel/LifecycleCoordinator.ts +10 -1
  23. package/core/kernel/config/defineConfig.ts +5 -1
  24. package/core/kernel/startupFailure.ts +4 -2
  25. package/library/http-client/HttpClient.ts +7 -0
  26. package/library/http-client/configKeys.ts +36 -0
  27. package/library/validation/MessageRegistry.ts +39 -10
  28. package/library/validation/README.md +1 -1
  29. package/library/validation/RuleEngine.ts +1 -1
  30. package/library/validation/SPEC.md +7 -1
  31. package/library/validation/ValidationError.ts +4 -0
  32. package/library/validation/index.ts +1 -1
  33. package/library/validation/modelValidatorAdapter.ts +5 -6
  34. 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 { BadRequestError, PayloadTooLargeError } from "../Errors/HttpError";
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 new BadRequestError("Malformed JSON in request body");
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 new BadRequestError("Malformed form data in request body");
97
+ throw frameworkBadRequest("malformedForm");
98
98
  }) as ReturnType<Request["formData"]>;
99
99
  }
100
100
 
@@ -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: "Internal Server 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: "Payload Too Large", details: { maxBytes: this.maxBodyBytes } }));
449
+ json(413, { error: this.text("payloadTooLarge"), details: { maxBytes: this.maxBodyBytes } }));
438
450
  }
439
- return this.runShortCircuit(request, url, clientIp, () => json(400, { error: "Malformed request path" }));
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: "Payload Too Large", details: { maxBytes: effectiveMaxBodyBytes } }));
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: "Not Found" }));
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: "Method Not Allowed" }, { allow: match.allow.join(", ") }));
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: "Unsupported API version", details: { supported: match.supported } }));
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: "Not Found" }), request);
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: "Internal Server Error" }), request);
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
- error.details === undefined ? { error: error.message } : { error: error.message, details: error.details },
147
+ details === undefined ? { error: message } : { error: message, details },
131
148
  extra,
132
149
  );
133
150
  return;
134
151
  }
135
- const [status, title] = unexpectedStatus(error, options.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
- const REASONS: Readonly<Record<number, string>> = {
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, "Gateway Timeout"] : [502, "Bad Gateway"];
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, "Gateway Timeout"] : [502, "Bad Gateway"];
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, REASONS[mapped] ?? (mapped >= 500 ? "Server Error" : "Client Error")];
171
- return [500, "Internal Server Error"];
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
  }
@@ -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";
@@ -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.6" }, capabilities: { experimentalApi: true },
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");
@@ -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
- console.error(`[bazis] ${redactSensitiveText(error.message)} Forcing exit.`);
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
- await this.invokeHook(() => (this.hooks[index] as LifecycleHook).onInit?.(), signal);
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)) issue(key, "conflict", "conflicting environment aliases");
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
- const details = Object.fromEntries(Object.entries(current).filter(([name]) => !SHOWN.has(name)));
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
+ }