bazis 0.97.0 → 0.97.2

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.
@@ -150,8 +150,15 @@ export class HttpServer implements HostedService {
150
150
  if (this.options.securityHeaders !== false) {
151
151
  serverChain.push(securityHeaders(this.options.securityHeaders ?? {}));
152
152
  }
153
+ // Unexpected errors go to the application logger, like the access log.
154
+ const errorLogger = this.resolver.tryResolve(LOGGER);
153
155
  serverChain.push(
154
- errorHandler({ exposeDetails, ...this.options.errorHandler, onUnexpectedError: this.collectErrorHook() }),
156
+ errorHandler({
157
+ exposeDetails,
158
+ ...(errorLogger ? { logger: errorLogger } : {}),
159
+ ...this.options.errorHandler,
160
+ onUnexpectedError: this.collectErrorHook(),
161
+ }),
155
162
  );
156
163
  this.shortCircuitChain = [...serverChain];
157
164
  serverChain.push(...this.collectServerMiddleware());
@@ -1,4 +1,5 @@
1
- import { redactSensitive } from "../../../library/redaction";
1
+ import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
2
+ import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
2
3
  import type { HttpContext } from "../HttpContext/HttpContext";
3
4
  import { HttpError } from "../Errors/HttpError";
4
5
  import type { HttpMiddleware } from "./types";
@@ -9,10 +10,20 @@ export interface ErrorHandlerOptions {
9
10
  * never enable in production).
10
11
  */
11
12
  exposeDetails?: boolean;
12
- /** Called for non-{@link HttpError} failures (self-wired via `HTTP_ERROR_HOOK`, e.g. the logging module). */
13
+ /**
14
+ * Notified of every non-{@link HttpError} failure, for example to send it to
15
+ * an error tracker; `HTTP_ERROR_HOOK` registrations are merged in. It does not
16
+ * replace logging: the error is logged as well.
17
+ */
13
18
  onUnexpectedError?: (ctx: HttpContext, error: unknown) => void;
14
- /** Fallback when {@link onUnexpectedError} is omitted or does not handle the error. */
19
+ /** Replaces the built-in logging of unexpected errors. */
15
20
  logError?: (error: unknown) => void;
21
+ /**
22
+ * Logger for unexpected errors when {@link logError} is absent: one `error`
23
+ * line with the method, path, request id and the redacted error. The server
24
+ * passes the application `LOGGER`; without either, `console.error` is used.
25
+ */
26
+ logger?: Logger;
16
27
  }
17
28
 
18
29
  function jsonResponse(status: number, body: unknown, extraHeaders?: readonly [string, string][]): Response {
@@ -33,9 +44,30 @@ function safeJsonResponse(status: number, body: unknown, extraHeaders?: readonly
33
44
  }
34
45
  }
35
46
 
