bazis 0.98.29 → 0.98.31
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/app/runApp.ts +22 -2
- package/core/di/index.ts +1 -0
- package/core/di/module/collectModuleControllers.ts +33 -24
- package/core/di/module/createContainer.ts +8 -0
- package/core/di/module/index.ts +1 -1
- package/core/di/module/types/BazisModule.ts +7 -0
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/kernel/Kernel.ts +28 -7
- package/core/kernel/KernelBuilder.ts +14 -0
- package/core/kernel/LifecycleCoordinator.ts +8 -3
- package/core/kernel/types/KernelOptions.ts +8 -0
- package/core/scripts/di-generate.ts +2 -2
- package/core/websocket/DESIGN.md +13 -14
- package/core/websocket/WebSocketService.ts +47 -0
- package/core/websocket/adapter/redis-delivery-store.ts +2 -2
- package/core/websocket/index.ts +2 -1
- package/core/websocket/middleware.ts +20 -9
- package/core/websocket/reliable-delivery.ts +2 -1
- package/core/websocket/websocketModule.ts +33 -13
- package/core/websocket/ws-server.ts +80 -23
- package/library/orm/Schema/MigrationRunner.ts +20 -1
- package/library/orm/Schema/SchemaAdmission.ts +6 -1
- package/package.json +1 -1
package/core/app/runApp.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
collectModuleUiProfiles,
|
|
3
3
|
collectModuleControllers,
|
|
4
|
+
collectModuleGateways,
|
|
4
5
|
DI,
|
|
5
6
|
Global,
|
|
6
7
|
Module,
|
|
@@ -12,7 +13,8 @@ import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
|
|
|
12
13
|
import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
|
|
13
14
|
import { grpcModule, type GrpcModuleOptions } from "../grpc";
|
|
14
15
|
import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
|
|
15
|
-
import { KernelBuilder, Bazis, type KernelOptions, type ValidatableConfig } from "../kernel";
|
|
16
|
+
import { KernelBuilder, Bazis, KernelError, type KernelOptions, type ValidatableConfig } from "../kernel";
|
|
17
|
+
import { websocketModule, type WebSocketOptions } from "../websocket";
|
|
16
18
|
import {
|
|
17
19
|
isUiProfileAuthoringClass,
|
|
18
20
|
type UiProfileV1,
|
|
@@ -39,6 +41,12 @@ export interface RunAppOptions {
|
|
|
39
41
|
readonly http?: HttpModuleOptions;
|
|
40
42
|
/** gRPC server; discovers feature-module grpcControllers, independently of HTTP. */
|
|
41
43
|
readonly grpc?: GrpcModuleOptions;
|
|
44
|
+
/**
|
|
45
|
+
* WebSocket settings (authentication, adapter, limits). Gateways are
|
|
46
|
+
* collected from the `gateways` of the modules and served on the HTTP
|
|
47
|
+
* listener whenever a module declares one; this option only tunes them.
|
|
48
|
+
*/
|
|
49
|
+
readonly websocket?: WebSocketOptions;
|
|
42
50
|
/** Kernel options (environment, shutdown timeout, signals, ...). */
|
|
43
51
|
readonly kernel?: KernelOptions;
|
|
44
52
|
/**
|
|
@@ -88,6 +96,17 @@ export interface RunAppOptions {
|
|
|
88
96
|
readonly ui?: RunAppUiOptions;
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/** Adds the WebSocket module when a module declares gateways; settings alone without gateways are a mistake. */
|
|
100
|
+
function withWebSocket(root: BazisModuleRef, options: WebSocketOptions | undefined): BazisModuleRef {
|
|
101
|
+
if (collectModuleGateways([root]).length === 0) {
|
|
102
|
+
if (options !== undefined) {
|
|
103
|
+
throw new KernelError("runApp: websocket options are set, but no module declares gateways. Add gateways: [ChatGateway] to the module that owns the gateway.");
|
|
104
|
+
}
|
|
105
|
+
return root;
|
|
106
|
+
}
|
|
107
|
+
return { imports: [root, websocketModule({ ...options, imports: [root] })] };
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
function withUiRuntime(
|
|
92
111
|
root: BazisModuleRef,
|
|
93
112
|
option: RunAppOptions["ui"],
|
|
@@ -279,8 +298,9 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
|
|
|
279
298
|
// behavior after startup.
|
|
280
299
|
useModelValidator(validator);
|
|
281
300
|
|
|
301
|
+
const gatewayRoot = withWebSocket(appRoot, options.websocket);
|
|
282
302
|
const httpAppRoot = withUiRuntime(
|
|
283
|
-
{ imports: [
|
|
303
|
+
{ imports: [gatewayRoot, ...(options.http.imports ?? [])] },
|
|
284
304
|
options.ui,
|
|
285
305
|
options.http,
|
|
286
306
|
);
|
package/core/di/index.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import type { Class } from "../token";
|
|
2
2
|
import type { BazisModuleRef } from "./types";
|
|
3
3
|
|
|
4
|
+
type ClassListKey = "controllers" | "gateways";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
|
-
* Collects
|
|
6
|
-
* recursively). Duplicates by
|
|
7
|
+
* Collects the classes of one declarative list (`controllers`, `gateways`) from
|
|
8
|
+
* the module tree (the module itself plus `imports`, recursively). Duplicates by
|
|
9
|
+
* class reference are dropped.
|
|
7
10
|
*/
|
|
8
|
-
|
|
11
|
+
function collectModuleClasses(
|
|
9
12
|
roots: readonly BazisModuleRef[],
|
|
10
|
-
|
|
13
|
+
key: ClassListKey,
|
|
14
|
+
extra?: readonly Class<object>[],
|
|
11
15
|
): Class<object>[] {
|
|
12
16
|
const seen = new Set<Class<object>>();
|
|
13
17
|
const result: Class<object>[] = [];
|
|
18
|
+
const add = (list: readonly Class<object>[] | undefined): void => {
|
|
19
|
+
if (!list) return;
|
|
20
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
21
|
+
const entry = list[index] as Class<object>;
|
|
22
|
+
if (!seen.has(entry)) {
|
|
23
|
+
seen.add(entry);
|
|
24
|
+
result.push(entry);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
14
28
|
|
|
15
29
|
const visit = (moduleRef: BazisModuleRef, loaded: Set<BazisModuleRef>): void => {
|
|
16
30
|
if (loaded.has(moduleRef)) {
|
|
@@ -24,30 +38,25 @@ export function collectModuleControllers(
|
|
|
24
38
|
visit(imports[index] as BazisModuleRef, loaded);
|
|
25
39
|
}
|
|
26
40
|
}
|
|
27
|
-
|
|
28
|
-
const controllers = moduleRef.controllers;
|
|
29
|
-
if (controllers) {
|
|
30
|
-
for (let index = 0; index < controllers.length; index += 1) {
|
|
31
|
-
const controller = controllers[index] as Class<object>;
|
|
32
|
-
if (!seen.has(controller)) {
|
|
33
|
-
seen.add(controller);
|
|
34
|
-
result.push(controller);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
41
|
+
add(moduleRef[key]);
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
for (let index = 0; index < roots.length; index += 1) {
|
|
41
45
|
visit(roots[index] as BazisModuleRef, new Set());
|
|
42
46
|
}
|
|
43
|
-
|
|
44
|
-
for (let index = 0; index < extraControllers.length; index += 1) {
|
|
45
|
-
const controller = extraControllers[index] as Class<object>;
|
|
46
|
-
if (!seen.has(controller)) {
|
|
47
|
-
seen.add(controller);
|
|
48
|
-
result.push(controller);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
47
|
+
add(extra);
|
|
52
48
|
return result;
|
|
53
49
|
}
|
|
50
|
+
|
|
51
|
+
/** Controller classes of the module tree; `extraControllers` are appended once. */
|
|
52
|
+
export function collectModuleControllers(
|
|
53
|
+
roots: readonly BazisModuleRef[],
|
|
54
|
+
extraControllers?: readonly Class<object>[],
|
|
55
|
+
): Class<object>[] {
|
|
56
|
+
return collectModuleClasses(roots, "controllers", extraControllers);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** WebSocket gateway classes (`gateways` of `@Module`) of the module tree. */
|
|
60
|
+
export function collectModuleGateways(roots: readonly BazisModuleRef[]): Class<object>[] {
|
|
61
|
+
return collectModuleClasses(roots, "gateways");
|
|
62
|
+
}
|
|
@@ -144,6 +144,14 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
const gateways = moduleRef.gateways;
|
|
148
|
+
if (gateways) {
|
|
149
|
+
for (let index = 0; index < gateways.length; index += 1) {
|
|
150
|
+
// One instance per gateway: it keeps connection state across requests.
|
|
151
|
+
collection.add(singleton(gateways[index] as Class<object>));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
const background = moduleRef.background;
|
|
148
156
|
if (background) {
|
|
149
157
|
for (let index = 0; index < background.length; index += 1) {
|
package/core/di/module/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export {
|
|
|
26
26
|
type ModuleOwnedProviderContributor,
|
|
27
27
|
} from "./moduleOwnedProviderContributors";
|
|
28
28
|
export { createContainer, type CreateContainerOptions } from "./createContainer";
|
|
29
|
-
export { collectModuleControllers } from "./collectModuleControllers";
|
|
29
|
+
export { collectModuleControllers, collectModuleGateways } from "./collectModuleControllers";
|
|
30
30
|
export { collectModuleConfigs } from "./collectModuleConfigs";
|
|
31
31
|
export { collectModuleUiProfiles } from "./collectModuleUiProfiles";
|
|
32
32
|
export {
|
|
@@ -29,6 +29,13 @@ export interface BazisModuleMetadata {
|
|
|
29
29
|
* backward compatibility.
|
|
30
30
|
*/
|
|
31
31
|
readonly controllers?: readonly Class<object>[];
|
|
32
|
+
/**
|
|
33
|
+
* WebSocket gateways of the module (classes with `@WebSocketGateway`).
|
|
34
|
+
* Registered as singletons in this module, so a gateway sees the module's
|
|
35
|
+
* providers like a controller does; `websocketModule` collects them from
|
|
36
|
+
* `imports` and serves them on the HTTP listener.
|
|
37
|
+
*/
|
|
38
|
+
readonly gateways?: readonly Class<object>[];
|
|
32
39
|
/**
|
|
33
40
|
* `@UiProfile()` declarations owned by this module. The DI layer keeps them
|
|
34
41
|
* opaque; `runApp` resolves their controller/request/response references and
|
|
@@ -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.31" }, 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");
|
package/core/kernel/Kernel.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface KernelRuntimeOptions {
|
|
|
28
28
|
readonly signals: readonly NodeJS.Signals[];
|
|
29
29
|
readonly unhandledErrorPolicy: UnhandledErrorPolicy;
|
|
30
30
|
readonly startupReport: boolean;
|
|
31
|
+
/** Start the infrastructure and the schema only, then stop (BAZIS_SCHEMA_ONLY). */
|
|
32
|
+
readonly schemaOnly?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
interface StopRequest {
|
|
@@ -101,9 +103,19 @@ export class Kernel {
|
|
|
101
103
|
// Async singletons first: hosted services and controllers take them
|
|
102
104
|
// through constructors, which resolve synchronously.
|
|
103
105
|
await awaitAbortable(this.container.initializeAsyncSingletons(), abortController.signal);
|
|
104
|
-
await this.coordinator.start(abortController.signal, this.options.shutdownTimeoutMs);
|
|
106
|
+
await this.coordinator.start(abortController.signal, this.options.shutdownTimeoutMs, this.options.schemaOnly === true);
|
|
105
107
|
coordinatorStarted = true;
|
|
106
108
|
this.startupMs = performance.now() - this.timings.buildStartedAt;
|
|
109
|
+
if (this.options.schemaOnly === true) {
|
|
110
|
+
// The application itself never started: no lifetime callbacks, no
|
|
111
|
+
// STARTED event, no report. run() stops right away with exit code 0.
|
|
112
|
+
this.container.tryResolve(LOGGER)?.info("schema only: infrastructure and schema are ready, stopping (BAZIS_SCHEMA_ONLY)", {
|
|
113
|
+
hostedServices: this.coordinator.startedHostedCount,
|
|
114
|
+
startupMs: Number(this.startupMs.toFixed(1)),
|
|
115
|
+
});
|
|
116
|
+
this.state = "started";
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
107
119
|
await this.lifetime.notifyStarted(abortController.signal);
|
|
108
120
|
await this.container.resolve(EventBus).publish(
|
|
109
121
|
APPLICATION_STARTED,
|
|
@@ -222,16 +234,21 @@ export class Kernel {
|
|
|
222
234
|
|
|
223
235
|
private async runShutdownSequence(request: StopRequest): Promise<void> {
|
|
224
236
|
const errors: unknown[] = [];
|
|
237
|
+
// A schema-only start never announced the application, so it does not
|
|
238
|
+
// announce its stop either.
|
|
239
|
+
const announced = this.options.schemaOnly !== true;
|
|
225
240
|
try {
|
|
226
|
-
await this.lifetime.notifyStopping();
|
|
241
|
+
if (announced) await this.lifetime.notifyStopping();
|
|
227
242
|
} catch (error) {
|
|
228
243
|
errors.push(error);
|
|
229
244
|
}
|
|
230
245
|
try {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
if (announced) {
|
|
247
|
+
await this.container.resolve(EventBus).publish(APPLICATION_STOPPING, {
|
|
248
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
249
|
+
exitCode: request.exitCode,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
235
252
|
} catch (error) {
|
|
236
253
|
errors.push(error);
|
|
237
254
|
}
|
|
@@ -249,7 +266,7 @@ export class Kernel {
|
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
try {
|
|
252
|
-
await this.lifetime.notifyStopped();
|
|
269
|
+
if (announced) await this.lifetime.notifyStopped();
|
|
253
270
|
} catch (error) {
|
|
254
271
|
errors.push(error);
|
|
255
272
|
}
|
|
@@ -287,6 +304,10 @@ export class Kernel {
|
|
|
287
304
|
return request.exitCode;
|
|
288
305
|
}
|
|
289
306
|
|
|
307
|
+
if (this.options.schemaOnly === true) {
|
|
308
|
+
// The schema is in place: nothing to serve, stop as a finished job.
|
|
309
|
+
this.requestStop(this.stopRequested ?? { exitCode: 0 });
|
|
310
|
+
}
|
|
290
311
|
const request = await new Promise<StopRequest>((resolve) => {
|
|
291
312
|
// lifetime.stop() or a process signal may have fired during start().
|
|
292
313
|
if (this.stopRequested) {
|
|
@@ -6,6 +6,7 @@ import { ConfigRegistry } from "./config/ConfigRegistry";
|
|
|
6
6
|
import { argsSource, envSource } from "./config/sources";
|
|
7
7
|
import { Environment } from "./Environment";
|
|
8
8
|
import { EventBus } from "./events/EventBus";
|
|
9
|
+
import { KernelError } from "./errors";
|
|
9
10
|
import { HealthService } from "./health/HealthService";
|
|
10
11
|
import { Kernel, type KernelTimings } from "./Kernel";
|
|
11
12
|
import { ConsoleLogger } from "./logging/ConsoleLogger";
|
|
@@ -46,6 +47,7 @@ export class KernelBuilder {
|
|
|
46
47
|
private validateOnBuild = true;
|
|
47
48
|
private overrides: readonly ProviderDefinition[] = [];
|
|
48
49
|
private startupReport?: boolean;
|
|
50
|
+
private schemaOnly?: boolean;
|
|
49
51
|
private logger?: Logger;
|
|
50
52
|
|
|
51
53
|
public constructor(private readonly rootModule: RootModuleInput) {}
|
|
@@ -65,6 +67,7 @@ export class KernelBuilder {
|
|
|
65
67
|
if (options.unhandledErrorPolicy !== undefined) this.unhandledErrorPolicy = options.unhandledErrorPolicy;
|
|
66
68
|
if (options.validateOnBuild !== undefined) this.validateOnBuild = options.validateOnBuild;
|
|
67
69
|
if (options.startupReport !== undefined) this.startupReport = options.startupReport;
|
|
70
|
+
if (options.schemaOnly !== undefined) this.schemaOnly = options.schemaOnly;
|
|
68
71
|
return this;
|
|
69
72
|
}
|
|
70
73
|
|
|
@@ -177,6 +180,17 @@ export class KernelBuilder {
|
|
|
177
180
|
signals: this.signals,
|
|
178
181
|
unhandledErrorPolicy: this.unhandledErrorPolicy,
|
|
179
182
|
startupReport: this.startupReport ?? !environment.isProduction,
|
|
183
|
+
schemaOnly: this.schemaOnly ?? schemaOnlyFromProcess(),
|
|
180
184
|
});
|
|
181
185
|
}
|
|
182
186
|
}
|
|
187
|
+
|
|
188
|
+
/** BAZIS_SCHEMA_ONLY: `1`/`true` or `0`/`false`/unset; anything else is a mistake worth stopping for. */
|
|
189
|
+
function schemaOnlyFromProcess(): boolean {
|
|
190
|
+
const raw = process.env.BAZIS_SCHEMA_ONLY;
|
|
191
|
+
if (raw === undefined || raw === "") return false;
|
|
192
|
+
const normalized = raw.trim().toLowerCase();
|
|
193
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
194
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
195
|
+
throw new KernelError(`BAZIS_SCHEMA_ONLY must be 1, true, 0 or false, got ${JSON.stringify(raw.slice(0, 40))}.`);
|
|
196
|
+
}
|
|
@@ -55,16 +55,21 @@ export class LifecycleCoordinator {
|
|
|
55
55
|
return this.rollbackStartedAt === undefined ? 0 : performance.now() - this.rollbackStartedAt;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* `infrastructureOnly` starts only hosted services with a negative phase
|
|
60
|
+
* (connections and the database schema) and no lifecycle hooks.
|
|
61
|
+
*/
|
|
62
|
+
public async start(signal?: AbortSignal, rollbackTimeoutMs = 0, infrastructureOnly = false): Promise<void> {
|
|
59
63
|
signal?.throwIfAborted();
|
|
60
64
|
validateOptionsOnStart(this.resolver);
|
|
61
|
-
const
|
|
65
|
+
const resolved = resolveHostedServices(this.resolver);
|
|
66
|
+
const hosted = infrastructureOnly ? resolved.filter((service) => (service.phase ?? 0) < 0) : resolved;
|
|
62
67
|
// Background failures go to the application logger, like HTTP errors.
|
|
63
68
|
const logger = this.resolver.tryResolve?.(LOGGER);
|
|
64
69
|
if (logger) for (const service of hosted) service.useDiagnostics?.(logger);
|
|
65
70
|
const validation = validateHostedServicePlan(hosted, signal);
|
|
66
71
|
if (validation) await awaitAbortable(validation, signal);
|
|
67
|
-
this.hooks = this.resolver.resolveAll(LIFECYCLE_HOOK);
|
|
72
|
+
this.hooks = infrastructureOnly ? [] : this.resolver.resolveAll(LIFECYCLE_HOOK);
|
|
68
73
|
|
|
69
74
|
try {
|
|
70
75
|
for (let index = 0; index < this.hooks.length; index += 1) {
|
|
@@ -20,4 +20,12 @@ export interface KernelOptions {
|
|
|
20
20
|
readonly validateOnBuild?: boolean;
|
|
21
21
|
/** Startup report in the console. Default: only outside production. */
|
|
22
22
|
readonly startupReport?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Start only the infrastructure and the database schema (hosted services with
|
|
25
|
+
* a negative phase: connections, migrations, `ensureCreated`), then stop with
|
|
26
|
+
* exit code 0. HTTP, background services and lifecycle hooks do not run.
|
|
27
|
+
* For a deployment step with the schema owner's credentials.
|
|
28
|
+
* Default: the BAZIS_SCHEMA_ONLY variable (`1`/`true`), otherwise false.
|
|
29
|
+
*/
|
|
30
|
+
readonly schemaOnly?: boolean;
|
|
23
31
|
}
|
|
@@ -212,7 +212,7 @@ let explicitRequestModelRoots = new Set<ts.ClassDeclaration>();
|
|
|
212
212
|
let agentCodegenSources: AgentCodegenSource[] = [];
|
|
213
213
|
|
|
214
214
|
const ROUTE_DECORATORS = new Set(["Get", "Post", "Put", "Patch", "Delete", "Options", "Head", "All"]);
|
|
215
|
-
const CODEGEN_CHANNEL_DECORATORS = new Set(["Controller", "GrpcController", "Agent", "Tool", "Prompt", "RequestModel", "UiProfile"]);
|
|
215
|
+
const CODEGEN_CHANNEL_DECORATORS = new Set(["Controller", "GrpcController", "WebSocketGateway", "Agent", "Tool", "Prompt", "RequestModel", "UiProfile"]);
|
|
216
216
|
const PRIMITIVE_QUERY_TYPES: Record<string, HttpBindingSpec["type"]> = {
|
|
217
217
|
string: "string",
|
|
218
218
|
number: "number",
|
|
@@ -867,7 +867,7 @@ function collectDiClassRegistrations(source: ts.SourceFile): void {
|
|
|
867
867
|
if (!object || visitedModules.has(object)) return;
|
|
868
868
|
visitedModules.add(object);
|
|
869
869
|
const properties = propertiesOf(object);
|
|
870
|
-
for (const name of ["controllers", "grpcControllers", "background", "tools"]) {
|
|
870
|
+
for (const name of ["controllers", "grpcControllers", "gateways", "background", "tools"]) {
|
|
871
871
|
const classes = properties.get(name);
|
|
872
872
|
if (classes && ts.isArrayLiteralExpression(classes)) for (const entry of classes.elements) record(entry);
|
|
873
873
|
}
|
package/core/websocket/DESIGN.md
CHANGED
|
@@ -22,22 +22,21 @@ export class ChatGateway {
|
|
|
22
22
|
}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Declare it in its module, like a controller; `runApp` serves it on the HTTP listener:
|
|
26
26
|
|
|
27
27
|
```ts
|
|
28
|
-
import { Module } from "@/core/di";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
httpModule({ port: 3000 }),
|
|
35
|
-
websocketModule({ gateways: [ChatGateway] }),
|
|
36
|
-
],
|
|
37
|
-
})
|
|
38
|
-
export class AppModule {}
|
|
28
|
+
import { Module, singleton } from "@/core/di";
|
|
29
|
+
|
|
30
|
+
@Module({ gateways: [ChatGateway], providers: [singleton(ChatHistory)] })
|
|
31
|
+
export class ChatModule {}
|
|
32
|
+
|
|
33
|
+
await runApp(AppModule, { http: { port: 3000 }, websocket: { requireAuth: true, authenticator } });
|
|
39
34
|
```
|
|
40
35
|
|
|
36
|
+
Without `runApp`, `websocketModule({ imports: [ChatModule], ... })` next to
|
|
37
|
+
`httpModule` does the same. Services send with `WebSocketService`:
|
|
38
|
+
`this.ws.to("user:ann").emit("order-paid", { id })`.
|
|
39
|
+
|
|
41
40
|
The gateway above upgrades on `/ws/chat`.
|
|
42
41
|
|
|
43
42
|
## Handler Convention
|
|
@@ -102,7 +101,7 @@ default, `replayDelivery: "transport"`.
|
|
|
102
101
|
### Client-confirmed replay
|
|
103
102
|
|
|
104
103
|
Opt in with `?replay=client-ack` on each connection, or require it for all clients
|
|
105
|
-
with `
|
|
104
|
+
with `runApp({ websocket: { replayDelivery: "client-ack" } })`. The adapter
|
|
106
105
|
must support CAS. A saved client-ack session cannot reconnect in transport mode;
|
|
107
106
|
such an upgrade returns 409. Invalid/duplicate replay query values return 400.
|
|
108
107
|
|
|
@@ -151,7 +150,7 @@ Pass an existing native Bun RedisClient from the host infrastructure:
|
|
|
151
150
|
const adapter = new RedisWebSocketAdapter(hostRedisClient, {
|
|
152
151
|
keyPrefix: "my-app:ws", // same on participating nodes; distinct per application
|
|
153
152
|
});
|
|
154
|
-
|
|
153
|
+
await runApp(AppModule, { http: {}, websocket: { adapter, replayDelivery: "client-ack" } });
|
|
155
154
|
```
|
|
156
155
|
|
|
157
156
|
The adapter owns only a duplicate connection for pub/sub. Closing the adapter
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { WebSocketServer } from "./ws-server";
|
|
2
|
+
import type { SocketBroadcast } from "./types";
|
|
3
|
+
import type { ReliableBroadcastOptions, ReliableBroadcastReceipt } from "./reliable-delivery";
|
|
4
|
+
|
|
5
|
+
/** `to(room)` of one namespace, usable from any service. */
|
|
6
|
+
export interface WebSocketNamespace {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
to(room: string): SocketBroadcast;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sends events to rooms from outside a gateway handler: a service, an HTTP
|
|
13
|
+
* controller or a background job. Registered by `websocketModule` and exported
|
|
14
|
+
* globally, so any service can take it in its constructor.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* this.ws.to("user:ann").emit("order-paid", { id }); // one namespace
|
|
18
|
+
* this.ws.namespace("chat").to("general").emit("message", m); // a chosen one
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export class WebSocketService {
|
|
22
|
+
public constructor(private readonly server: WebSocketServer) {}
|
|
23
|
+
|
|
24
|
+
/** The broadcaster of a namespace (`"chat"` and `"/chat"` are the same). */
|
|
25
|
+
public namespace(name: string): WebSocketNamespace {
|
|
26
|
+
const server = this.server;
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
to(room: string): SocketBroadcast {
|
|
30
|
+
return {
|
|
31
|
+
emit: (event, data) => server.broadcast(name, room, event, data),
|
|
32
|
+
emitReliable: (event, data, options: ReliableBroadcastOptions): Promise<ReliableBroadcastReceipt> =>
|
|
33
|
+
server.broadcastReliable(name, room, event, data, options),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `to(room)` of the only namespace; with several, name one with `namespace()`. */
|
|
40
|
+
public to(room: string): SocketBroadcast {
|
|
41
|
+
const names = this.server.namespaceNames;
|
|
42
|
+
if (names.length !== 1) {
|
|
43
|
+
throw new Error(`WebSocketService.to(room) needs exactly one namespace, found ${names.length} (${names.join(", ")}); use namespace(name).to(room).`);
|
|
44
|
+
}
|
|
45
|
+
return this.namespace(names[0] as string).to(room);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -56,13 +56,13 @@ export class RedisRoomDeliveryStore implements ReliableRoomDelivery {
|
|
|
56
56
|
if (!deliveryUuid.test(request.messageId) || !Number.isSafeInteger(request.expiresAt)
|
|
57
57
|
|| typeof request.namespace !== "string" || request.namespace.length > 256 || request.namespace.includes("\0")
|
|
58
58
|
|| typeof request.room !== "string" || !request.room.length || request.room.length > 256 || request.room.includes("\0")
|
|
59
|
-
|| typeof request.excludeSid !== "string" || request.excludeSid.length > 128
|
|
59
|
+
|| (request.excludeSid !== undefined && (typeof request.excludeSid !== "string" || request.excludeSid.length > 128))
|
|
60
60
|
|| request.packet.type !== "event" || typeof request.packet.event !== "string" || !request.packet.event.length
|
|
61
61
|
|| request.packet.event.length > 256) throw new WebSocketDeliveryError("INVALID_PUBLICATION");
|
|
62
62
|
const packet = JSON.stringify({ ...request.packet, v: 1, namespace: request.namespace, deliveryId: request.messageId });
|
|
63
63
|
if (Buffer.byteLength(packet) > this.limits.maxQueueBytes) throw new WebSocketDeliveryError("QUEUE_CAPACITY");
|
|
64
64
|
const metadata = JSON.stringify({ messageId: request.messageId, expiresAt: request.expiresAt,
|
|
65
|
-
namespace: request.namespace, room: request.room, excludeSid: request.excludeSid });
|
|
65
|
+
namespace: request.namespace, room: request.room, excludeSid: request.excludeSid ?? "" });
|
|
66
66
|
const fingerprint = canonicalJsonHashV1("bazis.websocket.publication/v1", { metadata: JSON.parse(metadata), packet: JSON.parse(packet) });
|
|
67
67
|
const result = await this.operations.send("EVAL", [PUBLISH, "0", this.prefix, metadata, JSON.stringify(this.limits), fingerprint, packet]) as string[];
|
|
68
68
|
if (result[0] !== "OK") throw new WebSocketDeliveryError(result[0] ?? "STORE_PROTOCOL");
|
package/core/websocket/index.ts
CHANGED
|
@@ -48,7 +48,8 @@ export {
|
|
|
48
48
|
type WebSocketServerStats,
|
|
49
49
|
type WsConnectionData,
|
|
50
50
|
} from "./ws-server";
|
|
51
|
-
export { websocketModule, type WebSocketModuleConfig } from "./websocketModule";
|
|
51
|
+
export { websocketModule, type WebSocketModuleConfig, type WebSocketOptions } from "./websocketModule";
|
|
52
|
+
export { WebSocketService, type WebSocketNamespace } from "./WebSocketService";
|
|
52
53
|
export { TopicCache, topicCache, type TopicCacheOptions, type TopicCacheStats } from "./topic-cache";
|
|
53
54
|
export {
|
|
54
55
|
encodeServerPacket,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { SocketUser } from "./types";
|
|
2
|
+
import { JwtError } from "../../library/jwt";
|
|
3
|
+
import { UnauthorizedError } from "../http";
|
|
2
4
|
|
|
3
5
|
const MAX_SOCKET_USER_ID_LENGTH = 256;
|
|
4
6
|
|
|
@@ -110,15 +112,24 @@ export function createWsAuthMiddleware(
|
|
|
110
112
|
options?: { required?: boolean; allowQueryToken?: boolean },
|
|
111
113
|
): WsMiddleware {
|
|
112
114
|
return async (ctx, next) => {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
let user: SocketUser | null;
|
|
116
|
+
try {
|
|
117
|
+
user = await authenticator(
|
|
118
|
+
extractWebSocketToken(ctx.request, { allowQueryToken: options?.allowQueryToken }),
|
|
119
|
+
{
|
|
120
|
+
signal: ctx.signal,
|
|
121
|
+
namespace: ctx.namespace,
|
|
122
|
+
path: ctx.path,
|
|
123
|
+
request: ctx.request,
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// A bad or expired token is a refusal, not a server failure.
|
|
128
|
+
if (error instanceof JwtError || error instanceof UnauthorizedError) {
|
|
129
|
+
throw new WsUpgradeError("Unauthorized", 401);
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
122
133
|
if (user !== null && !isValidSocketUser(user)) {
|
|
123
134
|
throw new WsUpgradeError("Unauthorized", 401);
|
|
124
135
|
}
|
|
@@ -16,7 +16,8 @@ export interface ReliableBroadcastReceipt {
|
|
|
16
16
|
export interface ReliableRoomPublication extends ReliableBroadcastOptions {
|
|
17
17
|
readonly namespace: string;
|
|
18
18
|
readonly room: string;
|
|
19
|
-
|
|
19
|
+
/** The publishing session; omitted for a publication from a service. */
|
|
20
|
+
readonly excludeSid?: string;
|
|
20
21
|
readonly packet: ServerPacket;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DI,
|
|
3
|
+
collectModuleGateways,
|
|
3
4
|
markGlobal,
|
|
4
5
|
Module,
|
|
5
|
-
singleton,
|
|
6
|
-
type Class,
|
|
7
6
|
type BazisModule,
|
|
7
|
+
type BazisModuleRef,
|
|
8
8
|
type ProviderDefinition,
|
|
9
9
|
type ServiceResolver,
|
|
10
10
|
} from "../di";
|
|
11
11
|
import { WEBSOCKET_UPGRADE } from "../http";
|
|
12
|
+
import { LOGGER } from "../kernel";
|
|
13
|
+
import { WebSocketService } from "./WebSocketService";
|
|
12
14
|
import { WebSocketExplorer } from "./explorer";
|
|
13
15
|
import { WebSocketServer, type WebSocketMemoryLimits } from "./ws-server";
|
|
14
16
|
import type { WsAccessTokenAuthenticator, WsMiddleware } from "./middleware";
|
|
@@ -19,11 +21,10 @@ import type { WebSocketDiagnostic } from "./reliable-delivery";
|
|
|
19
21
|
|
|
20
22
|
export interface WebSocketModuleConfig {
|
|
21
23
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* `@SubscribeMessage` handlers.
|
|
24
|
+
* Modules whose `gateways` (classes with `@WebSocketGateway`) are served.
|
|
25
|
+
* The whole tree is searched, like `httpModule` searches for controllers.
|
|
25
26
|
*/
|
|
26
|
-
readonly
|
|
27
|
+
readonly imports?: readonly BazisModuleRef[];
|
|
27
28
|
/** Kernel-owned upgrade policy using DI; runs before gateway middleware. */
|
|
28
29
|
readonly middlewareFactory?: (services: ServiceResolver) => WsMiddleware;
|
|
29
30
|
/** Upgrade-time authentication; `null`/omitted means anonymous connections. */
|
|
@@ -47,29 +48,39 @@ export interface WebSocketModuleConfig {
|
|
|
47
48
|
readonly exposeHandlerErrors?: boolean;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/** Server-wide WebSocket settings of `runApp({ websocket })`; gateways come from the modules. */
|
|
52
|
+
export type WebSocketOptions = Omit<WebSocketModuleConfig, "imports">;
|
|
53
|
+
|
|
50
54
|
/**
|
|
51
|
-
*
|
|
55
|
+
* Serves the gateways declared by the modules of `imports` on the shared HTTP
|
|
56
|
+
* listener. `runApp` installs it next to the HTTP module whenever a module
|
|
57
|
+
* declares `gateways`; `runApp({ websocket })` passes the settings.
|
|
52
58
|
*
|
|
53
59
|
* ```ts
|
|
54
|
-
* @Module({
|
|
55
|
-
* class
|
|
60
|
+
* @Module({ gateways: [ChatGateway], providers: [singleton(ChatHistory)] })
|
|
61
|
+
* class ChatModule {}
|
|
56
62
|
* ```
|
|
57
63
|
*
|
|
58
64
|
* The built `WebSocketServer` is exposed under {@link WEBSOCKET_UPGRADE}; the
|
|
59
65
|
* HTTP server resolves it and wires `Bun.serve({ websocket })` automatically.
|
|
66
|
+
* `WebSocketService` is exported globally for sending from services.
|
|
60
67
|
*/
|
|
61
68
|
export function websocketModule(config: WebSocketModuleConfig): BazisModule {
|
|
69
|
+
const gateways = collectModuleGateways(config.imports ?? []);
|
|
70
|
+
if (gateways.length === 0) {
|
|
71
|
+
throw new Error("websocketModule: no module of imports declares gateways. Add gateways: [ChatGateway] to the module that owns the gateway.");
|
|
72
|
+
}
|
|
62
73
|
const providers: ProviderDefinition[] = [
|
|
63
|
-
...config.gateways.map((Gateway) => singleton(Gateway)),
|
|
64
74
|
DI.singleton(
|
|
65
75
|
DI.factoryProviderWithResolver(WEBSOCKET_UPGRADE, [], (resolver) => {
|
|
66
|
-
const instances =
|
|
76
|
+
const instances = gateways.map((Gateway) => resolver.resolve(Gateway));
|
|
67
77
|
const namespaces = new WebSocketExplorer().explore(instances);
|
|
68
78
|
if (config.middlewareFactory) {
|
|
69
79
|
const middleware = config.middlewareFactory(resolver);
|
|
70
80
|
if (typeof middleware !== "function") throw new Error("WebSocket middlewareFactory must return middleware.");
|
|
71
81
|
for (const namespace of namespaces) namespace.middleware.unshift(middleware);
|
|
72
82
|
}
|
|
83
|
+
const logger = resolver.tryResolve(LOGGER);
|
|
73
84
|
return new WebSocketServer({
|
|
74
85
|
namespaces,
|
|
75
86
|
authenticator: config.authenticator ?? null,
|
|
@@ -82,14 +93,23 @@ export function websocketModule(config: WebSocketModuleConfig): BazisModule {
|
|
|
82
93
|
...(config.onDiagnostic ? { onDiagnostic: config.onDiagnostic } : {}),
|
|
83
94
|
...(config.limits ? { limits: config.limits } : {}),
|
|
84
95
|
...(config.exposeHandlerErrors !== undefined ? { exposeHandlerErrors: config.exposeHandlerErrors } : {}),
|
|
96
|
+
...(logger ? { logger } : {}),
|
|
85
97
|
});
|
|
86
98
|
}),
|
|
87
99
|
),
|
|
100
|
+
DI.singleton(
|
|
101
|
+
DI.factoryProviderWithResolver(WebSocketService, [], (resolver) => {
|
|
102
|
+
const upgrade = resolver.resolve(WEBSOCKET_UPGRADE);
|
|
103
|
+
if (!(upgrade instanceof WebSocketServer)) throw new Error("WebSocketService needs the WebSocketServer registered by websocketModule.");
|
|
104
|
+
return new WebSocketService(upgrade);
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
88
107
|
];
|
|
89
108
|
|
|
90
109
|
// Global + exported so the HTTP module can resolve the upgrade port without
|
|
91
|
-
// importing the websocket module (one-way: websocket builds on http)
|
|
92
|
-
|
|
110
|
+
// importing the websocket module (one-way: websocket builds on http), and
|
|
111
|
+
// any service can take WebSocketService.
|
|
112
|
+
@Module({ imports: config.imports, providers, exports: [WEBSOCKET_UPGRADE, WebSocketService] })
|
|
93
113
|
class WebSocketModule {}
|
|
94
114
|
|
|
95
115
|
return markGlobal(WebSocketModule);
|
|
@@ -40,7 +40,11 @@ import { InMemoryWebSocketAdapter } from "./adapter/in-memory.adapter";
|
|
|
40
40
|
import type { PacketCodec } from "./codec/packet-codec.interface";
|
|
41
41
|
import { payloadToBytes, payloadToText } from "./codec/packet-codec.interface";
|
|
42
42
|
import { jsonPacketCodec } from "./codec/json.codec";
|
|
43
|
-
import { WebSocketDeliveryError, type WebSocketDiagnostic, type ReliableSessionOwner } from "./reliable-delivery";
|
|
43
|
+
import { WebSocketDeliveryError, type WebSocketDiagnostic, type ReliableBroadcastOptions, type ReliableBroadcastReceipt, type ReliableSessionOwner } from "./reliable-delivery";
|
|
44
|
+
import type { Logger } from "../kernel";
|
|
45
|
+
import { redactSensitive } from "../../library/redaction";
|
|
46
|
+
import { normalizeNamespace } from "./decorators";
|
|
47
|
+
import type { OutgoingPacket } from "./socket-wrapper";
|
|
44
48
|
|
|
45
49
|
export interface WebSocketMemoryLimits {
|
|
46
50
|
readonly maxRateLimitBuckets?: number;
|
|
@@ -93,6 +97,8 @@ export interface WebSocketServerOptions {
|
|
|
93
97
|
limits?: WebSocketMemoryLimits;
|
|
94
98
|
/** Expose validator/handler exception messages. Disabled by default. */
|
|
95
99
|
exposeHandlerErrors?: boolean;
|
|
100
|
+
/** Receives unexpected handler and upgrade failures (redacted). */
|
|
101
|
+
logger?: Logger;
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
export interface WebSocketServerStats {
|
|
@@ -352,10 +358,76 @@ export class WebSocketServer {
|
|
|
352
358
|
if (handshakeController.signal.aborted && this.closed) {
|
|
353
359
|
return new Response("WebSocket service unavailable", { status: 503 });
|
|
354
360
|
}
|
|
361
|
+
this.options.logger?.error("WebSocket upgrade failed", { path: registered.path, error: redactSensitive(error) });
|
|
355
362
|
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
356
363
|
}
|
|
357
364
|
}
|
|
358
365
|
|
|
366
|
+
/** Namespaces served by this server, normalized (`/chat`). */
|
|
367
|
+
public get namespaceNames(): readonly string[] {
|
|
368
|
+
return [...this.namespacesByName.keys()];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Sends an event to every member of a room from outside a handler (a service,
|
|
373
|
+
* a controller, a background job). Delivery is the same as `socket.to(room)`:
|
|
374
|
+
* live members of this node and, through the adapter, of other nodes;
|
|
375
|
+
* offline members get it queued for reconnect replay.
|
|
376
|
+
*/
|
|
377
|
+
public broadcast(namespace: string, room: string, event: string, data?: unknown): void {
|
|
378
|
+
const registered = this.requireNamespace(namespace);
|
|
379
|
+
this.publishToRoom(registered, room, { type: "event", namespace: registered.namespace, event, data }, outboundLimit(registered));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** `socket.to(room).emitReliable` from outside a handler; needs the adapter capability. */
|
|
383
|
+
public async broadcastReliable(namespace: string, room: string, event: string, data: unknown, options: ReliableBroadcastOptions): Promise<ReliableBroadcastReceipt> {
|
|
384
|
+
const registered = this.requireNamespace(namespace);
|
|
385
|
+
const store = this.adapter.reliableRooms;
|
|
386
|
+
if (!store) throw new WebSocketDeliveryError("RELIABLE_DELIVERY_UNAVAILABLE");
|
|
387
|
+
const packet: OutgoingPacket = { type: "event", namespace: registered.namespace, event, data };
|
|
388
|
+
if (payloadBytes(this.codec.encodeServer({ ...packet, deliveryId: options.messageId })) > outboundLimit(registered)) {
|
|
389
|
+
throw new WebSocketDeliveryError("PAYLOAD_CAPACITY");
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
const receipt = await store.publish({ ...options, namespace: registered.namespace, room, packet: { ...packet, v: 1 } });
|
|
393
|
+
this.diagnose("reliable-published", receipt.duplicate ? 0 : receipt.recipients);
|
|
394
|
+
return receipt;
|
|
395
|
+
} catch (error) {
|
|
396
|
+
this.diagnose("reliable-rejected");
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private requireNamespace(namespace: string): RegisteredNamespace {
|
|
402
|
+
if (this.closed || !this.initialized) throw new Error("WebSocket server is not running.");
|
|
403
|
+
const registered = this.namespacesByName.get(normalizeNamespace(namespace));
|
|
404
|
+
if (!registered) throw new Error(`Unknown WebSocket namespace "${namespace}"; registered: ${this.namespaceNames.join(", ")}.`);
|
|
405
|
+
return registered;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Fan-out to a room: the offline queue first (strict, so a rejection is not
|
|
410
|
+
* hidden behind a partial live delivery), then live members of this node,
|
|
411
|
+
* then other nodes through the adapter. `origin` is excluded, like `to()`.
|
|
412
|
+
*/
|
|
413
|
+
private publishToRoom(registered: RegisteredNamespace, room: string, packet: OutgoingPacket, maxPayloadBytes: number, origin?: ServerWebSocket<WsConnectionData>): void {
|
|
414
|
+
const namespace = registered.namespace;
|
|
415
|
+
const topic = this.topics.get(namespace, room);
|
|
416
|
+
const payload = this.codec.encodeServer(packet);
|
|
417
|
+
const bytes = payloadToBytes(payload);
|
|
418
|
+
if (bytes.byteLength > maxPayloadBytes) {
|
|
419
|
+
throw new Error("WebSocket broadcast payload exceeds the namespace limit.");
|
|
420
|
+
}
|
|
421
|
+
const admission = this.sessionManager.enqueueToOfflineRoomMembers(namespace, room, { v: 1, ...packet }, origin?.data.sid, bytes.byteLength, true);
|
|
422
|
+
if (admission.truncated) {
|
|
423
|
+
this.diagnose("queue-rejected");
|
|
424
|
+
throw new WebSocketDeliveryError("OFFLINE_QUEUE_CAPACITY");
|
|
425
|
+
}
|
|
426
|
+
if (origin) origin.publish(topic, payload);
|
|
427
|
+
else this.bunServer?.publish(topic, this.codec.isBinary(payload) ? payload : payloadToText(payload));
|
|
428
|
+
this.trackAdapterOperation(this.adapter.publish(topic, bytes));
|
|
429
|
+
}
|
|
430
|
+
|
|
359
431
|
private async performUpgrade(
|
|
360
432
|
request: Request,
|
|
361
433
|
server: Server<WsConnectionData>,
|
|
@@ -590,30 +662,10 @@ export class WebSocketServer {
|
|
|
590
662
|
}
|
|
591
663
|
},
|
|
592
664
|
broadcast: (room, packet) => {
|
|
593
|
-
if (signal?.aborted) {
|
|
665
|
+
if (signal?.aborted || !registered) {
|
|
594
666
|
return;
|
|
595
667
|
}
|
|
596
|
-
|
|
597
|
-
const topic = this.topics.get(namespace, room);
|
|
598
|
-
const payload = this.codec.encodeServer(packet);
|
|
599
|
-
const bytes = payloadToBytes(payload);
|
|
600
|
-
if (bytes.byteLength > maxPayloadBytes) {
|
|
601
|
-
throw new Error("WebSocket broadcast payload exceeds the namespace limit.");
|
|
602
|
-
}
|
|
603
|
-
const admission = this.sessionManager.enqueueToOfflineRoomMembers(
|
|
604
|
-
namespace,
|
|
605
|
-
room,
|
|
606
|
-
{ v: 1, ...packet },
|
|
607
|
-
ws.data.sid,
|
|
608
|
-
bytes.byteLength,
|
|
609
|
-
true,
|
|
610
|
-
);
|
|
611
|
-
if (admission.truncated) {
|
|
612
|
-
this.diagnose("queue-rejected");
|
|
613
|
-
throw new WebSocketDeliveryError("OFFLINE_QUEUE_CAPACITY");
|
|
614
|
-
}
|
|
615
|
-
ws.publish(topic, payload);
|
|
616
|
-
this.trackAdapterOperation(this.adapter.publish(topic, bytes));
|
|
668
|
+
this.publishToRoom(registered, room, packet, maxPayloadBytes, ws);
|
|
617
669
|
},
|
|
618
670
|
broadcastReliable: async (room, packet, options) => {
|
|
619
671
|
signal?.throwIfAborted();
|
|
@@ -1241,6 +1293,11 @@ export class WebSocketServer {
|
|
|
1241
1293
|
const cause = error instanceof WsDispatchError ? error.cause : error;
|
|
1242
1294
|
if (cause instanceof WebSocketDeliveryError) this.sendError(ws, cause.message, packet.id, cause.code);
|
|
1243
1295
|
else this.sendError(ws, this.options.exposeHandlerErrors === true && rawMessage ? rawMessage : safeMessage, packet.id);
|
|
1296
|
+
// A validation failure is the client's mistake; anything else is ours and
|
|
1297
|
+
// belongs in the log, like an unhandled HTTP error.
|
|
1298
|
+
if (!(error instanceof WsDispatchError && error.kind === "validation") && !(cause instanceof WebSocketDeliveryError)) {
|
|
1299
|
+
this.options.logger?.error("WebSocket handler failed", { namespace: ws.data.namespace, event: packet.event, error: redactSensitive(cause) });
|
|
1300
|
+
}
|
|
1244
1301
|
}
|
|
1245
1302
|
throwIfAborted(signal);
|
|
1246
1303
|
}
|
|
@@ -130,10 +130,24 @@ export class MigrationRunner {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
private async ensureHistory(): Promise<void> {
|
|
133
|
+
// CREATE TABLE IF NOT EXISTS needs the CREATE privilege on the schema even
|
|
134
|
+
// when the table exists, so an application user with data rights only
|
|
135
|
+
// could never start. Create the table only when it is missing.
|
|
136
|
+
const [found] = await this.provider.query(`SELECT to_regclass(${this.param(0)}) IS NOT NULL AS present`, [this.quote(HISTORY_TABLE)]);
|
|
137
|
+
if (found?.present === true) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
133
140
|
await this.provider.execute(
|
|
134
141
|
`CREATE TABLE IF NOT EXISTS ${this.quote(HISTORY_TABLE)} (${this.quote("MigrationId")} TEXT PRIMARY KEY, ${this.quote("AppliedAt")} TEXT NOT NULL)`,
|
|
135
142
|
[],
|
|
136
|
-
)
|
|
143
|
+
).catch((error: unknown) => {
|
|
144
|
+
if (sqlState(error) !== "42501") throw error;
|
|
145
|
+
throw new OrmError(
|
|
146
|
+
`The migration history table "${HISTORY_TABLE}" does not exist and this database user may not create it (PostgreSQL 42501). ` +
|
|
147
|
+
"Run the first start with the schema owner's credentials (BAZIS_SCHEMA_ONLY=1 applies the schema and exits), or grant CREATE on the schema.",
|
|
148
|
+
{ cause: error },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
137
151
|
}
|
|
138
152
|
|
|
139
153
|
private async loadApplied(): Promise<Set<string>> {
|
|
@@ -168,6 +182,11 @@ export class MigrationRunner {
|
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
|
|
185
|
+
function sqlState(error: unknown): string | undefined {
|
|
186
|
+
const code = (error as { errno?: unknown; code?: unknown } | null)?.errno ?? (error as { code?: unknown } | null)?.code;
|
|
187
|
+
return typeof code === "string" ? code : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
/** Names the failed migration; its transaction was rolled back, earlier ones stay applied. */
|
|
172
191
|
function migrationFailed(subject: string, error: unknown): unknown {
|
|
173
192
|
// An unknown COMMIT outcome must not be reported as a rollback.
|
|
@@ -73,7 +73,12 @@ export class SchemaAdmissionEngine {
|
|
|
73
73
|
// The PostgreSQL message names objects, not row values (those stay in its detail field).
|
|
74
74
|
const reason = `${describeOperation(operation)} failed${sqlState ? ` with PostgreSQL ${sqlState}` : ""}${error instanceof Error && error.message ? `: ${error.message}` : ""}`;
|
|
75
75
|
if (["23502", "23503", "23505", "23514"].includes(sqlState)) return new SchemaAdmissionError("ORM_SCHEMA_ADDITIVE_DATA_VIOLATION", `Existing PostgreSQL data violates the declared additive schema change: ${reason}. Fix the data, then start again.`);
|
|
76
|
-
|
|
76
|
+
// 42501: an application user with data rights only. The schema change is
|
|
77
|
+
// right; it has to be applied by a user that owns the tables.
|
|
78
|
+
const hint = sqlState === "42501"
|
|
79
|
+
? " This database user may not change the schema: apply the change with the schema owner's credentials (BAZIS_SCHEMA_ONLY=1 applies the schema and exits), or grant the rights."
|
|
80
|
+
: "";
|
|
81
|
+
return new SchemaAdmissionError(operation.kind === "createSchema" || operation.kind === "createTable" ? "ORM_SCHEMA_CREATE_FAILED" : "ORM_SCHEMA_ADDITIVE_DDL_FAILED", `PostgreSQL additive schema admission failed: ${reason}.${hint}`);
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.98.
|
|
3
|
+
"version": "0.98.31",
|
|
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",
|