bazis 0.98.31 → 0.98.33
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/background/BackgroundService.ts +16 -3
- package/core/background/decorator.ts +2 -1
- package/core/background/index.ts +0 -1
- package/core/di/extensions/hosted-service.ts +20 -0
- package/core/di/extensions/index.ts +1 -1
- package/core/di/module/collectModuleControllers.ts +4 -12
- package/core/di/module/createContainer.ts +8 -1
- package/core/grpc/Grpc.module.ts +1 -2
- package/core/grpc/GrpcCall.ts +51 -2
- package/core/grpc/GrpcClient.ts +11 -5
- package/core/grpc/GrpcClientCall.ts +4 -0
- package/core/grpc/GrpcServer.ts +9 -3
- package/core/grpc/GrpcTransport.ts +2 -2
- package/core/grpc/contracts.ts +11 -4
- package/core/grpc/moduleControllers.ts +2 -2
- package/core/http/Authorization/bearerToken.ts +2 -2
- package/core/http/Authorization/metadata.ts +17 -2
- package/core/http/Decorators/controller.ts +1 -1
- package/core/http/Errors/upstreamFailure.ts +22 -0
- package/core/http/HttpServer.ts +4 -4
- package/core/http/Middleware/errorHandler.ts +3 -0
- package/core/http/httpModule.ts +5 -8
- package/core/http/index.ts +2 -0
- package/core/http/options.ts +5 -5
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/health/HealthService.ts +3 -2
- package/package.json +1 -1
- package/core/background/backgroundModule.ts +0 -42
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HostedService, HostedServiceDiagnostics } from "../di";
|
|
1
|
+
import type { HostedService, HostedServiceDiagnostics, HostedServiceHealth } from "../di";
|
|
2
2
|
import { backgroundOptionsOf } from "./decorator";
|
|
3
3
|
import { delay } from "./delay";
|
|
4
4
|
import { redactSensitive } from "../../library/redaction";
|
|
@@ -56,6 +56,8 @@ export abstract class BackgroundService implements HostedService {
|
|
|
56
56
|
|
|
57
57
|
/** The application logger, passed by the kernel; console until then. */
|
|
58
58
|
private diagnostics?: HostedServiceDiagnostics;
|
|
59
|
+
/** Set when the service stopped for good after a crash: `/health` reports it. */
|
|
60
|
+
private terminated?: string;
|
|
59
61
|
|
|
60
62
|
public constructor(options?: BackgroundServiceOptions) {
|
|
61
63
|
// Explicit `super(...)` options win over `@Background({...})` metadata.
|
|
@@ -68,6 +70,15 @@ export abstract class BackgroundService implements HostedService {
|
|
|
68
70
|
/** The background workload. Must observe `signal` for cooperative shutdown. */
|
|
69
71
|
protected abstract execute(signal: AbortSignal): void | Promise<void>;
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Unhealthy once the service stopped for good: it crashed without a restart
|
|
75
|
+
* policy, or used up its restarts. A pause before a restart, a tick failure
|
|
76
|
+
* of a periodic service and a normal end of `execute` stay healthy.
|
|
77
|
+
*/
|
|
78
|
+
public checkHealth(): HostedServiceHealth {
|
|
79
|
+
return this.terminated === undefined ? { healthy: true } : { healthy: false, details: this.terminated };
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
public useDiagnostics(diagnostics: HostedServiceDiagnostics): void {
|
|
72
83
|
this.diagnostics = diagnostics;
|
|
73
84
|
}
|
|
@@ -119,9 +130,11 @@ export abstract class BackgroundService implements HostedService {
|
|
|
119
130
|
this.reportError(error, restarts);
|
|
120
131
|
if (restarts >= maxRestarts) {
|
|
121
132
|
// Without this line the service would just go quiet.
|
|
122
|
-
|
|
133
|
+
const stopped = maxRestarts === 0
|
|
123
134
|
? "stopped after a crash and will not run again (no restart policy)"
|
|
124
|
-
: `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again
|
|
135
|
+
: `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again`;
|
|
136
|
+
report(this.diagnostics, "error", this.constructor.name, stopped, { restarts });
|
|
137
|
+
this.terminated = `${stopped}: ${error instanceof Error ? error.message : String(error)}`;
|
|
125
138
|
return;
|
|
126
139
|
}
|
|
127
140
|
restarts += 1;
|
|
@@ -26,7 +26,8 @@ interface BackgroundMetadataCarrier {
|
|
|
26
26
|
* ```
|
|
27
27
|
*
|
|
28
28
|
* Options passed explicitly to `super(...)` take precedence over the decorator.
|
|
29
|
-
* Registration stays explicit — list the class in `
|
|
29
|
+
* Registration stays explicit — list the class in `background` of its module:
|
|
30
|
+
* `@Module({ background: [Heartbeat] })`.
|
|
30
31
|
*/
|
|
31
32
|
export function Background(options: BackgroundDecoratorOptions = {}) {
|
|
32
33
|
return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
|
package/core/background/index.ts
CHANGED
|
@@ -10,6 +10,5 @@ export {
|
|
|
10
10
|
type BackgroundServiceOptions,
|
|
11
11
|
type PeriodicBackgroundServiceOptions,
|
|
12
12
|
} from "./BackgroundService";
|
|
13
|
-
export { backgroundModule, type BackgroundModuleConfig } from "./backgroundModule";
|
|
14
13
|
export { Background, backgroundOptionsOf, type BackgroundDecoratorOptions } from "./decorator";
|
|
15
14
|
export { delay } from "./delay";
|
|
@@ -22,7 +22,18 @@ export interface HostedServiceDiagnostics {
|
|
|
22
22
|
warn(message: string, fields?: Readonly<Record<string, unknown>>): void;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** What a hosted service reports to `/health`. */
|
|
26
|
+
export interface HostedServiceHealth {
|
|
27
|
+
readonly healthy: boolean;
|
|
28
|
+
readonly details?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export interface HostedService {
|
|
32
|
+
/**
|
|
33
|
+
* The service's own state for `/health`. A `background` service of a module
|
|
34
|
+
* is reported as `background:<Class>`; without this method it is healthy.
|
|
35
|
+
*/
|
|
36
|
+
checkHealth?(): HostedServiceHealth;
|
|
26
37
|
/** Shared validators see real service identities, including inside supervised wrappers. */
|
|
27
38
|
readonly planValidator?: HostedServicePlanValidator;
|
|
28
39
|
/** Receives the application logger before start, when one is registered. */
|
|
@@ -39,6 +50,15 @@ export interface HostedService {
|
|
|
39
50
|
|
|
40
51
|
export const HOSTED_SERVICE = createToken<HostedService>("IHostedService");
|
|
41
52
|
|
|
53
|
+
/** A health check contributed by a hosted service; the kernel reports it next to its own checks. */
|
|
54
|
+
export interface HostedServiceHealthCheck {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
check(): HostedServiceHealth;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Enumerable: one entry per `background` service of the module graph. */
|
|
60
|
+
export const HOSTED_SERVICE_HEALTH = createToken<HostedServiceHealthCheck>("BazisHostedServiceHealth");
|
|
61
|
+
|
|
42
62
|
interface HostedServiceResolver {
|
|
43
63
|
resolveAll(token: typeof HOSTED_SERVICE): readonly HostedService[];
|
|
44
64
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createInstance, type ActivatorDependency } from "./activator";
|
|
2
2
|
export { Application, runApplication, type RunApplicationOptions } from "./application";
|
|
3
|
-
export { addHostedService, HOSTED_SERVICE, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServicePlanValidator } from "./hosted-service";
|
|
3
|
+
export { addHostedService, HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServiceHealth, type HostedServiceHealthCheck, type HostedServicePlanValidator } from "./hosted-service";
|
|
4
4
|
export { SERVICE_PROVIDER } from "./service-provider-token";
|
|
5
5
|
export {
|
|
6
6
|
addOptions,
|
|
@@ -8,11 +8,7 @@ type ClassListKey = "controllers" | "gateways";
|
|
|
8
8
|
* the module tree (the module itself plus `imports`, recursively). Duplicates by
|
|
9
9
|
* class reference are dropped.
|
|
10
10
|
*/
|
|
11
|
-
function collectModuleClasses(
|
|
12
|
-
roots: readonly BazisModuleRef[],
|
|
13
|
-
key: ClassListKey,
|
|
14
|
-
extra?: readonly Class<object>[],
|
|
15
|
-
): Class<object>[] {
|
|
11
|
+
function collectModuleClasses(roots: readonly BazisModuleRef[], key: ClassListKey): Class<object>[] {
|
|
16
12
|
const seen = new Set<Class<object>>();
|
|
17
13
|
const result: Class<object>[] = [];
|
|
18
14
|
const add = (list: readonly Class<object>[] | undefined): void => {
|
|
@@ -44,16 +40,12 @@ function collectModuleClasses(
|
|
|
44
40
|
for (let index = 0; index < roots.length; index += 1) {
|
|
45
41
|
visit(roots[index] as BazisModuleRef, new Set());
|
|
46
42
|
}
|
|
47
|
-
add(extra);
|
|
48
43
|
return result;
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
/** Controller classes of the module tree
|
|
52
|
-
export function collectModuleControllers(
|
|
53
|
-
roots
|
|
54
|
-
extraControllers?: readonly Class<object>[],
|
|
55
|
-
): Class<object>[] {
|
|
56
|
-
return collectModuleClasses(roots, "controllers", extraControllers);
|
|
46
|
+
/** Controller classes (`controllers` of `@Module`) of the module tree. */
|
|
47
|
+
export function collectModuleControllers(roots: readonly BazisModuleRef[]): Class<object>[] {
|
|
48
|
+
return collectModuleClasses(roots, "controllers");
|
|
57
49
|
}
|
|
58
50
|
|
|
59
51
|
/** WebSocket gateway classes (`gateways` of `@Module`) of the module tree. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DiContainer } from "../container";
|
|
2
2
|
import { ServiceCollection } from "../ServiceCollection";
|
|
3
|
-
import { HOSTED_SERVICE, type HostedService } from "../extensions/hosted-service";
|
|
3
|
+
import { HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, type HostedService } from "../extensions/hosted-service";
|
|
4
4
|
import { SERVICE_PROVIDER, SERVICE_PROVIDER_BY_TYPE } from "../extensions/service-provider-token";
|
|
5
5
|
import { isClassProvider, ProviderDefinition, type Provider } from "../provider";
|
|
6
6
|
import { getClassDeps } from "../internal/classDeps";
|
|
@@ -162,6 +162,13 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
|
|
|
162
162
|
collection.add(
|
|
163
163
|
DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass))),
|
|
164
164
|
);
|
|
165
|
+
// `/health` sees a service that stopped for good, not only the process.
|
|
166
|
+
collection.add(
|
|
167
|
+
DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE_HEALTH, [], (resolver) => {
|
|
168
|
+
const service = resolver.resolve(ServiceClass);
|
|
169
|
+
return { name: `background:${ServiceClass.name}`, check: () => service.checkHealth?.() ?? { healthy: true } };
|
|
170
|
+
})),
|
|
171
|
+
);
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
|
package/core/grpc/Grpc.module.ts
CHANGED
|
@@ -5,10 +5,9 @@ import { collectGrpcControllers, GRPC_CONTROLLERS } from "./moduleControllers";
|
|
|
5
5
|
|
|
6
6
|
/** gRPC counterpart of httpModule: controllers belong to their feature modules. */
|
|
7
7
|
export function grpcModule(options: GrpcModuleOptions = {}): BazisModule {
|
|
8
|
-
const selected = collectGrpcControllers(options.imports ?? []
|
|
8
|
+
const selected = collectGrpcControllers(options.imports ?? []);
|
|
9
9
|
@Module({
|
|
10
10
|
imports: options.imports,
|
|
11
|
-
grpcControllers: options.controllers,
|
|
12
11
|
providers: [DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => {
|
|
13
12
|
const container = resolver.resolve(SERVICE_PROVIDER);
|
|
14
13
|
if (!(container instanceof DiContainer)) throw new TypeError("gRPC requires a module DI container.");
|
package/core/grpc/GrpcCall.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ModuleOwnedProviderActivation, ServiceProvider } from "../di";
|
|
2
2
|
import type { GrpcContext } from "./contracts";
|
|
3
|
+
import { GrpcError } from "./GrpcError";
|
|
4
|
+
import type { AuthorizeCheck } from "../http/Authorization/metadata";
|
|
5
|
+
import { ForbiddenError, UnauthorizedError } from "../http/Errors/HttpError";
|
|
6
|
+
import { JwtError } from "../../library/jwt/errors";
|
|
7
|
+
import { redactSensitive } from "../../library/redaction";
|
|
8
|
+
import type { Logger } from "../kernel/logging/Logger";
|
|
3
9
|
import type { MethodDefinition } from "./serviceDefinition";
|
|
4
10
|
import { GrpcTransport } from "./GrpcTransport";
|
|
5
11
|
import { GrpcStatus } from "./GrpcStatus";
|
|
@@ -14,13 +20,25 @@ export class GrpcCall {
|
|
|
14
20
|
private readonly activation: ModuleOwnedProviderActivation<object>,
|
|
15
21
|
private readonly provider: ServiceProvider,
|
|
16
22
|
private readonly binding?: GrpcRequestBinding,
|
|
23
|
+
/** `@Authorize` checks of the controller and the method, in order. */
|
|
24
|
+
private readonly checks: readonly AuthorizeCheck[] = [],
|
|
25
|
+
private readonly logger?: Logger,
|
|
17
26
|
) {}
|
|
18
27
|
|
|
19
28
|
cancel(): void { this.transport.cancel(GrpcStatus.UNAVAILABLE, "Server stopped."); }
|
|
20
29
|
|
|
21
30
|
async run(): Promise<void> {
|
|
22
31
|
const scope = this.provider.createScope();
|
|
23
|
-
const
|
|
32
|
+
const info = this.transport.context;
|
|
33
|
+
const context: GrpcContext = Object.freeze({
|
|
34
|
+
...info,
|
|
35
|
+
services: scope,
|
|
36
|
+
state: new Map<string, unknown>(),
|
|
37
|
+
header: (name: string) => {
|
|
38
|
+
const value = info.metadata.get(name)[0];
|
|
39
|
+
return typeof value === "string" ? value : undefined;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
24
42
|
let result: unknown;
|
|
25
43
|
let failure: unknown;
|
|
26
44
|
let failed = false;
|
|
@@ -35,6 +53,9 @@ export class GrpcCall {
|
|
|
35
53
|
for await (const value of requests) yield bind(value);
|
|
36
54
|
}
|
|
37
55
|
try {
|
|
56
|
+
context.signal.throwIfAborted();
|
|
57
|
+
// Like HTTP: access is decided before the request is read.
|
|
58
|
+
await authorize(this.checks, context);
|
|
38
59
|
context.signal.throwIfAborted();
|
|
39
60
|
const input = this.definition.requestStream
|
|
40
61
|
? (this.binding ? validatedRequests() : requests)
|
|
@@ -62,7 +83,14 @@ export class GrpcCall {
|
|
|
62
83
|
// Do not dispose dependencies while non-cooperative handler code is running.
|
|
63
84
|
try { await scope.dispose(); } catch (error) { failure = error; failed = true; }
|
|
64
85
|
}
|
|
65
|
-
if (failed)
|
|
86
|
+
if (failed) {
|
|
87
|
+
// An intentional GrpcError and a cancelled call are not server failures;
|
|
88
|
+
// anything else is a bug and belongs in the log, like an HTTP 500.
|
|
89
|
+
if (!(failure instanceof GrpcError) && !context.signal.aborted) {
|
|
90
|
+
this.logger?.error("gRPC handler failed", { path: this.definition.path, error: redactSensitive(failure) });
|
|
91
|
+
}
|
|
92
|
+
this.transport.fail(failure);
|
|
93
|
+
}
|
|
66
94
|
else {
|
|
67
95
|
try {
|
|
68
96
|
if (!this.definition.responseStream) await this.transport.write(result);
|
|
@@ -71,3 +99,24 @@ export class GrpcCall {
|
|
|
71
99
|
}
|
|
72
100
|
}
|
|
73
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Runs the `@Authorize` checks: `false` and `ForbiddenError` are
|
|
105
|
+
* PERMISSION_DENIED, `UnauthorizedError` and `JwtError` (missing, malformed,
|
|
106
|
+
* expired token) are UNAUTHENTICATED. Any other error propagates as a bug.
|
|
107
|
+
*/
|
|
108
|
+
async function authorize(checks: readonly AuthorizeCheck[], context: GrpcContext): Promise<void> {
|
|
109
|
+
for (const check of checks) {
|
|
110
|
+
let allowed: boolean;
|
|
111
|
+
try {
|
|
112
|
+
allowed = await check(context);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof UnauthorizedError || error instanceof JwtError) {
|
|
115
|
+
throw new GrpcError(GrpcStatus.UNAUTHENTICATED, "Unauthenticated.");
|
|
116
|
+
}
|
|
117
|
+
if (error instanceof ForbiddenError) throw new GrpcError(GrpcStatus.PERMISSION_DENIED, "Permission denied.");
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (!allowed) throw new GrpcError(GrpcStatus.PERMISSION_DENIED, "Permission denied.");
|
|
121
|
+
}
|
|
122
|
+
}
|
package/core/grpc/GrpcClient.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { connect, type ClientHttp2Session, type SecureClientSessionOptions } fro
|
|
|
2
2
|
import type { GrpcCallOptions, GrpcClientOptions, GrpcResponse, GrpcResponseStream } from "./clientContracts";
|
|
3
3
|
import { GrpcClientCall } from "./GrpcClientCall";
|
|
4
4
|
import { GrpcError } from "./GrpcError";
|
|
5
|
+
import { markUpstreamFailure } from "../http/Errors/upstreamFailure";
|
|
5
6
|
import { GrpcStatus } from "./GrpcStatus";
|
|
6
7
|
import { Metadata } from "./Metadata";
|
|
7
8
|
import type { MethodDefinition, ServiceDefinition } from "./serviceDefinition";
|
|
@@ -114,10 +115,10 @@ export class GrpcClient {
|
|
|
114
115
|
deadline = Math.min(deadline, explicit);
|
|
115
116
|
}
|
|
116
117
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) throw new TypeError("Invalid gRPC signal.");
|
|
117
|
-
if (options.signal?.aborted) throw new GrpcError(GrpcStatus.CANCELLED, "RPC cancelled.");
|
|
118
|
-
if (deadline <= Date.now()) throw new GrpcError(GrpcStatus.DEADLINE_EXCEEDED, "Deadline exceeded.");
|
|
119
|
-
if (this.closed) throw new GrpcError(GrpcStatus.CANCELLED, "gRPC client is closed.");
|
|
120
|
-
if (this.calls.size >= this.callLimit) throw new GrpcError(GrpcStatus.RESOURCE_EXHAUSTED, "Too many concurrent gRPC calls.");
|
|
118
|
+
if (options.signal?.aborted) throw upstream(new GrpcError(GrpcStatus.CANCELLED, "RPC cancelled."));
|
|
119
|
+
if (deadline <= Date.now()) throw upstream(new GrpcError(GrpcStatus.DEADLINE_EXCEEDED, "Deadline exceeded."));
|
|
120
|
+
if (this.closed) throw upstream(new GrpcError(GrpcStatus.CANCELLED, "gRPC client is closed."));
|
|
121
|
+
if (this.calls.size >= this.callLimit) throw upstream(new GrpcError(GrpcStatus.RESOURCE_EXHAUSTED, "Too many concurrent gRPC calls."));
|
|
121
122
|
let session: ClientHttp2Session, stream: ReturnType<ClientHttp2Session["request"]>;
|
|
122
123
|
try {
|
|
123
124
|
session = this.session();
|
|
@@ -125,7 +126,7 @@ export class GrpcClient {
|
|
|
125
126
|
const wireTimeout = remaining <= 99_999_999 ? `${remaining}m` : `${Math.ceil(remaining / 1000)}S`;
|
|
126
127
|
stream = session.request({ ...metadata.toHttp2Headers(), ":method": "POST", ":path": method.path,
|
|
127
128
|
"content-type": "application/grpc+proto", te: "trailers", "grpc-accept-encoding": "identity", "grpc-timeout": wireTimeout }, { endStream: false });
|
|
128
|
-
} catch { throw new GrpcError(GrpcStatus.UNAVAILABLE, "Unable to open gRPC connection."); }
|
|
129
|
+
} catch { throw upstream(new GrpcError(GrpcStatus.UNAVAILABLE, "Unable to open gRPC connection.")); }
|
|
129
130
|
const call = new GrpcClientCall<T>(stream, method, { deadline, signal: options.signal, sendLimit: this.sendLimit, receiveLimit: this.receiveLimit }, () => {
|
|
130
131
|
this.calls.delete(call);
|
|
131
132
|
this.sessions.get(session)?.delete(call);
|
|
@@ -170,3 +171,8 @@ function metadataCopy(value: Metadata | undefined): Metadata {
|
|
|
170
171
|
if (!(value instanceof Metadata)) throw new TypeError("Expected Bazis Metadata.");
|
|
171
172
|
return value.clone();
|
|
172
173
|
}
|
|
174
|
+
|
|
175
|
+
/** A failed call of this client is a failure of another service for an HTTP request: 504 or 502. */
|
|
176
|
+
function upstream(error: GrpcError): GrpcError {
|
|
177
|
+
return markUpstreamFailure(error, error.code === GrpcStatus.DEADLINE_EXCEEDED ? "timeout" : "failure");
|
|
178
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { constants, type Http2Session, type ClientHttp2Stream, type IncomingHttpHeaders } from "node:http2";
|
|
2
|
+
import { markUpstreamFailure } from "../http/Errors/upstreamFailure";
|
|
2
3
|
import type { GrpcResponseStream } from "./clientContracts";
|
|
3
4
|
import { GrpcError } from "./GrpcError";
|
|
4
5
|
import { GrpcStatus } from "./GrpcStatus";
|
|
@@ -83,6 +84,9 @@ export class GrpcClientCall<T> implements GrpcResponseStream<T> {
|
|
|
83
84
|
if (!this.terminalHeaders) this.fail(new GrpcError(GrpcStatus.UNAVAILABLE, "gRPC connection closed before completion."));
|
|
84
85
|
}
|
|
85
86
|
fail(error: GrpcError): void {
|
|
87
|
+
// In an HTTP request this is a failure of another service: 504 or 502.
|
|
88
|
+
// Marked even after finish: the same error is rethrown to the caller.
|
|
89
|
+
markUpstreamFailure(error, error.code === GrpcStatus.DEADLINE_EXCEEDED ? "timeout" : "failure");
|
|
86
90
|
if (this.finished) return;
|
|
87
91
|
this.failure = error;
|
|
88
92
|
this.abort.abort(error);
|
package/core/grpc/GrpcServer.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { MethodDefinition } from "./serviceDefinition";
|
|
|
11
11
|
import { modelValidatorAdapter } from "../../library/validation";
|
|
12
12
|
import { getGrpcRequestBinding } from "./GrpcBinding.contract";
|
|
13
13
|
import { GrpcRequestBinding } from "./GrpcRequest.binding";
|
|
14
|
+
import { resolveAuthorizeMeta, type AuthorizeCheck } from "../http/Authorization/metadata";
|
|
15
|
+
import { LOGGER, type Logger } from "../kernel/logging/Logger";
|
|
14
16
|
|
|
15
17
|
type Contribution = ModuleOwnedProviderContribution<GrpcControllerDefinition, object>;
|
|
16
18
|
|
|
@@ -24,7 +26,8 @@ export class GrpcServer implements HostedService {
|
|
|
24
26
|
private readonly sendLimit: number;
|
|
25
27
|
private readonly maxConcurrentCalls: number;
|
|
26
28
|
private readonly shutdownTimeoutMs: number;
|
|
27
|
-
private readonly routes = new Map<string, { entry: Contribution; method: MethodDefinition; name: string | symbol; binding?: GrpcRequestBinding }>();
|
|
29
|
+
private readonly routes = new Map<string, { entry: Contribution; method: MethodDefinition; name: string | symbol; binding?: GrpcRequestBinding; checks: readonly AuthorizeCheck[] }>();
|
|
30
|
+
private readonly logger?: Logger;
|
|
28
31
|
private readonly sessions = new Set<ServerHttp2Session>();
|
|
29
32
|
private readonly active = new Map<GrpcCall, Promise<void>>();
|
|
30
33
|
private state: "idle" | "starting" | "running" | "stopping" | "stopped" = "idle";
|
|
@@ -34,6 +37,7 @@ export class GrpcServer implements HostedService {
|
|
|
34
37
|
private boundPort?: number;
|
|
35
38
|
|
|
36
39
|
constructor(options: GrpcModuleOptions, private readonly provider: ServiceProvider, controllers: readonly Contribution[]) {
|
|
40
|
+
this.logger = provider.tryResolve(LOGGER);
|
|
37
41
|
const address = options.address ?? "127.0.0.1:50051";
|
|
38
42
|
const match = typeof address === "string" && /^(?:\[([^\]]+)\]|([^:[\]\s]+)):(\d{1,5})$/.exec(address);
|
|
39
43
|
if (!match || Number(match[3]) > 65535 || options.address === null) throw new TypeError("gRPC address must be host:port or [IPv6]:port.");
|
|
@@ -68,7 +72,9 @@ export class GrpcServer implements HostedService {
|
|
|
68
72
|
throw new TypeError(`gRPC request stream/DTO mismatch: ${method.path}.`);
|
|
69
73
|
}
|
|
70
74
|
const model = explicit ?? generated?.model;
|
|
71
|
-
|
|
75
|
+
const access = resolveAuthorizeMeta(entry.payload.controller, name);
|
|
76
|
+
const checks = access.allowAnonymous ? [] : access.authorize?.checks ?? [];
|
|
77
|
+
this.routes.set(method.path, { entry, method, name, binding: model ? new GrpcRequestBinding(model, validator) : undefined, checks });
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
80
|
const tls = credentials.http2Options();
|
|
@@ -113,7 +119,7 @@ export class GrpcServer implements HostedService {
|
|
|
113
119
|
reject(error instanceof GrpcError ? error.code : GrpcStatus.INVALID_ARGUMENT, error instanceof GrpcError ? error.message : "Invalid gRPC headers.");
|
|
114
120
|
return;
|
|
115
121
|
}
|
|
116
|
-
const execution = new GrpcCall(transport, route.method, route.name, route.entry.activation, this.provider, route.binding);
|
|
122
|
+
const execution = new GrpcCall(transport, route.method, route.name, route.entry.activation, this.provider, route.binding, route.checks, this.logger);
|
|
117
123
|
const pending = execution.run().catch((error: unknown) => transport.fail(error))
|
|
118
124
|
.finally(() => this.active.delete(execution));
|
|
119
125
|
this.active.set(execution, pending);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IncomingHttpHeaders, ServerHttp2Stream } from "node:http2";
|
|
2
|
-
import type {
|
|
2
|
+
import type { GrpcCallInfo } from "./contracts";
|
|
3
3
|
import { Metadata } from "./Metadata";
|
|
4
4
|
import { GrpcError } from "./GrpcError";
|
|
5
5
|
import { GrpcStatus } from "./GrpcStatus";
|
|
@@ -7,7 +7,7 @@ import type { MethodDefinition } from "./serviceDefinition";
|
|
|
7
7
|
|
|
8
8
|
/** Owns the standard five-byte gRPC envelope and one built-in HTTP/2 stream. */
|
|
9
9
|
export class GrpcTransport {
|
|
10
|
-
readonly context:
|
|
10
|
+
readonly context: GrpcCallInfo;
|
|
11
11
|
private readonly abort = new AbortController();
|
|
12
12
|
private sentHeaders = false;
|
|
13
13
|
private ended = false;
|
package/core/grpc/contracts.ts
CHANGED
|
@@ -4,9 +4,10 @@ import type { ServerOptions, ServiceDefinition } from "./serviceDefinition";
|
|
|
4
4
|
import type { Class, BazisModuleRef } from "../di";
|
|
5
5
|
import type { ModelValidator } from "../http/Binding/modelValidator";
|
|
6
6
|
import type { RequestModelClass } from "../http/Binding/requestModelRegistry";
|
|
7
|
+
import type { AuthorizeContext } from "../http/Authorization/metadata";
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
-
export interface
|
|
9
|
+
/** Transport facts of one RPC, before its DI scope exists. */
|
|
10
|
+
export interface GrpcCallInfo {
|
|
10
11
|
readonly metadata: Metadata;
|
|
11
12
|
readonly signal: AbortSignal;
|
|
12
13
|
readonly deadline: Date | number;
|
|
@@ -15,10 +16,16 @@ export interface GrpcContext {
|
|
|
15
16
|
sendMetadata(metadata: Metadata): void;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* One instance per RPC, including the complete lifetime of a streaming RPC.
|
|
21
|
+
* It is also what `@Authorize` checks see: `header(name)` reads text metadata,
|
|
22
|
+
* `services` is the RPC scope, `state` is shared with the handler.
|
|
23
|
+
*/
|
|
24
|
+
export interface GrpcContext extends GrpcCallInfo, AuthorizeContext {}
|
|
25
|
+
|
|
18
26
|
export interface GrpcModuleOptions {
|
|
27
|
+
/** Modules whose `grpcControllers` are served (the whole tree). */
|
|
19
28
|
readonly imports?: readonly BazisModuleRef[];
|
|
20
|
-
/** Normally contributed by feature-module grpcControllers. */
|
|
21
|
-
readonly controllers?: readonly Class<object>[];
|
|
22
29
|
/** DTO validation, captured per server. Default: Bazis's modelValidatorAdapter. */
|
|
23
30
|
readonly validator?: ModelValidator;
|
|
24
31
|
/** host:port or [IPv6]:port, default 127.0.0.1:50051. Port 0 selects a free port. */
|
|
@@ -24,8 +24,8 @@ registerModuleOwnedProviderContributor((metadata, context) => {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
/** @internal Discover only the feature tree published by this transport. */
|
|
27
|
-
export function collectGrpcControllers(roots: readonly BazisModuleRef[]
|
|
28
|
-
const result = new Set(
|
|
27
|
+
export function collectGrpcControllers(roots: readonly BazisModuleRef[]): Set<Class<object>> {
|
|
28
|
+
const result = new Set<Class<object>>();
|
|
29
29
|
const visited = new Set<BazisModuleRef>();
|
|
30
30
|
const visit = (module: BazisModuleRef): void => {
|
|
31
31
|
if (visited.has(module)) return;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AuthorizeContext } from "./metadata";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The token of an `Authorization: Bearer <token>` header, or `undefined`
|
|
5
5
|
* without the header, with another scheme or with an empty token. The scheme
|
|
6
6
|
* is case-insensitive (RFC 7235); surrounding spaces are trimmed.
|
|
7
7
|
*/
|
|
8
|
-
export function bearerToken(ctx: Pick<
|
|
8
|
+
export function bearerToken(ctx: Pick<AuthorizeContext, "header">): string | undefined {
|
|
9
9
|
const match = /^\s*bearer\s+(\S+)\s*$/i.exec(ctx.header("authorization") ?? "");
|
|
10
10
|
return match?.[1];
|
|
11
11
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ServiceScope } from "../../di";
|
|
2
2
|
|
|
3
3
|
// Same one-line polyfill as the rest of the HTTP module: Bun executes TC39
|
|
4
4
|
// decorators natively, but Symbol.metadata may be missing in the runtime.
|
|
@@ -6,6 +6,19 @@ import type { HttpContext } from "../HttpContext/HttpContext";
|
|
|
6
6
|
|
|
7
7
|
const AUTHORIZE_META = Symbol.for("bazis:http:authorize");
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* What an access check sees. An HTTP request (`HttpContext`) and a gRPC call
|
|
11
|
+
* (`GrpcContext`) both provide it, so one check protects both transports.
|
|
12
|
+
*/
|
|
13
|
+
export interface AuthorizeContext {
|
|
14
|
+
/** A request header (HTTP) or a text metadata value (gRPC), by case-insensitive name. */
|
|
15
|
+
header(name: string): string | undefined;
|
|
16
|
+
/** The DI scope of this request or call. */
|
|
17
|
+
readonly services: ServiceScope;
|
|
18
|
+
/** Values shared by the checks and the handler of this request or call. */
|
|
19
|
+
readonly state: Map<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
/**
|
|
10
23
|
* Access check for `@Authorize`. Receives the request context and decides
|
|
11
24
|
* whether to let it through. The HTTP kernel does not know *what* is checked
|
|
@@ -18,8 +31,10 @@ const AUTHORIZE_META = Symbol.for("bazis:http:authorize");
|
|
|
18
31
|
* (for example `UnauthorizedError` → `401` when there are no credentials at all).
|
|
19
32
|
*
|
|
20
33
|
* Dependencies (services, DB) come from `ctx.services` (the request scope).
|
|
34
|
+
* On a gRPC call `false`/`ForbiddenError` is PERMISSION_DENIED and
|
|
35
|
+
* `UnauthorizedError`/`JwtError` is UNAUTHENTICATED.
|
|
21
36
|
*/
|
|
22
|
-
export type AuthorizeCheck = (ctx:
|
|
37
|
+
export type AuthorizeCheck = (ctx: AuthorizeContext) => boolean | Promise<boolean>;
|
|
23
38
|
|
|
24
39
|
/** Authorization requirement: one or more checks (all must pass). */
|
|
25
40
|
export interface AuthorizeOptions {
|
|
@@ -12,7 +12,7 @@ import { ownMeta } from "./metadata";
|
|
|
12
12
|
* ```
|
|
13
13
|
*
|
|
14
14
|
* The controller is a regular DI service: register it in a module
|
|
15
|
-
* (`controllers: [...]` on `BazisModule
|
|
15
|
+
* (`controllers: [...]` on `BazisModule`)
|
|
16
16
|
* with scoped lifetime and declare constructor dependencies as usual — auto deps work.
|
|
17
17
|
*/
|
|
18
18
|
export function Controller(prefix = "") {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marks an error of a call to another service. The HTTP error boundary answers
|
|
3
|
+
* `504 Gateway Timeout` for a timeout and `502 Bad Gateway` otherwise, like it
|
|
4
|
+
* does for `HttpClientError`. Clients of other protocols (gRPC) set the mark,
|
|
5
|
+
* so the HTTP layer does not depend on them.
|
|
6
|
+
*/
|
|
7
|
+
export type UpstreamFailure = "timeout" | "failure";
|
|
8
|
+
|
|
9
|
+
const UPSTREAM_FAILURE = Symbol.for("bazis.http.upstreamFailure");
|
|
10
|
+
|
|
11
|
+
/** Sets the mark on `error` (non-enumerable) and returns it. */
|
|
12
|
+
export function markUpstreamFailure<E extends object>(error: E, kind: UpstreamFailure): E {
|
|
13
|
+
Object.defineProperty(error, UPSTREAM_FAILURE, { value: kind, configurable: true });
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The mark of an upstream failure, if any. */
|
|
18
|
+
export function upstreamFailure(error: unknown): UpstreamFailure | undefined {
|
|
19
|
+
if (error === null || typeof error !== "object") return undefined;
|
|
20
|
+
const kind = (error as { [UPSTREAM_FAILURE]?: unknown })[UPSTREAM_FAILURE];
|
|
21
|
+
return kind === "timeout" || kind === "failure" ? kind : undefined;
|
|
22
|
+
}
|
package/core/http/HttpServer.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { Router } from "./Routing/Router";
|
|
|
19
19
|
import { parseRequestPath } from "./Routing/template";
|
|
20
20
|
import { assertVersioningOptions, createVersionReader, type VersionReader } from "./Versioning/VersionReader";
|
|
21
21
|
import { HTTP_ERROR_HOOK, ROUTE_MIDDLEWARE_COMPOSER, SERVER_MIDDLEWARE, sortByOrder } from "./middlewareTokens";
|
|
22
|
-
import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions } from "./options";
|
|
22
|
+
import type { HealthEndpointOptions, RouteMiddlewareComposer, HttpModuleOptions, HttpServerOptions } from "./options";
|
|
23
23
|
import { WEBSOCKET_UPGRADE, type WebSocketUpgrade } from "./WebSocketUpgrade";
|
|
24
24
|
import { HttpSetupError } from "./Errors/HttpError";
|
|
25
25
|
import { getModelValidator, type ModelValidator } from "./Binding/modelValidator";
|
|
@@ -96,7 +96,7 @@ export class HttpServer implements HostedService {
|
|
|
96
96
|
private docs?: PreparedOpenApiDocs;
|
|
97
97
|
|
|
98
98
|
constructor(
|
|
99
|
-
private readonly options:
|
|
99
|
+
private readonly options: HttpServerOptions,
|
|
100
100
|
/** Root container: the per-request scope factory. */
|
|
101
101
|
private readonly provider: ServiceProvider,
|
|
102
102
|
/** Root resolver for optional kernel services (Environment). */
|
|
@@ -181,11 +181,11 @@ export class HttpServer implements HostedService {
|
|
|
181
181
|
this.options.versioning,
|
|
182
182
|
this.collectRouteComposer(),
|
|
183
183
|
);
|
|
184
|
-
this.router = builder.build(this.options.controllers
|
|
184
|
+
this.router = builder.build(this.options.controllers);
|
|
185
185
|
this.readVersion = createVersionReader(this.options.versioning);
|
|
186
186
|
this.docs = prepareOpenApiDocs({
|
|
187
187
|
option: this.options.docs,
|
|
188
|
-
controllers: this.options.controllers
|
|
188
|
+
controllers: this.options.controllers,
|
|
189
189
|
globalPrefix: this.options.prefix,
|
|
190
190
|
versioning: this.options.versioning,
|
|
191
191
|
environment: this.resolver.tryResolve(Environment),
|
|
@@ -3,6 +3,7 @@ import { redactSensitive, redactSensitiveText } from "../../../library/redaction
|
|
|
3
3
|
import { HttpClientError, HttpErrorCode } from "../../../library/http-client/errors";
|
|
4
4
|
import type { HttpContext } from "../HttpContext/HttpContext";
|
|
5
5
|
import { HttpError } from "../Errors/HttpError";
|
|
6
|
+
import { upstreamFailure } from "../Errors/upstreamFailure";
|
|
6
7
|
import type { HttpMiddleware } from "./types";
|
|
7
8
|
|
|
8
9
|
export interface ErrorHandlerOptions {
|
|
@@ -162,6 +163,8 @@ function unexpectedStatus(error: unknown, mapper?: (error: unknown) => number |
|
|
|
162
163
|
if (error instanceof HttpClientError && error.code !== undefined) {
|
|
163
164
|
return error.code === HttpErrorCode.Timeout ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
|
|
164
165
|
}
|
|
166
|
+
const upstream = upstreamFailure(error);
|
|
167
|
+
if (upstream !== undefined) return upstream === "timeout" ? [504, "Gateway Timeout"] : [502, "Bad Gateway"];
|
|
165
168
|
let mapped: number | undefined;
|
|
166
169
|
try { mapped = mapper?.(error); } catch { mapped = undefined; }
|
|
167
170
|
if (mapped !== undefined && Number.isInteger(mapped) && mapped >= 400 && mapped <= 599) return [mapped, REASONS[mapped] ?? (mapped >= 500 ? "Server Error" : "Client Error")];
|
package/core/http/httpModule.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { DI, HOSTED_SERVICE, Module, SERVICE_PROVIDER, collectModuleControllers, type BazisModule, type ProviderDefinition } from "../di";
|
|
2
2
|
import { HttpServer } from "./HttpServer";
|
|
3
|
-
import type { HttpModuleOptions } from "./options";
|
|
3
|
+
import type { HttpModuleOptions, HttpServerOptions } from "./options";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Builds the HTTP module: discovers
|
|
7
|
-
* `
|
|
6
|
+
* Builds the HTTP module: discovers the `controllers` of the modules in
|
|
7
|
+
* `imports` and registers the HttpServer hosted service.
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
10
|
* @Module({
|
|
@@ -22,11 +22,9 @@ import type { HttpModuleOptions } from "./options";
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
export function httpModule(options: HttpModuleOptions): BazisModule {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const serverOptions: HttpModuleOptions = {
|
|
25
|
+
const serverOptions: HttpServerOptions = {
|
|
28
26
|
...options,
|
|
29
|
-
controllers:
|
|
27
|
+
controllers: collectModuleControllers(options.imports ?? []),
|
|
30
28
|
};
|
|
31
29
|
|
|
32
30
|
const providers: ProviderDefinition[] = [
|
|
@@ -40,7 +38,6 @@ export function httpModule(options: HttpModuleOptions): BazisModule {
|
|
|
40
38
|
@Module({
|
|
41
39
|
imports: options.imports,
|
|
42
40
|
providers,
|
|
43
|
-
controllers: options.controllers,
|
|
44
41
|
})
|
|
45
42
|
class HttpModule {}
|
|
46
43
|
|
package/core/http/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export { anyOf } from "./Authorization/anyOf";
|
|
|
39
39
|
export {
|
|
40
40
|
resolveAuthorizeMeta,
|
|
41
41
|
type AuthorizeCheck,
|
|
42
|
+
type AuthorizeContext,
|
|
42
43
|
type AuthorizeOptions,
|
|
43
44
|
type ResolvedAuthorizeMeta,
|
|
44
45
|
} from "./Authorization/metadata";
|
|
@@ -140,3 +141,4 @@ export {
|
|
|
140
141
|
UnauthorizedError,
|
|
141
142
|
UnsupportedMediaTypeError,
|
|
142
143
|
} from "./Errors/HttpError";
|
|
144
|
+
export { markUpstreamFailure, upstreamFailure, type UpstreamFailure } from "./Errors/upstreamFailure";
|
package/core/http/options.ts
CHANGED
|
@@ -56,11 +56,6 @@ export interface HttpModuleOptions {
|
|
|
56
56
|
* Authorized/CORS-allowed clients can then inspect each redirect Location.
|
|
57
57
|
*/
|
|
58
58
|
readonly inspectableRedirects?: boolean;
|
|
59
|
-
/**
|
|
60
|
-
* Controller classes (decorated with @Controller). Optional when controllers
|
|
61
|
-
* are declared on imported modules via `controllers` on `BazisModule`.
|
|
62
|
-
*/
|
|
63
|
-
readonly controllers?: readonly Class<object>[];
|
|
64
59
|
/** Request-model validator captured by this HTTP server instance. */
|
|
65
60
|
readonly validator?: ModelValidator;
|
|
66
61
|
/**
|
|
@@ -136,3 +131,8 @@ export interface HttpModuleOptions {
|
|
|
136
131
|
/** Hosted-service startup phase (default 10 — after infrastructure). */
|
|
137
132
|
readonly phase?: number;
|
|
138
133
|
}
|
|
134
|
+
|
|
135
|
+
/** @internal What `httpModule` hands to the server: the options plus the controllers it collected. */
|
|
136
|
+
export interface HttpServerOptions extends HttpModuleOptions {
|
|
137
|
+
readonly controllers: readonly Class<object>[];
|
|
138
|
+
}
|
|
@@ -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.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.98.33" }, 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");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { HOSTED_SERVICE_HEALTH, type ServiceResolver } from "../../di";
|
|
2
2
|
import { redactSensitiveText } from "../../../library/redaction";
|
|
3
3
|
import { awaitAbortable } from "../internal/awaitAbortable";
|
|
4
4
|
import { KernelError } from "../errors";
|
|
@@ -21,7 +21,8 @@ export class HealthService {
|
|
|
21
21
|
const timeoutMs = bounded(options.timeoutMs ?? 5000, "timeoutMs", 2147483647);
|
|
22
22
|
const checkTimeoutMs = bounded(options.checkTimeoutMs ?? 1000, "checkTimeoutMs", 2147483647);
|
|
23
23
|
const concurrency = bounded(options.concurrency ?? 4, "concurrency", 1024);
|
|
24
|
-
|
|
24
|
+
// Hosted services (background) report whether they still run.
|
|
25
|
+
const checks: readonly HealthCheck[] = [...this.resolver.resolveAll(HEALTH_CHECK), ...this.resolver.resolveAll(HOSTED_SERVICE_HEALTH)];
|
|
25
26
|
const entries: HealthReportEntry[] = new Array(checks.length);
|
|
26
27
|
const report = new AbortController();
|
|
27
28
|
const timer = setTimeout(() => report.abort(new Error("Health report timed out.")), timeoutMs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.33",
|
|
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",
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DI,
|
|
3
|
-
HOSTED_SERVICE,
|
|
4
|
-
Module,
|
|
5
|
-
singleton,
|
|
6
|
-
type Class,
|
|
7
|
-
type HostedService,
|
|
8
|
-
type BazisModule,
|
|
9
|
-
type ProviderDefinition,
|
|
10
|
-
} from "../di";
|
|
11
|
-
|
|
12
|
-
export interface BackgroundModuleConfig {
|
|
13
|
-
/**
|
|
14
|
-
* Background service classes (subclasses of `BackgroundService` /
|
|
15
|
-
* `PeriodicBackgroundService`). Each is registered as a singleton (with
|
|
16
|
-
* auto-resolved constructor dependencies) and exposed as a `HOSTED_SERVICE`,
|
|
17
|
-
* so the kernel starts and gracefully stops it with the rest of the app.
|
|
18
|
-
*/
|
|
19
|
-
readonly services: readonly Class<HostedService>[];
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Registers background services in DI and wires them into the kernel lifecycle.
|
|
24
|
-
*
|
|
25
|
-
* ```ts
|
|
26
|
-
* @Module({ imports: [backgroundModule({ services: [SessionCleanup, Heartbeat] })] })
|
|
27
|
-
* class AppModule {}
|
|
28
|
-
* ```
|
|
29
|
-
*/
|
|
30
|
-
export function backgroundModule(config: BackgroundModuleConfig): BazisModule {
|
|
31
|
-
const providers: ProviderDefinition[] = config.services.flatMap((ServiceClass) => [
|
|
32
|
-
singleton(ServiceClass),
|
|
33
|
-
DI.singleton(
|
|
34
|
-
DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass)),
|
|
35
|
-
),
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
@Module({ providers })
|
|
39
|
-
class BackgroundModule {}
|
|
40
|
-
|
|
41
|
-
return BackgroundModule;
|
|
42
|
-
}
|