bazis 0.100.3 → 0.100.5
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/cache/distributed/connectionCache.ts +24 -0
- package/core/cache/distributed/index.ts +1 -0
- package/core/cache/http/missingCacheModuleCheck.ts +27 -6
- package/core/cache/index.ts +1 -0
- package/core/cache/internal/buildCacheProviders.ts +16 -15
- package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
- package/core/infra/connectors/llm.ts +10 -5
- package/core/infra/connectors/redis.ts +2 -3
- package/core/websocket/adapter/redis-delivery-store.ts +20 -3
- package/core/websocket/middleware.ts +6 -0
- package/core/websocket/websocketModule.ts +20 -4
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { DistributedCacheStores } from "./DistributedCacheStores";
|
|
2
|
+
import type { DistributedCacheSetOptions, IDistributedCache } from "./IDistributedCache";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `IDistributedCache` of one connection that services inject by type:
|
|
6
|
+
* values of `@CacheableRedis` and its own `get`/`getOrCreateAsync`; a tag is
|
|
7
|
+
* evicted from those values and from the `@OutputRedisCache` responses of the
|
|
8
|
+
* same connection, as `ICache.evictByTag` does in memory.
|
|
9
|
+
*/
|
|
10
|
+
export function connectionCache(stores: DistributedCacheStores, connection: string = stores.serviceCache.connectionNames[0]!): IDistributedCache {
|
|
11
|
+
const values = stores.serviceCache.resolve(connection);
|
|
12
|
+
const responses = stores.outputCache.resolve(connection);
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
connectionName: values.connectionName,
|
|
15
|
+
keyPrefix: values.keyPrefix,
|
|
16
|
+
get: <T>(key: string) => values.get<T>(key),
|
|
17
|
+
getOrCreateAsync: <T>(key: string, factory: () => Promise<T | undefined>, options: DistributedCacheSetOptions) => values.getOrCreateAsync<T>(key, factory, options),
|
|
18
|
+
remove: (key: string) => values.remove(key),
|
|
19
|
+
async evictByTag(tag: string): Promise<number> {
|
|
20
|
+
const [fromValues, fromResponses] = await Promise.all([values.evictByTag(tag), responses.evictByTag(tag)]);
|
|
21
|
+
return fromValues + fromResponses;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,23 +1,44 @@
|
|
|
1
|
-
import { singletonFactoryWithResolver, type ProviderDefinition } from "../../di";
|
|
1
|
+
import { singletonFactoryWithResolver, type InjectionToken, type ProviderDefinition } from "../../di";
|
|
2
2
|
import { ROUTE_MIDDLEWARE_COMPOSER, type RouteMiddlewareComposerRegistration } from "../../http";
|
|
3
3
|
import { LOGGER } from "../../kernel";
|
|
4
4
|
import { outputCacheMetaOf, resolveOutputCacheRequirement } from "../decorators/outputCacheMetadata";
|
|
5
|
+
import { outputRedisCacheMetaOf, resolveOutputRedisCacheRequirement } from "../decorators/outputRedisCacheMetadata";
|
|
5
6
|
import { ICache } from "../ICache";
|
|
7
|
+
import { DISTRIBUTED_OUTPUT_CACHE } from "../tokens/DISTRIBUTED_CACHE";
|
|
8
|
+
import type { CachedHttpPayload } from "./CachedHttpPayload";
|
|
9
|
+
import { createRouteOutputCacheComposerWithOptions } from "./composeOutputCache";
|
|
10
|
+
|
|
11
|
+
/** After the authorization checks, like the output cache of the cache module. */
|
|
12
|
+
const OUTPUT_CACHE_COMPOSER_ORDER = 100;
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* A route composer the application composition installs next to the HTTP
|
|
9
|
-
* server
|
|
10
|
-
*
|
|
11
|
-
*
|
|
16
|
+
* server for applications without a cache module (`cache: memory()`):
|
|
17
|
+
*
|
|
18
|
+
* - `@OutputRedisCache` needs only the distributed backend of `@Infra`, so
|
|
19
|
+
* the routes are served from Redis; without a backend the start fails and
|
|
20
|
+
* names the route;
|
|
21
|
+
* - `@OutputCache` needs the in-memory store: the start warns that the
|
|
22
|
+
* decorator has no effect.
|
|
12
23
|
*/
|
|
13
24
|
export function missingCacheModuleCheck(): ProviderDefinition {
|
|
14
25
|
return singletonFactoryWithResolver(ROUTE_MIDDLEWARE_COMPOSER, [], (resolver): RouteMiddlewareComposerRegistration => {
|
|
15
26
|
const cacheInstalled = resolver.tryResolve(ICache) !== undefined;
|
|
16
27
|
const logger = resolver.tryResolve(LOGGER);
|
|
28
|
+
const redis = createRouteOutputCacheComposerWithOptions({
|
|
29
|
+
policies: {},
|
|
30
|
+
globalEnabled: true,
|
|
31
|
+
cacheToken: ICache as InjectionToken<ICache<CachedHttpPayload>>,
|
|
32
|
+
securityWarnings: { behavior: "throw" },
|
|
33
|
+
distributedEnabled: resolver.has(DISTRIBUTED_OUTPUT_CACHE),
|
|
34
|
+
});
|
|
17
35
|
return {
|
|
18
|
-
order:
|
|
19
|
-
compose: (controllerClass, methodName) => {
|
|
36
|
+
order: OUTPUT_CACHE_COMPOSER_ORDER,
|
|
37
|
+
compose: (controllerClass, methodName, meta, action) => {
|
|
20
38
|
if (cacheInstalled) return [];
|
|
39
|
+
if (resolveOutputRedisCacheRequirement(outputRedisCacheMetaOf(controllerClass), methodName) !== undefined) {
|
|
40
|
+
return redis(controllerClass, methodName, meta, action);
|
|
41
|
+
}
|
|
21
42
|
const requirement = resolveOutputCacheRequirement(outputCacheMetaOf(controllerClass), methodName);
|
|
22
43
|
if (requirement === undefined || requirement.enabled === false) return [];
|
|
23
44
|
const message = `[cache] @OutputCache on ${controllerClass.name}.${String(methodName)} has no effect: `
|
package/core/cache/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CLASS_PROVIDER_HOOK,
|
|
4
4
|
OptionsValidationError,
|
|
5
5
|
singleton,
|
|
6
|
+
singletonFactoryWithResolver,
|
|
6
7
|
singletonValue,
|
|
7
8
|
type InjectionToken,
|
|
8
9
|
type Options,
|
|
@@ -17,6 +18,7 @@ import type { CachedHttpPayload } from "../http/CachedHttpPayload";
|
|
|
17
18
|
import { ICache } from "../ICache";
|
|
18
19
|
import { MemoryCache } from "../MemoryCache";
|
|
19
20
|
import { CACHE_POLICIES } from "../tokens/CACHE_POLICIES";
|
|
21
|
+
import { DISTRIBUTED_OUTPUT_CACHE } from "../tokens/DISTRIBUTED_CACHE";
|
|
20
22
|
import { CACHE_OPTIONS, validateCacheOptions, type CacheOptions } from "../types/CacheOptions";
|
|
21
23
|
import type { CachePolicyRegistry } from "../types/CachePolicy";
|
|
22
24
|
|
|
@@ -113,22 +115,21 @@ function cacheOptionsProvider(config: CacheModuleConfig): ProviderDefinition {
|
|
|
113
115
|
/** Registers the `@OutputCache` / `@OutputRedisCache` middleware via the HTTP DI token. */
|
|
114
116
|
function outputCacheComposerProvider(config: CacheModuleConfig): ProviderDefinition {
|
|
115
117
|
const token = config.token ?? ICache;
|
|
116
|
-
|
|
117
|
-
policies: config.policies ?? {},
|
|
118
|
-
globalEnabled: config.outputCache?.enabled !== false,
|
|
119
|
-
cacheToken: token as InjectionToken<ICache<CachedHttpPayload>>,
|
|
120
|
-
securityWarnings: {
|
|
121
|
-
behavior: outputCacheSecurityBehavior(config.outputCache),
|
|
122
|
-
requireAuthenticationByDefault: config.outputCache?.requireAuthenticationByDefault,
|
|
123
|
-
},
|
|
124
|
-
// Registries for `@OutputRedisCache` are published by Infra through DI.
|
|
125
|
-
// Without a registry the middleware reports an error on the first request.
|
|
126
|
-
distributedEnabled: true,
|
|
127
|
-
});
|
|
128
|
-
return singletonValue(ROUTE_MIDDLEWARE_COMPOSER, {
|
|
118
|
+
return singletonFactoryWithResolver(ROUTE_MIDDLEWARE_COMPOSER, [], (resolver) => ({
|
|
129
119
|
order: OUTPUT_CACHE_COMPOSER_ORDER,
|
|
130
|
-
compose
|
|
131
|
-
|
|
120
|
+
compose: createRouteOutputCacheComposerWithOptions({
|
|
121
|
+
policies: config.policies ?? {},
|
|
122
|
+
globalEnabled: config.outputCache?.enabled !== false,
|
|
123
|
+
cacheToken: token as InjectionToken<ICache<CachedHttpPayload>>,
|
|
124
|
+
securityWarnings: {
|
|
125
|
+
behavior: outputCacheSecurityBehavior(config.outputCache),
|
|
126
|
+
requireAuthenticationByDefault: config.outputCache?.requireAuthenticationByDefault,
|
|
127
|
+
},
|
|
128
|
+
// `@OutputRedisCache` needs the backend Infra publishes; without one the
|
|
129
|
+
// start fails and names the route instead of every request failing.
|
|
130
|
+
distributedEnabled: resolver.has(DISTRIBUTED_OUTPUT_CACHE),
|
|
131
|
+
}),
|
|
132
|
+
} satisfies RouteMiddlewareComposerRegistration));
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
function outputCacheSecurityBehavior(options: CacheOutputCacheOptions | undefined): "throw" | "warn" | "ignore" {
|
|
@@ -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.100.
|
|
235
|
+
clientInfo: { name: "bazis", title: "Bazis", version: "0.100.5" }, 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");
|
|
@@ -31,7 +31,9 @@ export interface LlmConnectorOptions {
|
|
|
31
31
|
/**
|
|
32
32
|
* Check the provider at start (the adapter's `connect`: `GET /models` for an
|
|
33
33
|
* OpenAI-compatible API, five seconds), so a wrong key or address stops the
|
|
34
|
-
* start instead of the first agent request.
|
|
34
|
+
* start instead of the first agent request. By default it runs in
|
|
35
|
+
* development and production and not in the `test` environment, where a
|
|
36
|
+
* test replaces the model; `true` checks there too, `false` never checks.
|
|
35
37
|
*/
|
|
36
38
|
readonly checkOnStart?: boolean;
|
|
37
39
|
}
|
|
@@ -92,7 +94,7 @@ export function llmConnect<T extends LlmConfigShape>(
|
|
|
92
94
|
adapter: LlmProviderAdapter,
|
|
93
95
|
options: LlmConnectorOptions = {},
|
|
94
96
|
): InfraConnector<AgentModelProvider> {
|
|
95
|
-
const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[] }>();
|
|
97
|
+
const described = new WeakMap<AgentModelProvider, { connection: LlmConnectionOptions; settings: string[]; check: boolean }>();
|
|
96
98
|
return identifyConnector({
|
|
97
99
|
token: AGENT_MODEL_PROVIDER,
|
|
98
100
|
config,
|
|
@@ -101,11 +103,14 @@ export function llmConnect<T extends LlmConfigShape>(
|
|
|
101
103
|
create(configs) {
|
|
102
104
|
const connection = resolveLlmConnectionOptions(config, options, configs);
|
|
103
105
|
const provider = adapter.create(connection);
|
|
104
|
-
|
|
106
|
+
// The environment of this kernel's configuration: tests do not call the real provider.
|
|
107
|
+
const environment = (configs?.get(config) as { environment?: string } | undefined)?.environment;
|
|
108
|
+
const check = options.checkOnStart ?? environment !== "test";
|
|
109
|
+
described.set(provider, { connection, settings: settingNames(config, configs, ["baseUrl", "apiKey"]), check });
|
|
105
110
|
return provider;
|
|
106
111
|
},
|
|
107
112
|
connect(provider, signal) {
|
|
108
|
-
if (options.checkOnStart === false) return;
|
|
113
|
+
if (described.get(provider)?.check === false || options.checkOnStart === false) return;
|
|
109
114
|
return adapter.connect?.(provider, signal);
|
|
110
115
|
},
|
|
111
116
|
describe(provider, { connected }) {
|
|
@@ -114,7 +119,7 @@ export function llmConnect<T extends LlmConfigShape>(
|
|
|
114
119
|
return {
|
|
115
120
|
kind: `${known.connection.provider} · ${known.connection.model}`,
|
|
116
121
|
address: known.connection.baseUrl,
|
|
117
|
-
details: connected &&
|
|
122
|
+
details: connected && known.check && adapter.connect !== undefined ? ["checked"] : [],
|
|
118
123
|
settings: known.settings,
|
|
119
124
|
};
|
|
120
125
|
},
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
DISTRIBUTED_OUTPUT_CACHE,
|
|
5
5
|
DISTRIBUTED_SERVICE_CACHE,
|
|
6
6
|
IDistributedCache,
|
|
7
|
+
connectionCache,
|
|
7
8
|
type DistributedCacheStores,
|
|
8
9
|
} from "../../cache";
|
|
9
10
|
import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
|
|
@@ -79,9 +80,7 @@ function distributedCacheProviders(tuning: RedisDistributedCacheTuning, token: I
|
|
|
79
80
|
// This connector's one connection by type, for services that evict tags or
|
|
80
81
|
// read values; its name may be "default" or the configured `connection`.
|
|
81
82
|
DI.singleton(
|
|
82
|
-
DI.factoryProvider(IDistributedCache, [DISTRIBUTED_CACHE_BACKEND], (backend: DistributedCacheStores) =>
|
|
83
|
-
backend.serviceCache.resolve(backend.serviceCache.connectionNames[0]),
|
|
84
|
-
),
|
|
83
|
+
DI.factoryProvider(IDistributedCache, [DISTRIBUTED_CACHE_BACKEND], (backend: DistributedCacheStores) => connectionCache(backend)),
|
|
85
84
|
),
|
|
86
85
|
];
|
|
87
86
|
}
|
|
@@ -14,6 +14,14 @@ export interface RedisReliableDeliveryOptions {
|
|
|
14
14
|
readonly maxReadBytes?: number;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Redis settings without which an acknowledged write may be lost. */
|
|
18
|
+
const DURABLE_REDIS: Readonly<Record<string, string>> = Object.freeze({
|
|
19
|
+
appendonly: "yes",
|
|
20
|
+
appendfsync: "always",
|
|
21
|
+
"no-appendfsync-on-rewrite": "no",
|
|
22
|
+
"maxmemory-policy": "noeviction",
|
|
23
|
+
});
|
|
24
|
+
|
|
17
25
|
export class RedisRoomDeliveryStore implements ReliableRoomDelivery {
|
|
18
26
|
private readonly limits: Required<Omit<RedisReliableDeliveryOptions, "requireAof">>;
|
|
19
27
|
private readonly requireAof: boolean;
|
|
@@ -47,9 +55,18 @@ export class RedisRoomDeliveryStore implements ReliableRoomDelivery {
|
|
|
47
55
|
if (Array.isArray(settings)) for (let i = 0; i < settings.length; i += 2) values.set(settings[i]!, settings[i + 1]!);
|
|
48
56
|
else for (const [key, value] of Object.entries(settings)) values.set(key, value);
|
|
49
57
|
const info = await this.operations.send("INFO", ["persistence"]) as string;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
const problems = Object.entries(DURABLE_REDIS)
|
|
59
|
+
.filter(([name, needed]) => values.get(name) !== needed)
|
|
60
|
+
.map(([name, needed]) => `${name} is ${values.get(name) ?? "unknown"}, needs ${needed}`);
|
|
61
|
+
if (!info.includes("aof_last_write_status:ok")) problems.push("the last append-only file write failed (aof_last_write_status)");
|
|
62
|
+
if (!info.includes("loading:0")) problems.push("Redis is still loading its data");
|
|
63
|
+
if (problems.length > 0) {
|
|
64
|
+
throw new WebSocketDeliveryError(
|
|
65
|
+
"DURABILITY_CONFIGURATION",
|
|
66
|
+
`WebSocket delivery needs a Redis that does not lose writes: ${problems.join("; ")}. `
|
|
67
|
+
+ "Set them in redis.conf or with CONFIG SET; otherwise undelivered messages and sessions may be lost.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
async publish(request: ReliableRoomPublication): Promise<ReliableBroadcastReceipt> {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ServiceResolver } from "../di";
|
|
1
2
|
import type { SocketUser } from "./types";
|
|
2
3
|
import { JwtError } from "../../library/jwt";
|
|
3
4
|
import { UnauthorizedError } from "../http";
|
|
@@ -23,6 +24,11 @@ export interface WsAuthenticationContext {
|
|
|
23
24
|
readonly namespace: string;
|
|
24
25
|
readonly path?: string;
|
|
25
26
|
readonly request?: Request;
|
|
27
|
+
/**
|
|
28
|
+
* The application's services, for a token check that needs them
|
|
29
|
+
* (`services.resolve(AuthTokens)`). Set by `runApp({ websocket })`.
|
|
30
|
+
*/
|
|
31
|
+
readonly services?: ServiceResolver;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
export type WsAccessTokenAuthenticator = (
|
|
@@ -35,8 +35,14 @@ export interface WebSocketModuleConfig {
|
|
|
35
35
|
readonly allowQueryToken?: boolean;
|
|
36
36
|
/** Default allowed origins when a gateway does not specify its own. */
|
|
37
37
|
readonly cors?: { origins?: string[] };
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Broadcast/session adapter built from the application's services (the
|
|
40
|
+
* in-memory single-node adapter when absent). Several instances share rooms
|
|
41
|
+
* and sessions through Redis:
|
|
42
|
+
* `adapter: (services) => new RedisWebSocketAdapter(services.resolve(IRedis))`.
|
|
43
|
+
* Called when the HTTP server starts, after the infrastructure is connected.
|
|
44
|
+
*/
|
|
45
|
+
readonly adapter?: (services: ServiceResolver) => WebSocketAdapter;
|
|
40
46
|
/** Wire codec (defaults to JSON). */
|
|
41
47
|
readonly codec?: PacketCodec;
|
|
42
48
|
/** Default replay confirmation mode. A client can opt in with ?replay=client-ack. */
|
|
@@ -83,11 +89,12 @@ export function websocketModule(config: WebSocketModuleConfig): BazisModule {
|
|
|
83
89
|
const logger = resolver.tryResolve(LOGGER);
|
|
84
90
|
return new WebSocketServer({
|
|
85
91
|
namespaces,
|
|
86
|
-
|
|
92
|
+
// The token check gets the application's services, like the adapter.
|
|
93
|
+
authenticator: config.authenticator ? (token, context) => config.authenticator!(token, { ...context!, services: resolver }) : null,
|
|
87
94
|
requireAuth: config.requireAuth ?? false,
|
|
88
95
|
allowQueryToken: config.allowQueryToken ?? false,
|
|
89
96
|
...(config.cors?.origins ? { defaultCorsOrigins: config.cors.origins } : {}),
|
|
90
|
-
...(config.adapter ? { adapter: config.adapter } : {}),
|
|
97
|
+
...(config.adapter ? { adapter: buildAdapter(config.adapter, resolver) } : {}),
|
|
91
98
|
...(config.codec ? { codec: config.codec } : {}),
|
|
92
99
|
...(config.replayDelivery ? { replayDelivery: config.replayDelivery } : {}),
|
|
93
100
|
...(config.onDiagnostic ? { onDiagnostic: config.onDiagnostic } : {}),
|
|
@@ -114,3 +121,12 @@ export function websocketModule(config: WebSocketModuleConfig): BazisModule {
|
|
|
114
121
|
|
|
115
122
|
return markGlobal(WebSocketModule);
|
|
116
123
|
}
|
|
124
|
+
|
|
125
|
+
function buildAdapter(factory: unknown, services: ServiceResolver): WebSocketAdapter {
|
|
126
|
+
if (typeof factory !== "function") {
|
|
127
|
+
throw new Error("websocket.adapter must be a function of the application services: adapter: (services) => new RedisWebSocketAdapter(services.resolve(IRedis)).");
|
|
128
|
+
}
|
|
129
|
+
const adapter = (factory as (services: ServiceResolver) => WebSocketAdapter)(services);
|
|
130
|
+
if (adapter === null || typeof adapter !== "object") throw new Error("websocket.adapter must return a WebSocket adapter.");
|
|
131
|
+
return adapter;
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bazis",
|
|
3
|
-
"version": "0.100.
|
|
3
|
+
"version": "0.100.5",
|
|
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",
|