sonamu 0.11.16 → 0.13.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.
Files changed (322) hide show
  1. package/dist/_virtual/_rolldown/runtime.js +1 -2
  2. package/dist/ai/agents/agent.js +2 -3
  3. package/dist/ai/agents/index.js +1 -2
  4. package/dist/ai/index.js +1 -2
  5. package/dist/ai/providers/rtzr/api.js +1 -2
  6. package/dist/ai/providers/rtzr/error.js +2 -3
  7. package/dist/ai/providers/rtzr/model.js +1 -2
  8. package/dist/ai/providers/rtzr/options.js +1 -2
  9. package/dist/ai/providers/rtzr/provider.js +1 -2
  10. package/dist/ai/providers/rtzr/utils.js +1 -2
  11. package/dist/api/base-frame.js +4 -4
  12. package/dist/api/caster.d.ts +1 -1
  13. package/dist/api/caster.d.ts.map +1 -1
  14. package/dist/api/caster.js +9 -9
  15. package/dist/api/code-converters.js +6 -5
  16. package/dist/api/config.d.ts +1 -1
  17. package/dist/api/config.d.ts.map +1 -1
  18. package/dist/api/config.js +2 -3
  19. package/dist/api/context.d.ts +2 -3
  20. package/dist/api/context.d.ts.map +1 -1
  21. package/dist/api/context.js +2 -3
  22. package/dist/api/decorators.js +2 -3
  23. package/dist/api/http-validator.js +2 -3
  24. package/dist/api/index.js +8 -3
  25. package/dist/api/secret.js +1 -2
  26. package/dist/api/sonamu.d.ts +3 -5
  27. package/dist/api/sonamu.d.ts.map +1 -1
  28. package/dist/api/sonamu.js +30 -24
  29. package/dist/api/validator.js +1 -2
  30. package/dist/api/websocket-helpers.js +2 -3
  31. package/dist/auth/audit-log/builders.js +1 -2
  32. package/dist/auth/audit-log/client-ip.js +2 -3
  33. package/dist/auth/audit-log/events.js +1 -2
  34. package/dist/auth/audit-log/plugin.d.ts.map +1 -1
  35. package/dist/auth/audit-log/plugin.js +2 -3
  36. package/dist/auth/audit-log-ingestor.js +2 -3
  37. package/dist/auth/auth-generator.js +2 -3
  38. package/dist/auth/better-auth-entities.js +1 -2
  39. package/dist/auth/better-auth-request.js +2 -3
  40. package/dist/auth/index.js +2 -3
  41. package/dist/auth/knex-adapter.js +2 -3
  42. package/dist/auth/plugins/entity-definitions/admin.js +1 -2
  43. package/dist/auth/plugins/entity-definitions/anonymous.js +1 -2
  44. package/dist/auth/plugins/entity-definitions/api-key.js +1 -2
  45. package/dist/auth/plugins/entity-definitions/audit-log.js +1 -2
  46. package/dist/auth/plugins/entity-definitions/index.js +1 -2
  47. package/dist/auth/plugins/entity-definitions/jwt.js +1 -2
  48. package/dist/auth/plugins/entity-definitions/organization.js +1 -2
  49. package/dist/auth/plugins/entity-definitions/passkey.js +1 -2
  50. package/dist/auth/plugins/entity-definitions/phone-number.js +1 -2
  51. package/dist/auth/plugins/entity-definitions/sso.js +1 -2
  52. package/dist/auth/plugins/entity-definitions/two-factor.js +1 -2
  53. package/dist/auth/plugins/entity-definitions/username.js +1 -2
  54. package/dist/auth/plugins/wrappers/admin.d.ts +33 -32
  55. package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
  56. package/dist/auth/plugins/wrappers/admin.js +2 -3
  57. package/dist/auth/plugins/wrappers/anonymous.js +2 -3
  58. package/dist/auth/plugins/wrappers/api-key.js +2 -3
  59. package/dist/auth/plugins/wrappers/jwt.js +2 -3
  60. package/dist/auth/plugins/wrappers/organization.js +2 -3
  61. package/dist/auth/plugins/wrappers/passkey.js +2 -3
  62. package/dist/auth/plugins/wrappers/phone-number.d.ts +43 -1
  63. package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
  64. package/dist/auth/plugins/wrappers/phone-number.js +2 -3
  65. package/dist/auth/plugins/wrappers/sso.d.ts +138 -187
  66. package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -1
  67. package/dist/auth/plugins/wrappers/sso.js +1 -2
  68. package/dist/auth/plugins/wrappers/two-factor.d.ts +51 -10
  69. package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
  70. package/dist/auth/plugins/wrappers/two-factor.js +2 -3
  71. package/dist/auth/plugins/wrappers/username.d.ts +39 -5
  72. package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
  73. package/dist/auth/plugins/wrappers/username.js +2 -3
  74. package/dist/bin/build-config.js +5 -5
  75. package/dist/bin/compiler-policy.js +2 -3
  76. package/dist/bin/fixture.js +7 -5
  77. package/dist/bin/hmr-hook-register.js +1 -2
  78. package/dist/bin/migrate-targets.js +2 -3
  79. package/dist/bin/test-command.js +2 -3
  80. package/dist/bin/ts-loader-register.js +2 -3
  81. package/dist/bin/ts-loader-registration.js +2 -3
  82. package/dist/bin/typecheck-web.js +2 -3
  83. package/dist/cache/cache-manager.js +1 -2
  84. package/dist/cache/decorator.js +2 -3
  85. package/dist/cache/drivers.js +1 -2
  86. package/dist/cache/index.js +2 -3
  87. package/dist/cache-control/cache-control.js +1 -2
  88. package/dist/code-formatting/index.js +2 -3
  89. package/dist/code-formatting/oxfmt.js +2 -3
  90. package/dist/code-formatting/oxlint.js +2 -3
  91. package/dist/compress/compress.js +1 -2
  92. package/dist/compress/index.js +2 -2
  93. package/dist/compress/types.d.ts +1 -1
  94. package/dist/compress/types.d.ts.map +1 -1
  95. package/dist/cone/cone-generator.js +2 -3
  96. package/dist/database/_batch_update.js +1 -2
  97. package/dist/database/base-model.js +4 -4
  98. package/dist/database/db.js +4 -4
  99. package/dist/database/knex.js +2 -3
  100. package/dist/database/puri-wrapper.js +2 -3
  101. package/dist/database/puri.js +23 -22
  102. package/dist/database/puri.types.js +1 -2
  103. package/dist/database/transaction-context.js +1 -2
  104. package/dist/database/upsert-builder.js +2 -3
  105. package/dist/dict/en.js +2 -3
  106. package/dist/dict/index.js +3 -3
  107. package/dist/dict/ko.js +2 -3
  108. package/dist/dict/rc-keys.js +1 -2
  109. package/dist/dict/sd.js +2 -3
  110. package/dist/dict/sonamu-dictionary.js +7 -6
  111. package/dist/dict/utils.js +2 -3
  112. package/dist/entity/entity-manager.d.ts +66 -66
  113. package/dist/entity/entity-manager.js +4 -4
  114. package/dist/entity/entity-template-cone.js +2 -3
  115. package/dist/entity/entity.js +21 -18
  116. package/dist/env.js +1 -2
  117. package/dist/exceptions/error-handler.d.ts.map +1 -1
  118. package/dist/exceptions/error-handler.js +2 -3
  119. package/dist/exceptions/so-exceptions.js +2 -3
  120. package/dist/filter/index.js +3 -2
  121. package/dist/filter/types.js +1 -2
  122. package/dist/filter/utils.js +7 -6
  123. package/dist/index.d.ts +1 -0
  124. package/dist/index.d.ts.map +1 -1
  125. package/dist/index.js +42 -6
  126. package/dist/logger/category.js +2 -3
  127. package/dist/logger/configure.js +5 -5
  128. package/dist/migration/code-generation.js +23 -16
  129. package/dist/migration/index-where-predicate.js +2 -3
  130. package/dist/migration/migration-set.js +2 -3
  131. package/dist/migration/migrator.js +2 -3
  132. package/dist/migration/postgresql-schema-reader.js +6 -9
  133. package/dist/migration/slack-confirm.js +2 -3
  134. package/dist/naite/naite-reporter.js +2 -3
  135. package/dist/naite/naite.js +10 -8
  136. package/dist/runtime-type.js +2 -3
  137. package/dist/ssr/hmr-opt-out.js +1 -2
  138. package/dist/ssr/index.js +7 -4
  139. package/dist/ssr/registry.js +1 -2
  140. package/dist/ssr/renderer.js +2 -3
  141. package/dist/storage/base-file.js +1 -2
  142. package/dist/storage/buffered-file.js +2 -3
  143. package/dist/storage/drivers.js +1 -2
  144. package/dist/storage/index.js +2 -3
  145. package/dist/storage/s3-driver.js +1 -2
  146. package/dist/storage/storage-manager.js +2 -3
  147. package/dist/storage/uploaded-file.js +2 -3
  148. package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
  149. package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
  150. package/dist/stream/__tests__/ws-test-helpers.js +259 -0
  151. package/dist/stream/index.d.ts +1 -0
  152. package/dist/stream/index.d.ts.map +1 -1
  153. package/dist/stream/index.js +15 -4
  154. package/dist/stream/sse.js +1 -2
  155. package/dist/stream/ws-audience-resolver.d.ts +8 -5
  156. package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
  157. package/dist/stream/ws-audience-resolver.js +17 -15
  158. package/dist/stream/ws-audience.js +1 -2
  159. package/dist/stream/ws-cluster-bus.d.ts +9 -4
  160. package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
  161. package/dist/stream/ws-cluster-bus.js +2 -3
  162. package/dist/stream/ws-core.d.ts +10 -0
  163. package/dist/stream/ws-core.d.ts.map +1 -1
  164. package/dist/stream/ws-delivery.d.ts +30 -4
  165. package/dist/stream/ws-delivery.d.ts.map +1 -1
  166. package/dist/stream/ws-delivery.js +200 -54
  167. package/dist/stream/ws-frame.d.ts +15 -0
  168. package/dist/stream/ws-frame.d.ts.map +1 -0
  169. package/dist/stream/ws-frame.js +28 -0
  170. package/dist/stream/ws-local-connection-store.d.ts +4 -4
  171. package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
  172. package/dist/stream/ws-local-connection-store.js +3 -4
  173. package/dist/stream/ws-presence-index.d.ts +36 -0
  174. package/dist/stream/ws-presence-index.d.ts.map +1 -0
  175. package/dist/stream/ws-presence-index.js +176 -0
  176. package/dist/stream/ws-presence-store.d.ts +39 -39
  177. package/dist/stream/ws-presence-store.d.ts.map +1 -1
  178. package/dist/stream/ws-presence-store.js +23 -154
  179. package/dist/stream/ws-registry.d.ts +45 -17
  180. package/dist/stream/ws-registry.d.ts.map +1 -1
  181. package/dist/stream/ws-registry.js +170 -62
  182. package/dist/stream/ws-session-id.d.ts +14 -0
  183. package/dist/stream/ws-session-id.d.ts.map +1 -0
  184. package/dist/stream/ws-session-id.js +36 -0
  185. package/dist/stream/ws-telemetry-memory.js +3 -4
  186. package/dist/stream/ws-telemetry-trace.js +1 -2
  187. package/dist/stream/ws-telemetry.js +2 -3
  188. package/dist/stream/ws.d.ts +20 -10
  189. package/dist/stream/ws.d.ts.map +1 -1
  190. package/dist/stream/ws.js +60 -38
  191. package/dist/syncer/api-parser.js +2 -3
  192. package/dist/syncer/checksum.js +4 -4
  193. package/dist/syncer/code-generator.js +4 -4
  194. package/dist/syncer/entity-operations.js +2 -3
  195. package/dist/syncer/event-batcher.js +1 -2
  196. package/dist/syncer/file-copy.js +2 -3
  197. package/dist/syncer/file-patterns.js +2 -3
  198. package/dist/syncer/file-tracking.js +2 -3
  199. package/dist/syncer/filesystem-dependencies.js +1 -2
  200. package/dist/syncer/index.js +8 -2
  201. package/dist/syncer/module-loader.js +8 -6
  202. package/dist/syncer/syncer-actions.js +8 -7
  203. package/dist/syncer/syncer.js +13 -8
  204. package/dist/syncer/watcher.js +2 -3
  205. package/dist/tasks/decorator.js +1 -2
  206. package/dist/tasks/step-wrapper.js +2 -3
  207. package/dist/tasks/workflow-manager.js +2 -3
  208. package/dist/template/entity-converter.js +17 -16
  209. package/dist/template/helpers.js +2 -3
  210. package/dist/template/implementations/entity.template.js +2 -3
  211. package/dist/template/implementations/entry-server.template.js +2 -3
  212. package/dist/template/implementations/generated.template.js +2 -3
  213. package/dist/template/implementations/generated_http.template.js +6 -6
  214. package/dist/template/implementations/generated_sso.template.js +2 -3
  215. package/dist/template/implementations/http_validators.template.js +2 -3
  216. package/dist/template/implementations/init_types.template.js +2 -3
  217. package/dist/template/implementations/model.template.js +4 -4
  218. package/dist/template/implementations/model_test.template.js +2 -3
  219. package/dist/template/implementations/queries.template.js +2 -3
  220. package/dist/template/implementations/sd.template.js +2 -3
  221. package/dist/template/implementations/services.template.js +2 -3
  222. package/dist/template/implementations/view_form.template.js +12 -9
  223. package/dist/template/implementations/view_id_all_select.template.js +2 -3
  224. package/dist/template/implementations/view_list.template.d.ts.map +1 -1
  225. package/dist/template/implementations/view_list.template.js +19 -17
  226. package/dist/template/implementations/view_search_input.template.js +2 -3
  227. package/dist/template/index.js +9 -3
  228. package/dist/template/template-manager.js +2 -3
  229. package/dist/template/template-types.js +1 -2
  230. package/dist/template/template.js +2 -3
  231. package/dist/template/zod-converter.js +54 -47
  232. package/dist/testing/_relation-graph.js +2 -3
  233. package/dist/testing/bootstrap.js +2 -3
  234. package/dist/testing/data-explorer.js +2 -3
  235. package/dist/testing/dev-test-routes.js +2 -3
  236. package/dist/testing/dev-vitest-manager.js +15 -12
  237. package/dist/testing/faker-mappings.js +1 -2
  238. package/dist/testing/filesystem.js +2 -3
  239. package/dist/testing/fixture-generator.js +2 -3
  240. package/dist/testing/fixture-loader.js +1 -2
  241. package/dist/testing/fixture-manager.js +12 -9
  242. package/dist/testing/global-setup.js +5 -5
  243. package/dist/testing/index.js +6 -3
  244. package/dist/testing/naite-vitest-reporter.js +1 -2
  245. package/dist/testing/parallel-db-manager.js +2 -3
  246. package/dist/testing/vitest-helpers.js +2 -3
  247. package/dist/tooling/__tests__/helpers/sonamu-test-root.js +2 -3
  248. package/dist/tooling/cdd-service.js +1 -2
  249. package/dist/tooling/cli-tooling.js +1 -2
  250. package/dist/tooling/fixture-service.js +1 -2
  251. package/dist/tooling/index.js +1 -2
  252. package/dist/tooling/test-run-client.js +2 -3
  253. package/dist/tsdown.api.config.js +1 -2
  254. package/dist/types/types.js +2 -3
  255. package/dist/ui/ai-api.d.ts.map +1 -1
  256. package/dist/ui/ai-api.js +4 -5
  257. package/dist/ui/ai-client.d.ts +2 -2
  258. package/dist/ui/ai-client.d.ts.map +1 -1
  259. package/dist/ui/ai-client.js +9 -8
  260. package/dist/ui/api.d.ts.map +1 -1
  261. package/dist/ui/api.js +11 -8
  262. package/dist/ui/async-route.js +1 -2
  263. package/dist/ui/cdd-service.js +22 -21
  264. package/dist/ui/migrations-api.js +6 -5
  265. package/dist/ui-web/assets/index-QDMyy31K.js +180 -0
  266. package/dist/ui-web/assets/index-dV7akMFB.css +1 -0
  267. package/dist/ui-web/index.html +2 -2
  268. package/dist/utils/async-utils.js +1 -2
  269. package/dist/utils/class-name.js +1 -2
  270. package/dist/utils/console-util.js +1 -2
  271. package/dist/utils/controller.js +2 -3
  272. package/dist/utils/esm-utils.js +1 -2
  273. package/dist/utils/fs-utils.js +2 -3
  274. package/dist/utils/lodash-able.js +1 -2
  275. package/dist/utils/model.js +1 -2
  276. package/dist/utils/object-utils.js +2 -3
  277. package/dist/utils/path-utils.js +2 -3
  278. package/dist/utils/process-utils.js +2 -3
  279. package/dist/utils/runtime-value.js +1 -2
  280. package/dist/utils/sql-parser.js +2 -3
  281. package/dist/utils/type-utils.js +8 -8
  282. package/dist/utils/utils.js +2 -3
  283. package/dist/utils/zod-error.js +2 -3
  284. package/dist/vector/chunking.js +1 -2
  285. package/dist/vector/config.js +1 -2
  286. package/dist/vector/embedding.js +4 -4
  287. package/package.json +63 -63
  288. package/src/ai/agents/agent.ts +1 -1
  289. package/src/api/__tests__/sonamu.plugins.test.ts +11 -1
  290. package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
  291. package/src/api/__tests__/sonamu.websocket.test.ts +241 -24
  292. package/src/api/config.ts +1 -1
  293. package/src/api/context.ts +2 -8
  294. package/src/api/sonamu.ts +46 -28
  295. package/src/auth/audit-log/plugin.ts +2 -0
  296. package/src/compress/types.ts +1 -1
  297. package/src/exceptions/error-handler.ts +3 -2
  298. package/src/index.ts +1 -0
  299. package/src/stream/__tests__/ws-contracts.test.ts +483 -265
  300. package/src/stream/__tests__/ws-multinode.test.ts +326 -0
  301. package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
  302. package/src/stream/__tests__/ws-test-helpers.ts +360 -0
  303. package/src/stream/__tests__/ws.test.ts +342 -145
  304. package/src/stream/index.ts +1 -0
  305. package/src/stream/ws-audience-resolver.ts +21 -19
  306. package/src/stream/ws-cluster-bus.ts +12 -4
  307. package/src/stream/ws-core.ts +14 -0
  308. package/src/stream/ws-delivery.ts +321 -97
  309. package/src/stream/ws-frame.ts +39 -0
  310. package/src/stream/ws-local-connection-store.ts +10 -9
  311. package/src/stream/ws-presence-index.ts +273 -0
  312. package/src/stream/ws-presence-store.ts +64 -284
  313. package/src/stream/ws-registry.ts +272 -85
  314. package/src/stream/ws-session-id.ts +39 -0
  315. package/src/stream/ws.ts +91 -50
  316. package/src/template/implementations/model.template.ts +3 -3
  317. package/src/template/implementations/view_list.template.ts +12 -13
  318. package/src/ui/ai-api.ts +4 -2
  319. package/src/ui/ai-client.ts +2 -2
  320. package/src/ui/api.ts +58 -62
  321. package/dist/ui-web/assets/index-BMdIyKXS.css +0 -1
  322. package/dist/ui-web/assets/index-LpE8u19O.js +0 -178