47
+ type ErrorLog = (ctx: HttpContext, error: unknown) => void;
48
+
49
+ function resolveErrorLog(options: ErrorHandlerOptions): ErrorLog {
50
+ const { logError, logger } = options;
51
+ if (logError) {
52
+ return (_ctx, error) => logError(error);
53
+ }
54
+ if (logger) {
55
+ return (ctx, error) => {
56
+ const requestId = ctx.state.get(REQUEST_ID_STATE_KEY);
57
+ logger.error(`${ctx.method} ${ctx.path} failed`, {
58
+ method: ctx.method,
59
+ path: ctx.path,
60
+ ...(typeof requestId === "string" ? { requestId } : {}),
61
+ error: redactSensitive(error),
62
+ });
63
+ };
64
+ }
65
+ return (_ctx, error) => console.error("[http] unhandled error:", redactSensitive(error));
66
+ }
67
+
36
68
  function safelyReportUnexpected(
37
69
  options: ErrorHandlerOptions,
38
- fallback: (error: unknown) => void,
70
+ log: ErrorLog,
39
71
  ctx: HttpContext,
40
72
  error: unknown,
41
73
  ): void {
@@ -43,13 +75,13 @@ function safelyReportUnexpected(
43
75
  options.onUnexpectedError?.(ctx, error);
44
76
  } catch (hookError) {
45
77
  try {
46
- fallback(hookError);
78
+ log(ctx, hookError);
47
79
  } catch {
48
80
  // Logging hooks are best-effort; response safety is primary.
49
81
  }
50
82
  }
51
83
  try {
52
- fallback(error);
84
+ log(ctx, error);
53
85
  } catch {
54
86
  // A broken sink must not escape the HTTP error boundary.
55
87
  }
@@ -61,7 +93,7 @@ function safelyReportUnexpected(
61
93
  * else becomes 500 Internal Server Error.
62
94
  */
63
95
  export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware {
64
- const fallback = options.logError ?? ((error: unknown) => console.error("[http] unhandled error:", redactSensitive(error)));
96
+ const log = resolveErrorLog(options);
65
97
  return async (ctx, next) => {
66
98
  try {
67
99
  await next();
@@ -87,12 +119,14 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
87
119
  );
88
120
  return;
89
121
  }
90
- safelyReportUnexpected(options, fallback, ctx, error);
122
+ safelyReportUnexpected(options, log, ctx, error);
91
123
  ctx.response = options.exposeDetails
92
124
  ? safeJsonResponse(500, {
93
125
  error: "Internal Server Error",
94
- message: error instanceof Error ? error.message : String(error),
95
- stack: error instanceof Error ? error.stack : undefined,
126
+ // Development only, but redacted like the log: a secret in an error
127
+ // message must not reach a browser or a shared screenshot.
128
+ message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
129
+ stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
96
130
  })
97
131
  : safeJsonResponse(500, { error: "Internal Server Error" });
98
132
  }
package/core/http/SPEC.md CHANGED
@@ -405,9 +405,10 @@ Exported from `bazis/core/http`. They are connected through `@Middleware(...)`,
405
405
 
406
406
  | `ErrorHandlerOptions` field | Type | Default | Description |
407
407
  | --- | --- | --- | --- |
408
- | `exposeDetails` | `boolean` | `httpModule({ exposeErrorDetails })` / environment | message/stack in 500 responses; never enable in production |
409
- | `onUnexpectedError` | `(ctx, error) => void` | DI `HTTP_ERROR_HOOK` hooks | called for non-`HttpError` failures |
410
- | `logError` | `(error) => void` | `console.error` | fallback when no hook handles the error |
408
+ | `exposeDetails` | `boolean` | `httpModule({ exposeErrorDetails })` / environment | redacted message/stack in 500 responses; never enable in production |
409
+ | `onUnexpectedError` | `(ctx, error) => void` | DI `HTTP_ERROR_HOOK` hooks | notification for non-`HttpError` failures (e.g. an error tracker); the error is still logged |
410
+ | `logError` | `(error) => void` | — | replaces the built-in logging of unexpected errors |
411
+ | `logger` | `Logger` | the application `LOGGER` | one `error` line: `"<METHOD> <path> failed"` with `method`, `path`, `requestId` and the redacted `error`; `console.error` when there is no logger |
411
412
 
412
413
  `HttpError` → JSON with its status; anything else → 500.
413
414
 
@@ -31,8 +31,8 @@ export const ROUTE_MIDDLEWARE_COMPOSER = createToken<RouteMiddlewareComposerRegi
31
31
 
32
32
  /**
33
33
  * Hook invoked by the built-in error boundary on a non-{@link HttpError}
34
- * failure. Self-wiring: e.g. the logging module registers one to record the
35
- * error, keeping `@/core/http` free of logging imports. Collected via `resolveAll`.
34
+ * failure, for example to report it to an error tracker. It is a notification:
35
+ * the error is still logged and answered with 500. Collected via `resolveAll`.
36
36
  */
37
37
  export type HttpErrorHook = (ctx: HttpContext, error: unknown) => void;
38
38
 
@@ -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.97.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.97.2" }, 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");
@@ -14,6 +14,7 @@ export class ApplicationLifetime {
14
14
  private readonly stopRequestListeners: ((exitCode: number) => void)[] = [];
15
15
  private startedFlag = false;
16
16
  private stoppingFlag = false;
17
+ private stoppedFlag = false;
17
18
 
18
19
  public get isStarted(): boolean {
19
20
  return this.startedFlag;
@@ -23,15 +24,35 @@ export class ApplicationLifetime {
23
24
  return this.stoppingFlag;
24
25
  }
25
26
 
27
+ /**
28
+ * Runs `callback` once the application has started. A subscription made
29
+ * after that moment (for example in a service first created by a request)
30
+ * runs right away, like .NET's ApplicationStarted; a failure of such a late
31
+ * callback is an unhandled error (logged, graceful stop with exit code 1).
32
+ */
26
33
  public onStarted(callback: LifetimeCallback): void {
34
+ if (this.startedFlag) {
35
+ runLate(callback);
36
+ return;
37
+ }
27
38
  this.startedCallbacks.push(callback);
28
39
  }
29
40
 
41
+ /** Runs `callback` when graceful shutdown begins; right away if it already has. */
30
42
  public onStopping(callback: LifetimeCallback): void {
43
+ if (this.stoppingFlag) {
44
+ runLate(callback);
45
+ return;
46
+ }
31
47
  this.stoppingCallbacks.push(callback);
32
48
  }
33
49
 
50
+ /** Runs `callback` after the application has stopped; right away if it already has. */
34
51
  public onStopped(callback: LifetimeCallback): void {
52
+ if (this.stoppedFlag) {
53
+ runLate(callback);
54
+ return;
55
+ }
35
56
  this.stoppedCallbacks.push(callback);
36
57
  }
37
58
 
@@ -61,10 +82,17 @@ export class ApplicationLifetime {
61
82
 
62
83
  /** @internal */
63
84
  public async notifyStopped(): Promise<void> {
85
+ this.stoppedFlag = true;
64
86
  await runCallbacks(this.stoppedCallbacks);
65
87
  }
66
88
  }
67
89
 
90
+ // A late subscription runs on its own: a rejection surfaces as an unhandled
91
+ // error instead of disappearing silently.
92
+ function runLate(callback: LifetimeCallback): void {
93
+ void Promise.resolve().then(callback);
94
+ }
95
+
68
96
  // A failing subscriber must not break the rest of the lifecycle chain:
69
97
  // run everything, then report all failures at once.
70
98
  async function runCallbacks(callbacks: readonly LifetimeCallback[], signal?: AbortSignal): Promise<void> {
@@ -16,7 +16,7 @@ Contracts of the lifecycle and configuration fixes: [partial passport](MODULE.md
16
16
  ## Lifecycle
17
17
 
18
18
  - `LifecycleCoordinator.ts`: boot/shutdown order: options fail-fast → `onInit` → hosted services by phase → `onBootstrap`; shutdown in reverse order with `shutdownTimeout`; rollback when startup fails.
19
- - `ApplicationLifetime.ts`: injectable lifetime: `onStarted/onStopping/onStopped` + programmatic `stop(exitCode)`.
19
+ - `ApplicationLifetime.ts`: injectable lifetime: `onStarted/onStopping/onStopped` (a subscription made after its moment runs right away) + programmatic `stop(exitCode)`.
20
20
  - `lifecycleHooks.ts`: the `LIFECYCLE_HOOK` token (enumerable) + `addLifecycleHook`.
21
21
  - `Environment.ts`: the environment (`development|production|test`) from `BAZIS_ENV`/`NODE_ENV`, the `debug` flag.
22
22
  - `SupervisedHostedService.ts`: retry with exponential backoff; passes the startup signal and stops retrying on cancellation.
@@ -156,33 +156,35 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
156
156
  const priority = Math.max(...candidates.map(candidate => candidate.priority));
157
157
  const winners = candidates.filter(candidate => candidate.priority === priority);
158
158
  const selected = winners[0];
159
- if (winners.some(candidate => candidate.raw !== selected?.raw)) issues.push(`${fullKey(key)}: conflicting environment aliases`);
159
+ if (winners.some(candidate => candidate.raw !== selected?.raw)) issues.push(`${fullKey(key)} — conflicting environment aliases`);
160
160
  const raw = selected?.raw ?? (isSecret(cell) || isEnum(cell) ? cell.default : cell);
161
161
  let value: ConfigValueType | undefined;
162
162
  if (type === "secret") {
163
- if (typeof raw !== "string" || !raw.trim()) issues.push(`${fullKey(key)}: required non-empty secret is not set (${names.get(key)!.join(", ")})`);
163
+ if (typeof raw !== "string" || !raw.trim()) issues.push(`${fullKey(key)} — required non-empty secret is not set (${names.get(key)!.join(", ")})`);
164
164
  else value = new Secret(raw);
165
165
  } else if (type === "number") {
166
166
  const parsed = Number(raw);
167
- if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)}: expected a finite number`);
167
+ if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number`);
168
168
  else value = parsed;
169
169
  } else if (type === "boolean") {
170
170
  const normalized = String(raw).toLowerCase();
171
171
  if (normalized === "true" || normalized === "1") value = true;
172
172
  else if (normalized === "false" || normalized === "0") value = false;
173
- else issues.push(`${fullKey(key)}: expected a boolean`);
173
+ else issues.push(`${fullKey(key)} — expected a boolean`);
174
174
  } else if (typeof raw === "string") value = raw;
175
- else issues.push(`${fullKey(key)}: expected a string`);
175
+ else issues.push(`${fullKey(key)} — expected a string`);
176
176
  if (value === undefined) continue;
177
- if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)}: value is not one of the declared enum values`);
177
+ if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — value is not one of the declared enum values`);
178
178
  try {
179
179
  const issue = validators[key]?.(value);
180
- if (issue) issues.push(`${fullKey(key)}: ${issue}`);
181
- } catch { issues.push(`${fullKey(key)}: validator failed`); }
180
+ if (issue) issues.push(`${fullKey(key)} — ${issue}`);
181
+ } catch { issues.push(`${fullKey(key)} — validator failed`); }
182
182
  values.set(key, value);
183
183
  inspection.push(Object.freeze({ key: fullKey(key), type: type as ConfigInspection["type"], env: names.get(key)!, source: selected?.source ?? (override === undefined ? "default" : environment), value: safeValue(fullKey(key), value) }));
184
184
  }
185
185
  if (issues.length) {
186
+ // "key — text", not "key: text": the console redaction would read
187
+ // "db.password: required" as a secret value and hide the word "required".
186
188
  let message = issues.join("; ");
187
189
  for (const [key, value] of values) {
188
190
  const sensitive = value instanceof Secret ? value.reveal() : typeof value === "string" && isSensitiveKey(key) ? value : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.97.0",
3
+ "version": "0.97.2",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",