@cosmicdrift/kumiko-framework 0.236.0 → 0.236.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.236.0",
3
+ "version": "0.236.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.236.0",
201
+ "@cosmicdrift/kumiko-types": "0.236.1",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.236.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.236.1",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -2,7 +2,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
2
2
  import { createTestRedis, type TestRedis } from "../../stack";
3
3
  import { waitFor } from "../../testing/wait-for";
4
4
  import { generateId } from "../../utils";
5
- import { createRedisSseBroker, type RedisSseBroker } from "../redis-sse-broker";
5
+ import {
6
+ createDefaultSseBroker,
7
+ createRedisSseBroker,
8
+ isRedisSseBroker,
9
+ type RedisSseBroker,
10
+ } from "../redis-sse-broker";
6
11
  import type { SseEvent } from "../sse-broker";
7
12
 
8
13
  let testRedis: TestRedis;
@@ -149,3 +154,35 @@ describe("createRedisSseBroker", () => {
149
154
  }
150
155
  });
151
156
  });
157
+
158
+ describe("createDefaultSseBroker", () => {
159
+ test("builds a Redis-backed broker under REDIS_URL that participates in cross-pod fanout", async () => {
160
+ const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker({
161
+ REDIS_URL: testRedis.redisUrl,
162
+ });
163
+ expect(ownedRedisSseBroker).toBeDefined();
164
+ expect(isRedisSseBroker(sseBroker)).toBe(true);
165
+ if (ownedRedisSseBroker) brokers.push(ownedRedisSseBroker);
166
+
167
+ const otherPod = trackedBroker();
168
+ const channel = `default-broker-${generateId()}`;
169
+ const received: SseEvent[] = [];
170
+ otherPod.addClient(
171
+ channel,
172
+ (event) => received.push(event),
173
+ () => {},
174
+ );
175
+
176
+ await waitFor(() => {
177
+ sseBroker.pushToChannel(channel, { type: "unit.updated", data: { id: "1" } });
178
+ return received.length >= 1;
179
+ });
180
+ expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
181
+ });
182
+
183
+ test("falls back to the in-memory broker without REDIS_URL", () => {
184
+ const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker({});
185
+ expect(ownedRedisSseBroker).toBeUndefined();
186
+ expect(isRedisSseBroker(sseBroker)).toBe(false);
187
+ });
188
+ });
package/src/api/index.ts CHANGED
@@ -42,7 +42,7 @@ export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
42
42
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
43
43
  export { patAllows, qnMatches } from "./pat-scope";
44
44
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
45
- export { createRedisSseBroker } from "./redis-sse-broker";
45
+ export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
46
46
  export { type RequestContextData, requestContext } from "./request-context";
47
47
  export { requestIdMiddleware } from "./request-id-middleware";
48
48
  export { createApiRoutes } from "./routes";
@@ -22,6 +22,34 @@ export type RedisSseBroker = SseBroker & {
22
22
  close(): Promise<void>;
23
23
  };
24
24
 
25
+ // The one property RedisSseBroker adds over SseBroker — cheap and reliable
26
+ // to narrow on, so callers that only hold an SseBroker (e.g. a test that
27
+ // pulled it back out of a generic ServerOptions.sseBroker slot) can tell
28
+ // which lifecycle they own without an unsound `as` cast.
29
+ export function isRedisSseBroker(broker: SseBroker): broker is RedisSseBroker {
30
+ return "close" in broker;
31
+ }
32
+
33
+ // Single decision point for "which SseBroker should a caller default to
34
+ // when it doesn't inject its own": REDIS_URL present → cross-replica
35
+ // Redis-backed broker, absent → the in-memory one. Both buildServer and
36
+ // runProdApp must funnel through this rather than each deciding on their
37
+ // own — runProdApp doing exactly that (building an in-memory broker
38
+ // unconditionally, then passing it in as ServerOptions.sseBroker) is why
39
+ // fw#2625's fanout fix (fw#2630) shipped without ever taking effect in
40
+ // production: an explicit ServerOptions.sseBroker always wins in
41
+ // buildServer, so its own REDIS_URL-based default never ran.
42
+ export function createDefaultSseBroker(env: Record<string, string | undefined> = process.env): {
43
+ readonly sseBroker: SseBroker;
44
+ // Present only when REDIS_URL resolved. undefined means the fallback
45
+ // in-memory broker was used, which has nothing to release on shutdown.
46
+ readonly ownedRedisSseBroker: RedisSseBroker | undefined;
47
+ } {
48
+ const redisUrl = env["REDIS_URL"];
49
+ const ownedRedisSseBroker = redisUrl ? createRedisSseBroker({ redisUrl }) : undefined;
50
+ return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
51
+ }
52
+
25
53
  function isSseEvent(value: unknown): value is SseEvent {
26
54
  return (
27
55
  typeof value === "object" &&
package/src/api/server.ts CHANGED
@@ -57,7 +57,7 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
57
57
  import { observabilityMiddleware } from "./observability-middleware";
58
58
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
59
59
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
60
- import { createRedisSseBroker } from "./redis-sse-broker";
60
+ import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
61
61
  import { requestContext } from "./request-context";
62
62
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
63
63
  import {
@@ -68,7 +68,7 @@ import {
68
68
  registerVersionRoute,
69
69
  } from "./route-registrars";
70
70
  import { createApiRoutes } from "./routes";
71
- import { createSseBroker, type SseBroker } from "./sse-broker";
71
+ import type { SseBroker } from "./sse-broker";
72
72
  import { createSseRoute } from "./sse-route";
73
73
 
74
74
  export type ServerOptions = {
@@ -318,17 +318,19 @@ export function buildServer(options: ServerOptions): KumikoServer {
318
318
  options.jwtTtl ?? defaultJwtTtl,
319
319
  );
320
320
  // An explicit ServerOptions.sseBroker always wins (caller owns its
321
- // lifecycle). Otherwise, REDIS_URL decides the default: without
322
- // cross-replica fanout, replicas > 1 silently drops SSE/access-
323
- // invalidation events for whichever pod didn't win the shared cursor
324
- // (fw#2625) — the safe default has to be the correct one, not the
325
- // single-process one.
326
- const ownedRedisSseBroker = options.sseBroker
327
- ? undefined
328
- : process.env["REDIS_URL"]
329
- ? createRedisSseBroker({ redisUrl: process.env["REDIS_URL"] })
330
- : undefined;
331
- const sseBroker = options.sseBroker ?? ownedRedisSseBroker ?? createSseBroker();
321
+ // lifecycle) — createDefaultSseBroker is the single decision point every
322
+ // other caller must funnel through instead of picking a broker on its
323
+ // own, so the REDIS_URL-gated cross-replica default (fw#2625) can't be
324
+ // silently bypassed the way runProdApp bypassed it before this existed.
325
+ let sseBroker: SseBroker;
326
+ let ownedRedisSseBroker: RedisSseBroker | undefined;
327
+ if (options.sseBroker) {
328
+ sseBroker = options.sseBroker;
329
+ } else {
330
+ const defaults = createDefaultSseBroker();
331
+ sseBroker = defaults.sseBroker;
332
+ ownedRedisSseBroker = defaults.ownedRedisSseBroker;
333
+ }
332
334
 
333
335
  // Resolve the per-process instance identifier. Prefer explicit
334
336
  // ServerOptions.instanceId (tests, deliberate wiring), fall back to the