bazis 0.98.10 → 0.98.12

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.
@@ -1,5 +1,6 @@
1
1
  import { REQUEST_ID_STATE_KEY, type Logger } from "../../kernel";
2
2
  import { redactSensitive, redactSensitiveText } from "../../../library/redaction";
3
+ import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
3
4
  import type { HttpContext } from "../HttpContext/HttpContext";
4
5
  import { HttpError } from "../Errors/HttpError";
5
6
  import type { HttpMiddleware } from "./types";
@@ -124,15 +125,30 @@ export function errorHandler(options: ErrorHandlerOptions = {}): HttpMiddleware
124
125
  return;
125
126
  }
126
127
  safelyReportUnexpected(options, log, ctx, error);
128
+ const [status, title] = unexpectedStatus(error);
127
129
  ctx.response = options.exposeDetails
128
- ? safeJsonResponse(500, {
129
- error: "Internal Server Error",
130
+ ? safeJsonResponse(status, {
131
+ error: title,
130
132
  // Development only, but redacted like the log: a secret in an error
131
133
  // message must not reach a browser or a shared screenshot.
132
134
  message: redactSensitiveText(error instanceof Error ? error.message : String(error)),
133
135
  stack: error instanceof Error && error.stack !== undefined ? redactSensitiveText(error.stack) : undefined,
134
136
  })
135
- : safeJsonResponse(500, { error: "Internal Server Error" });
137
+ : safeJsonResponse(status, { error: title });
136
138
  }
137
139
  };
138
140
  }
141
+
142
+ /**
143
+ * An unhandled outbound HTTP failure is the upstream's fault, not ours:
144
+ * 504 when the call timed out, 502 otherwise. A client error without a code
145
+ * is a misconfiguration of our request (for example an invalid maxRedirects)
146
+ * and stays 500, like everything else. Upstream details never reach the
147
+ * client; the log keeps the full error.
148
+ */
149
+ function unexpectedStatus(error: unknown): readonly [number, string] {
150
+ if (error instanceof HttpClientError && error.code !== undefined) {
151
+ return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
152
+ }
153
+ return [500, "Internal Server Error"];
154
+ }
@@ -16,8 +16,8 @@ import { DI, Module, createToken, type BazisModule, type ProviderDefinition } fr
16
16
  import { getOutboundCorrelationHeaders } from "../kernel";
17
17
  import {
18
18
  HttpClient,
19
+ HttpClientFactory,
19
20
  HttpClientFactoryBuilder,
20
- type HttpClientFactory,
21
21
  type HeaderBag,
22
22
  type RequestConfig,
23
23
  } from "../../library/http-client";
@@ -44,9 +44,10 @@ export interface HttpClientModuleConfig {
44
44
 
45
45
  /**
46
46
  * Registers the outbound HTTP client in DI with correlation propagation wired
47
- * to the kernel request context:
48
- * - {@link HTTP_CLIENT_FACTORY} — named clients (fail-fast on unknown names);
49
- * - {@link HTTP_CLIENT} — the default injectable {@link HttpClient}.
47
+ * to the kernel request context. A service takes it by type:
48
+ * - `HttpClient` (or the {@link HTTP_CLIENT} token) — the default client;
49
+ * - `HttpClientFactory` (or {@link HTTP_CLIENT_FACTORY}) — named clients
50
+ * (fail-fast on unknown names).
50
51
  */
51
52
  export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModule {
52
53
  const correlationHeaders = config.correlationHeaders ?? getOutboundCorrelationHeaders;
@@ -83,11 +84,14 @@ export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModu
83
84
  }
84
85
  const factory = builder.build();
85
86
 
87
+ const client = factory.createClient();
88
+ // One factory and one default client, under the class (injection by type)
89
+ // and under the original tokens.
86
90
  const providers: ProviderDefinition[] = [
91
+ DI.singleton(DI.valueProvider(HttpClientFactory, factory)),
87
92
  DI.singleton(DI.valueProvider(HTTP_CLIENT_FACTORY, factory)),
88
- DI.singleton(
89
- DI.factoryProvider(HTTP_CLIENT, [HTTP_CLIENT_FACTORY], (resolved: HttpClientFactory) => resolved.createClient()),
90
- ),
93
+ DI.singleton(DI.valueProvider(HttpClient, client)),
94
+ DI.singleton(DI.valueProvider(HTTP_CLIENT, client)),
91
95
  ];
92
96
 
93
97
  @Module({ providers })
@@ -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.98.10" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.12" }, 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");
@@ -154,18 +154,22 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
154
154
  const values = new Map<string, ConfigValueType>();
