bazis 0.100.2 → 0.100.4

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.
@@ -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
+ }
@@ -17,3 +17,4 @@ export type {
17
17
  DistributedCacheStores,
18
18
  DistributedCachePing,
19
19
  } from "./DistributedCacheStores";
20
+ export { connectionCache } from "./connectionCache";
@@ -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: it adds no middleware and only warns at startup about each
10
- * `@OutputCache` route when no cache module is installed — then the decorator
11
- * does nothing and every request runs the action.
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: 0,
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: `
@@ -52,6 +52,7 @@ export {
52
52
  type DistributedCachePing,
53
53
  type DistributedCacheSetOptions,
54
54
  IDistributedCache,
55
+ connectionCache,
55
56
  } from "./distributed";
56
57
  export {
57
58
  DISTRIBUTED_CACHE_BACKEND,
@@ -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
- const compose = createRouteOutputCacheComposerWithOptions({
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
- } satisfies RouteMiddlewareComposerRegistration);
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" {
@@ -231,7 +231,8 @@ export class HttpServer implements HostedService {
231
231
  error: (error) => this.transportError(error),
232
232
  }),
233
233
  );
234
- } this.reportStarted(routes);
234
+ }
235
+ this.reportStarted(routes);
235
236
  }
236
237
 
237
238
  /** Routes and the address for the startup screen. */
@@ -369,7 +370,8 @@ export class HttpServer implements HostedService {
369
370
  }
370
371
  }
371
372
 
372
- private async dispatch(request: Request, clientIp?: string): Promise<Response> {
373
+ private async dispatch(incoming: Request, clientIp?: string): Promise<Response> {
374
+ const request = this.withAbsoluteUrl(incoming);
373
375
  let response: Response;
374
376
  try {
375
377
  response = await this.handle(request, clientIp);
@@ -395,6 +397,18 @@ export class HttpServer implements HostedService {
395
397
  return response;
396
398
  }
397
399
 
400
+ /**
401
+ * HTTP/1.0 allows a request without `Host`; Bun then gives a path, not a
402
+ * URL. Such a request is addressed to this server, so it gets the server's
403
+ * own origin and the rest of the pipeline sees an ordinary absolute URL.
404
+ */
405
+ private withAbsoluteUrl(request: Request): Request {
406
+ if (URL.canParse(request.url)) return request;
407
+ const server = this.server;
408
+ const host = server?.hostname === undefined || server.hostname === "0.0.0.0" || server.hostname === "::" ? "localhost" : server.hostname.includes(":") ? `[${server.hostname}]` : server.hostname;
409
+ return new Request(new URL(request.url, `http://${host}:${server?.port ?? 80}`).href, request);
410
+ }
411
+
398
412
  private async handle(request: Request, clientIp?: string): Promise<Response> {
399
413
  const url = new URL(request.url);
400
414
 
@@ -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.2" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.100.4" }, 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");
@@ -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
  }
@@ -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
- /** Pluggable broadcast/session adapter (defaults to in-memory, single-node). */
39
- readonly adapter?: WebSocketAdapter;
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
- authenticator: config.authenticator ?? null,
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.2",
3
+ "version": "0.100.4",
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",