bazis 0.99.6 → 0.100.1

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 (50) hide show
  1. package/cli/build.ts +171 -36
  2. package/cli/codegen.ts +155 -4
  3. package/cli/generateProject.ts +8 -2
  4. package/cli/main.ts +134 -51
  5. package/cli/naming.ts +3 -0
  6. package/cli/ui.ts +86 -0
  7. package/core/agent/index.ts +8 -0
  8. package/core/app/runApp.ts +4 -1
  9. package/core/di/extensions/hosted-service.ts +6 -0
  10. package/core/http/HttpServer.ts +33 -2
  11. package/core/http/Routing/RouterBuilder.ts +21 -0
  12. package/core/infra/Infra.ts +8 -2
  13. package/core/infra/InfraConnector.ts +13 -0
  14. package/core/infra/InfraLifecycle.ts +27 -0
  15. package/core/infra/connectorConfig.ts +10 -0
  16. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  17. package/core/infra/connectors/llm.ts +23 -2
  18. package/core/infra/connectors/openaiCompatible.ts +26 -1
  19. package/core/infra/connectors/opensearch.ts +36 -8
  20. package/core/infra/connectors/postgres.ts +29 -4
  21. package/core/infra/connectors/redis.ts +26 -3
  22. package/core/infra/index.ts +2 -2
  23. package/core/kernel/Bazis.ts +21 -1
  24. package/core/kernel/Kernel.ts +54 -0
  25. package/core/kernel/KernelBuilder.ts +39 -1
  26. package/core/kernel/LifecycleCoordinator.ts +101 -9
  27. package/core/kernel/config/ConfigRegistry.ts +5 -0
  28. package/core/kernel/config/ConfigValidationError.ts +37 -0
  29. package/core/kernel/config/callerFile.ts +23 -0
  30. package/core/kernel/config/defineConfig.ts +21 -17
  31. package/core/kernel/errors/StartupFailuresError.ts +17 -0
  32. package/core/kernel/errors/index.ts +1 -0
  33. package/core/kernel/index.ts +12 -0
  34. package/core/kernel/report/StartupReport.ts +134 -0
  35. package/core/kernel/report/StartupScreen.ts +459 -0
  36. package/core/kernel/report/moduleTree.ts +97 -0
  37. package/core/kernel/report/startupProblems.ts +186 -0
  38. package/core/orm/OrmHostedPlan.validator.ts +3 -3
  39. package/core/orm/OrmLifecycle.ts +29 -7
  40. package/core/orm/buildOrmProviders.ts +7 -3
  41. package/core/orm/databaseConnector.ts +17 -1
  42. package/core/orm/index.ts +11 -0
  43. package/core/orm/strictSchemaHostedIdentity.ts +16 -1
  44. package/core/scripts/di-generate.ts +58 -9
  45. package/library/terminal/index.ts +4 -0
  46. package/library/terminal/language.ts +60 -0
  47. package/library/terminal/messages.ts +400 -0
  48. package/library/terminal/screen.ts +358 -0
  49. package/library/terminal/style.ts +143 -0
  50. 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
- return adapter.create(resolveLlmConnectionOptions(config, options, configs));
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.request(this.healthPath, "GET", undefined, signal);
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,8 +1,9 @@
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
+ import { identifyConnector } from "../connectorIdentity";
6
7
 
7
8
  /** Default cluster request timeout (ms): fail fast instead of hanging. */
8
9
  const DEFAULT_OPENSEARCH_TIMEOUT_MS = 5000;
@@ -124,6 +125,11 @@ export class OpenSearchClient {
124
125
  }
125
126
 
126
127
  /** Healthy only for explicitly accepted cluster states. */
128
+ /** Cluster address without credentials. */
129
+ public get url(): string {
130
+ return this.base;
131
+ }
132
+
127
133
  public async ping(signal?: AbortSignal): Promise<boolean> {
128
134
  try {
129
135
  const health = await this.clusterHealth(signal);
@@ -289,17 +295,39 @@ export function openSearchConnect<T extends OpenSearchConfigShape>(
289
295
  config: AppConfig<T>,
290
296
  options: OpenSearchConnectorOptions = {},
291
297
  ): InfraConnector<OpenSearchClient> {
292
- return {
298
+ const settings = new WeakMap<OpenSearchClient, string[]>();
299
+ return identifyConnector({
293
300
  token: options.token ?? IOpenSearch,
294
301
  config,
295
302
  create(configs) {
296
- return new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
303
+ const client = new OpenSearchClient(buildOpenSearchOptions(config, options, configs));
304
+ settings.set(client, settingNames(config, configs, ["url", "username", "password"]));
305
+ return client;
297
306
  },
298
307
  async connect(client, signal) {
299
- const alive = await client.ping(signal);
300
- if (!alive) {
301
- throw new InfraError(`Infra connector "opensearch": cluster is not reachable or unhealthy.`);
308
+ let status: string | undefined;
309
+ try {
310
+ status = (await client.clusterHealth(signal))?.status;
311
+ } catch (error) {
312
+ throw new InfraError(`Infra connector "opensearch": cannot reach ${client.url} — ${errorMessage(error)}`);
302
313
  }
314
+ if (status !== "green" && status !== "yellow") {
315
+ throw new InfraError(`Infra connector "opensearch": cluster ${client.url} is unhealthy (status ${status ?? "unknown"}).`);
316
+ }
317
+ },
318
+ async describe(client, { connected, signal }) {
319
+ if (!connected) return { kind: "OpenSearch", address: client.url, settings: settings.get(client) ?? [] };
320
+ const [info, health] = await Promise.all([
321
+ client.request<{ version?: { number?: string; distribution?: string } }>("GET", "/", undefined, signal),
322
+ client.clusterHealth(signal),
323
+ ]);
324
+ const product = info.version?.distribution === "opensearch" || info.version?.distribution === undefined ? "OpenSearch" : info.version.distribution;
325
+ return {
326
+ kind: info.version?.number ? `${product} ${info.version.number}` : product,
327
+ address: client.url,
328
+ details: [health.status],
329
+ settings: settings.get(client) ?? [],
330
+ };
303
331
  },
304
332
  dispose(client) {
305
333
  client.dispose();
@@ -307,5 +335,5 @@ export function openSearchConnect<T extends OpenSearchConfigShape>(
307
335
  healthCheck(client, signal) {
308
336
  return client.ping(signal);
309
337
  },
310
- };
338
+ }, "opensearch");
311
339
  }
@@ -1,8 +1,9 @@
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
+ import { identifyConnector } from "../connectorIdentity";
6
7
 
7
8
  /**
8
9
  * PostgreSQL client (native Bun `SQL`, no external dependencies) of the
@@ -82,11 +83,23 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
82
83
  if (!Number.isSafeInteger(shutdownTimeoutMs) || shutdownTimeoutMs < 0 || shutdownTimeoutMs > 2147483647) {
83
84
  throw new InfraError("postgres shutdownTimeoutMs must be an integer 0..2147483647");
84
85
  }
85
- return {
86
+ const described = new WeakMap<SQL, { options: { hostname?: string; port?: number; database?: string; username?: string; max?: number }; settings: string[] }>();
87
+ return identifyConnector({
86
88
  token: options.token ?? IPostgres,
87
89
  config,
88
90
  create(configs) {
89
- return new SQL(postgresConnectionOptions(config, configs));
91
+ const connection = postgresConnectionOptions(config, configs);
92
+ const client = new SQL(connection);
93
+ 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"]) });
94
+ return client;
95
+ },
96
+ async describe(client, { connected }) {
97
+ const known = described.get(client);
98
+ const version = connected ? await serverVersion((sql) => client.unsafe(sql)) : undefined;
99
+ return {
100
+ kind: version === undefined ? "PostgreSQL" : `PostgreSQL ${version}`,
101
+ ...(known === undefined ? {} : { address: postgresAddress(known.options), details: [`pool ${known.options.max ?? 10}`], settings: known.settings }),
102
+ };
90
103
  },
91
104
  async connect(client) {
92
105
  try {
@@ -106,5 +119,17 @@ export function postgres<T extends PostgresConfigShape>(config: AppConfig<T>, op
106
119
  return false;
107
120
  }
108
121
  },
109
- };
122
+ }, "postgres");
123
+ }
124
+
125
+ /** `postgres@127.0.0.1:5432/app`: user, host, port and database, never the password. */
126
+ export function postgresAddress(options: { readonly hostname?: string; readonly port?: number | string; readonly database?: string; readonly username?: string }): string {
127
+ return `${options.username ?? ""}${options.username ? "@" : ""}${options.hostname ?? "localhost"}:${options.port ?? 5432}/${options.database ?? ""}`;
128
+ }
129
+
130
+ /** `17.6` from `SHOW server_version`; undefined when the server does not say. */
131
+ export async function serverVersion(query: (sql: string) => Promise<unknown>): Promise<string | undefined> {
132
+ const rows = await query("SHOW server_version") as readonly Record<string, unknown>[];
133
+ const value = rows[0]?.server_version;
134
+ return typeof value === "string" ? value.split(" ")[0] : undefined;
110
135
  }
@@ -9,9 +9,10 @@ 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
+ import { identifyConnector } from "../connectorIdentity";
15
16
 
16
17
  const DEFAULT_REDIS_CONNECT_TIMEOUT_MS = 5_000;
17
18
 
@@ -109,12 +110,13 @@ export function redisConnect<T extends RedisConfigShape>(
109
110
  throw new InfraError("redisConnect connectTimeoutMs must be a positive integer.");
110
111
  }
111
112
  const urls = new WeakMap<RedisClient, string>();
113
+ const settings = new WeakMap<RedisClient, string[]>();
112
114
  const cacheProviders =
113
115
  cache === undefined
114
116
  ? undefined
115
117
  : distributedCacheProviders(cache === "distributed" ? {} : withoutMode(cache), token);
116
118
 
117
- return {
119
+ return identifyConnector({
118
120
  token,
119
121
  config,
120
122
  providers: cacheProviders,
@@ -123,8 +125,19 @@ export function redisConnect<T extends RedisConfigShape>(
123
125
  const url = redisUrl(reader(config, configs).get("url"));
124
126
  const client = new RedisClient(url);
125
127
  urls.set(client, url);
128
+ settings.set(client, settingNames(config, configs, ["url"]));
126
129
  return client;
127
130
  },
131
+ async describe(client, { connected }) {
132
+ const info = connected ? String(await client.send("INFO", ["server"])) : "";
133
+ const valkey = /valkey_version:([^\r\n]+)/.exec(info)?.[1];
134
+ const redis = /redis_version:([^\r\n]+)/.exec(info)?.[1];
135
+ return {
136
+ kind: valkey ? `Valkey ${valkey}` : redis ? `Redis ${redis}` : "Redis",
137
+ address: redisAddress(urls.get(client) ?? ""),
138
+ settings: settings.get(client) ?? [],
139
+ };
140
+ },
128
141
  async connect(client) {
129
142
  // The address without a password: it names the server in the error.
130
143
  const target = redactSensitiveText(urls.get(client) ?? "redis");
@@ -154,7 +167,7 @@ export function redisConnect<T extends RedisConfigShape>(
154
167
  return false;
155
168
  }
156
169
  },
157
- };
170
+ }, "redis");
158
171
  }
159
172
 
160
173
  function redisUrl(value: unknown): string {
@@ -172,3 +185,13 @@ function withoutMode(cache: { readonly mode: "distributed" } & RedisDistributedC
172
185
  const { mode: _mode, ...tuning } = cache;
173
186
  return tuning;
174
187
  }
188
+
189
+ /** `127.0.0.1:6379/0` without the scheme, user and password. */
190
+ function redisAddress(url: string): string {
191
+ try {
192
+ const parsed = new URL(url);
193
+ return `${parsed.hostname}:${parsed.port || "6379"}${parsed.pathname === "/" ? "" : parsed.pathname}`;
194
+ } catch {
195
+ return redactSensitiveText(url);
196
+ }
197
+ }
@@ -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
 
@@ -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 (error instanceof DiError || error instanceof KernelError) {
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)}`);
@@ -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: this.startupReport ?? !environment.isProduction,
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
  }