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.
- package/core/http/HttpServer.ts +8 -1
- package/core/http/Middleware/errorHandler.ts +44 -10
- package/core/http/SPEC.md +4 -3
- package/core/http/middlewareTokens.ts +2 -2
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/ApplicationLifetime.ts +28 -0
- package/core/kernel/README.md +1 -1
- package/core/kernel/config/defineConfig.ts +10 -8
- package/package.json +1 -1
package/core/http/HttpServer.ts
CHANGED
|
@@ -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({
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
122
|
+
safelyReportUnexpected(options, log, ctx, error);
|
|
91
123
|
ctx.response = options.exposeDetails
|
|
92
124
|
? safeJsonResponse(500, {
|
|
93
125
|
error: "Internal Server Error",
|
|
94
|
-
|
|
95
|
-
|
|
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 |
|
|
410
|
-
| `logError` | `(error) => void` |
|
|
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
|
|
35
|
-
* error
|
|
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.
|
|
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> {
|
package/core/kernel/README.md
CHANGED
|
@@ -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)}
|
|
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)}
|
|
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)}
|
|
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)}
|
|
173
|
+
else issues.push(`${fullKey(key)} — expected a boolean`);
|
|
174
174
|
} else if (typeof raw === "string") value = raw;
|
|
175
|
-
else issues.push(`${fullKey(key)}
|
|
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)}
|
|
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)}
|
|
181
|
-
} catch { issues.push(`${fullKey(key)}
|
|
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