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
@@ -3,6 +3,7 @@ export * from "./ws-audience";
3
3
  export * from "./ws-cluster-bus";
4
4
  export * from "./ws-core";
5
5
  export * from "./ws-presence-store";
6
+ export * from "./ws-session-id";
6
7
  export * from "./ws-telemetry";
7
8
  export * from "./ws-telemetry-memory";
8
9
  export * from "./ws-telemetry-trace";
@@ -1,11 +1,10 @@
1
1
  import { type WebSocketAudience } from "./ws-audience";
2
2
  import { type WebSocketPresenceStore } from "./ws-presence-store";
3
+ import { parseWebSocketSessionNodeId } from "./ws-session-id";
3
4
 
4
- export type WebSocketRoutingPlan = {
5
- localSessionIds: string[];
6
- remoteNodeIds: string[];
7
- };
8
-
5
+ /**
6
+ * publish 대상 중 원격 노드를 찾는다. 로컬 대상은 registry 메모리로 해석하므로 여기서는 원격 노드만 다룬다.
7
+ */
9
8
  export class WebSocketAudienceResolver {
10
9
  constructor(
11
10
  private readonly options: {
@@ -14,22 +13,25 @@ export class WebSocketAudienceResolver {
14
13
  },
15
14
  ) {}
16
15
 
17
- resolve(audience: WebSocketAudience): WebSocketRoutingPlan {
18
- const localSessionIds: string[] = [];
19
- const remoteNodeIds = new Set<string>();
20
-
21
- for (const meta of this.options.presenceStore.queryAudience(audience)) {
22
- if (meta.nodeId === this.options.nodeId) {
23
- localSessionIds.push(meta.sessionId);
24
- continue;
16
+ /**
17
+ * `connections` audience는 세션 ID에 든 노드 ID로 store 조회 없이 라우팅한다.
18
+ * 그 밖의 audience는 store.resolveRoute를 호출하며, 동기 throw도 rejection으로 바꾼다.
19
+ */
20
+ async resolveRemoteNodeIds(audience: WebSocketAudience): Promise<string[]> {
21
+ if (audience.type === "connections") {
22
+ const nodeIds = new Set<string>();
23
+ for (const connectionId of audience.connectionIds) {
24
+ const nodeId = parseWebSocketSessionNodeId(connectionId);
25
+ if (nodeId !== undefined && nodeId !== this.options.nodeId) {
26
+ nodeIds.add(nodeId);
27
+ }
25
28
  }
26
-
27
- remoteNodeIds.add(meta.nodeId);
29
+ return [...nodeIds];
28
30
  }
29
31
 
30
- return {
31
- localSessionIds,
32
- remoteNodeIds: [...remoteNodeIds],
33
- };
32
+ const plan = await Promise.resolve().then(() =>
33
+ this.options.presenceStore.resolveRoute(audience, this.options.nodeId),
34
+ );
35
+ return [...new Set(plan.remoteNodeIds)].filter((nodeId) => nodeId !== this.options.nodeId);
34
36
  }
35
37
  }
@@ -1,13 +1,18 @@
1
1
  import { type WebSocketAudience } from "./ws-audience";
2
+ import { type MaybePromise } from "./ws-core";
2
3
 
4
+ /**
5
+ * 노드 간 publish envelope. 원본 data 대신 발행 노드가 route별로 한 번 검증·인코딩한 frame을 싣는다.
6
+ * 수신 노드는 자기 메모리로 audience를 해석하고, 각 연결의 route frame을 재검증 없이 그대로 보낸다.
7
+ */
3
8
  export type WebSocketClusterEnvelope = {
4
9
  id: string;
5
10
  sourceNodeId: string;
6
- targetNodeIds?: string[];
7
- namespace?: string;
11
+ targetNodeIds: string[];
8
12
  audience: WebSocketAudience;
9
13
  event: string;
10
- data: unknown;
14
+ // routeKey → 인코딩된 frame. 이벤트를 선언하지 않았거나 검증에 실패한 route는 빠진다
15
+ frames: Record<string, string>;
11
16
  emittedAt: number;
12
17
  };
13
18
 
@@ -16,8 +21,11 @@ export type WebSocketClusterEnvelopeHandler = (
16
21
  ) => void | Promise<void>;
17
22
 
18
23
  export interface WebSocketClusterBus {
24
+ // 설정하면 runtime nodeId와 같아야 한다. 다르면 runtime 생성이 실패한다
25
+ readonly nodeId?: string;
19
26
  publish(envelope: WebSocketClusterEnvelope): void | Promise<void>;
20
- subscribe(handler: WebSocketClusterEnvelopeHandler): () => void;
27
+ // 구독 완료를 promise로 알릴 수 있다. 반환한 해제 함수는 runtime shutdown에서 한 번 호출된다
28
+ subscribe(handler: WebSocketClusterEnvelopeHandler): MaybePromise<() => void>;
21
29
  shutdown(): void | Promise<void>;
22
30
  }
23
31
 
@@ -1,5 +1,8 @@
1
+ import { type z } from "zod";
2
+
1
3
  export type WebSocketUserId = number | string;
2
4
  export type WebSocketRoomId = string;
5
+ export type MaybePromise<T> = T | Promise<T>;
3
6
 
4
7
  export interface ManagedWebSocketConnection {
5
8
  id: string;
@@ -11,6 +14,17 @@ export interface ManagedWebSocketConnection {
11
14
  close(code?: number, reason?: string): void;
12
15
  }
13
16
 
17
+ /**
18
+ * registry가 소유하는 로컬 연결. 인코딩된 frame을 그대로 보내는 경로와 route 식별자를 가진다.
19
+ */
20
+ export interface WebSocketFrameConnection extends ManagedWebSocketConnection {
21
+ // route table의 키. 원격 노드가 보낸 envelope에서 이 연결이 받을 frame을 고를 때 쓴다
22
+ readonly routeKey?: string;
23
+ // route table에 없는 연결이 로컬 publish를 받을 때 쓰는 자기 스키마
24
+ readonly outEvents: z.ZodObject;
25
+ sendFrame(event: string, frame: string): void;
26
+ }
27
+
14
28
  export type WebSocketRegistryStats = {
15
29
  totalConnections: number;
16
30
  totalRooms: number;
@@ -1,10 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
+ import { type z } from "zod";
4
+
3
5
  import { runtimeTypeOf } from "../runtime-type";
4
6
  import { type WebSocketAudience } from "./ws-audience";
5
7
  import { type WebSocketAudienceResolver } from "./ws-audience-resolver";
6
8
  import { type WebSocketClusterBus, type WebSocketClusterEnvelope } from "./ws-cluster-bus";
7
- import { type ManagedWebSocketConnection } from "./ws-core";
9
+ import { type WebSocketFrameConnection } from "./ws-core";
10
+ import { encodeWebSocketFrame, type WebSocketFrameEncoding } from "./ws-frame";
8
11
  import { type WebSocketLocalConnectionStore } from "./ws-local-connection-store";
9
12
  import {
10
13
  NoopWebSocketTelemetryController,
@@ -14,15 +17,33 @@ import {
14
17
  // fan-out을 event loop tick 단위로 나눠 한 번에 긴 동기 루프를 만들지 않게 함
15
18
  const FAN_OUT_BATCH_SIZE = 250;
16
19
 
20
+ export type WebSocketRouteDefinition = {
21
+ routeKey: string;
22
+ outEvents: z.ZodObject;
23
+ };
24
+
25
+ type FanOutTarget = {
26
+ connection: WebSocketFrameConnection;
27
+ frame: string;
28
+ };
29
+
30
+ type FanOutJob = {
31
+ targets: FanOutTarget[];
32
+ event: string;
33
+ cursor: number;
34
+ };
35
+
36
+ type Unsubscribe = () => void;
37
+
17
38
  export class WebSocketDeliveryEngine {
18
- private readonly pendingFanOutJobs: Array<{
19
- targets: ManagedWebSocketConnection[];
20
- event: string;
21
- data: unknown;
22
- cursor: number;
23
- }> = [];
39
+ private readonly pendingFanOutJobs: FanOutJob[] = [];
24
40
  private fanOutFlushScheduled = false;
25
- private readonly unsubscribe: () => void;
41
+ // routeKey → outEvents. 모든 노드가 같은 route 집합을 등록하므로 routeKey는 클러스터 전체에서 일관된다
42
+ private readonly routes = new Map<string, z.ZodObject>();
43
+ // 노드 단위 직렬 publish 큐. 앞선 publish의 라우팅이 느려도 원격 envelope 순서가 뒤바뀌지 않는다
44
+ private publishQueue: Promise<void> = Promise.resolve();
45
+ private readonly subscription: Promise<Unsubscribe | undefined>;
46
+ private stopped = false;
26
47
  private readonly telemetry: WebSocketTelemetryController;
27
48
  private readonly unregisterMetricSource: () => void;
28
49
 
@@ -30,98 +51,146 @@ export class WebSocketDeliveryEngine {
30
51
  private readonly options: {
31
52
  nodeId: string;
32
53
  localConnections: WebSocketLocalConnectionStore;
54
+ // registry 메모리 기준으로 audience에 속한 로컬 활성 세션 ID를 반환한다
55
+ resolveLocalSessionIds: (audience: WebSocketAudience) => string[];
33
56
  audienceResolver: WebSocketAudienceResolver;
34
57
  clusterBus: WebSocketClusterBus;
35
58
  telemetryController?: WebSocketTelemetryController;
36
59
  },
37
60
  ) {
38
61
  this.telemetry = this.options.telemetryController ?? new NoopWebSocketTelemetryController();
39
- this.unsubscribe = this.options.clusterBus.subscribe((envelope) => {
40
- this.handleClusterEnvelope(envelope);
41
- });
62
+ this.subscription = this.subscribe();
42
63
  this.unregisterMetricSource = this.telemetry.registerMetricSource({
43
64
  collect: () => this.getTelemetrySnapshot(),
44
65
  });
45
66
  }
46
67
 
47
- publishToAudience<Data>(audience: WebSocketAudience, event: string, data: Data): void {
48
- const routingPlan = this.options.audienceResolver.resolve(audience);
49
- const localTargets = this.options.localConnections.getConnections(routingPlan.localSessionIds);
68
+ registerRoute(routeKey: string, outEvents: z.ZodObject): void {
69
+ this.routes.set(routeKey, outEvents);
70
+ }
50
71
 
51
- this.telemetry.emit({
52
- name: "ws.fanout.resolved",
53
- level: "debug",
54
- detail: {
55
- localTargetCount: localTargets.length,
56
- remoteNodeCount: routingPlan.remoteNodeIds.length,
57
- },
58
- });
59
- this.telemetry.recordMetric({
60
- name: "sonamu.ws.fanout.targets",
61
- kind: "histogram",
62
- value: localTargets.length + routingPlan.remoteNodeIds.length,
63
- unit: "1",
64
- tags: { audience_type: audience.type },
65
- });
72
+ replaceRoutes(routes: WebSocketRouteDefinition[]): void {
73
+ this.routes.clear();
74
+ for (const route of routes) {
75
+ this.routes.set(route.routeKey, route.outEvents);
76
+ }
77
+ }
66
78
 
67
- this.enqueueFanOut(localTargets, event, data);
79
+ /**
80
+ * 발행 노드에서 route별로 한 번 검증·인코딩하고, 로컬 fan-out은 즉시 큐에 넣는다.
81
+ * 원격 라우팅과 cluster publish는 노드 단위 직렬 큐에서 처리한다. 반환한 promise는 reject하지 않는다.
82
+ */
83
+ publishToAudience<Data>(audience: WebSocketAudience, event: string, data: Data): Promise<void> {
84
+ try {
85
+ let declared = false;
86
+ let rejected = false;
87
+ const record = (encoding: WebSocketFrameEncoding) => {
88
+ if (encoding.status !== "unknownEvent") {
89
+ declared = true;
90
+ }
91
+ if (encoding.status === "invalidPayload") {
92
+ rejected = true;
93
+ }
94
+ };
68
95
 
69
- if (routingPlan.remoteNodeIds.length === 0) {
70
- return;
71
- }
96
+ // 이벤트를 선언한 route마다 한 번만 검증·인코딩한다. 원격 수신자도 이 frame을 그대로 받는다
97
+ const routeFrames: Record<string, string> = {};
98
+ for (const [routeKey, outEvents] of this.routes) {
99
+ const encoding = encodeWebSocketFrame(outEvents, event, data);
100
+ record(encoding);
101
+ if (encoding.status === "encoded") {
102
+ routeFrames[routeKey] = encoding.frame;
103
+ }
104
+ }
72
105
 
73
- const envelope: WebSocketClusterEnvelope = {
74
- id: randomUUID(),
75
- sourceNodeId: this.options.nodeId,
76
- targetNodeIds: routingPlan.remoteNodeIds,
77
- audience,
78
- event,
79
- data,
80
- emittedAt: Date.now(),
81
- };
106
+ // route table에 없는 로컬 연결은 자기 스키마로 인코딩한다(스키마 객체당 한 번)
107
+ const fallbackEncodings = new Map<z.ZodObject, WebSocketFrameEncoding>();
108
+ const localTargets: FanOutTarget[] = [];
109
+ for (const connection of this.options.localConnections.getConnections(
110
+ this.options.resolveLocalSessionIds(audience),
111
+ )) {
112
+ let frame: string | undefined;
113
+ if (connection.routeKey !== undefined && this.routes.has(connection.routeKey)) {
114
+ frame = getOwnFrame(routeFrames, connection.routeKey);
115
+ } else {
116
+ let encoding = fallbackEncodings.get(connection.outEvents);
117
+ if (!encoding) {
118
+ encoding = encodeWebSocketFrame(connection.outEvents, event, data);
119
+ fallbackEncodings.set(connection.outEvents, encoding);
120
+ record(encoding);
121
+ }
122
+ frame = encoding.status === "encoded" ? encoding.frame : undefined;
123
+ }
82
124
 
83
- const publishStartedAt = performance.now();
84
- Promise.resolve(this.options.clusterBus.publish(envelope))
85
- .then(() => {
86
- const durationMs = performance.now() - publishStartedAt;
87
- this.telemetry.emit({
88
- name: "ws.cluster.envelope.published",
89
- level: "debug",
90
- detail: { event, targetNodeCount: routingPlan.remoteNodeIds.length },
91
- });
92
- this.telemetry.recordSpan({
93
- operationName: "ws.cluster.publish",
94
- kind: "producer",
95
- durationMs,
96
- status: "unset",
97
- attributes: {
98
- event,
99
- targetNodeCount: routingPlan.remoteNodeIds.length,
100
- },
101
- });
102
- })
103
- .catch((cause: unknown) => {
104
- const durationMs = performance.now() - publishStartedAt;
105
- this.telemetry.emit({
106
- name: "ws.cluster.envelope.failed",
107
- level: "error",
108
- detail: { event },
109
- });
110
- this.telemetry.recordSpan({
111
- operationName: "ws.cluster.publish",
112
- kind: "producer",
113
- durationMs,
114
- status: "error",
115
- attributes: { event },
116
- errorType: cause instanceof Error ? cause.name : runtimeTypeOf(cause),
125
+ // 이벤트를 선언하지 않았거나 검증에 실패한 route의 연결은 닫지 않고 건너뛴다
126
+ if (frame !== undefined) {
127
+ localTargets.push({ connection, frame });
128
+ }
129
+ }
130
+
131
+ if (!declared) {
132
+ this.emitPublishRejected(event, "unknownEvent");
133
+ } else if (rejected) {
134
+ this.emitPublishRejected(event, "invalidPayload");
135
+ }
136
+
137
+ this.enqueueFanOut(localTargets, event);
138
+
139
+ if (Object.keys(routeFrames).length === 0) {
140
+ this.emitFanOutResolved(audience, localTargets.length, 0);
141
+ return Promise.resolve();
142
+ }
143
+
144
+ // 원격 라우팅(store 조회)은 앞선 publish를 기다리지 않고 지금 바로 시작한다. store가 멈춰도 버스트 비용이 N×T가 아닌 약 T다.
145
+ // 반면 envelope 발행은 노드 단위 큐에 이어 붙여 publish 순서를 지킨다. 뒤 publish의 라우팅이 먼저 끝나도 앞 발행을 기다린다
146
+ const routing = this.resolveRemoteNodeIds(audience, event);
147
+ const task = this.publishQueue
148
+ .then(() => routing)
149
+ .then((remoteNodeIds) =>
150
+ this.publishRemote(audience, event, routeFrames, localTargets.length, remoteNodeIds),
151
+ )
152
+ .catch((cause: unknown) => {
153
+ this.emitRouteFailed(event, audience, cause);
117
154
  });
118
- });
155
+ this.publishQueue = task;
156
+ return task;
157
+ } catch (cause) {
158
+ // 스키마 transform 예외 등 예상하지 못한 실패도 호출자에게 reject로 새지 않게 한다
159
+ this.emitRouteFailed(event, audience, cause);
160
+ return Promise.resolve();
161
+ }
162
+ }
163
+
164
+ // 진행 중인 publish(원격 포함)와 구독 시도가 모두 끝나면 resolve한다
165
+ async drainPublishes(): Promise<void> {
166
+ await Promise.all([this.publishQueue, this.subscription]);
119
167
  }
120
168
 
121
- async shutdown(): Promise<void> {
169
+ /**
170
+ * 구독을 해제하고 cluster bus를 종료한다. 구독이 deadline까지 끝나지 않으면 늦게 끝날 때 해제한다.
171
+ */
172
+ async shutdown(deadline: Promise<void>): Promise<void> {
173
+ this.stopped = true;
122
174
  this.unregisterMetricSource();
123
- this.unsubscribe();
124
- await this.options.clusterBus.shutdown();
175
+
176
+ const unsubscribe = await Promise.race([this.subscription, deadline.then(() => null)]);
177
+ if (unsubscribe === null) {
178
+ this.subscription.then((lateUnsubscribe) => {
179
+ this.safeUnsubscribe(lateUnsubscribe);
180
+ });
181
+ } else {
182
+ this.safeUnsubscribe(unsubscribe);
183
+ }
184
+
185
+ try {
186
+ await Promise.resolve().then(() => this.options.clusterBus.shutdown());
187
+ } catch (cause) {
188
+ this.telemetry.emit({
189
+ name: "ws.cluster.shutdown.failed",
190
+ level: "error",
191
+ detail: { errorType: describeError(cause) },
192
+ });
193
+ }
125
194
  }
126
195
 
127
196
  getTelemetrySnapshot() {
@@ -137,7 +206,117 @@ export class WebSocketDeliveryEngine {
137
206
  };
138
207
  }
139
208
 
209
+ // 구독 실패는 로컬 전용 동작으로 수렴시키고 telemetry만 남긴다
210
+ private subscribe(): Promise<Unsubscribe | undefined> {
211
+ return Promise.resolve()
212
+ .then(() =>
213
+ this.options.clusterBus.subscribe((envelope) => {
214
+ this.handleClusterEnvelope(envelope);
215
+ }),
216
+ )
217
+ .then(
218
+ (unsubscribe) => unsubscribe,
219
+ (cause: unknown) => {
220
+ this.telemetry.emit({
221
+ name: "ws.cluster.subscribe.failed",
222
+ level: "error",
223
+ detail: { errorType: describeError(cause) },
224
+ });
225
+ return undefined;
226
+ },
227
+ );
228
+ }
229
+
230
+ private safeUnsubscribe(unsubscribe: Unsubscribe | undefined): void {
231
+ try {
232
+ unsubscribe?.();
233
+ } catch (cause) {
234
+ this.telemetry.emit({
235
+ name: "ws.cluster.unsubscribe.failed",
236
+ level: "error",
237
+ detail: { errorType: describeError(cause) },
238
+ });
239
+ }
240
+ }
241
+
242
+ // 라우팅 실패는 여기서 telemetry로 바꿔 undefined를 돌려준다. 즉시 catch를 붙이므로 큐 차례를 기다리는 동안에도 unhandled rejection이 생기지 않는다
243
+ private resolveRemoteNodeIds(
244
+ audience: WebSocketAudience,
245
+ event: string,
246
+ ): Promise<string[] | undefined> {
247
+ return this.options.audienceResolver.resolveRemoteNodeIds(audience).catch((cause: unknown) => {
248
+ this.emitRouteFailed(event, audience, cause);
249
+ return undefined;
250
+ });
251
+ }
252
+
253
+ private async publishRemote(
254
+ audience: WebSocketAudience,
255
+ event: string,
256
+ frames: Record<string, string>,
257
+ localTargetCount: number,
258
+ resolvedNodeIds: string[] | undefined,
259
+ ): Promise<void> {
260
+ // 라우팅 실패(undefined) 시 로컬 전달은 이미 끝났으므로 이 publish의 원격 envelope만 건너뛴다
261
+ const remoteNodeIds = resolvedNodeIds ?? [];
262
+ this.emitFanOutResolved(audience, localTargetCount, remoteNodeIds.length);
263
+ if (remoteNodeIds.length === 0) {
264
+ return;
265
+ }
266
+
267
+ const envelope: WebSocketClusterEnvelope = {
268
+ id: randomUUID(),
269
+ sourceNodeId: this.options.nodeId,
270
+ targetNodeIds: remoteNodeIds,
271
+ audience,
272
+ event,
273
+ frames,
274
+ emittedAt: Date.now(),
275
+ };
276
+
277
+ const publishStartedAt = performance.now();
278
+ try {
279
+ await Promise.resolve().then(() => this.options.clusterBus.publish(envelope));
280
+ const durationMs = performance.now() - publishStartedAt;
281
+ this.telemetry.emit({
282
+ name: "ws.cluster.envelope.published",
283
+ level: "debug",
284
+ detail: { event, targetNodeCount: remoteNodeIds.length },
285
+ });
286
+ this.telemetry.recordSpan({
287
+ operationName: "ws.cluster.publish",
288
+ kind: "producer",
289
+ durationMs,
290
+ status: "unset",
291
+ attributes: {
292
+ event,
293
+ targetNodeCount: remoteNodeIds.length,
294
+ },
295
+ });
296
+ } catch (cause) {
297
+ const durationMs = performance.now() - publishStartedAt;
298
+ this.telemetry.emit({
299
+ name: "ws.cluster.envelope.failed",
300
+ level: "error",
301
+ detail: { event },
302
+ });
303
+ this.telemetry.recordSpan({
304
+ operationName: "ws.cluster.publish",
305
+ kind: "producer",
306
+ durationMs,
307
+ status: "error",
308
+ attributes: { event },
309
+ errorType: describeError(cause),
310
+ });
311
+ }
312
+ }
313
+
314
+ // 수신 노드는 store 없이 자기 메모리로 audience를 해석하고, 각 연결의 route frame을 재검증 없이 그대로 보낸다
140
315
  private handleClusterEnvelope(envelope: WebSocketClusterEnvelope): void {
316
+ if (this.stopped) {
317
+ return;
318
+ }
319
+
141
320
  if (envelope.sourceNodeId === this.options.nodeId) {
142
321
  this.telemetry.emit({
143
322
  name: "ws.cluster.envelope.ignored",
@@ -148,7 +327,6 @@ export class WebSocketDeliveryEngine {
148
327
  }
149
328
 
150
329
  if (
151
- envelope.targetNodeIds &&
152
330
  envelope.targetNodeIds.length > 0 &&
153
331
  !envelope.targetNodeIds.includes(this.options.nodeId)
154
332
  ) {
@@ -167,9 +345,19 @@ export class WebSocketDeliveryEngine {
167
345
  detail: { event: envelope.event, sourceNodeId: envelope.sourceNodeId },
168
346
  });
169
347
 
170
- const routingPlan = this.options.audienceResolver.resolve(envelope.audience);
171
- const localTargets = this.options.localConnections.getConnections(routingPlan.localSessionIds);
172
- this.enqueueFanOut(localTargets, envelope.event, envelope.data);
348
+ const localTargets: FanOutTarget[] = [];
349
+ for (const connection of this.options.localConnections.getConnections(
350
+ this.options.resolveLocalSessionIds(envelope.audience),
351
+ )) {
352
+ const frame =
353
+ connection.routeKey === undefined
354
+ ? undefined
355
+ : getOwnFrame(envelope.frames, connection.routeKey);
356
+ if (frame !== undefined) {
357
+ localTargets.push({ connection, frame });
358
+ }
359
+ }
360
+ this.enqueueFanOut(localTargets, envelope.event);
173
361
 
174
362
  this.telemetry.recordSpan({
175
363
  operationName: "ws.cluster.receive",
@@ -184,11 +372,42 @@ export class WebSocketDeliveryEngine {
184
372
  });
185
373
  }
186
374
 
187
- private enqueueFanOut<Data>(
188
- targets: ManagedWebSocketConnection[],
189
- event: string,
190
- data: Data,
375
+ private emitPublishRejected(event: string, reason: "unknownEvent" | "invalidPayload"): void {
376
+ this.telemetry.emit({
377
+ name: "ws.publish.rejected",
378
+ level: "error",
379
+ detail: { event, reason },
380
+ });
381
+ }
382
+
383
+ private emitRouteFailed(event: string, audience: WebSocketAudience, cause: unknown): void {
384
+ this.telemetry.emit({
385
+ name: "ws.fanout.route.failed",
386
+ level: "error",
387
+ detail: { event, audienceType: audience.type, errorType: describeError(cause) },
388
+ });
389
+ }
390
+
391
+ private emitFanOutResolved(
392
+ audience: WebSocketAudience,
393
+ localTargetCount: number,
394
+ remoteNodeCount: number,
191
395
  ): void {
396
+ this.telemetry.emit({
397
+ name: "ws.fanout.resolved",
398
+ level: "debug",
399
+ detail: { localTargetCount, remoteNodeCount },
400
+ });
401
+ this.telemetry.recordMetric({
402
+ name: "sonamu.ws.fanout.targets",
403
+ kind: "histogram",
404
+ value: localTargetCount + remoteNodeCount,
405
+ unit: "1",
406
+ tags: { audience_type: audience.type },
407
+ });
408
+ }
409
+
410
+ private enqueueFanOut(targets: FanOutTarget[], event: string): void {
192
411
  if (targets.length === 0) {
193
412
  return;
194
413
  }
@@ -196,7 +415,6 @@ export class WebSocketDeliveryEngine {
196
415
  this.pendingFanOutJobs.push({
197
416
  targets,
198
417
  event,
199
- data,
200
418
  cursor: 0,
201
419
  });
202
420
  this.telemetry.emit({
@@ -230,7 +448,7 @@ export class WebSocketDeliveryEngine {
230
448
  const job = this.pendingFanOutJobs[0];
231
449
 
232
450
  while (job.cursor < job.targets.length && processed < FAN_OUT_BATCH_SIZE) {
233
- this.safePublish(job.targets[job.cursor], job.event, job.data);
451
+ this.safeSend(job.targets[job.cursor], job.event);
234
452
  job.cursor += 1;
235
453
  processed += 1;
236
454
  }
@@ -241,7 +459,7 @@ export class WebSocketDeliveryEngine {
241
459
  }
242
460
  } catch (error) {
243
461
  status = "error";
244
- errorType = error instanceof Error ? error.name : runtimeTypeOf(error);
462
+ errorType = describeError(error);
245
463
  throw error;
246
464
  } finally {
247
465
  const durationMs = performance.now() - startedAt;
@@ -265,14 +483,11 @@ export class WebSocketDeliveryEngine {
265
483
  }
266
484
  }
267
485
 
268
- private safePublish<Data>(
269
- connection: ManagedWebSocketConnection,
270
- event: string,
271
- data: Data,
272
- ): void {
486
+ private safeSend(target: FanOutTarget, event: string): void {
487
+ const { connection, frame } = target;
273
488
  try {
274
489
  if (!connection.closed) {
275
- connection.publishUntyped(event, data);
490
+ connection.sendFrame(event, frame);
276
491
  }
277
492
  } catch {
278
493
  this.telemetry.emit({
@@ -287,3 +502,12 @@ export class WebSocketDeliveryEngine {
287
502
  }
288
503
  }
289
504
  }
505
+
506
+ // 원격에서 온 frames는 prototype 키를 무시하고 자기 속성만 읽는다
507
+ function getOwnFrame(frames: Record<string, string>, routeKey: string): string | undefined {
508
+ return Object.hasOwn(frames, routeKey) ? frames[routeKey] : undefined;
509
+ }
510
+
511
+ function describeError(cause: unknown): string {
512
+ return cause instanceof Error ? cause.name : runtimeTypeOf(cause);
513
+ }