@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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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)
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|