@cosmicdrift/kumiko-server-runtime 0.235.4 → 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-server-runtime",
3
- "version": "0.235.4",
3
+ "version": "0.236.1",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,8 +80,8 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.235.4",
84
- "@cosmicdrift/kumiko-framework": "0.235.4",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.236.1",
84
+ "@cosmicdrift/kumiko-framework": "0.236.1",
85
85
  "temporal-polyfill": "^0.3.2"
86
86
  },
87
87
  "publishConfig": {
@@ -20,7 +20,12 @@ import {
20
20
  userSessionEntity,
21
21
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
22
22
  import { userEntity } from "@cosmicdrift/kumiko-bundled-features/user";
23
- import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
23
+ import {
24
+ createRedisSseBroker,
25
+ isRedisSseBroker,
26
+ NO_ROUTE_MATCH_HEADER_NAME,
27
+ type SseEvent,
28
+ } from "@cosmicdrift/kumiko-framework/api";
24
29
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
25
30
  import { InMemoryKmsAdapter, type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
26
31
  import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
@@ -43,6 +48,8 @@ import {
43
48
  createProjectionStateTable,
44
49
  } from "@cosmicdrift/kumiko-framework/pipeline";
45
50
  import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
51
+ import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
52
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
46
53
  import { Queue } from "bullmq";
47
54
  import postgres from "postgres";
48
55
  import { z } from "zod";
@@ -1191,4 +1198,59 @@ describe("runProdApp — /metrics endpoint (fw#1352)", () => {
1191
1198
  const res = await handle.entrypoint.app.fetch(new Request("http://test/metrics"));
1192
1199
  expect(res.status).toBe(404);
1193
1200
  });
1201
+
1202
+ // fw#2630 shipped a Redis Pub/Sub fanout fix for SSE/access-invalidation
1203
+ // across replicas, but runProdApp built its own in-memory sseBroker
1204
+ // unconditionally and passed it in as ServerOptions.sseBroker — which
1205
+ // always wins over buildServer's REDIS_URL default — so the fix never
1206
+ // took effect in production. These tests exercise the level prod
1207
+ // actually boots through (runProdApp), not buildServer/createRedisSseBroker
1208
+ // directly, which is why the tests above were green while prod was broken.
1209
+ describe("sseBroker wiring (fw#2630 Redis fanout)", () => {
1210
+ test("wires a Redis-backed sseBroker so SSE events fan out across replicas", async () => {
1211
+ const handle = await boot();
1212
+ const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
1213
+ const channel = `runprod-sse-${generateId()}`;
1214
+ const received: SseEvent[] = [];
1215
+ const otherReplica = createRedisSseBroker({ redisUrl });
1216
+ try {
1217
+ otherReplica.addClient(
1218
+ channel,
1219
+ (event) => received.push(event),
1220
+ () => {},
1221
+ );
1222
+ await waitFor(() => {
1223
+ handle.entrypoint.sseBroker.pushToChannel(channel, {
1224
+ type: "unit.updated",
1225
+ data: { id: "1" },
1226
+ });
1227
+ return received.length >= 1;
1228
+ });
1229
+ expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
1230
+ } finally {
1231
+ await otherReplica.close();
1232
+ }
1233
+ });
1234
+
1235
+ test("closes the Redis-backed sseBroker on shutdown (no leaked connection)", async () => {
1236
+ const handle = await boot();
1237
+ const broker = handle.entrypoint.sseBroker;
1238
+ if (!isRedisSseBroker(broker)) {
1239
+ throw new Error("expected a Redis-backed sseBroker under REDIS_URL");
1240
+ }
1241
+ let closeCalls = 0;
1242
+ const originalClose = broker.close.bind(broker);
1243
+ broker.close = async () => {
1244
+ closeCalls += 1;
1245
+ await originalClose();
1246
+ };
1247
+
1248
+ await handle.stop();
1249
+ // avoid a second stop()/close() from the module-level afterEach
1250
+ const idx = prodAppHandles.indexOf(handle);
1251
+ if (idx >= 0) prodAppHandles.splice(idx, 1);
1252
+
1253
+ expect(closeCalls).toBe(1);
1254
+ });
1255
+ });
1194
1256
  });
@@ -74,8 +74,8 @@ import {
74
74
  } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
75
75
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
76
76
  import {
77
+ createDefaultSseBroker,
77
78
  createRedisLoginRateLimiter,
78
- createSseBroker,
79
79
  type LoginRateLimiter,
80
80
  loadJwtSecretOrKeyring,
81
81
  type SseBroker,
@@ -898,13 +898,18 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
898
898
  // gegen die DB resolven müssen (z.B. Subdomain-Tenant-Lookup im
899
899
  // tenantResolver) — die Factory closure'd `db` und der Resolver kann
900
900
  // sie zur Request-Zeit aufrufen.
901
- // sseBroker hier bauen (statt's createApiEntrypoint intern machen zu
902
- // lassen) damit extraContext-Factories ihn schon zur Boot-Zeit closure'n
903
- // können — z.B. ein extraContext-Provider der direkt SSE-Events
904
- // publisht. Wir reichen denselben Broker dann an createApiEntrypoint
905
- // durch (sseBroker?-option), damit der Server-internal-Broadcast und
906
- // App-spezifische Publishes über genau einen Broker laufen.
907
- const sseBroker = createSseBroker();
901
+ // Build the sseBroker here (instead of letting createApiEntrypoint do it
902
+ // internally) so extraContext factories can close over it at boot time —
903
+ // e.g. an extraContext provider that publishes SSE events directly. We
904
+ // pass the same broker into createApiEntrypoint (sseBroker? option) so
905
+ // the server-internal broadcast and app-specific publishes run over
906
+ // exactly one broker. createDefaultSseBroker (not createSseBroker
907
+ // directly) is what makes that broker Redis-backed under REDIS_URL —
908
+ // an unconditional in-memory broker here would silently win over
909
+ // buildServer's own REDIS_URL default (ServerOptions.sseBroker always
910
+ // wins), which is exactly how fw#2630's cross-replica fanout fix shipped
911
+ // without ever taking effect in production.
912
+ const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker(envSource);
908
913
  const deps: RunProdAppDeps = { db, redis, registry, sseBroker };
909
914
  const resolvedExtraContext =
910
915
  typeof options.extraContext === "function"
@@ -1170,6 +1175,20 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1170
1175
  }),
1171
1176
  });
1172
1177
 
1178
+ // The broker built above is always ours (runProdApp never accepts a
1179
+ // caller-supplied sseBroker), so — same reasoning as buildServer's own
1180
+ // ownedRedisSseBroker hook — closing it on shutdown is our job, not the
1181
+ // entrypoint's. Registered after createApiEntrypoint/createAllInOneEntrypoint
1182
+ // (which already registered the eventDispatcher/jobRunner hooks above),
1183
+ // so it drains LIFO before those, matching the close-before-teardown
1184
+ // order fw#2630 established for buildServer's own hook.
1185
+ if (ownedRedisSseBroker) {
1186
+ const broker = ownedRedisSseBroker;
1187
+ entrypoint.lifecycle.registerShutdownHook("prodAppRedisSseBroker", async () => {
1188
+ await broker.close();
1189
+ });
1190
+ }
1191
+
1173
1192
  // 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
1174
1193
  // in die index.html injiziert damit createKumikoApp() im Browser
1175
1194
  // `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad