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.
- package/dist/_virtual/_rolldown/runtime.js +1 -2
- package/dist/ai/agents/agent.js +2 -3
- package/dist/ai/agents/index.js +1 -2
- package/dist/ai/index.js +1 -2
- package/dist/ai/providers/rtzr/api.js +1 -2
- package/dist/ai/providers/rtzr/error.js +2 -3
- package/dist/ai/providers/rtzr/model.js +1 -2
- package/dist/ai/providers/rtzr/options.js +1 -2
- package/dist/ai/providers/rtzr/provider.js +1 -2
- package/dist/ai/providers/rtzr/utils.js +1 -2
- package/dist/api/base-frame.js +4 -4
- package/dist/api/caster.d.ts +1 -1
- package/dist/api/caster.d.ts.map +1 -1
- package/dist/api/caster.js +9 -9
- package/dist/api/code-converters.js +6 -5
- package/dist/api/config.d.ts +1 -1
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +2 -3
- package/dist/api/context.d.ts +2 -3
- package/dist/api/context.d.ts.map +1 -1
- package/dist/api/context.js +2 -3
- package/dist/api/decorators.js +2 -3
- package/dist/api/http-validator.js +2 -3
- package/dist/api/index.js +8 -3
- package/dist/api/secret.js +1 -2
- package/dist/api/sonamu.d.ts +3 -5
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +30 -24
- package/dist/api/validator.js +1 -2
- package/dist/api/websocket-helpers.js +2 -3
- package/dist/auth/audit-log/builders.js +1 -2
- package/dist/auth/audit-log/client-ip.js +2 -3
- package/dist/auth/audit-log/events.js +1 -2
- package/dist/auth/audit-log/plugin.d.ts.map +1 -1
- package/dist/auth/audit-log/plugin.js +2 -3
- package/dist/auth/audit-log-ingestor.js +2 -3
- package/dist/auth/auth-generator.js +2 -3
- package/dist/auth/better-auth-entities.js +1 -2
- package/dist/auth/better-auth-request.js +2 -3
- package/dist/auth/index.js +2 -3
- package/dist/auth/knex-adapter.js +2 -3
- package/dist/auth/plugins/entity-definitions/admin.js +1 -2
- package/dist/auth/plugins/entity-definitions/anonymous.js +1 -2
- package/dist/auth/plugins/entity-definitions/api-key.js +1 -2
- package/dist/auth/plugins/entity-definitions/audit-log.js +1 -2
- package/dist/auth/plugins/entity-definitions/index.js +1 -2
- package/dist/auth/plugins/entity-definitions/jwt.js +1 -2
- package/dist/auth/plugins/entity-definitions/organization.js +1 -2
- package/dist/auth/plugins/entity-definitions/passkey.js +1 -2
- package/dist/auth/plugins/entity-definitions/phone-number.js +1 -2
- package/dist/auth/plugins/entity-definitions/sso.js +1 -2
- package/dist/auth/plugins/entity-definitions/two-factor.js +1 -2
- package/dist/auth/plugins/entity-definitions/username.js +1 -2
- package/dist/auth/plugins/wrappers/admin.d.ts +33 -32
- package/dist/auth/plugins/wrappers/admin.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/admin.js +2 -3
- package/dist/auth/plugins/wrappers/anonymous.js +2 -3
- package/dist/auth/plugins/wrappers/api-key.js +2 -3
- package/dist/auth/plugins/wrappers/jwt.js +2 -3
- package/dist/auth/plugins/wrappers/organization.js +2 -3
- package/dist/auth/plugins/wrappers/passkey.js +2 -3
- package/dist/auth/plugins/wrappers/phone-number.d.ts +43 -1
- package/dist/auth/plugins/wrappers/phone-number.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/phone-number.js +2 -3
- package/dist/auth/plugins/wrappers/sso.d.ts +138 -187
- package/dist/auth/plugins/wrappers/sso.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/sso.js +1 -2
- package/dist/auth/plugins/wrappers/two-factor.d.ts +51 -10
- package/dist/auth/plugins/wrappers/two-factor.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/two-factor.js +2 -3
- package/dist/auth/plugins/wrappers/username.d.ts +39 -5
- package/dist/auth/plugins/wrappers/username.d.ts.map +1 -1
- package/dist/auth/plugins/wrappers/username.js +2 -3
- package/dist/bin/build-config.js +5 -5
- package/dist/bin/compiler-policy.js +2 -3
- package/dist/bin/fixture.js +7 -5
- package/dist/bin/hmr-hook-register.js +1 -2
- package/dist/bin/migrate-targets.js +2 -3
- package/dist/bin/test-command.js +2 -3
- package/dist/bin/ts-loader-register.js +2 -3
- package/dist/bin/ts-loader-registration.js +2 -3
- package/dist/bin/typecheck-web.js +2 -3
- package/dist/cache/cache-manager.js +1 -2
- package/dist/cache/decorator.js +2 -3
- package/dist/cache/drivers.js +1 -2
- package/dist/cache/index.js +2 -3
- package/dist/cache-control/cache-control.js +1 -2
- package/dist/code-formatting/index.js +2 -3
- package/dist/code-formatting/oxfmt.js +2 -3
- package/dist/code-formatting/oxlint.js +2 -3
- package/dist/compress/compress.js +1 -2
- package/dist/compress/index.js +2 -2
- package/dist/compress/types.d.ts +1 -1
- package/dist/compress/types.d.ts.map +1 -1
- package/dist/cone/cone-generator.js +2 -3
- package/dist/database/_batch_update.js +1 -2
- package/dist/database/base-model.js +4 -4
- package/dist/database/db.js +4 -4
- package/dist/database/knex.js +2 -3
- package/dist/database/puri-wrapper.js +2 -3
- package/dist/database/puri.js +23 -22
- package/dist/database/puri.types.js +1 -2
- package/dist/database/transaction-context.js +1 -2
- package/dist/database/upsert-builder.js +2 -3
- package/dist/dict/en.js +2 -3
- package/dist/dict/index.js +3 -3
- package/dist/dict/ko.js +2 -3
- package/dist/dict/rc-keys.js +1 -2
- package/dist/dict/sd.js +2 -3
- package/dist/dict/sonamu-dictionary.js +7 -6
- package/dist/dict/utils.js +2 -3
- package/dist/entity/entity-manager.d.ts +66 -66
- package/dist/entity/entity-manager.js +4 -4
- package/dist/entity/entity-template-cone.js +2 -3
- package/dist/entity/entity.js +21 -18
- package/dist/env.js +1 -2
- package/dist/exceptions/error-handler.d.ts.map +1 -1
- package/dist/exceptions/error-handler.js +2 -3
- package/dist/exceptions/so-exceptions.js +2 -3
- package/dist/filter/index.js +3 -2
- package/dist/filter/types.js +1 -2
- package/dist/filter/utils.js +7 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +42 -6
- package/dist/logger/category.js +2 -3
- package/dist/logger/configure.js +5 -5
- package/dist/migration/code-generation.js +23 -16
- package/dist/migration/index-where-predicate.js +2 -3
- package/dist/migration/migration-set.js +2 -3
- package/dist/migration/migrator.js +2 -3
- package/dist/migration/postgresql-schema-reader.js +6 -9
- package/dist/migration/slack-confirm.js +2 -3
- package/dist/naite/naite-reporter.js +2 -3
- package/dist/naite/naite.js +10 -8
- package/dist/runtime-type.js +2 -3
- package/dist/ssr/hmr-opt-out.js +1 -2
- package/dist/ssr/index.js +7 -4
- package/dist/ssr/registry.js +1 -2
- package/dist/ssr/renderer.js +2 -3
- package/dist/storage/base-file.js +1 -2
- package/dist/storage/buffered-file.js +2 -3
- package/dist/storage/drivers.js +1 -2
- package/dist/storage/index.js +2 -3
- package/dist/storage/s3-driver.js +1 -2
- package/dist/storage/storage-manager.js +2 -3
- package/dist/storage/uploaded-file.js +2 -3
- package/dist/stream/__tests__/ws-test-helpers.d.ts +108 -0
- package/dist/stream/__tests__/ws-test-helpers.d.ts.map +1 -0
- package/dist/stream/__tests__/ws-test-helpers.js +259 -0
- package/dist/stream/index.d.ts +1 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +15 -4
- package/dist/stream/sse.js +1 -2
- package/dist/stream/ws-audience-resolver.d.ts +8 -5
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -1
- package/dist/stream/ws-audience-resolver.js +17 -15
- package/dist/stream/ws-audience.js +1 -2
- package/dist/stream/ws-cluster-bus.d.ts +9 -4
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -1
- package/dist/stream/ws-cluster-bus.js +2 -3
- package/dist/stream/ws-core.d.ts +10 -0
- package/dist/stream/ws-core.d.ts.map +1 -1
- package/dist/stream/ws-delivery.d.ts +30 -4
- package/dist/stream/ws-delivery.d.ts.map +1 -1
- package/dist/stream/ws-delivery.js +200 -54
- package/dist/stream/ws-frame.d.ts +15 -0
- package/dist/stream/ws-frame.d.ts.map +1 -0
- package/dist/stream/ws-frame.js +28 -0
- package/dist/stream/ws-local-connection-store.d.ts +4 -4
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -1
- package/dist/stream/ws-local-connection-store.js +3 -4
- package/dist/stream/ws-presence-index.d.ts +36 -0
- package/dist/stream/ws-presence-index.d.ts.map +1 -0
- package/dist/stream/ws-presence-index.js +176 -0
- package/dist/stream/ws-presence-store.d.ts +39 -39
- package/dist/stream/ws-presence-store.d.ts.map +1 -1
- package/dist/stream/ws-presence-store.js +23 -154
- package/dist/stream/ws-registry.d.ts +45 -17
- package/dist/stream/ws-registry.d.ts.map +1 -1
- package/dist/stream/ws-registry.js +170 -62
- package/dist/stream/ws-session-id.d.ts +14 -0
- package/dist/stream/ws-session-id.d.ts.map +1 -0
- package/dist/stream/ws-session-id.js +36 -0
- package/dist/stream/ws-telemetry-memory.js +3 -4
- package/dist/stream/ws-telemetry-trace.js +1 -2
- package/dist/stream/ws-telemetry.js +2 -3
- package/dist/stream/ws.d.ts +20 -10
- package/dist/stream/ws.d.ts.map +1 -1
- package/dist/stream/ws.js +60 -38
- package/dist/syncer/api-parser.js +2 -3
- package/dist/syncer/checksum.js +4 -4
- package/dist/syncer/code-generator.js +4 -4
- package/dist/syncer/entity-operations.js +2 -3
- package/dist/syncer/event-batcher.js +1 -2
- package/dist/syncer/file-copy.js +2 -3
- package/dist/syncer/file-patterns.js +2 -3
- package/dist/syncer/file-tracking.js +2 -3
- package/dist/syncer/filesystem-dependencies.js +1 -2
- package/dist/syncer/index.js +8 -2
- package/dist/syncer/module-loader.js +8 -6
- package/dist/syncer/syncer-actions.js +8 -7
- package/dist/syncer/syncer.js +13 -8
- package/dist/syncer/watcher.js +2 -3
- package/dist/tasks/decorator.js +1 -2
- package/dist/tasks/step-wrapper.js +2 -3
- package/dist/tasks/workflow-manager.js +2 -3
- package/dist/template/entity-converter.js +17 -16
- package/dist/template/helpers.js +2 -3
- package/dist/template/implementations/entity.template.js +2 -3
- package/dist/template/implementations/entry-server.template.js +2 -3
- package/dist/template/implementations/generated.template.js +2 -3
- package/dist/template/implementations/generated_http.template.js +6 -6
- package/dist/template/implementations/generated_sso.template.js +2 -3
- package/dist/template/implementations/http_validators.template.js +2 -3
- package/dist/template/implementations/init_types.template.js +2 -3
- package/dist/template/implementations/model.template.js +4 -4
- package/dist/template/implementations/model_test.template.js +2 -3
- package/dist/template/implementations/queries.template.js +2 -3
- package/dist/template/implementations/sd.template.js +2 -3
- package/dist/template/implementations/services.template.js +2 -3
- package/dist/template/implementations/view_form.template.js +12 -9
- package/dist/template/implementations/view_id_all_select.template.js +2 -3
- package/dist/template/implementations/view_list.template.d.ts.map +1 -1
- package/dist/template/implementations/view_list.template.js +19 -17
- package/dist/template/implementations/view_search_input.template.js +2 -3
- package/dist/template/index.js +9 -3
- package/dist/template/template-manager.js +2 -3
- package/dist/template/template-types.js +1 -2
- package/dist/template/template.js +2 -3
- package/dist/template/zod-converter.js +54 -47
- package/dist/testing/_relation-graph.js +2 -3
- package/dist/testing/bootstrap.js +2 -3
- package/dist/testing/data-explorer.js +2 -3
- package/dist/testing/dev-test-routes.js +2 -3
- package/dist/testing/dev-vitest-manager.js +15 -12
- package/dist/testing/faker-mappings.js +1 -2
- package/dist/testing/filesystem.js +2 -3
- package/dist/testing/fixture-generator.js +2 -3
- package/dist/testing/fixture-loader.js +1 -2
- package/dist/testing/fixture-manager.js +12 -9
- package/dist/testing/global-setup.js +5 -5
- package/dist/testing/index.js +6 -3
- package/dist/testing/naite-vitest-reporter.js +1 -2
- package/dist/testing/parallel-db-manager.js +2 -3
- package/dist/testing/vitest-helpers.js +2 -3
- package/dist/tooling/__tests__/helpers/sonamu-test-root.js +2 -3
- package/dist/tooling/cdd-service.js +1 -2
- package/dist/tooling/cli-tooling.js +1 -2
- package/dist/tooling/fixture-service.js +1 -2
- package/dist/tooling/index.js +1 -2
- package/dist/tooling/test-run-client.js +2 -3
- package/dist/tsdown.api.config.js +1 -2
- package/dist/types/types.js +2 -3
- package/dist/ui/ai-api.d.ts.map +1 -1
- package/dist/ui/ai-api.js +4 -5
- package/dist/ui/ai-client.d.ts +2 -2
- package/dist/ui/ai-client.d.ts.map +1 -1
- package/dist/ui/ai-client.js +9 -8
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +11 -8
- package/dist/ui/async-route.js +1 -2
- package/dist/ui/cdd-service.js +22 -21
- package/dist/ui/migrations-api.js +6 -5
- package/dist/ui-web/assets/index-QDMyy31K.js +180 -0
- package/dist/ui-web/assets/index-dV7akMFB.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/async-utils.js +1 -2
- package/dist/utils/class-name.js +1 -2
- package/dist/utils/console-util.js +1 -2
- package/dist/utils/controller.js +2 -3
- package/dist/utils/esm-utils.js +1 -2
- package/dist/utils/fs-utils.js +2 -3
- package/dist/utils/lodash-able.js +1 -2
- package/dist/utils/model.js +1 -2
- package/dist/utils/object-utils.js +2 -3
- package/dist/utils/path-utils.js +2 -3
- package/dist/utils/process-utils.js +2 -3
- package/dist/utils/runtime-value.js +1 -2
- package/dist/utils/sql-parser.js +2 -3
- package/dist/utils/type-utils.js +8 -8
- package/dist/utils/utils.js +2 -3
- package/dist/utils/zod-error.js +2 -3
- package/dist/vector/chunking.js +1 -2
- package/dist/vector/config.js +1 -2
- package/dist/vector/embedding.js +4 -4
- package/package.json +63 -63
- package/src/ai/agents/agent.ts +1 -1
- package/src/api/__tests__/sonamu.plugins.test.ts +11 -1
- package/src/api/__tests__/sonamu.websocket-routes.test.ts +234 -0
- package/src/api/__tests__/sonamu.websocket.test.ts +241 -24
- package/src/api/config.ts +1 -1
- package/src/api/context.ts +2 -8
- package/src/api/sonamu.ts +46 -28
- package/src/auth/audit-log/plugin.ts +2 -0
- package/src/compress/types.ts +1 -1
- package/src/exceptions/error-handler.ts +3 -2
- package/src/index.ts +1 -0
- package/src/stream/__tests__/ws-contracts.test.ts +483 -265
- package/src/stream/__tests__/ws-multinode.test.ts +326 -0
- package/src/stream/__tests__/ws-registry-async.test.ts +855 -0
- package/src/stream/__tests__/ws-test-helpers.ts +360 -0
- package/src/stream/__tests__/ws.test.ts +342 -145
- package/src/stream/index.ts +1 -0
- package/src/stream/ws-audience-resolver.ts +21 -19
- package/src/stream/ws-cluster-bus.ts +12 -4
- package/src/stream/ws-core.ts +14 -0
- package/src/stream/ws-delivery.ts +321 -97
- package/src/stream/ws-frame.ts +39 -0
- package/src/stream/ws-local-connection-store.ts +10 -9
- package/src/stream/ws-presence-index.ts +273 -0
- package/src/stream/ws-presence-store.ts +64 -284
- package/src/stream/ws-registry.ts +272 -85
- package/src/stream/ws-session-id.ts +39 -0
- package/src/stream/ws.ts +91 -50
- package/src/template/implementations/model.template.ts +3 -3
- package/src/template/implementations/view_list.template.ts +12 -13
- package/src/ui/ai-api.ts +4 -2
- package/src/ui/ai-client.ts +2 -2
- package/src/ui/api.ts +58 -62
- package/dist/ui-web/assets/index-BMdIyKXS.css +0 -1
- package/dist/ui-web/assets/index-LpE8u19O.js +0 -178
package/src/stream/index.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
7
|
-
namespace?: string;
|
|
11
|
+
targetNodeIds: string[];
|
|
8
12
|
audience: WebSocketAudience;
|
|
9
13
|
event: string;
|
|
10
|
-
|
|
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
|
-
|
|
27
|
+
// 구독 완료를 promise로 알릴 수 있다. 반환한 해제 함수는 runtime shutdown에서 한 번 호출된다
|
|
28
|
+
subscribe(handler: WebSocketClusterEnvelopeHandler): MaybePromise<() => void>;
|
|
21
29
|
shutdown(): void | Promise<void>;
|
|
22
30
|
}
|
|
23
31
|
|
package/src/stream/ws-core.ts
CHANGED
|
@@ -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
|
|
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:
|
|
19
|
-
targets: ManagedWebSocketConnection[];
|
|
20
|
-
event: string;
|
|
21
|
-
data: unknown;
|
|
22
|
-
cursor: number;
|
|
23
|
-
}> = [];
|
|
39
|
+
private readonly pendingFanOutJobs: FanOutJob[] = [];
|
|
24
40
|
private fanOutFlushScheduled = false;
|
|
25
|
-
|
|
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.
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
registerRoute(routeKey: string, outEvents: z.ZodObject): void {
|
|
69
|
+
this.routes.set(routeKey, outEvents);
|
|
70
|
+
}
|
|
50
71
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
169
|
+
/**
|
|
170
|
+
* 구독을 해제하고 cluster bus를 종료한다. 구독이 deadline까지 끝나지 않으면 늦게 끝날 때 해제한다.
|
|
171
|
+
*/
|
|
172
|
+
async shutdown(deadline: Promise<void>): Promise<void> {
|
|
173
|
+
this.stopped = true;
|
|
122
174
|
this.unregisterMetricSource();
|
|
123
|
-
|
|
124
|
-
await this.
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
269
|
-
connection
|
|
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.
|
|
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
|
+
}
|