bazis 0.99.6 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/build.ts +171 -36
- package/cli/codegen.ts +155 -4
- package/cli/generateProject.ts +8 -2
- package/cli/main.ts +134 -51
- package/cli/naming.ts +3 -0
- package/cli/ui.ts +86 -0
- package/core/agent/index.ts +8 -0
- package/core/app/runApp.ts +4 -1
- package/core/di/extensions/hosted-service.ts +6 -0
- package/core/http/HttpServer.ts +33 -2
- package/core/http/Routing/RouterBuilder.ts +21 -0
- package/core/infra/Infra.ts +8 -2
- package/core/infra/InfraConnector.ts +13 -0
- package/core/infra/InfraLifecycle.ts +27 -0
- package/core/infra/connectorConfig.ts +10 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +23 -2
- package/core/infra/connectors/openaiCompatible.ts +26 -1
- package/core/infra/connectors/opensearch.ts +33 -6
- package/core/infra/connectors/postgres.ts +26 -2
- package/core/infra/connectors/redis.ts +23 -1
- package/core/infra/index.ts +2 -2
- package/core/kernel/Bazis.ts +21 -1
- package/core/kernel/Kernel.ts +54 -0
- package/core/kernel/KernelBuilder.ts +39 -1
- package/core/kernel/LifecycleCoordinator.ts +101 -9
- package/core/kernel/config/ConfigRegistry.ts +5 -0
- package/core/kernel/config/ConfigValidationError.ts +37 -0
- package/core/kernel/config/callerFile.ts +23 -0
- package/core/kernel/config/defineConfig.ts +21 -17
- package/core/kernel/errors/StartupFailuresError.ts +17 -0
- package/core/kernel/errors/index.ts +1 -0
- package/core/kernel/index.ts +12 -0
- package/core/kernel/report/StartupReport.ts +134 -0
- package/core/kernel/report/StartupScreen.ts +459 -0
- package/core/kernel/report/moduleTree.ts +97 -0
- package/core/kernel/report/startupProblems.ts +186 -0
- package/core/orm/OrmLifecycle.ts +29 -7
- package/core/orm/buildOrmProviders.ts +7 -3
- package/core/orm/databaseConnector.ts +17 -1
- package/core/orm/index.ts +11 -0
- package/core/scripts/di-generate.ts +58 -9
- package/library/terminal/index.ts +4 -0
- package/library/terminal/language.ts +60 -0
- package/library/terminal/messages.ts +400 -0
- package/library/terminal/screen.ts +358 -0
- package/library/terminal/style.ts +143 -0
- package/package.json +2 -1
|
@@ -2,7 +2,7 @@ import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
|
|
|
2
2
|
import { AGENT_MODEL_PROVIDER, AGENT_RUNTIME_OPTIONS, AgentRuntime, type AgentModelProvider } from "../../agent";
|
|
3
3
|
import { DI, DiError, HOSTED_SERVICE, SERVICE_PROVIDER, type ProviderDefinition } from "../../di";
|
|
4
4
|
import { LOGGER } from "../../kernel/logging/Logger";
|
|
5
|
-
import { reader, requireValue } from "../connectorConfig";
|
|
5
|
+
import { reader, requireValue, settingNames } from "../connectorConfig";
|
|
6
6
|
import { InfraError, type InfraConnector } from "../InfraConnector";
|
|
7
7
|
import { identifyConnector } from "../connectorIdentity";
|
|
8
8
|
|
|
@@ -28,6 +28,12 @@ export interface LlmConnectionOptions {
|
|
|
28
28
|
export interface LlmConnectorOptions {
|
|
29
29
|
/** Request timeout. Defaults to 60s; `0` explicitly disables it. */
|
|
30
30
|
readonly timeoutMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Check the provider at start (the adapter's `connect`: `GET /models` for an
|
|
33
|
+
* OpenAI-compatible API, five seconds), so a wrong key or address stops the
|
|
34
|
+
* start instead of the first agent request. Default `true`.
|
|
35
|
+
*/
|
|
36
|
+
readonly checkOnStart?: boolean;
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
export interface LlmProviderAdapter {
|
|
@@ -86,17 +92,32 @@ export function llmConnect<T extends LlmConfigShape>(
|
|
|
86
92
|
adapter: LlmProviderAdapter,
|
|
87
93
|
options: LlmConnectorOptions = {},
|
|
88
94
|
): InfraConnector<AgentModelProvider> {
|
|
95
|
+
const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[] }>();
|
|
89
96
|
return identifyConnector({
|
|
90
97
|
token: AGENT_MODEL_PROVIDER,
|
|
91
98
|
config,
|
|
92
99
|
providers: agentRuntimeProviders,
|
|
93
100
|
exports: [AgentRuntime],
|
|
94
101
|
create(configs) {
|
|
95
|
-
|
|
102
|
+
const connection = resolveLlmConnectionOptions(config, options, configs);
|
|
103
|
+
const provider = adapter.create(connection);
|
|
104
|
+
described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]) });
|
|
105
|
+
return provider;
|
|
96
106
|
},
|
|
97
107
|
connect(provider, signal) {
|
|
108
|
+
if (options.checkOnStart === false) return;
|
|
98
109
|
return adapter.connect?.(provider, signal);
|
|
99
110
|
},
|
|
111
|
+
describe(provider, { connected }) {
|
|
112
|
+
const known = described.get(provider);
|
|
113
|
+
if (known === undefined) return {};
|
|
114
|
+
return {
|
|
115
|
+
kind: `${known.connection.provider} · ${known.connection.model}`,
|
|
116
|
+
address: known.connection.baseUrl,
|
|
117
|
+
details: connected && options.checkOnStart !== false && adapter.connect !== undefined ? ["checked"] : [],
|
|
118
|
+
settings: known.settings,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
100
121
|
dispose(provider) {
|
|
101
122
|
return adapter.dispose?.(provider);
|
|
102
123
|
},
|
|
@@ -22,10 +22,14 @@ import {
|
|
|
22
22
|
type LlmProviderAdapter,
|
|
23
23
|
} from "./llm";
|
|
24
24
|
import { readOpenAiTextStream } from "./openaiTextStream";
|
|
25
|
+
import { InfraError } from "../InfraConnector";
|
|
26
|
+
import { redactSensitiveText } from "../../../library/redaction";
|
|
25
27
|
|
|
26
28
|
const DEFAULT_CHAT_COMPLETIONS_PATH = "/chat/completions";
|
|
27
29
|
const DEFAULT_HEALTH_PATH = "/models";
|
|
28
30
|
const DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
31
|
+
/** The start check is short: a provider that does not answer in five seconds is not ready. */
|
|
32
|
+
const START_CHECK_TIMEOUT_MS = 5_000;
|
|
29
33
|
|
|
30
34
|
export interface OpenAiCompatibleAdapterOptions {
|
|
31
35
|
readonly fetch?: OpenAiCompatibleFetch;
|
|
@@ -390,13 +394,23 @@ export class OpenAiCompatibleModelProvider implements AgentModelProvider {
|
|
|
390
394
|
|
|
391
395
|
public async ping(signal?: AbortSignal): Promise<boolean> {
|
|
392
396
|
try {
|
|
393
|
-
await this.
|
|
397
|
+
await this.probe(signal);
|
|
394
398
|
return true;
|
|
395
399
|
} catch {
|
|
396
400
|
return false;
|
|
397
401
|
}
|
|
398
402
|
}
|
|
399
403
|
|
|
404
|
+
/** Base URL of the provider, for diagnostics. */
|
|
405
|
+
public get address(): string {
|
|
406
|
+
return this.baseUrl;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The health request that rejects with the reason: wrong key, address, no answer. */
|
|
410
|
+
public async probe(signal?: AbortSignal): Promise<void> {
|
|
411
|
+
await this.request(this.healthPath, "GET", undefined, signal);
|
|
412
|
+
}
|
|
413
|
+
|
|
400
414
|
/** End active requests and reject subsequent calls through this provider. */
|
|
401
415
|
public dispose(): void {
|
|
402
416
|
this.lifetime.abort(new OpenAiCompatibleProviderError("OpenAI-compatible provider is disposed."));
|
|
@@ -581,6 +595,17 @@ export function openAiCompatibleAdapter(options: OpenAiCompatibleAdapterOptions
|
|
|
581
595
|
create(connection) {
|
|
582
596
|
return new OpenAiCompatibleModelProvider(connection, options);
|
|
583
597
|
},
|
|
598
|
+
async connect(provider, signal) {
|
|
599
|
+
if (!healthCheckEnabled || !(provider instanceof OpenAiCompatibleModelProvider)) return;
|
|
600
|
+
const deadline = AbortSignal.timeout(START_CHECK_TIMEOUT_MS);
|
|
601
|
+
try {
|
|
602
|
+
await provider.probe(signal === undefined ? deadline : AbortSignal.any([signal, deadline]));
|
|
603
|
+
} catch (error) {
|
|
604
|
+
if (signal?.aborted) throw error;
|
|
605
|
+
const reason = deadline.aborted ? `no answer within ${START_CHECK_TIMEOUT_MS} ms` : error instanceof Error ? error.message : String(error);
|
|
606
|
+
throw new InfraError(`Infra connector "llm": ${provider.address} is not available — ${redactSensitiveText(reason)}`);
|
|
607
|
+
}
|
|
608
|
+
},
|
|
584
609
|
dispose(provider) {
|
|
585
610
|
if (provider instanceof OpenAiCompatibleModelProvider) provider.dispose();
|
|
586
611
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createToken, type InjectionToken } from "../../di";
|
|
2
2
|
import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
|
|
3
|
-
import { reader, requireValue } from "../connectorConfig";
|
|
4
|
-
import { InfraError, type InfraConnector } from "../InfraConnector";
|
|
3
|
+
import { reader, requireValue, settingNames } from "../connectorConfig";
|
|
4
|
+
import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
|
|
5
5
|
import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
|
|
6
6
|
|
|
7
7
|
/** Default cluster request timeout (ms): fail fast instead of hanging. */
|
|
@@ -124,6 +124,11 @@ export class OpenSearchClient {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Healthy only for explicitly accepted cluster states. */
|
|
127
|
+
/** Cluster address without credentials. */
|
|
128
|
+
public get url(): string {
|
|
129
|
+
return this.base;
|
|
130
|
+
}
|
|
131
|
+
|
|
127
132
|
public async ping(signal?: AbortSignal): Promise<boolean> {
|
|
128
133
|
try {
|
|
129
134
|
const health = await this.clusterHealth(signal);
|
|
@@ -289,17 +294,39 @@ export function openSearchConnect<T extends OpenSearchConfigShape>(
|
|
|
289
294
|
config: AppConfig<T>,
|
|
290
295
|
options: OpenSearchConnectorOptions = {},
|
|
291
296
|
): InfraConnector<OpenSearchClient> {
|
|
297
|
+
const settings = new WeakMap<OpenSearchClient, string[]>();
|
|
292
298
|
return {
|
|
293
299
|
token: options.token ?? IOpenSearch,
|
|
294
300
|
config,
|
|
295
301
|
create(configs) {
|
|
296
|
-
|
|
302
|
+
const client = new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
|
|
303
|
+
settings.set(client, settingNames(config, configs, ["url", "username", "password"]));
|
|
304
|
+
return client;
|
|
297
305
|
},
|
|
298
306
|
async connect(client, signal) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
let status: string | undefined;
|
|
308
|
+
try {
|
|
309
|
+
status = (await client.clusterHealth(signal))?.status;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw new InfraError(`Infra connector "opensearch": cannot reach ${client.url} — ${errorMessage(error)}`);
|
|
302
312
|
}
|
|
313
|
+
if (status !== "green" && status !== "yellow") {
|
|
314
|
+
throw new InfraError(`Infra connector "opensearch": cluster ${client.url} is unhealthy (status ${status ?? "unknown"}).`);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
async describe(client, { connected, signal }) {
|
|
318
|
+
if (!connected) return { kind: "OpenSearch", address: client.url, settings: settings.get(client) ?? [] };
|
|
319
|
+
const [info, health] = await Promise.all([
|
|
320
|
+
client.request<{ version?: { number?: string; distribution?: string } }>("GET", "/", undefined, signal),
|
|
321
|
+
client.clusterHealth(signal),
|
|
322
|
+
]);
|
|
323
|
+
const product = info.version?.distribution === "opensearch" || info.version?.distribution === undefined ? "OpenSearch" : info.version.distribution;
|
|
324
|
+
return {
|
|
325
|
+
kind: info.version?.number ? `${product} ${info.version.number}` : product,
|
|
326
|
+
address: client.url,
|
|
327
|
+
details: [health.status],
|
|
328
|
+
settings: settings.get(client) ?? [],
|
|
329
|
+
};
|
|
303
330
|
},
|
|
304
331
|
dispose(client) {
|
|
305
332
|
client.dispose();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SQL } from "bun";
|
|
2
2
|
import { createToken, type InjectionToken } from "../../di";
|
|
3
3
|
import type { AppConfig, ConfigRegistry, Secret } from "../../kernel";
|
|
4
|
-
import { reader, requireValue } from "../connectorConfig";
|
|
4
|
+
import { reader, requireValue, settingNames } from "../connectorConfig";
|
|
5
5
|
import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -82,11 +82,23 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
|
|
|
82
82
|
if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0 || shutdownTimeoutMs > 2147483647) {
|
|
83
83
|
throw new InfraError("postgres shutdownTimeoutMs must be an integer 0..2147483647");
|
|
84
84
|
}
|
|
85
|
+
const described = new WeakMap<SQL, { options: { hostname?: string; port?: number; database?: string; username?: string; max?: number }; settings: string[] }>();
|
|
85
86
|
return {
|
|
86
87
|
token: options.token ?? IPostgres,
|
|
87
88
|
config,
|
|
88
89
|
create(configs) {
|
|
89
|
-
|
|
90
|
+
const connection = postgresConnectionOptions(config, configs);
|
|
91
|
+
const client = new SQL(connection);
|
|
92
|
+
described.set(client, { options: connection as { hostname?: string; port?: number; database?: string; username?: string; max?: number }, settings: settingNames(config, configs, ["host", "port", "database", "username", "password"]) });
|
|
93
|
+
return client;
|
|
94
|
+
},
|
|
95
|
+
async describe(client, { connected }) {
|
|
96
|
+
const known = described.get(client);
|
|
97
|
+
const version = connected ? await serverVersion((sql) => client.unsafe(sql)) : undefined;
|
|
98
|
+
return {
|
|
99
|
+
kind: version === undefined ? "PostgreSQL" : `PostgreSQL ${version}`,
|
|
100
|
+
...(known === undefined ? {} : { address: postgresAddress(known.options), details: [`pool ${known.options.max ?? 10}`], settings: known.settings }),
|
|
101
|
+
};
|
|
90
102
|
},
|
|
91
103
|
async connect(client) {
|
|
92
104
|
try {
|
|
@@ -108,3 +120,15 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
|
|
|
108
120
|
},
|
|
109
121
|
};
|
|
110
122
|
}
|
|
123
|
+
|
|
124
|
+
/** `postgres@127.0.0.1:5432/app`: user, host, port and database, never the password. */
|
|
125
|
+
export function postgresAddress(options: { readonly hostname?: string; readonly port?: number | string; readonly database?: string; readonly username?: string }): string {
|
|
126
|
+
return `${options.username ?? ""}${options.username ? "@" : ""}${options.hostname ?? "localhost"}:${options.port ?? 5432}/${options.database ?? ""}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `17.6` from `SHOW server_version`; undefined when the server does not say. */
|
|
130
|
+
export async function serverVersion(query: (sql: string) => Promise<unknown>): Promise<string | undefined> {
|
|
131
|
+
const rows = await query("SHOW server_version") as readonly Record<string, unknown>[];
|
|
132
|
+
const value = rows[0]?.server_version;
|
|
133
|
+
return typeof value === "string" ? value.split(" ")[0] : undefined;
|
|
134
|
+
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
|
|
10
10
|
import type { AppConfig, Secret } from "../../kernel";
|
|
11
11
|
import { RedisDistributedCacheBackend, type RedisDistributedCacheTuning } from "../cache/RedisDistributedCacheBackend";
|
|
12
|
-
import { reader, requireValue } from "../connectorConfig";
|
|
12
|
+
import { reader, requireValue, settingNames } from "../connectorConfig";
|
|
13
13
|
import { errorMessage, InfraError, type InfraConnector } from "../InfraConnector";
|
|
14
14
|
import { redactSensitiveText } from "../../../library/redaction";
|
|
15
15
|
|
|
@@ -109,6 +109,7 @@ export function redisConnect<T extends RedisConfigShape>(
|
|
|
109
109
|
throw new InfraError("redisConnect connectTimeoutMs must be a positive integer.");
|
|
110
110
|
}
|
|
111
111
|
const urls = new WeakMap<RedisClient, string>();
|
|
112
|
+
const settings = new WeakMap<RedisClient, string[]>();
|
|
112
113
|
const cacheProviders =
|
|
113
114
|
cache === undefined
|
|
114
115
|
? undefined
|
|
@@ -123,8 +124,19 @@ export function redisConnect<T extends RedisConfigShape>(
|
|
|
123
124
|
const url = redisUrl(reader(config, configs).get("url"));
|
|
124
125
|
const client = new RedisClient(url);
|
|
125
126
|
urls.set(client, url);
|
|
127
|
+
settings.set(client, settingNames(config, configs, ["url"]));
|
|
126
128
|
return client;
|
|
127
129
|
},
|
|
130
|
+
async describe(client, { connected }) {
|
|
131
|
+
const info = connected ? String(await client.send("INFO", ["server"])) : "";
|
|
132
|
+
const valkey = /valkey_version:([^\r\n]+)/.exec(info)?.[1];
|
|
133
|
+
const redis = /redis_version:([^\r\n]+)/.exec(info)?.[1];
|
|
134
|
+
return {
|
|
135
|
+
kind: valkey ? `Valkey ${valkey}` : redis ? `Redis ${redis}` : "Redis",
|
|
136
|
+
address: redisAddress(urls.get(client) ?? ""),
|
|
137
|
+
settings: settings.get(client) ?? [],
|
|
138
|
+
};
|
|
139
|
+
},
|
|
128
140
|
async connect(client) {
|
|
129
141
|
// The address without a password: it names the server in the error.
|
|
130
142
|
const target = redactSensitiveText(urls.get(client) ?? "redis");
|
|
@@ -172,3 +184,13 @@ function withoutMode(cache: { readonly mode: "distributed" } & RedisDistributedC
|
|
|
172
184
|
const { mode: _mode, ...tuning } = cache;
|
|
173
185
|
return tuning;
|
|
174
186
|
}
|
|
187
|
+
|
|
188
|
+
/** `127.0.0.1:6379/0` without the scheme, user and password. */
|
|
189
|
+
function redisAddress(url: string): string {
|
|
190
|
+
try {
|
|
191
|
+
const parsed = new URL(url);
|
|
192
|
+
return `${parsed.hostname}:${parsed.port || "6379"}${parsed.pathname === "/" ? "" : parsed.pathname}`;
|
|
193
|
+
} catch {
|
|
194
|
+
return redactSensitiveText(url);
|
|
195
|
+
}
|
|
196
|
+
}
|
package/core/infra/index.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// (`SQL`, `RedisClient`) and `fetch`, with zero external dependencies.
|
|
5
5
|
|
|
6
6
|
export { Infra, infraModule, type InfraManifest } from "./Infra";
|
|
7
|
-
export { InfraError, errorMessage, type InfraConnector } from "./InfraConnector";
|
|
7
|
+
export { InfraError, errorMessage, type ConnectionDescription, type InfraConnector } from "./InfraConnector";
|
|
8
8
|
export { InfraLifecycle } from "./InfraLifecycle";
|
|
9
|
-
export { reader, requireValue, type ConfigReader } from "./connectorConfig";
|
|
9
|
+
export { reader, requireValue, settingNames, type ConfigReader } from "./connectorConfig";
|
|
10
10
|
export { codexAppServerConnect, CODEX_APP_SERVER } from "./connectors/codex";
|
|
11
11
|
export { CodexError, RU_CODEX_MESSAGES, type CodexErrorCode, CODEX_MODEL_ID, CODEX_REASONING_EFFORT, type CodexClient, type CodexConfigShape, type CodexLogin, type CodexLoginMethod, type CodexModel, type CodexStatus, type CodexRunInput, type CodexChatMessage, type CodexTool, type CodexToolCall, type CodexToolResult } from "./connectors/codex/contracts";
|
|
12
12
|
|
package/core/kernel/Bazis.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { KernelError, ShutdownTimeoutError } from "./errors";
|
|
|
5
5
|
import type { KernelOptions } from "./types";
|
|
6
6
|
import { redactSensitiveText } from "../../library/redaction";
|
|
7
7
|
import { formatStartupFailure } from "./startupFailure";
|
|
8
|
+
import { StartupScreen, startupScreenEnabled } from "./report/StartupScreen";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Entry-point facade.
|
|
@@ -31,12 +32,26 @@ export class Bazis {
|
|
|
31
32
|
if (options) {
|
|
32
33
|
builder.useOptions(options);
|
|
33
34
|
}
|
|
35
|
+
// On a terminal the start is a screen: what started, or what failed and why.
|
|
36
|
+
const screen = startupScreenEnabled() ? new StartupScreen() : undefined;
|
|
37
|
+
let stopWaiting: (() => void) | undefined;
|
|
38
|
+
if (screen !== undefined && options?.startupReport !== false) {
|
|
39
|
+
builder.useStartupScreen((started) => {
|
|
40
|
+
stopWaiting?.();
|
|
41
|
+
screen.started(started);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
34
44
|
configure?.(builder);
|
|
35
45
|
|
|
36
46
|
let kernel: Kernel;
|
|
37
47
|
try {
|
|
38
48
|
kernel = await builder.build();
|
|
39
49
|
} catch (error) {
|
|
50
|
+
if (screen !== undefined && !(error instanceof ShutdownTimeoutError)) {
|
|
51
|
+
screen.failed(error, undefined, builder.report);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
40
55
|
if (error instanceof DiError || error instanceof KernelError) {
|
|
41
56
|
console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
|
|
42
57
|
process.exitCode = 1;
|
|
@@ -46,10 +61,13 @@ export class Bazis {
|
|
|
46
61
|
}
|
|
47
62
|
|
|
48
63
|
try {
|
|
64
|
+
if (kernel.report.screen) stopWaiting = screen?.waiting(kernel);
|
|
49
65
|
const exitCode = await kernel.run();
|
|
66
|
+
stopWaiting?.();
|
|
50
67
|
process.exitCode = exitCode;
|
|
51
68
|
return exitCode;
|
|
52
69
|
} catch (error) {
|
|
70
|
+
stopWaiting?.();
|
|
53
71
|
if (error instanceof ShutdownTimeoutError) {
|
|
54
72
|
// Graceful shutdown is stuck: hanging handles would keep the process
|
|
55
73
|
// alive forever, so this is the one place a hard exit is correct.
|
|
@@ -58,7 +76,9 @@ export class Bazis {
|
|
|
58
76
|
}
|
|
59
77
|
// Configuration problems surfacing at start (e.g. options fail-fast)
|
|
60
78
|
// deserve the same friendly output as build-time ones.
|
|
61
|
-
if (
|
|
79
|
+
if (screen !== undefined) {
|
|
80
|
+
screen.failed(error, kernel);
|
|
81
|
+
} else if (error instanceof DiError || error instanceof KernelError) {
|
|
62
82
|
console.error(`[bazis] configuration error: ${redactSensitiveText(error.message)}`);
|
|
63
83
|
} else {
|
|
64
84
|
console.error(`[bazis] application failed: ${formatStartupFailure(error)}`);
|
package/core/kernel/Kernel.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { validateTimeout } from "./internal/validateTimeout";
|
|
|
15
15
|
import { awaitShutdown } from "./internal/awaitShutdown";
|
|
16
16
|
import { awaitAbortable } from "./internal/awaitAbortable";
|
|
17
17
|
import { reportDiagnosticFailure } from "./internal/reportDiagnosticFailure";
|
|
18
|
+
import { StartupReport } from "./report/StartupReport";
|
|
19
|
+
import type { BazisModuleRef } from "../di";
|
|
20
|
+
import type { StartupBatchTiming } from "./LifecycleCoordinator";
|
|
18
21
|
|
|
19
22
|
export interface KernelTimings {
|
|
20
23
|
readonly configMs: number;
|
|
@@ -30,6 +33,12 @@ export interface KernelRuntimeOptions {
|
|
|
30
33
|
readonly startupReport: boolean;
|
|
31
34
|
/** Start the infrastructure and the schema only, then stop (BAZIS_SCHEMA_ONLY). */
|
|
32
35
|
readonly schemaOnly?: boolean;
|
|
36
|
+
/** Facts for the startup screen; a fresh one when absent. */
|
|
37
|
+
readonly report?: StartupReport;
|
|
38
|
+
/** The application root module, for the module tree of the startup screen. */
|
|
39
|
+
readonly root?: BazisModuleRef;
|
|
40
|
+
/** Renders the startup screen in place of the `bazis started` log line. */
|
|
41
|
+
readonly onStarted?: (kernel: Kernel) => void;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
interface StopRequest {
|
|
@@ -56,6 +65,7 @@ export class Kernel {
|
|
|
56
65
|
private shutdownPromise?: Promise<void>;
|
|
57
66
|
private startupCleanupFailure?: unknown;
|
|
58
67
|
private startupAbort?: AbortController;
|
|
68
|
+
private ownReport?: StartupReport;
|
|
59
69
|
|
|
60
70
|
public constructor(
|
|
61
71
|
public readonly container: DiContainer,
|
|
@@ -76,6 +86,42 @@ export class Kernel {
|
|
|
76
86
|
return this.state === "started";
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
/** What subsystems recorded while starting: connections, schema, routes. */
|
|
90
|
+
public get report(): StartupReport {
|
|
91
|
+
this.ownReport ??= this.options.report ?? new StartupReport();
|
|
92
|
+
return this.ownReport;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The application root module, when the builder knows it. */
|
|
96
|
+
public get rootModule(): BazisModuleRef | undefined {
|
|
97
|
+
return this.options.root;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Configuration and container build time, and the moment the build began. */
|
|
101
|
+
public get buildTimings(): KernelTimings {
|
|
102
|
+
return this.timings;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Wall time of every started group of hosted services. */
|
|
106
|
+
public get startTimings(): readonly StartupBatchTiming[] {
|
|
107
|
+
return this.coordinator.startTimings;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Hosted services whose start failed. */
|
|
111
|
+
public get failedServices(): readonly string[] {
|
|
112
|
+
return this.coordinator.failedServiceNames;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Hosted services whose start is running now. */
|
|
116
|
+
public get startingServices(): readonly string[] {
|
|
117
|
+
return this.coordinator.startingServiceNames;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** From the start of the build to a started application, ms; 0 before. */
|
|
121
|
+
public get startupDurationMs(): number {
|
|
122
|
+
return this.startupMs;
|
|
123
|
+
}
|
|
124
|
+
|
|
79
125
|
public start(): Promise<void> {
|
|
80
126
|
if (this.startPromise !== undefined) {
|
|
81
127
|
return this.startPromise;
|
|
@@ -396,6 +442,14 @@ export class Kernel {
|
|
|
396
442
|
}
|
|
397
443
|
|
|
398
444
|
private printStartupReport(): void {
|
|
445
|
+
if (this.options.onStarted !== undefined) {
|
|
446
|
+
try {
|
|
447
|
+
this.options.onStarted(this);
|
|
448
|
+
return;
|
|
449
|
+
} catch {
|
|
450
|
+
// A broken screen falls back to the log line below.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
399
453
|
const logger = this.container.tryResolve(LOGGER);
|
|
400
454
|
const fields = {
|
|
401
455
|
environment: this.environment.name,
|
|
@@ -13,6 +13,7 @@ import { ConsoleLogger } from "./logging/ConsoleLogger";
|
|
|
13
13
|
import { LOGGER, type Logger } from "./logging/Logger";
|
|
14
14
|
import type { ConfigSource, EnvironmentName, KernelOptions, UnhandledErrorPolicy } from "./types";
|
|
15
15
|
import { validateTimeout } from "./internal/validateTimeout";
|
|
16
|
+
import { StartupReport } from "./report/StartupReport";
|
|
16
17
|
|
|
17
18
|
/** Root module or a profile factory (Spring profiles, but explicit). */
|
|
18
19
|
export type RootModuleInput = BazisModuleRef | ((environment: Environment) => BazisModuleRef);
|
|
@@ -49,6 +50,14 @@ export class KernelBuilder {
|
|
|
49
50
|
private startupReport?: boolean;
|
|
50
51
|
private schemaOnly?: boolean;
|
|
51
52
|
private logger?: Logger;
|
|
53
|
+
private startupScreen?: (kernel: Kernel) => void;
|
|
54
|
+
private applicationRoot?: BazisModuleRef;
|
|
55
|
+
private builtReport?: StartupReport;
|
|
56
|
+
|
|
57
|
+
/** Facts of the last {@link build}, also when it failed after the configuration was read. */
|
|
58
|
+
public get report(): StartupReport | undefined {
|
|
59
|
+
return this.builtReport;
|
|
60
|
+
}
|
|
52
61
|
|
|
53
62
|
public constructor(private readonly rootModule: RootModuleInput) {}
|
|
54
63
|
|
|
@@ -104,6 +113,25 @@ export class KernelBuilder {
|
|
|
104
113
|
return this;
|
|
105
114
|
}
|
|
106
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Renders the startup screen instead of the `bazis started` log line, and
|
|
118
|
+
* makes subsystems record their facts for it rather than print them.
|
|
119
|
+
* `Bazis.run` sets it on an interactive terminal.
|
|
120
|
+
*/
|
|
121
|
+
public useStartupScreen(render: (kernel: Kernel) => void): this {
|
|
122
|
+
this.startupScreen = render;
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @internal The application's own root module, inside the hosts `runApp`
|
|
128
|
+
* wraps it in: the module tree of the startup screen starts from it.
|
|
129
|
+
*/
|
|
130
|
+
public useApplicationRoot(root: BazisModuleRef): this {
|
|
131
|
+
this.applicationRoot = root;
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
107
135
|
/**
|
|
108
136
|
* Replacements for registered providers, for tests (see `createContainer`
|
|
109
137
|
* `overrides`): registered after the whole graph and visible to every module.
|
|
@@ -138,6 +166,12 @@ export class KernelBuilder {
|
|
|
138
166
|
const userRoot = resolveUserRoot(this.rootModule, environment);
|
|
139
167
|
const configs = collectModuleConfigs([userRoot]);
|
|
140
168
|
const configRegistry = new ConfigRegistry(configs, environment.name, configuration);
|
|
169
|
+
const startupReport = this.startupReport ?? !environment.isProduction;
|
|
170
|
+
// The screen replaces the report line, so it follows the same switch.
|
|
171
|
+
const screen = this.startupScreen !== undefined && startupReport;
|
|
172
|
+
const report = new StartupReport(screen);
|
|
173
|
+
report.configuration(configRegistry.inspect());
|
|
174
|
+
this.builtReport = report;
|
|
141
175
|
|
|
142
176
|
// Kernel infrastructure is a global module: any user module can depend on
|
|
143
177
|
// Environment / Configuration / ApplicationLifetime / EventBus without
|
|
@@ -146,6 +180,7 @@ export class KernelBuilder {
|
|
|
146
180
|
@Module({
|
|
147
181
|
providers: [
|
|
148
182
|
DI.singleton(DI.valueProvider(ConfigRegistry, configRegistry)),
|
|
183
|
+
DI.singleton(DI.valueProvider(StartupReport, report)),
|
|
149
184
|
...configRegistry.providers,
|
|
150
185
|
DI.singleton(DI.valueProvider(Environment, environment)),
|
|
151
186
|
DI.singleton(DI.valueProvider(Configuration, configuration)),
|
|
@@ -179,8 +214,11 @@ export class KernelBuilder {
|
|
|
179
214
|
shutdownTimeoutMs: this.shutdownTimeoutMs,
|
|
180
215
|
signals: this.signals,
|
|
181
216
|
unhandledErrorPolicy: this.unhandledErrorPolicy,
|
|
182
|
-
startupReport
|
|
217
|
+
startupReport,
|
|
183
218
|
schemaOnly: this.schemaOnly ?? schemaOnlyFromProcess(),
|
|
219
|
+
report,
|
|
220
|
+
root: this.applicationRoot ?? userRoot,
|
|
221
|
+
...(screen ? { onStarted: this.startupScreen } : {}),
|
|
184
222
|
});
|
|
185
223
|
}
|
|
186
224
|
}
|