@@ -1,383 +1,601 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
2
3
 
4
+ import { createWebSocketRuntime } from "../ws";
3
5
  import { WebSocketAudience } from "../ws-audience";
4
- import { WebSocketAudienceResolver } from "../ws-audience-resolver";
5
- import {
6
- NoopWebSocketClusterBus,
7
- type WebSocketClusterBus,
8
- type WebSocketClusterEnvelope,
9
- type WebSocketClusterEnvelopeHandler,
10
- } from "../ws-cluster-bus";
11
- import { type ManagedWebSocketConnection } from "../ws-core";
12
- import { WebSocketDeliveryEngine } from "../ws-delivery";
13
- import { WebSocketLocalConnectionStore } from "../ws-local-connection-store";
6
+ import { NoopWebSocketClusterBus, type WebSocketClusterEnvelope } from "../ws-cluster-bus";
14
7
  import { InMemoryWebSocketPresenceStore } from "../ws-presence-store";
15
-
16
- type PublishedData = Parameters<ManagedWebSocketConnection["publishUntyped"]>[1];
17
-
18
- async function waitForAsyncQueue(rounds: number = 3): Promise<void> {
19
- for (let index = 0; index < rounds; index += 1) {
20
- await new Promise<void>((resolve) => {
21
- setImmediate(resolve);
22
- });
23
- }
24
- }
25
-
26
- class ContractClusterBus implements WebSocketClusterBus {
27
- readonly published: WebSocketClusterEnvelope[] = [];
28
- private readonly handlers = new Set<WebSocketClusterEnvelopeHandler>();
29
-
30
- publish(envelope: WebSocketClusterEnvelope): void {
31
- this.published.push(envelope);
32
- }
33
-
34
- subscribe(handler: WebSocketClusterEnvelopeHandler): () => void {
35
- this.handlers.add(handler);
36
- return () => {
37
- this.handlers.delete(handler);
38
- };
39
- }
40
-
41
- emit(envelope: WebSocketClusterEnvelope): void {
42
- for (const handler of this.handlers) {
43
- handler(envelope);
44
- }
45
- }
46
-
47
- shutdown(): void {
48
- this.handlers.clear();
49
- }
8
+ import {
9
+ FakeWebSocket,
10
+ RecordingClusterBus,
11
+ ScriptedPresenceStore,
12
+ asWebSocket,
13
+ flushAsync,
14
+ flushUntil,
15
+ } from "./ws-test-helpers";
16
+
17
+ const OutEvents = z.object({
18
+ onReady: z.object({
19
+ ok: z.boolean(),
20
+ }),
21
+ });
22
+ const InEvents = z.object({});
23
+
24
+ async function registerSession(
25
+ store: InMemoryWebSocketPresenceStore,
26
+ input: { sessionId: string; nodeId: string; namespace: string; active?: boolean },
27
+ ): Promise<void> {
28
+ await store.register({
29
+ sessionId: input.sessionId,
30
+ nodeId: input.nodeId,
31
+ namespace: input.namespace,
32
+ active: input.active ?? true,
33
+ connectedAt: new Date("2026-01-01T00:00:00.000Z"),
34
+ });
50
35
  }
51
36
 
52
- class ContractConnection implements ManagedWebSocketConnection {
53
- readonly sent: Array<{ event: string; data: PublishedData }> = [];
54
- closed = false;
55
-
56
- constructor(
57
- readonly id: string,
58
- readonly namespace: string,
59
- ) {}
60
-
61
- publishUntyped<Data>(event: string, data: Data): void {
62
- this.sent.push({ event, data });
63
- }
64
-
65
- close(): void {
66
- this.closed = true;
67
- }
37
+ function createEnvelope(input: {
38
+ id: string;
39
+ sourceNodeId?: string;
40
+ targetNodeIds?: string[];
41
+ audience?: WebSocketAudience;
42
+ event?: string;
43
+ frames: Record<string, string>;
44
+ }): WebSocketClusterEnvelope {
45
+ return {
46
+ id: input.id,
47
+ sourceNodeId: input.sourceNodeId ?? "node-b",
48
+ targetNodeIds: input.targetNodeIds ?? ["node-a"],
49
+ audience: input.audience ?? WebSocketAudience.all("chat"),
50
+ event: input.event ?? "onReady",
51
+ frames: input.frames,
52
+ emittedAt: Date.now(),
53
+ };
68
54
  }
69
55
 
70
56
  describe("WebSocket contract tests", () => {
71
- describe("PresenceStore", () => {
72
- it("keeps inactive sessions out of counts and audience queries until activation", () => {
57
+ describe("PresenceStore (InMemoryWebSocketPresenceStore)", () => {
58
+ it("활성화 전의 세션은 라우팅과 방 인원수에서 제외한다", async () => {
73
59
  const store = new InMemoryWebSocketPresenceStore();
74
- store.register({
60
+ await registerSession(store, {
75
61
  sessionId: "s1",
76
62
  nodeId: "node-a",
77
63
  namespace: "chat",
78
64
  active: false,
79
65
  });
66
+ await store.join("s1", "room-1");
80
67
 
81
- expect(store.getConnectionCount("chat")).toBe(0);
82
- expect(store.queryAudience(WebSocketAudience.all("chat"))).toHaveLength(0);
68
+ expect(await store.resolveRoute(WebSocketAudience.all("chat"), "node-a")).toEqual({
69
+ localSessionIds: [],
70
+ remoteNodeIds: [],
71
+ });
72
+ expect(await store.getRoomMemberCount("room-1", "chat")).toBe(0);
83
73
 
84
- store.activate("s1");
74
+ await store.activate("s1");
85
75
 
86
- expect(store.getConnectionCount("chat")).toBe(1);
87
- expect(
88
- store.queryAudience(WebSocketAudience.all("chat")).map((meta) => meta.sessionId),
89
- ).toEqual(["s1"]);
76
+ expect(await store.resolveRoute(WebSocketAudience.all("chat"), "node-a")).toEqual({
77
+ localSessionIds: ["s1"],
78
+ remoteNodeIds: [],
79
+ });
80
+ expect(await store.getRoomMemberCount("room-1", "chat")).toBe(1);
90
81
  });
91
82
 
92
- it("isolates room and user bindings by namespace", () => {
83
+ it("방과 사용자 바인딩을 namespace별로 격리한다", async () => {
93
84
  const store = new InMemoryWebSocketPresenceStore();
94
- store.register({
95
- sessionId: "chat-1",
96
- nodeId: "node-a",
97
- namespace: "chat",
98
- });
99
- store.register({
100
- sessionId: "admin-1",
101
- nodeId: "node-a",
102
- namespace: "admin",
103
- });
85
+ await registerSession(store, { sessionId: "chat-1", nodeId: "node-a", namespace: "chat" });
86
+ await registerSession(store, { sessionId: "admin-1", nodeId: "node-a", namespace: "admin" });
104
87
 
105
- store.join("chat-1", "room-1");
106
- store.join("admin-1", "room-1");
107
- store.setUserId("chat-1", "user-1");
108
- store.setUserId("admin-1", "user-1");
88
+ await store.join("chat-1", "room-1");
89
+ await store.join("admin-1", "room-1");
90
+ await store.setUserId("chat-1", "user-1");
91
+ await store.setUserId("admin-1", "user-1");
109
92
 
110
93
  expect(
111
- store.queryAudience(WebSocketAudience.room("room-1", "chat")).map((meta) => meta.sessionId),
94
+ (await store.resolveRoute(WebSocketAudience.room("room-1", "chat"), "node-a"))
95
+ .localSessionIds,
112
96
  ).toEqual(["chat-1"]);
113
97
  expect(
114
- store
115
- .queryAudience(WebSocketAudience.user("user-1", "admin"))
116
- .map((meta) => meta.sessionId),
98
+ (await store.resolveRoute(WebSocketAudience.user("user-1", "admin"), "node-a"))
99
+ .localSessionIds,
117
100
  ).toEqual(["admin-1"]);
118
101
  });
119
102
 
120
- it("cleans room and user bindings when a session is unregistered", () => {
103
+ it("세션을 해제하면 방과 사용자 바인딩도 정리한다", async () => {
121
104
  const store = new InMemoryWebSocketPresenceStore();
122
- store.register({
123
- sessionId: "s1",
124
- nodeId: "node-a",
125
- namespace: "chat",
126
- });
127
- store.join("s1", "room-1");
128
- store.setUserId("s1", "user-1");
105
+ await registerSession(store, { sessionId: "s1", nodeId: "node-a", namespace: "chat" });
106
+ await store.join("s1", "room-1");
107
+ await store.setUserId("s1", "user-1");
129
108
 
130
- expect(store.unregister("s1")?.sessionId).toBe("s1");
109
+ await store.unregister("s1");
131
110
 
132
111
  expect(store.getConnection("s1")).toBeUndefined();
133
- expect(store.queryAudience(WebSocketAudience.room("room-1", "chat"))).toHaveLength(0);
134
- expect(store.queryAudience(WebSocketAudience.user("user-1", "chat"))).toHaveLength(0);
135
- expect(store.getStats()).toMatchObject({
136
- totalConnections: 0,
137
- totalRooms: 0,
138
- });
112
+ expect(
113
+ (await store.resolveRoute(WebSocketAudience.room("room-1", "chat"), "node-a"))
114
+ .localSessionIds,
115
+ ).toHaveLength(0);
116
+ expect(
117
+ (await store.resolveRoute(WebSocketAudience.user("user-1", "chat"), "node-a"))
118
+ .localSessionIds,
119
+ ).toHaveLength(0);
120
+ expect(await store.getRoomMemberCount("room-1")).toBe(0);
139
121
  });
140
122
 
141
- it("dedupes union audience results", () => {
123
+ it("union audience의 중복 세션과 중복 노드를 제거한다", async () => {
142
124
  const store = new InMemoryWebSocketPresenceStore();
143
- store.register({
144
- sessionId: "s1",
145
- nodeId: "node-a",
146
- namespace: "chat",
147
- });
148
- store.join("s1", "room-1");
149
- store.setUserId("s1", "user-1");
150
-
151
- const metas = store.queryAudience(
125
+ await registerSession(store, { sessionId: "s1", nodeId: "node-a", namespace: "chat" });
126
+ await registerSession(store, { sessionId: "r1", nodeId: "node-b", namespace: "chat" });
127
+ await store.join("s1", "room-1");
128
+ await store.setUserId("s1", "user-1");
129
+ await store.join("r1", "room-1");
130
+ await store.setUserId("r1", "user-1");
131
+
132
+ const plan = await store.resolveRoute(
152
133
  WebSocketAudience.union(
153
134
  WebSocketAudience.room("room-1", "chat"),
154
135
  WebSocketAudience.user("user-1", "chat"),
155
136
  ),
137
+ "node-a",
156
138
  );
157
139
 
158
- expect(metas.map((meta) => meta.sessionId)).toEqual(["s1"]);
140
+ expect(plan).toEqual({ localSessionIds: ["s1"], remoteNodeIds: ["node-b"] });
159
141
  });
160
- });
161
142
 
162
- describe("AudienceResolver", () => {
163
- it("splits local session ids from unique remote node ids", () => {
143
+ it("로컬 세션 ID와 중복 없는 원격 노드 ID를 나눠 반환하고 비활성 원격 세션은 제외한다", async () => {
164
144
  const store = new InMemoryWebSocketPresenceStore();
165
- store.register({
166
- sessionId: "local-1",
167
- nodeId: "node-a",
145
+ await registerSession(store, { sessionId: "local-1", nodeId: "node-a", namespace: "chat" });
146
+ await registerSession(store, { sessionId: "remote-1", nodeId: "node-b", namespace: "chat" });
147
+ await registerSession(store, { sessionId: "remote-2", nodeId: "node-b", namespace: "chat" });
148
+ await registerSession(store, {
149
+ sessionId: "inactive-1",
150
+ nodeId: "node-c",
168
151
  namespace: "chat",
152
+ active: false,
169
153
  });
170
- store.register({
171
- sessionId: "remote-1",
172
- nodeId: "node-b",
173
- namespace: "chat",
154
+ for (const sessionId of ["local-1", "remote-1", "remote-2", "inactive-1"]) {
155
+ await store.join(sessionId, "room-1");
156
+ }
157
+
158
+ expect(await store.resolveRoute(WebSocketAudience.room("room-1", "chat"), "node-a")).toEqual({
159
+ localSessionIds: ["local-1"],
160
+ remoteNodeIds: ["node-b"],
174
161
  });
175
- store.register({
176
- sessionId: "remote-2",
177
- nodeId: "node-b",
178
- namespace: "chat",
162
+ // 같은 store를 다른 노드 관점에서 조회하면 로컬/원격이 뒤바뀐다
163
+ const fromNodeB = await store.resolveRoute(
164
+ WebSocketAudience.room("room-1", "chat"),
165
+ "node-b",
166
+ );
167
+ expect([...fromNodeB.localSessionIds].sort()).toEqual(["remote-1", "remote-2"]);
168
+ expect(fromNodeB.remoteNodeIds).toEqual(["node-a"]);
169
+ });
170
+
171
+ it("namespace 없는 audience는 모든 namespace의 세션을 라우팅한다", async () => {
172
+ const store = new InMemoryWebSocketPresenceStore();
173
+ await registerSession(store, { sessionId: "chat-1", nodeId: "node-a", namespace: "chat" });
174
+ await registerSession(store, { sessionId: "admin-1", nodeId: "node-b", namespace: "admin" });
175
+ await store.join("chat-1", "room-1");
176
+ await store.join("admin-1", "room-1");
177
+
178
+ expect(await store.resolveRoute(WebSocketAudience.room("room-1"), "node-a")).toEqual({
179
+ localSessionIds: ["chat-1"],
180
+ remoteNodeIds: ["node-b"],
179
181
  });
180
- store.join("local-1", "room-1");
181
- store.join("remote-1", "room-1");
182
- store.join("remote-2", "room-1");
183
- const resolver = new WebSocketAudienceResolver({
184
- nodeId: "node-a",
185
- presenceStore: store,
182
+ expect(await store.resolveRoute(WebSocketAudience.all(), "node-a")).toEqual({
183
+ localSessionIds: ["chat-1"],
184
+ remoteNodeIds: ["node-b"],
186
185
  });
186
+ });
187
187
 
188
- expect(resolver.resolve(WebSocketAudience.room("room-1", "chat"))).toEqual({
189
- localSessionIds: ["local-1"],
190
- remoteNodeIds: ["node-b"],
188
+ it("getRoomMemberCount는 활성 세션만 namespace 유무에 따라 클러스터 전체에서 센다", async () => {
189
+ const store = new InMemoryWebSocketPresenceStore();
190
+ await registerSession(store, { sessionId: "a1", nodeId: "node-a", namespace: "chat" });
191
+ await registerSession(store, { sessionId: "b1", nodeId: "node-b", namespace: "chat" });
192
+ await registerSession(store, {
193
+ sessionId: "b2",
194
+ nodeId: "node-b",
195
+ namespace: "chat",
196
+ active: false,
191
197
  });
198
+ await registerSession(store, { sessionId: "b3", nodeId: "node-b", namespace: "admin" });
199
+ for (const sessionId of ["a1", "b1", "b2", "b3"]) {
200
+ await store.join(sessionId, "room-1");
201
+ }
202
+
203
+ expect(await store.getRoomMemberCount("room-1", "chat")).toBe(2);
204
+ expect(await store.getRoomMemberCount("room-1", "admin")).toBe(1);
205
+ expect(await store.getRoomMemberCount("room-1")).toBe(3);
206
+ expect(await store.getRoomMemberCount("room-404")).toBe(0);
207
+ });
208
+
209
+ it('userId는 타입까지 일치해야 매칭된다 (1과 "1"은 다르다)', async () => {
210
+ const store = new InMemoryWebSocketPresenceStore();
211
+ await registerSession(store, { sessionId: "numeric", nodeId: "node-a", namespace: "chat" });
212
+ await registerSession(store, { sessionId: "string", nodeId: "node-a", namespace: "chat" });
213
+ await store.setUserId("numeric", 1);
214
+ await store.setUserId("string", "1");
215
+
216
+ expect(
217
+ (await store.resolveRoute(WebSocketAudience.user(1, "chat"), "node-a")).localSessionIds,
218
+ ).toEqual(["numeric"]);
219
+ expect(
220
+ (await store.resolveRoute(WebSocketAudience.user("1", "chat"), "node-a")).localSessionIds,
221
+ ).toEqual(["string"]);
222
+ expect(store.getConnection("numeric")?.userId).toBe(1);
223
+ expect(store.getConnection("string")?.userId).toBe("1");
224
+ });
225
+
226
+ it("presence는 snapshot 복사본이라 반환값을 바꿔도 store 상태는 그대로다", async () => {
227
+ const store = new InMemoryWebSocketPresenceStore();
228
+ await registerSession(store, { sessionId: "s1", nodeId: "node-a", namespace: "chat" });
229
+ await store.join("s1", "room-1");
230
+
231
+ const snapshot = store.getConnection("s1");
232
+ expect(snapshot).toBeDefined();
233
+ snapshot!.rooms.add("room-hacked");
234
+ snapshot!.rooms.delete("room-1");
235
+ snapshot!.active = false;
236
+ snapshot!.userId = "intruder";
237
+
238
+ const fresh = store.getConnection("s1");
239
+ expect(fresh).not.toBe(snapshot);
240
+ expect([...fresh!.rooms]).toEqual(["room-1"]);
241
+ expect(fresh!.active).toBe(true);
242
+ expect(fresh!.userId).toBeUndefined();
243
+ expect(await store.getRoomMemberCount("room-hacked")).toBe(0);
244
+ expect(
245
+ (await store.resolveRoute(WebSocketAudience.user("intruder", "chat"), "node-a"))
246
+ .localSessionIds,
247
+ ).toEqual([]);
248
+ expect(
249
+ (await store.resolveRoute(WebSocketAudience.room("room-1", "chat"), "node-a"))
250
+ .localSessionIds,
251
+ ).toEqual(["s1"]);
192
252
  });
193
253
  });
194
254
 
195
255
  describe("ClusterBus", () => {
196
- it("allows subscribers to unsubscribe without affecting later publishes", () => {
197
- const bus = new ContractClusterBus();
256
+ it("구독을 해제하면 이후 envelope을 받지 않는다", async () => {
257
+ const bus = new RecordingClusterBus();
198
258
  const received: string[] = [];
199
- const unsubscribe = bus.subscribe((envelope) => {
259
+ const unsubscribe = await bus.subscribe((envelope) => {
200
260
  received.push(envelope.id);
201
261
  });
202
262
 
203
- bus.emit(createEnvelope("e1"));
263
+ await bus.emit(createEnvelope({ id: "e1", frames: {} }));
204
264
  unsubscribe();
205
- bus.emit(createEnvelope("e2"));
265
+ await bus.emit(createEnvelope({ id: "e2", frames: {} }));
206
266
 
207
267
  expect(received).toEqual(["e1"]);
208
268
  });
209
269
 
210
- it("keeps NoopClusterBus publish, subscribe, and shutdown safe", async () => {
270
+ it("NoopClusterBus의 publish, subscribe, shutdown은 안전하다", async () => {
211
271
  const bus = new NoopWebSocketClusterBus();
212
- const unsubscribe = bus.subscribe(() => {
272
+ const unsubscribe = await bus.subscribe(() => {
213
273
  throw new Error("noop bus must not retain handlers");
214
274
  });
215
275
 
216
- await Promise.resolve(bus.publish(createEnvelope("noop")));
276
+ await Promise.resolve(bus.publish(createEnvelope({ id: "noop", frames: {} })));
217
277
  expect(unsubscribe()).toBeUndefined();
218
278
  await Promise.resolve(bus.shutdown());
219
279
  });
220
- });
221
280
 
222
- describe("DeliveryEngine", () => {
223
- it("queues local delivery and reports queued local target count", async () => {
224
- const presenceStore = new InMemoryWebSocketPresenceStore();
225
- const localConnections = new WebSocketLocalConnectionStore();
226
- const clusterBus = new ContractClusterBus();
227
- const connection = new ContractConnection("s1", "chat");
228
- localConnections.register(connection);
229
- presenceStore.register({
230
- sessionId: "s1",
231
- nodeId: "node-a",
281
+ it("subscribe가 Promise<unsubscribe>를 반환해도 runtime이 envelope을 받고 shutdown에서 해제한다", async () => {
282
+ const bus = new RecordingClusterBus();
283
+ let unsubscribeCalls = 0;
284
+ bus.subscribe = async (handler) => {
285
+ bus.handlers.add(handler);
286
+ return () => {
287
+ unsubscribeCalls += 1;
288
+ bus.handlers.delete(handler);
289
+ };
290
+ };
291
+ const runtime = createWebSocketRuntime({ nodeId: "node-a", clusterBus: bus });
292
+ runtime.registerRoute("/chat", { outEvents: OutEvents });
293
+ const socket = new FakeWebSocket();
294
+ await runtime.registerConnection(asWebSocket(socket), {
295
+ routeKey: "/chat",
296
+ outEvents: OutEvents,
297
+ inEvents: InEvents,
232
298
  namespace: "chat",
299
+ heartbeat: 0,
233
300
  });
234
- const engine = createDeliveryEngine({
235
- nodeId: "node-a",
236
- presenceStore,
237
- localConnections,
238
- clusterBus,
239
- });
301
+ await flushUntil(() => bus.handlers.size > 0);
302
+
303
+ const frame = JSON.stringify({ event: "onReady", data: { ok: true } });
304
+ await bus.emit(createEnvelope({ id: "remote", frames: { "/chat": frame } }));
305
+ await flushUntil(() => socket.sentMessages.length > 0);
306
+
307
+ expect(socket.sentMessages).toEqual([frame]);
240
308
 
241
- engine.publishToAudience(WebSocketAudience.all("chat"), "onReady", {
242
- ok: true,
309
+ await runtime.shutdown();
310
+
311
+ expect(unsubscribeCalls).toBe(1);
312
+ expect(bus.shutdownCalls).toBe(1);
313
+ });
314
+ });
315
+
316
+ describe("Delivery", () => {
317
+ it("로컬 전달은 큐를 거쳐 비동기로 전송된다", async () => {
318
+ const runtime = createWebSocketRuntime({ nodeId: "node-a" });
319
+ const socket = new FakeWebSocket();
320
+ await runtime.registerConnection(asWebSocket(socket), {
321
+ outEvents: OutEvents,
322
+ inEvents: InEvents,
323
+ namespace: "chat",
324
+ heartbeat: 0,
243
325
  });
244
326
 
245
- expect(connection.sent).toHaveLength(0);
327
+ const publishing = runtime.broadcast("onReady", { ok: true }, "chat");
246
328
 
247
- await waitForAsyncQueue();
329
+ expect(socket.sentMessages).toHaveLength(0);
248
330
 
249
- expect(connection.sent).toEqual([
250
- {
251
- event: "onReady",
252
- data: {
253
- ok: true,
254
- },
255
- },
331
+ await publishing;
332
+ await flushAsync();
333
+
334
+ expect(socket.sentMessages.map((message) => JSON.parse(message))).toEqual([
335
+ { event: "onReady", data: { ok: true } },
256
336
  ]);
257
337
  });
258
338
 
259
- it("publishes one remote envelope per remote-node routing plan", async () => {
339
+ it("원격 노드가 여러 개여도 publish 한 번에 envelope 하나를 보내고, envelope은 원본 data 대신 route별 frame을 싣는다", async () => {
260
340
  const presenceStore = new InMemoryWebSocketPresenceStore();
261
- const localConnections = new WebSocketLocalConnectionStore();
262
- const clusterBus = new ContractClusterBus();
263
- presenceStore.register({
341
+ const clusterBus = new RecordingClusterBus();
342
+ await registerSession(presenceStore, {
264
343
  sessionId: "remote-1",
265
344
  nodeId: "node-b",
266
345
  namespace: "chat",
267
346
  });
268
- presenceStore.join("remote-1", "room-1");
269
- const engine = createDeliveryEngine({
270
- nodeId: "node-a",
271
- presenceStore,
272
- localConnections,
273
- clusterBus,
274
- });
275
-
276
- engine.publishToAudience(WebSocketAudience.room("room-1", "chat"), "evt", {
277
- ok: true,
347
+ await registerSession(presenceStore, {
348
+ sessionId: "remote-2",
349
+ nodeId: "node-c",
350
+ namespace: "chat",
278
351
  });
352
+ await presenceStore.join("remote-1", "room-1");
353
+ await presenceStore.join("remote-2", "room-1");
354
+ const runtime = createWebSocketRuntime({ nodeId: "node-a", presenceStore, clusterBus });
355
+ runtime.registerRoute("/chat", { outEvents: OutEvents });
279
356
 
280
- await waitForAsyncQueue();
357
+ await runtime.publishToRoom("room-1", "onReady", { ok: true }, "chat");
358
+ await flushAsync();
281
359
 
282
360
  expect(clusterBus.published).toHaveLength(1);
283
- expect(clusterBus.published[0]).toMatchObject({
361
+ const envelope = clusterBus.published[0];
362
+ expect(envelope).toMatchObject({
284
363
  sourceNodeId: "node-a",
285
- targetNodeIds: ["node-b"],
286
- event: "evt",
364
+ event: "onReady",
365
+ audience: WebSocketAudience.room("room-1", "chat"),
366
+ });
367
+ expect([...envelope.targetNodeIds].sort()).toEqual(["node-b", "node-c"]);
368
+ expect(Object.keys(envelope.frames)).toEqual(["/chat"]);
369
+ expect(JSON.parse(envelope.frames["/chat"])).toEqual({
370
+ event: "onReady",
371
+ data: { ok: true },
287
372
  });
373
+ expect(envelope).not.toHaveProperty("data");
288
374
  });
289
375
 
290
- it("ignores cluster envelopes from itself or targeted to another node", async () => {
291
- const presenceStore = new InMemoryWebSocketPresenceStore();
292
- const localConnections = new WebSocketLocalConnectionStore();
293
- const clusterBus = new ContractClusterBus();
294
- const connection = new ContractConnection("s1", "chat");
295
- localConnections.register(connection);
296
- presenceStore.register({
297
- sessionId: "s1",
298
- nodeId: "node-a",
376
+ it("자기 자신이 보냈거나 다른 노드를 대상으로 한 envelope은 무시한다", async () => {
377
+ const clusterBus = new RecordingClusterBus();
378
+ const runtime = createWebSocketRuntime({ nodeId: "node-a", clusterBus });
379
+ runtime.registerRoute("/chat", { outEvents: OutEvents });
380
+ const socket = new FakeWebSocket();
381
+ await runtime.registerConnection(asWebSocket(socket), {
382
+ routeKey: "/chat",
383
+ outEvents: OutEvents,
384
+ inEvents: InEvents,
299
385
  namespace: "chat",
386
+ heartbeat: 0,
300
387
  });
301
- createDeliveryEngine({
302
- nodeId: "node-a",
303
- presenceStore,
304
- localConnections,
305
- clusterBus,
388
+ await flushUntil(() => clusterBus.handlers.size > 0);
389
+ const frame = JSON.stringify({ event: "onReady", data: { ok: true } });
390
+
391
+ await clusterBus.emit(
392
+ createEnvelope({
393
+ id: "self",
394
+ sourceNodeId: "node-a",
395
+ targetNodeIds: ["node-a"],
396
+ frames: { "/chat": frame },
397
+ }),
398
+ );
399
+ await clusterBus.emit(
400
+ createEnvelope({
401
+ id: "other-target",
402
+ sourceNodeId: "node-b",
403
+ targetNodeIds: ["node-c"],
404
+ frames: { "/chat": frame },
405
+ }),
406
+ );
407
+ await flushAsync();
408
+
409
+ expect(socket.sentMessages).toHaveLength(0);
410
+ });
411
+
412
+ it("수신한 frame은 재검증·재인코딩 없이 그대로 로컬 대상에게만 보낸다", async () => {
413
+ const presenceStore = new ScriptedPresenceStore();
414
+ const clusterBus = new RecordingClusterBus();
415
+ const runtime = createWebSocketRuntime({ nodeId: "node-a", presenceStore, clusterBus });
416
+ runtime.registerRoute("/chat", { outEvents: OutEvents });
417
+ const roomSocket = new FakeWebSocket();
418
+ const outsiderSocket = new FakeWebSocket();
419
+ const roomConnection = await runtime.registerConnection(asWebSocket(roomSocket), {
420
+ routeKey: "/chat",
421
+ outEvents: OutEvents,
422
+ inEvents: InEvents,
423
+ namespace: "chat",
424
+ heartbeat: 0,
425
+ });
426
+ await runtime.registerConnection(asWebSocket(outsiderSocket), {
427
+ routeKey: "/chat",
428
+ outEvents: OutEvents,
429
+ inEvents: InEvents,
430
+ namespace: "chat",
431
+ heartbeat: 0,
432
+ });
433
+ await roomConnection.join("room-1");
434
+ await flushUntil(() => clusterBus.handlers.size > 0);
435
+ const callsBefore = presenceStore.calls.length;
436
+ // 수신 노드의 스키마로는 통과할 수 없는 frame이다. 재검증하면 1011로 닫히고, 재인코딩하면 바이트가 달라진다
437
+ const frame = '{"data":{"ok":"not-a-boolean"}, "event":"onReady"}';
438
+
439
+ await clusterBus.emit(
440
+ createEnvelope({
441
+ id: "remote",
442
+ audience: WebSocketAudience.room("room-1", "chat"),
443
+ frames: { "/chat": frame },
444
+ }),
445
+ );
446
+ await flushUntil(() => roomSocket.sentMessages.length > 0);
447
+ await flushAsync();
448
+
449
+ expect(roomSocket.sentMessages).toEqual([frame]);
450
+ expect(roomSocket.closedWith).toBeNull();
451
+ expect(outsiderSocket.sentMessages).toHaveLength(0);
452
+ // 수신 노드는 store가 아닌 자기 메모리로 audience를 해석한다
453
+ expect(presenceStore.calls.slice(callsBefore)).toEqual([]);
454
+ });
455
+
456
+ it("수신 노드는 연결마다 자기 route의 frame을 보내고, frame이 없는 route의 연결은 건너뛴다", async () => {
457
+ const clusterBus = new RecordingClusterBus();
458
+ const runtime = createWebSocketRuntime({ nodeId: "node-a", clusterBus });
459
+ const NoReadyEvents = z.object({ onOther: z.object({ value: z.number() }) });
460
+ runtime.registerRoute("/a", { outEvents: OutEvents });
461
+ runtime.registerRoute("/b", { outEvents: OutEvents });
462
+ runtime.registerRoute("/c", { outEvents: NoReadyEvents });
463
+ const sockets = { a: new FakeWebSocket(), b: new FakeWebSocket(), c: new FakeWebSocket() };
464
+ await runtime.registerConnection(asWebSocket(sockets.a), {
465
+ routeKey: "/a",
466
+ outEvents: OutEvents,
467
+ inEvents: InEvents,
468
+ namespace: "chat",
469
+ heartbeat: 0,
470
+ });
471
+ await runtime.registerConnection(asWebSocket(sockets.b), {
472
+ routeKey: "/b",
473
+ outEvents: OutEvents,
474
+ inEvents: InEvents,
475
+ namespace: "chat",
476
+ heartbeat: 0,
306
477
  });
478
+ await runtime.registerConnection(asWebSocket(sockets.c), {
479
+ routeKey: "/c",
480
+ outEvents: NoReadyEvents,
481
+ inEvents: InEvents,
482
+ namespace: "chat",
483
+ heartbeat: 0,
484
+ });
485
+ await flushUntil(() => clusterBus.handlers.size > 0);
486
+ const frameA = JSON.stringify({ event: "onReady", data: { ok: true, route: "a" } });
487
+ const frameB = JSON.stringify({ event: "onReady", data: { ok: true, route: "b" } });
307
488
 
308
- clusterBus.emit(createEnvelope("self", "node-a", ["node-a"]));
309
- clusterBus.emit(createEnvelope("other-target", "node-b", ["node-c"]));
489
+ await clusterBus.emit(
490
+ createEnvelope({ id: "remote", frames: { "/a": frameA, "/b": frameB } }),
491
+ );
492
+ await flushUntil(
493
+ () => sockets.a.sentMessages.length > 0 && sockets.b.sentMessages.length > 0,
494
+ );
495
+ await flushAsync();
310
496
 
311
- await waitForAsyncQueue();
497
+ expect(sockets.a.sentMessages).toEqual([frameA]);
498
+ expect(sockets.b.sentMessages).toEqual([frameB]);
499
+ expect(sockets.c.sentMessages).toEqual([]);
500
+ expect(sockets.c.closedWith).toBeNull();
501
+ });
502
+ });
312
503
 
313
- expect(connection.sent).toHaveLength(0);
504
+ describe("Route table", () => {
505
+ const NoticeEventsV1 = z.object({ onNotice: z.object({ level: z.number() }) });
506
+ const NoticeEventsV2 = z.object({
507
+ onNotice: z.object({ level: z.number(), source: z.string().default("v2") }),
314
508
  });
509
+ const AlertEvents = z.object({ onAlert: z.object({ message: z.string() }) });
315
510
 
316
- it("delivers accepted cluster envelopes only to local targets", async () => {
511
+ // 로컬 연결 없이 원격 세션만 room-1에 있는 노드를 만든다
512
+ async function createRemoteOnlyRuntime() {
317
513
  const presenceStore = new InMemoryWebSocketPresenceStore();
318
- const localConnections = new WebSocketLocalConnectionStore();
319
- const clusterBus = new ContractClusterBus();
320
- const connection = new ContractConnection("s1", "chat");
321
- localConnections.register(connection);
322
- presenceStore.register({
323
- sessionId: "s1",
324
- nodeId: "node-a",
514
+ await registerSession(presenceStore, {
515
+ sessionId: "remote-1",
516
+ nodeId: "node-b",
325
517
  namespace: "chat",
326
518
  });
327
- createDeliveryEngine({
519
+ await presenceStore.join("remote-1", "room-1");
520
+ const clusterBus = new RecordingClusterBus();
521
+ const runtime = createWebSocketRuntime({
328
522
  nodeId: "node-a",
329
523
  presenceStore,
330
- localConnections,
331
524
  clusterBus,
525
+ telemetry: true,
526
+ });
527
+ return { runtime, clusterBus };
528
+ }
529
+
530
+ it("로컬 연결이 없어도 등록된 route의 frame을 담은 envelope을 원격 노드로 보낸다", async () => {
531
+ const { runtime, clusterBus } = await createRemoteOnlyRuntime();
532
+ runtime.registerRoute("/x", { outEvents: NoticeEventsV1 });
533
+
534
+ await runtime.publishToRoom("room-1", "onNotice", { level: 1 }, "chat");
535
+ await flushAsync();
536
+
537
+ expect(clusterBus.published).toHaveLength(1);
538
+ expect(clusterBus.published[0].targetNodeIds).toEqual(["node-b"]);
539
+ expect(Object.keys(clusterBus.published[0].frames)).toEqual(["/x"]);
540
+ expect(JSON.parse(clusterBus.published[0].frames["/x"])).toEqual({
541
+ event: "onNotice",
542
+ data: { level: 1 },
332
543
  });
544
+ });
333
545
 
334
- clusterBus.emit(createEnvelope("remote", "node-b", ["node-a"]));
546
+ it("같은 routeKey로 registerRoute를 다시 호출하면 스키마가 교체된다", async () => {
547
+ const { runtime, clusterBus } = await createRemoteOnlyRuntime();
548
+ runtime.registerRoute("/x", { outEvents: NoticeEventsV1 });
549
+ runtime.registerRoute("/x", { outEvents: NoticeEventsV2 });
335
550
 
336
- await waitForAsyncQueue();
551
+ await runtime.publishToRoom("room-1", "onNotice", { level: 1 }, "chat");
552
+ await flushAsync();
337
553
 
338
- expect(connection.sent).toEqual([
339
- {
340
- event: "evt",
341
- data: {
342
- ok: true,
343
- },
344
- },
554
+ expect(Object.keys(clusterBus.published[0].frames)).toEqual(["/x"]);
555
+ expect(JSON.parse(clusterBus.published[0].frames["/x"])).toEqual({
556
+ event: "onNotice",
557
+ data: { level: 1, source: "v2" },
558
+ });
559
+ });
560
+
561
+ it("replaceRoutes는 route 집합 전체를 교체해 빠진 route는 제거하고 새 route와 새 스키마를 쓴다", async () => {
562
+ const { runtime, clusterBus } = await createRemoteOnlyRuntime();
563
+ runtime.registerRoute("/x", { outEvents: NoticeEventsV1 });
564
+ runtime.registerRoute("/y", { outEvents: NoticeEventsV1 });
565
+
566
+ runtime.replaceRoutes([
567
+ { routeKey: "/y", outEvents: NoticeEventsV2 },
568
+ { routeKey: "/z", outEvents: AlertEvents },
345
569
  ]);
570
+
571
+ await runtime.publishToRoom("room-1", "onNotice", { level: 3 }, "chat");
572
+ await runtime.publishToRoom("room-1", "onAlert", { message: "fire" }, "chat");
573
+ await flushAsync();
574
+
575
+ expect(clusterBus.published).toHaveLength(2);
576
+ expect(Object.keys(clusterBus.published[0].frames)).toEqual(["/y"]);
577
+ expect(JSON.parse(clusterBus.published[0].frames["/y"])).toEqual({
578
+ event: "onNotice",
579
+ data: { level: 3, source: "v2" },
580
+ });
581
+ expect(Object.keys(clusterBus.published[1].frames)).toEqual(["/z"]);
346
582
  });
347
- });
348
- });
349
583
 
350
- function createDeliveryEngine(input: {
351
- nodeId: string;
352
- presenceStore: InMemoryWebSocketPresenceStore;
353
- localConnections: WebSocketLocalConnectionStore;
354
- clusterBus: WebSocketClusterBus;
355
- }): WebSocketDeliveryEngine {
356
- return new WebSocketDeliveryEngine({
357
- nodeId: input.nodeId,
358
- localConnections: input.localConnections,
359
- audienceResolver: new WebSocketAudienceResolver({
360
- nodeId: input.nodeId,
361
- presenceStore: input.presenceStore,
362
- }),
363
- clusterBus: input.clusterBus,
364
- });
365
- }
584
+ it("어떤 route도 선언하지 않은 이벤트는 envelope 없이 resolve하고 ws.publish.rejected를 남긴다", async () => {
585
+ const { runtime, clusterBus } = await createRemoteOnlyRuntime();
586
+ runtime.replaceRoutes([{ routeKey: "/z", outEvents: AlertEvents }]);
366
587
 
367
- function createEnvelope(
368
- id: string,
369
- sourceNodeId = "node-b",
370
- targetNodeIds: string[] | undefined = ["node-a"],
371
- ): WebSocketClusterEnvelope {
372
- return {
373
- id,
374
- sourceNodeId,
375
- targetNodeIds,
376
- audience: WebSocketAudience.all("chat"),
377
- event: "evt",
378
- data: {
379
- ok: true,
380
- },
381
- emittedAt: Date.now(),
382
- };
383
- }
588
+ await expect(
589
+ runtime.publishToRoom("room-1", "onNotice", { level: 1 }, "chat"),
590
+ ).resolves.toBeUndefined();
591
+ await flushAsync();
592
+
593
+ expect(clusterBus.published).toEqual([]);
594
+ expect(
595
+ (
596
+ runtime.telemetryController.getEventStore()?.query({ name: "ws.publish.rejected" }) ?? []
597
+ ).map((record) => record.detail),
598
+ ).toEqual([expect.objectContaining({ event: "onNotice", reason: "unknownEvent" })]);
599
+ });
600
+ });
601
+ });