@cosmicdrift/kumiko-server-runtime 0.328.0 → 0.329.0

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.328.0",
3
+ "version": "0.329.0",
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,9 +80,9 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.328.0",
84
- "@cosmicdrift/kumiko-framework": "0.328.0",
85
- "@cosmicdrift/kumiko-headless": "0.328.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.329.0",
84
+ "@cosmicdrift/kumiko-framework": "0.329.0",
85
+ "@cosmicdrift/kumiko-headless": "0.329.0",
86
86
  "temporal-polyfill": "^0.3.2"
87
87
  },
88
88
  "publishConfig": {
@@ -6,7 +6,11 @@
6
6
  // bug the "no session" test exists to catch.
7
7
 
8
8
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
- import { AUTH_COOKIE_NAME, type KumikoWebSocketData } from "@cosmicdrift/kumiko-framework/api";
9
+ import {
10
+ AUTH_COOKIE_NAME,
11
+ type KumikoWebSocketData,
12
+ WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES,
13
+ } from "@cosmicdrift/kumiko-framework/api";
10
14
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
11
15
  import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
12
16
  import { buildBunServeOptions } from "../bun-serve-options";
@@ -14,6 +18,7 @@ import { buildBunServeOptions } from "../bun-serve-options";
14
18
  const APP_ORIGIN = "https://app.example";
15
19
  const EVIL_ORIGIN = "https://evil.example";
16
20
  const MAX_MESSAGE_BYTES = 1024;
21
+ const OPEN_FRAME_BYTES = 64 * 1024;
17
22
  const membershipQuery = "tenant:query:memberships";
18
23
 
19
24
  type Deferred = { readonly promise: Promise<void>; readonly resolve: () => void };
@@ -37,6 +42,11 @@ const heldSignal = (): AbortSignal | undefined => hangingSignal;
37
42
  let limitedClosed = deferred();
38
43
  let limitedConnectCalls = 0;
39
44
  let gatedRejects = true;
45
+ let openGate = deferred();
46
+ let openFinished = deferred();
47
+ const gatedOpenReceived: string[] = [];
48
+ let messageGate = deferred();
49
+ const slowMessageStarted: string[] = [];
40
50
 
41
51
  const wsFeature = defineFeature("ws-test", (r) => {
42
52
  r.webSocketRoute({
@@ -48,6 +58,31 @@ const wsFeature = defineFeature("ws-test", (r) => {
48
58
  },
49
59
  }),
50
60
  });
61
+ r.webSocketRoute({
62
+ path: "/api/ws/slow-open",
63
+ maxMessageBytes: OPEN_FRAME_BYTES,
64
+ connect: () => ({
65
+ onOpen: async () => {
66
+ await openGate.promise;
67
+ openFinished.resolve();
68
+ },
69
+ onMessage: (data, connection) => {
70
+ gatedOpenReceived.push(String(data).slice(0, 1));
71
+ connection.send(data);
72
+ },
73
+ }),
74
+ });
75
+ r.webSocketRoute({
76
+ path: "/api/ws/slow-message",
77
+ maxMessageBytes: OPEN_FRAME_BYTES,
78
+ connect: () => ({
79
+ onMessage: async (data, connection) => {
80
+ slowMessageStarted.push(String(data).slice(0, 1));
81
+ await messageGate.promise;
82
+ connection.send(data);
83
+ },
84
+ }),
85
+ });
51
86
  r.webSocketRoute({
52
87
  path: "/api/ws/hanging",
53
88
  connect: () => ({
@@ -352,6 +387,86 @@ describe("r.webSocketRoute (integration) — same-host origin, no allowlist", ()
352
387
  expect(hangingStarted).toEqual(["hangs"]);
353
388
  });
354
389
 
390
+ test("frames buffered while onOpen runs arrive in order once it finishes", async () => {
391
+ openGate = deferred();
392
+ openFinished = deferred();
393
+ gatedOpenReceived.length = 0;
394
+ const socket = connectSocket(harness.url("/api/ws/slow-open"), {
395
+ Cookie: cookie,
396
+ Origin: harness.httpOrigin,
397
+ });
398
+ await socket.opened;
399
+ const allReceived = harness.nextMessages(3);
400
+ for (const tag of ["a", "b", "c"]) socket.send(tag.repeat(OPEN_FRAME_BYTES));
401
+ await allReceived;
402
+ expect(gatedOpenReceived).toEqual([]);
403
+ openGate.resolve();
404
+ for (const tag of ["a", "b", "c"]) {
405
+ expect(String(await socket.next()).slice(0, 1)).toBe(tag);
406
+ }
407
+ expect(gatedOpenReceived).toEqual(["a", "b", "c"]);
408
+ socket.close();
409
+ await socket.closed;
410
+ });
411
+
412
+ test("frames over the limit behind a slow onOpen close with 1013 and never reach onMessage", async () => {
413
+ openGate = deferred();
414
+ openFinished = deferred();
415
+ gatedOpenReceived.length = 0;
416
+ const socket = connectSocket(harness.url("/api/ws/slow-open"), {
417
+ Cookie: cookie,
418
+ Origin: harness.httpOrigin,
419
+ });
420
+ await socket.opened;
421
+ const frameCount = WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES / OPEN_FRAME_BYTES + 1;
422
+ for (let sent = 0; sent < frameCount; sent += 1) {
423
+ socket.send("x".repeat(OPEN_FRAME_BYTES));
424
+ }
425
+ expect((await socket.closed).code).toBe(1013);
426
+ openGate.resolve();
427
+ await openFinished.promise;
428
+ await new Promise<void>((resolve) => setImmediate(resolve));
429
+ expect(gatedOpenReceived).toEqual([]);
430
+ });
431
+
432
+ test("frames queued behind a slow onMessage arrive in order once it finishes", async () => {
433
+ messageGate = deferred();
434
+ slowMessageStarted.length = 0;
435
+ const socket = connectSocket(harness.url("/api/ws/slow-message"), {
436
+ Cookie: cookie,
437
+ Origin: harness.httpOrigin,
438
+ });
439
+ await socket.opened;
440
+ const allReceived = harness.nextMessages(3);
441
+ for (const tag of ["a", "b", "c"]) socket.send(tag.repeat(OPEN_FRAME_BYTES));
442
+ await allReceived;
443
+ messageGate.resolve();
444
+ for (const tag of ["a", "b", "c"]) {
445
+ expect(String(await socket.next()).slice(0, 1)).toBe(tag);
446
+ }
447
+ expect(slowMessageStarted).toEqual(["a", "b", "c"]);
448
+ socket.close();
449
+ await socket.closed;
450
+ });
451
+
452
+ test("frames over the limit behind a slow onMessage close with 1013 and never start", async () => {
453
+ messageGate = deferred();
454
+ slowMessageStarted.length = 0;
455
+ const socket = connectSocket(harness.url("/api/ws/slow-message"), {
456
+ Cookie: cookie,
457
+ Origin: harness.httpOrigin,
458
+ });
459
+ await socket.opened;
460
+ const frameCount = WEBSOCKET_PENDING_BUFFER_LIMIT_BYTES / OPEN_FRAME_BYTES + 1;
461
+ for (let sent = 0; sent < frameCount; sent += 1) {
462
+ socket.send("x".repeat(OPEN_FRAME_BYTES));
463
+ }
464
+ expect((await socket.closed).code).toBe(1013);
465
+ messageGate.resolve();
466
+ await new Promise<void>((resolve) => setImmediate(resolve));
467
+ expect(slowMessageStarted).toEqual(["x"]);
468
+ });
469
+
355
470
  test("a message above maxMessageBytes closes the socket with 1009", async () => {
356
471
  const socket = connectSocket(harness.url("/api/ws/echo"), {
357
472
  Cookie: cookie,