@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.
|
|
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.
|
|
84
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
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 {
|
|
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
|
});
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
|
|
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
|