155
155
  const inspection: ConfigInspection[] = [];
156
156
  const issues: string[] = [];
157
+ // A wrong value names what came and where to change it: the variable that
158
+ // supplied it, or the variables that can override the value from the code.
159
+ const got = (raw: unknown) => raw === undefined ? "" : `, got ${JSON.stringify(String(raw)).slice(0, 80)}`;
160
+ const source = (key: string, selected: { readonly name: string } | undefined) => ` (${selected ? selected.name : names.get(key)!.join(", ")})`;
157
161
  for (const key of keys) {
158
162
  const base = schema.default[key]!;
159
163
  const override = schema[environment]?.[key];
160
164
  const cell = override ?? base;
161
165
  const type = isSecret(base) ? "secret" : isEnum(base) ? typeof base.default : typeof base;
162
- const candidates: { raw: string; source: string; priority: number }[] = [];
166
+ const candidates: { raw: string; source: string; priority: number; name: string }[] = [];
163
167
  for (const name of names.get(key)!) {
164
168
  const sourceKey = name.slice(ENV_PREFIX.length).toLowerCase().replaceAll("__", ".");
165
169
  const raw = configuration.get(sourceKey);
166
170
  if (raw === undefined) continue;
167
171
  const origin = configuration.origin(sourceKey);
168
- candidates.push({ raw, ...origin });
172
+ candidates.push({ raw, ...origin, name });
169
173
  }
170
174
  const priority = Math.max(...candidates.map(candidate => candidate.priority));
171
175
  const winners = candidates.filter(candidate => candidate.priority === priority);
@@ -178,17 +182,17 @@ export function defineConfig(prefixOrSchema: string | ConfigSchema<Defaults>, ma
178
182
  else value = new Secret(raw);
179
183
  } else if (type === "number") {
180
184
  const parsed = Number(raw);
181
- if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number`);
185
+ if (raw === undefined || (typeof raw === "string" && !raw.trim()) || !Number.isFinite(parsed)) issues.push(`${fullKey(key)} — expected a finite number${got(raw)}${source(key, selected)}`);
182
186
  else value = parsed;
183
187
  } else if (type === "boolean") {
184
188
  const normalized = String(raw).toLowerCase();
185
189
  if (normalized === "true" || normalized === "1") value = true;
186
190
  else if (normalized === "false" || normalized === "0") value = false;
187
- else issues.push(`${fullKey(key)} — expected a boolean`);
191
+ else issues.push(`${fullKey(key)} — expected a boolean (true, false, 1, 0)${got(raw)}${source(key, selected)}`);
188
192
  } else if (typeof raw === "string") value = raw;
189
193
  else issues.push(`${fullKey(key)} — expected a string`);
190
194
  if (value === undefined) continue;
191
- if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — value is not one of the declared enum values`);
195
+ if (isEnum(base) && !base.values.includes(value as string | number)) issues.push(`${fullKey(key)} — ${JSON.stringify(value)} is not allowed, use one of: ${base.values.join(", ")}${source(key, selected)}`);
192
196
  try {
193
197
  const issue = validators[key]?.(value);
194
198
  if (issue) issues.push(`${fullKey(key)} — ${issue}`);
@@ -1,4 +1,4 @@
1
- import { InfraError, reader, requireValue, postgresConnectionOptions, type InfraConnector, type PostgresConfigShape } from "../infra";
1
+ import { errorMessage, InfraError, reader, requireValue, postgresConnectionOptions, type InfraConnector, type PostgresConfigShape } from "../infra";
2
2
  import type { AppConfig, ConfigRegistry } from "../kernel";
3
3
  import { postgres, type DatabaseProvider, type PostgresServerTimeouts } from "../../library/orm";
4
4
  import { DATABASE_PROVIDER } from "./DATABASE_PROVIDER";
@@ -86,16 +86,26 @@ export function ormBazisConnect<T extends PostgresOrmConfigShape>(config: AppCon
86
86
  export function ormBazisConnect(
87
87
  config: AppConfig<PostgresOrmConfigShape>,
88
88
  ): InfraConnector<DatabaseProvider> {
89
+ // Where the connection goes, for the startup error; never the password.
90
+ let target = "PostgreSQL";
89
91
  return {
90
92
  token: DATABASE_PROVIDER,
91
93
  config,
92
94
  phase: DATABASE_PHASE,
93
95
  create(configs) {
96
+ const c = reader(config, configs);
97
+ target = `PostgreSQL at ${String(c.get("host"))}:${String(c.get("port"))} (database "${String(c.get("database"))}", user "${String(c.get("username"))}")`;
94
98
  return buildPostgresProvider(config, configs);
95
99
  },
96
100
  async connect(provider, signal) {
97
- if (!(await provider.ping(signal))) {
98
- throw new InfraError('Infra connector "database": postgres is not reachable.');
101
+ if (provider.probe === undefined) {
102
+ if (!(await provider.ping(signal))) throw new InfraError(`Infra connector "database": cannot connect to ${target}.`);
103
+ return;
104
+ }
105
+ try {
106
+ await provider.probe(signal);
107
+ } catch (error) {
108
+ throw new InfraError(`Infra connector "database": cannot connect to ${target}: ${connectionFailure(error)}`);
99
109
  }
100
110
  },
101
111
  async dispose(provider) {
@@ -106,3 +116,12 @@ export function ormBazisConnect(
106
116
  },
107
117
  };
108
118
  }
119
+
120
+ /** The driver's reason with its code: `password authentication failed for user "app" (28P01)`. */
121
+ function connectionFailure(error: unknown): string {
122
+ const details = error as { errno?: unknown; code?: unknown };
123
+ const code = typeof details.errno === "string" && details.errno ? details.errno
124
+ : typeof details.code === "string" && details.code ? details.code : undefined;
125
+ const message = errorMessage(error);
126
+ return code === undefined || message.includes(code) ? message : `${message} (${code})`;
127
+ }
@@ -6,7 +6,9 @@ import {
6
6
  appendQuery,
7
7
  basicAuthHeader,
8
8
  isBodyInit,
9
+ isAbsoluteUrl,
9
10
  mergeHeaderBags,
11
+ pageUrl,
10
12
  resolveUrl,
11
13
  shouldPropagateCorrelation,
12
14
  } from "./serialize";
@@ -188,7 +190,8 @@ export class HttpClient {
188
190
  return { ...merged, method: String(override.method ?? base.method ?? "GET").toUpperCase() };
189
191
  }
190
192
 
191
- private async dispatch<T>(config: ResolvedRequestConfig): Promise<HttpResponse<T>> {
193
+ private async dispatch<T>(requested: ResolvedRequestConfig): Promise<HttpResponse<T>> {
194
+ const config = withAbsoluteBaseUrl(requested);
192
195
  if (config.inspectableRedirects !== undefined && typeof config.inspectableRedirects !== "boolean") {
193
196
  throw new HttpClientError("inspectableRedirects must be a boolean", { config });
194
197
  }
@@ -650,6 +653,26 @@ function isReplayableBody(body: RequestInit["body"]): boolean {
650
653
  || (typeof ReadableStream !== "undefined" && body instanceof ReadableStream));
651
654
  }
652
655
 
656
+ /**
657
+ * A relative `baseUrl` (`"/api"`) is resolved against the page address once,
658
+ * so URL building and every origin check below see an absolute base. Outside
659
+ * a page there is nothing to resolve it against.
660
+ */
661
+ function withAbsoluteBaseUrl(config: ResolvedRequestConfig): ResolvedRequestConfig {
662
+ const { baseUrl } = config;
663
+ if (!baseUrl || isAbsoluteUrl(baseUrl)) {
664
+ return config;
665
+ }
666
+ const page = pageUrl();
667
+ if (page === undefined) {
668
+ throw new HttpClientError(
669
+ `baseUrl "${baseUrl}" is relative: outside a browser page it must be absolute, for example "http://localhost:3000${baseUrl.startsWith("/") ? "" : "/"}${baseUrl}"`,
670
+ { config },
671
+ );
672
+ }
673
+ return { ...config, baseUrl: new URL(baseUrl, page).toString() };
674
+ }
675
+
653
676
  function sameOrigin(left: string, right: string): boolean {
654
677
  try {
655
678
  return new URL(left).origin === new URL(right).origin;
@@ -2,9 +2,32 @@ import { HttpClient } from "./HttpClient";
2
2
  import { HttpClientConfigError } from "./errors";
3
3
  import type { RequestConfig } from "./types";
4
4
 
5
- /** Resolves pre-configured named clients (à la .NET `IHttpClientFactory`). */
6
- export interface HttpClientFactory {
7
- createClient(name?: string): HttpClient;
5
+ /**
6
+ * Resolves pre-configured named clients (à la .NET `IHttpClientFactory`).
7
+ * An abstract class, so a service can take it by type in its constructor.
8
+ */
9
+ export abstract class HttpClientFactory {
10
+ public abstract createClient(name?: string): HttpClient;
11
+ }
12
+
13
+ class ConfiguredHttpClientFactory extends HttpClientFactory {
14
+ public constructor(
15
+ private readonly defaults: RequestConfig,
16
+ private readonly named: ReadonlyMap<string, RequestConfig>,
17
+ ) {
18
+ super();
19
+ }
20
+
21
+ public createClient(name?: string): HttpClient {
22
+ if (name === undefined) {
23
+ return new HttpClient(this.defaults);
24
+ }
25
+ const config = this.named.get(name);
26
+ if (config === undefined) {
27
+ throw new HttpClientConfigError(name, [...this.named.keys()]);
28
+ }
29
+ return new HttpClient(this.defaults).create(config);
30
+ }
8
31
  }
9
32
 
10
33
  /**
@@ -27,19 +50,6 @@ export class HttpClientFactoryBuilder {
27
50
  }
28
51
 
29
52
  public build(): HttpClientFactory {
30
- const named = new Map(this.named);
31
- const defaults = this.defaults;
32
- return {
33
- createClient(name?: string): HttpClient {
34
- if (name === undefined) {
35
- return new HttpClient(defaults);
36
- }
37
- const config = named.get(name);
38
- if (config === undefined) {
39
- throw new HttpClientConfigError(name, [...named.keys()]);
40
- }
41
- return new HttpClient(defaults).create(config);
42
- },
43
- };
53
+ return new ConfiguredHttpClientFactory(this.defaults, new Map(this.named));
44
54
  }
45
55
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  export { HttpClient } from "./HttpClient";
14
14
  export { InspectableRedirectProtocol } from "./redirectProtocol";
15
- export { HttpClientFactoryBuilder, type HttpClientFactory } from "./HttpClientFactory";
15
+ export { HttpClientFactoryBuilder, HttpClientFactory } from "./HttpClientFactory";
16
16
  export { HttpClientError, HttpClientConfigError, HttpErrorCode } from "./errors";
17
17
  export { InterceptorManager, type FulfilledFn, type RejectedFn } from "./interceptors";
18
18
  export {
@@ -8,6 +8,20 @@ const ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
8
8
  * prefix), `baseUrl: "https://api/v1"` + `"/users"` → `…/v1/users`.
9
9
  * Absolute URLs and missing baseUrl pass through.
10
10
  */
11
+ export function isAbsoluteUrl(url: string): boolean {
12
+ return ABSOLUTE_URL.test(url);
13
+ }
14
+
15
+ /**
16
+ * The address of the current page in a browser (or worker), else undefined.
17
+ * A relative `baseUrl` such as `"/api"` is resolved against it, like axios's
18
+ * `baseURL` in a browser app.
19
+ */
20
+ export function pageUrl(): string | undefined {
21
+ const location = (globalThis as { location?: { href?: unknown } }).location;
22
+ return typeof location?.href === "string" ? location.href : undefined;
23
+ }
24
+
11
25
  export function resolveUrl(baseUrl: string | undefined, url: string): string {
12
26
  if (!baseUrl || ABSOLUTE_URL.test(url)) {
13
27
  return url;
@@ -409,13 +409,17 @@ export class PostgresProvider implements DatabaseProvider {
409
409
 
410
410
  async ping(signal?: AbortSignal): Promise<boolean> {
411
411
  try {
412
- await withPostgresScopeOptions({ signal }, () => this.pooledOperation((session) => this.runQuery(session, "SELECT 1", [])));
412
+ await this.probe(signal);
413
413
  return true;
414
414
  } catch {
415
415
  return false;
416
416
  }
417
417
  }
418
418
 
419
+ async probe(signal?: AbortSignal): Promise<void> {
420
+ await withPostgresScopeOptions({ signal }, () => this.pooledOperation((session) => this.runQuery(session, "SELECT 1", [])));
421
+ }
422
+
419
423
  async introspect(): Promise<IntrospectedSchema> {
420
424
  const tableRows = await this.query(
421
425
  `SELECT table_schema, table_name
@@ -96,6 +96,7 @@ export function createObservedProvider(provider: DatabaseProvider, observe: Tran
96
96
  afterCommit: provider.afterCommit?.bind(provider),
97
97
  afterRollback: provider.afterRollback?.bind(provider),
98
98
  ping: (signal?: AbortSignal): Promise<boolean> => observe(() => provider.ping(signal)),
99
+ probe: provider.probe ? (signal?: AbortSignal): Promise<void> => observe(() => provider.probe!(signal)) : undefined,
99
100
  introspect: () => observe(() => provider.introspect()),
100
101
  schemaAdmissionCapability: schema ? {
101
102
  version: schema.version,
@@ -178,6 +178,11 @@ export interface DatabaseProvider {
178
178
  afterRollback?(callback: TransactionCallback): void;
179
179
  /** Connection check for the health check. */
180
180
  ping(signal?: AbortSignal): Promise<boolean>;
181
+ /**
182
+ * Connection check that rejects with the driver's error instead of `false`,
183
+ * so a startup failure can say why (wrong password, missing database, …).
184
+ */
185
+ probe?(signal?: AbortSignal): Promise<void>;
181
186
  /** Introspection of the actual database schema (for the auto-migration). */
182
187
  introspect(): Promise<IntrospectedSchema>;
183
188
  /**
@@ -103,6 +103,7 @@ export function withRetry(provider: DatabaseProvider, options?: ExecutionStrateg
103
103
  afterCommit: provider.afterCommit ? (callback) => provider.afterCommit!(callback) : undefined,
104
104
  afterRollback: provider.afterRollback ? (callback) => provider.afterRollback!(callback) : undefined,
105
105
  ping: (signal) => provider.ping(signal),
106
+ probe: provider.probe ? (signal) => provider.probe!(signal) : undefined,
106
107
  introspect: () => provider.introspect(),
107
108
  schemaAdmissionCapability: provider.schemaAdmissionCapability,
108
109
  withMigrationLock: provider.withMigrationLock ? (work) => provider.withMigrationLock!(work) : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.10",
3
+ "version": "0.98.12",
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",