mega-framework 0.1.6 → 0.1.8
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/README.md +9 -0
- package/bin/mega-ws-hub.js +2 -2
- package/package.json +33 -9
- package/sample/crud/.env +10 -1
- package/sample/crud/.env.example +10 -1
- package/sample/crud/.mega/journal/history/20260612092543-create-users.json +261 -0
- package/sample/crud/.mega/journal/snapshot.json +261 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +22 -14
- package/sample/crud/apps/main/controllers/web-controller.js +7 -5
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +91 -13
- package/sample/crud/apps/main/migrations/20260606000002-create-boards.js +165 -0
- package/sample/crud/apps/main/migrations/20260606000003-create-logs.js +107 -0
- package/sample/crud/apps/main/models/log-partition-model.js +105 -0
- package/sample/crud/apps/main/models/note-model.js +79 -0
- package/sample/crud/apps/main/models/user-level-model.js +24 -0
- package/sample/crud/apps/main/models/user-model.js +146 -0
- package/sample/crud/apps/main/models/user-type-model.js +21 -0
- package/sample/crud/apps/main/models/wallet-model.js +24 -0
- package/sample/crud/apps/main/routes/users.js +55 -10
- package/sample/crud/apps/main/schedules/log-partition-schedule.js +33 -0
- package/sample/crud/apps/main/services/auth-service.js +39 -24
- package/sample/crud/apps/main/services/log-partition-service.js +101 -0
- package/sample/crud/apps/main/services/note-service.js +6 -6
- package/sample/crud/apps/main/services/redis-demo-service.js +3 -3
- package/sample/crud/apps/main/services/user-service.js +62 -21
- package/sample/crud/apps/main/views/auth/login.ejs +6 -6
- package/sample/crud/apps/main/views/auth/register.ejs +46 -5
- package/sample/crud/apps/main/views/users/edit.ejs +42 -5
- package/sample/crud/apps/main/views/users/list.ejs +6 -2
- package/sample/crud/apps/main/views/users/new.ejs +56 -4
- package/sample/crud/docs/log_partition_design.mm.md +23 -0
- package/sample/crud/mega.config.js +10 -2
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +20 -6
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +44 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +33 -7
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +10 -1
- package/src/adapters/mongo-adapter.js +40 -8
- package/src/adapters/postgres-adapter.js +33 -6
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +26 -3
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +173 -33
- package/src/cli/generators/index.js +140 -3
- package/src/cli/index.js +437 -155
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +30 -3
- package/src/core/boot.js +464 -245
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/ctx-builder.js +65 -3
- package/src/core/envelope.js +119 -12
- package/src/core/hub-link.js +89 -18
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +7 -3
- package/src/core/mega-app.js +253 -505
- package/src/core/mega-cluster.js +4 -1
- package/src/core/mega-server.js +40 -9
- package/src/core/migration/dialect-registry.js +107 -0
- package/src/core/migration/dialects/README.md +62 -0
- package/src/core/migration/dialects/maria.js +496 -0
- package/src/core/migration/dialects/mongo.js +824 -0
- package/src/core/migration/dialects/postgres.js +563 -0
- package/src/core/migration/dialects/sqlite.js +476 -0
- package/src/core/migration/differ.js +456 -0
- package/src/core/migration/generate.js +508 -0
- package/src/core/migration/journal.js +167 -0
- package/src/core/migration/model-scan.js +84 -0
- package/src/core/migration/mongo-migration-db.js +97 -0
- package/src/core/migration/schema-builder.js +400 -0
- package/src/core/migration/schema-validator.js +315 -0
- package/src/core/migration-lock.js +205 -0
- package/src/core/migration-runner.js +166 -38
- package/src/core/multipart.js +28 -5
- package/src/core/pipeline.js +131 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +70 -12
- package/src/core/session-store.js +14 -1
- package/src/core/workers-manager.js +12 -1
- package/src/core/ws-cluster.js +10 -3
- package/src/core/ws-message.js +48 -4
- package/src/core/ws-presence.js +636 -0
- package/src/core/ws-roster.js +50 -8
- package/src/core/ws-upgrade.js +223 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +35 -4
- package/src/lib/mega-job-queue.js +151 -34
- package/src/lib/mega-job.js +37 -1
- package/src/lib/mega-metrics.js +31 -13
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +114 -39
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +33 -6
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +139 -15
- package/src/models/crud-sql-builder.js +133 -0
- package/src/models/mega-model.js +82 -2
- package/src/models/model-crud.js +483 -0
- package/src/models/mongo-crud.js +285 -0
- package/templates/adr/code.tpl +23 -0
- package/templates/model/code-mongo.tpl +35 -0
- package/templates/model/code.tpl +15 -1
- package/templates/model/test-mongo.tpl +38 -0
- package/templates/model/test.tpl +4 -0
- package/types/adapters/adapter-manager.d.ts +95 -0
- package/types/adapters/adapter-options.d.ts +93 -0
- package/types/adapters/file-adapter.d.ts +105 -0
- package/types/adapters/file-session-adapter.d.ts +103 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +117 -0
- package/types/adapters/mega-adapter.d.ts +215 -0
- package/types/adapters/mega-bus-adapter.d.ts +45 -0
- package/types/adapters/mega-cache-adapter.d.ts +73 -0
- package/types/adapters/mega-db-adapter.d.ts +50 -0
- package/types/adapters/mega-lock-adapter.d.ts +62 -0
- package/types/adapters/mega-log-sink-adapter.d.ts +15 -0
- package/types/adapters/mega-session-adapter.d.ts +32 -0
- package/types/adapters/mongo-adapter.d.ts +150 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +141 -0
- package/types/adapters/redis-adapter.d.ts +78 -0
- package/types/adapters/redis-session-adapter.d.ts +82 -0
- package/types/adapters/redlock-adapter.d.ts +149 -0
- package/types/adapters/registry.d.ts +46 -0
- package/types/adapters/sqlite-adapter.d.ts +112 -0
- package/types/auth/index.d.ts +24 -0
- package/types/cli/commands/console-cmd.d.ts +37 -0
- package/types/cli/commands/new.d.ts +16 -0
- package/types/cli/commands/routes.d.ts +36 -0
- package/types/cli/commands/scaffold.d.ts +78 -0
- package/types/cli/commands/test-cmd.d.ts +14 -0
- package/types/cli/generators/index.d.ts +122 -0
- package/types/cli/index.d.ts +234 -0
- package/types/cli/template-engine.d.ts +40 -0
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ajv-mapper.d.ts +27 -0
- package/types/core/boot.d.ts +233 -0
- package/types/core/cluster-metrics.d.ts +52 -0
- package/types/core/config-loader.d.ts +13 -0
- package/types/core/config-validator.d.ts +30 -0
- package/types/core/ctx-builder.d.ts +103 -0
- package/types/core/envelope.d.ts +79 -0
- package/types/core/error-mapper.d.ts +17 -0
- package/types/core/formbody.d.ts +41 -0
- package/types/core/hub-link.d.ts +266 -0
- package/types/core/i18n.d.ts +178 -0
- package/types/core/index.d.ts +28 -0
- package/types/core/mega-app.d.ts +529 -0
- package/types/core/mega-cluster.d.ts +104 -0
- package/types/core/mega-server.d.ts +91 -0
- package/types/core/mega-service.d.ts +31 -0
- package/types/core/migration/dialect-registry.d.ts +22 -0
- package/types/core/migration/dialects/maria.d.ts +99 -0
- package/types/core/migration/dialects/mongo.d.ts +89 -0
- package/types/core/migration/dialects/postgres.d.ts +117 -0
- package/types/core/migration/dialects/sqlite.d.ts +111 -0
- package/types/core/migration/differ.d.ts +47 -0
- package/types/core/migration/generate.d.ts +56 -0
- package/types/core/migration/journal.d.ts +52 -0
- package/types/core/migration/model-scan.d.ts +19 -0
- package/types/core/migration/mongo-migration-db.d.ts +7 -0
- package/types/core/migration/schema-builder.d.ts +197 -0
- package/types/core/migration/schema-validator.d.ts +20 -0
- package/types/core/migration-lock.d.ts +33 -0
- package/types/core/migration-runner.d.ts +101 -0
- package/types/core/multipart.d.ts +86 -0
- package/types/core/openapi.d.ts +62 -0
- package/types/core/pipeline.d.ts +93 -0
- package/types/core/router.d.ts +159 -0
- package/types/core/routes-loader.d.ts +21 -0
- package/types/core/scope-registry.d.ts +14 -0
- package/types/core/security.d.ts +77 -0
- package/types/core/services-loader.d.ts +27 -0
- package/types/core/session-cleanup-schedule.d.ts +19 -0
- package/types/core/session-store.d.ts +25 -0
- package/types/core/session.d.ts +77 -0
- package/types/core/static-assets.d.ts +73 -0
- package/types/core/template.d.ts +106 -0
- package/types/core/workers-manager.d.ts +79 -0
- package/types/core/ws-cluster.d.ts +208 -0
- package/types/core/ws-compression.d.ts +112 -0
- package/types/core/ws-controller.d.ts +65 -0
- package/types/core/ws-message.d.ts +106 -0
- package/types/core/ws-presence.d.ts +273 -0
- package/types/core/ws-roster.d.ts +108 -0
- package/types/core/ws-upgrade.d.ts +260 -0
- package/types/errors/config-error.d.ts +10 -0
- package/types/errors/http-errors.d.ts +120 -0
- package/types/errors/index.d.ts +3 -0
- package/types/errors/mega-error.d.ts +32 -0
- package/types/index.d.ts +39 -0
- package/types/lib/asp/config.d.ts +49 -0
- package/types/lib/asp/crypto.d.ts +43 -0
- package/types/lib/asp/errors.d.ts +30 -0
- package/types/lib/asp/nonce-cache.d.ts +52 -0
- package/types/lib/asp/plugin.d.ts +30 -0
- package/types/lib/asp/ws-terminator.d.ts +45 -0
- package/types/lib/env-mapper.d.ts +14 -0
- package/types/lib/hub-protocol.d.ts +106 -0
- package/types/lib/index.d.ts +22 -0
- package/types/lib/logger/telegram-core.d.ts +104 -0
- package/types/lib/logger/telegram-transport.d.ts +45 -0
- package/types/lib/mega-brute-force.d.ts +66 -0
- package/types/lib/mega-circuit-breaker.d.ts +243 -0
- package/types/lib/mega-cron.d.ts +66 -0
- package/types/lib/mega-hash.d.ts +32 -0
- package/types/lib/mega-health.d.ts +48 -0
- package/types/lib/mega-job-queue.d.ts +188 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +145 -0
- package/types/lib/mega-logger.d.ts +45 -0
- package/types/lib/mega-metrics.d.ts +285 -0
- package/types/lib/mega-plugin.d.ts +245 -0
- package/types/lib/mega-retry.d.ts +85 -0
- package/types/lib/mega-schedule.d.ts +260 -0
- package/types/lib/mega-shutdown.d.ts +135 -0
- package/types/lib/mega-tracing.d.ts +224 -0
- package/types/lib/mega-worker.d.ts +129 -0
- package/types/lib/otel-resource.d.ts +16 -0
- package/types/lib/worker-runner/process-entry.d.ts +1 -0
- package/types/lib/worker-runner/task-dispatch.d.ts +28 -0
- package/types/lib/worker-runner/thread-entry.d.ts +1 -0
- package/types/lib/ws-hub.d.ts +259 -0
- package/types/models/crud-sql-builder.d.ts +48 -0
- package/types/models/index.d.ts +1 -0
- package/types/models/mega-model.d.ts +138 -0
- package/types/models/model-crud.d.ts +82 -0
- package/types/models/mongo-crud.d.ts +59 -0
- package/types/test/index.d.ts +84 -0
- package/.env +0 -127
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +0 -30
- package/sample/crud/apps/main/models/note.js +0 -71
- package/sample/crud/apps/main/models/user.js +0 -86
- package/sample/crud/package-lock.json +0 -5665
- package/sample/crud/yarn.lock +0 -2142
- package/sample/simple/package-lock.json +0 -1851
package/src/core/ws-roster.js
CHANGED
|
@@ -74,9 +74,25 @@ export class MegaWsRedisRoster {
|
|
|
74
74
|
if (typeof channel !== 'string' || typeof sessionId !== 'string') return
|
|
75
75
|
const key = this._key(channel)
|
|
76
76
|
const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt: Date.now() + this._ttlMs })
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
await this._redis.pexpire(key, this._ttlMs * 2)
|
|
77
|
+
// HSET + 채널 HASH 키 TTL(멤버 TTL 2배 — 전원 stale 돼도 키가 영구히 안 남게)을 pipeline 1 RT 로.
|
|
78
|
+
// 직렬 await 2회는 joinSession(채널 수만큼 add)·heartbeat 경로에서 왕복 수가 비용을 지배했다.
|
|
79
|
+
const results = await this._redis.pipeline().hset(key, sessionId, value).pexpire(key, this._ttlMs * 2).exec()
|
|
80
|
+
this._throwFirstPipelineError(results)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ioredis pipeline.exec() 결과(`[err, res][]`)에서 첫 명령 오류를 throw 한다 — pipeline 은 명령별
|
|
85
|
+
* 오류를 reject 가 아니라 결과 배열에 싣기 때문에, 묵히면 호출부의 기존 실패 처리(catch+warn)가
|
|
86
|
+
* 더는 동작하지 않는다(직렬 await 시절의 throw 계약 유지).
|
|
87
|
+
* @param {Array<[Error | null, any]> | null} results
|
|
88
|
+
* @returns {void}
|
|
89
|
+
* @private
|
|
90
|
+
*/
|
|
91
|
+
_throwFirstPipelineError(results) {
|
|
92
|
+
if (!Array.isArray(results)) return
|
|
93
|
+
for (const [err] of results) {
|
|
94
|
+
if (err) throw err
|
|
95
|
+
}
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
/**
|
|
@@ -139,13 +155,29 @@ export class MegaWsRedisRoster {
|
|
|
139
155
|
}
|
|
140
156
|
|
|
141
157
|
/**
|
|
142
|
-
* 로컬 멤버
|
|
158
|
+
* 로컬 멤버 전체의 `expiresAt` 을 한 pipeline 으로 일괄 갱신. 직렬 add 루프(멤버당 2 RT)는 멤버
|
|
159
|
+
* 1,000 에 주기당 수백 ms — 갱신 지연이 TTL 윈도를 침식해 살아있는 세션이 lazy 만료될 수 있었다.
|
|
160
|
+
* pipeline 이면 단일 왕복 수준(실측 ~200×). 채널 키 PEXPIRE 는 채널당 1회만 싣는다.
|
|
161
|
+
* @returns {Promise<void>} @private
|
|
143
162
|
*/
|
|
144
163
|
async _refreshLocal() {
|
|
145
164
|
const members = this._getLocalMembers()
|
|
165
|
+
if (members.length === 0) return
|
|
166
|
+
const expiresAt = Date.now() + this._ttlMs
|
|
167
|
+
const pipeline = this._redis.pipeline()
|
|
168
|
+
/** @type {Set<string>} PEXPIRE 를 이미 실은 채널(중복 제거). */
|
|
169
|
+
const touchedChannels = new Set()
|
|
146
170
|
for (const { channel, sessionId, member } of members) {
|
|
147
|
-
|
|
171
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
|
|
172
|
+
const key = this._key(channel)
|
|
173
|
+
const value = JSON.stringify({ userId: member?.userId, ...(member?.metadata ? { metadata: member.metadata } : {}), expiresAt })
|
|
174
|
+
pipeline.hset(key, sessionId, value)
|
|
175
|
+
if (!touchedChannels.has(channel)) {
|
|
176
|
+
touchedChannels.add(channel)
|
|
177
|
+
pipeline.pexpire(key, this._ttlMs * 2)
|
|
178
|
+
}
|
|
148
179
|
}
|
|
180
|
+
this._throwFirstPipelineError(await pipeline.exec())
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
/**
|
|
@@ -155,9 +187,19 @@ export class MegaWsRedisRoster {
|
|
|
155
187
|
async stop() {
|
|
156
188
|
if (this._hbTimer) clearInterval(this._hbTimer)
|
|
157
189
|
this._hbTimer = null
|
|
158
|
-
// graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리)
|
|
159
|
-
|
|
160
|
-
|
|
190
|
+
// graceful: 자기 로컬 멤버 즉시 제거(crash 가 아닌 정상 종료라 TTL 대기 없이 정리) — 한 pipeline 으로.
|
|
191
|
+
// 실패해도 종료는 계속(lazy 만료가 정리) — 단 명단에 TTL 까지 남으므로 묵히지 않고 알린다.
|
|
192
|
+
const members = this._getLocalMembers()
|
|
193
|
+
if (members.length === 0) return
|
|
194
|
+
const pipeline = this._redis.pipeline()
|
|
195
|
+
for (const { channel, sessionId } of members) {
|
|
196
|
+
if (typeof channel !== 'string' || typeof sessionId !== 'string') continue
|
|
197
|
+
pipeline.hdel(this._key(channel), sessionId)
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
this._throwFirstPipelineError(await pipeline.exec())
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this._log?.warn?.({ err }, 'ws-roster graceful remove failed (stale until TTL)')
|
|
161
203
|
}
|
|
162
204
|
}
|
|
163
205
|
}
|
package/src/core/ws-upgrade.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* @module core/ws-upgrade
|
|
27
27
|
*/
|
|
28
|
-
import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN } from './ws-message.js'
|
|
28
|
+
import { createWsMessage, parseWsMessage, generateMessageId, WS_TYPE_PATTERN, WS_PROTOCOL_VERSION } from './ws-message.js'
|
|
29
29
|
import { MegaAspDecryptError } from '../lib/asp/errors.js'
|
|
30
30
|
import * as MegaTracing from '../lib/mega-tracing.js'
|
|
31
31
|
import * as MegaMetrics from '../lib/mega-metrics.js'
|
|
@@ -49,9 +49,148 @@ export const CLOSE_CODE_INTERNAL_ERROR = 1011
|
|
|
49
49
|
/** 느린 소비자 백프레셔 close code (RFC 6455 §7.4.1 표준 1013 "Try Again Later", Med). */
|
|
50
50
|
export const CLOSE_CODE_SLOW_CONSUMER = 1013
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 재배치(requeue) close code — 4503. "이 워커 말고 다른 곳으로 즉시 재연결하라"는 신호로,
|
|
54
|
+
* bridge↔hub 의 drain(4503, `hub-protocol.js` CLOSE_CODE_DRAIN)과 같은 코드를 써서 클라이언트가
|
|
55
|
+
* 한 가지 규약("4503 = 세션 유지한 채 재연결")으로 두 링크를 모두 처리한다. admin-kick 의
|
|
56
|
+
* `requeue: true`(hub.disconnect) 가 사용 — kick(1008, 돌아오지 마라)과 의미가 반대다.
|
|
57
|
+
* HTTP 세션은 보존되므로 재연결 시 `before` 인증이 세션 쿠키로 자연 통과한다(transparent re-route).
|
|
58
|
+
*/
|
|
59
|
+
export const CLOSE_CODE_REQUEUE = 4503
|
|
60
|
+
|
|
52
61
|
/** send 버퍼(bufferedAmount) 기본 상한(바이트). 초과 = 소비자가 ack 를 못 따라옴 → 연결 종료(서버 OOM 방지). */
|
|
53
62
|
export const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
54
63
|
|
|
64
|
+
/**
|
|
65
|
+
* 프로세스 **합산** send 버퍼 기본 budget(바이트, ADR-215 — G5 audit M-6).
|
|
66
|
+
*
|
|
67
|
+
* per-conn 상한(위 16MiB)은 연결 1개의 OOM 방어일 뿐이라, 느린 소비자 N 개가 각자 cap 직전까지
|
|
68
|
+
* 쌓으면 합산은 무제한이었다(이론상 1,000개 × 16MiB = 16GB). 합산이 본 budget 을 넘으면 **가장 큰
|
|
69
|
+
* 송신 큐를 보유한 연결부터** 1013(slow consumer)으로 종료해 budget 아래로 회수한다.
|
|
70
|
+
* `configureWsSendBudget({ maxTotalBufferedBytes: 0 })` 으로 무제한 옵트아웃.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_MAX_TOTAL_BUFFERED_BYTES = 256 * 1024 * 1024
|
|
73
|
+
|
|
74
|
+
/** 합산 budget 스윕 최소 간격(ms) — 매 send 마다 전 연결 O(N) 합산을 돌지 않게 하는 비용 상한. */
|
|
75
|
+
const BUDGET_SWEEP_INTERVAL_MS = 250
|
|
76
|
+
|
|
77
|
+
/** budget 추적 대상 연결 — driveWsConnection 경유 실연결만(직접 생성한 테스트 더블은 미추적). @type {Set<MegaWsConnection>} */
|
|
78
|
+
const budgetConns = new Set()
|
|
79
|
+
|
|
80
|
+
/** @type {number} 현재 합산 budget(바이트). Infinity = 옵트아웃. */
|
|
81
|
+
let maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
|
|
82
|
+
|
|
83
|
+
/** @type {number} 마지막 budget 스윕 시각(epoch ms). */
|
|
84
|
+
let lastBudgetSweepAt = 0
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 프로세스 합산 send budget 을 조정한다(ADR-215). 부팅/운영 튜닝 진입점.
|
|
88
|
+
* @param {{ maxTotalBufferedBytes?: number }} [opts] - 바이트 budget. `0` = 무제한(옵트아웃).
|
|
89
|
+
* @returns {{ maxTotalBufferedBytes: number }} 적용된 현재 값.
|
|
90
|
+
* @throws {TypeError} 음수/비숫자 — 설정 실수 fail-fast.
|
|
91
|
+
*/
|
|
92
|
+
export function configureWsSendBudget({ maxTotalBufferedBytes: max } = {}) {
|
|
93
|
+
if (max !== undefined) {
|
|
94
|
+
if (typeof max !== 'number' || Number.isNaN(max) || max < 0) {
|
|
95
|
+
throw new TypeError(`configureWsSendBudget: maxTotalBufferedBytes must be a non-negative number (0 = unlimited). Got ${max}`)
|
|
96
|
+
}
|
|
97
|
+
maxTotalBufferedBytes = max === 0 ? Infinity : max
|
|
98
|
+
}
|
|
99
|
+
return { maxTotalBufferedBytes }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 합산 budget 스윕 — 추적 중 전 연결의 `bufferedAmount` 를 합산해 budget 초과면 가장 큰 큐 보유
|
|
104
|
+
* 연결부터 종료한다. 스로틀({@link BUDGET_SWEEP_INTERVAL_MS}) 안쪽 재호출은 no-op(send 핫패스 보호).
|
|
105
|
+
* @param {number} [now] - epoch ms(테스트 주입용).
|
|
106
|
+
* @returns {void}
|
|
107
|
+
*/
|
|
108
|
+
function sweepWsSendBudget(now = Date.now()) {
|
|
109
|
+
if (now - lastBudgetSweepAt < BUDGET_SWEEP_INTERVAL_MS) return
|
|
110
|
+
lastBudgetSweepAt = now
|
|
111
|
+
if (budgetConns.size === 0 || maxTotalBufferedBytes === Infinity) return
|
|
112
|
+
let total = 0
|
|
113
|
+
for (const c of budgetConns) total += c._raw.bufferedAmount ?? 0
|
|
114
|
+
while (total > maxTotalBufferedBytes) {
|
|
115
|
+
/** @type {MegaWsConnection | null} */
|
|
116
|
+
let worst = null
|
|
117
|
+
for (const c of budgetConns) {
|
|
118
|
+
if (worst === null || (c._raw.bufferedAmount ?? 0) > (worst._raw.bufferedAmount ?? 0)) worst = c
|
|
119
|
+
}
|
|
120
|
+
const worstBytes = worst === null ? 0 : (worst._raw.bufferedAmount ?? 0)
|
|
121
|
+
if (worst === null || worstBytes === 0) break // 잔여가 전부 0 이면 더 회수할 게 없음(무한루프 차단).
|
|
122
|
+
budgetConns.delete(worst) // close 이벤트 전에 즉시 제외 — 같은 스윕 내 재선정 방지.
|
|
123
|
+
total -= worstBytes
|
|
124
|
+
try {
|
|
125
|
+
worst._raw.close(CLOSE_CODE_SLOW_CONSUMER, 'backpressure: process-wide send budget exceeded')
|
|
126
|
+
} catch {
|
|
127
|
+
// 이미 닫히는 중이면 close 는 무의미 — per-conn 가드와 동일 의미(비치명적). Set 에선 이미 제거됨.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** budget 추적 등록(driveWsConnection 전용). @param {MegaWsConnection} conn @returns {void} */
|
|
133
|
+
function trackWsSendBudget(conn) {
|
|
134
|
+
budgetConns.add(conn)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** budget 추적 해제(연결 close 시). @param {MegaWsConnection} conn @returns {void} */
|
|
138
|
+
function untrackWsSendBudget(conn) {
|
|
139
|
+
budgetConns.delete(conn)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 테스트 격리용 — budget 상태 초기화(추적 Set·스로틀·budget 디폴트 복원).
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
export function _resetWsSendBudget() {
|
|
147
|
+
budgetConns.clear()
|
|
148
|
+
lastBudgetSweepAt = 0
|
|
149
|
+
maxTotalBufferedBytes = DEFAULT_MAX_TOTAL_BUFFERED_BYTES
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 테스트용 — 연결을 budget 추적에 등록. @param {MegaWsConnection} conn @returns {void} */
|
|
153
|
+
export function _trackWsSendBudget(conn) {
|
|
154
|
+
trackWsSendBudget(conn)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 테스트용 — 스로틀 우회 가능한 스윕 직접 호출. @param {number} [now] @returns {void} */
|
|
158
|
+
export function _sweepWsSendBudget(now) {
|
|
159
|
+
sweepWsSendBudget(now)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 클라↔bridge ping/pong liveness 기본 주기(ms) — 30초. 주기마다 ping 을 보내고 직전 주기의 pong 이
|
|
164
|
+
* 없으면 half-open(상대 사망·네트워크 단절) 으로 보고 terminate 한다 — 좀비 연결이 OS TCP 타임아웃까지
|
|
165
|
+
* `_wsConns`/roster 에 잔존하는 것을 막는다. 라우트 `opts.heartbeatMs` 로 조정, `0` = 끔.
|
|
166
|
+
*/
|
|
167
|
+
export const DEFAULT_WS_HEARTBEAT_MS = 30_000
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* onConnect 완료 전 도착한 프레임의 대기 큐 상한 — 초과 시 연결 종료(1013). onConnect 가 느릴 때
|
|
171
|
+
* 악의적/과속 클라이언트가 큐로 메모리를 채우는 것을 막는다.
|
|
172
|
+
*/
|
|
173
|
+
export const MAX_PENDING_FRAMES_BEFORE_CONNECT = 256
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 라우트 opts 의 `heartbeatMs` 를 검증해 반환한다. 미지정 → 기본 30초, `0` = liveness 끔.
|
|
177
|
+
* 무효값(음수/비정수/비숫자)은 운영 실수 — 조용히 보정하지 않고 warn 로그 후 기본값을 쓴다
|
|
178
|
+
* (연결 구동 시점이라 throw 하면 핸드셰이크 콜백 밖으로 새므로 로그+기본값이 안전한 fail-safe).
|
|
179
|
+
*
|
|
180
|
+
* @param {{ heartbeatMs?: number } | undefined} opts - WS 라우트 opts.
|
|
181
|
+
* @param {any} [log] - 로거(warn).
|
|
182
|
+
* @returns {number} 적용할 주기(ms). 0 = 끔.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveWsHeartbeatMs(opts, log) {
|
|
185
|
+
const v = opts?.heartbeatMs
|
|
186
|
+
if (v === undefined || v === null) return DEFAULT_WS_HEARTBEAT_MS
|
|
187
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
|
|
188
|
+
log?.warn?.({ heartbeatMs: v }, `ws route opts.heartbeatMs is invalid (integer >= 0 expected) — using default ${DEFAULT_WS_HEARTBEAT_MS}ms`)
|
|
189
|
+
return DEFAULT_WS_HEARTBEAT_MS
|
|
190
|
+
}
|
|
191
|
+
return v
|
|
192
|
+
}
|
|
193
|
+
|
|
55
194
|
/**
|
|
56
195
|
* WS 프레임 코덱 — 평문/암호 와이어 변환을 추상화한다.
|
|
57
196
|
* @typedef {Object} WsFrameCodec
|
|
@@ -122,6 +261,8 @@ export class MegaWsConnection {
|
|
|
122
261
|
this.channels = null
|
|
123
262
|
/** @type {Object|undefined} joinSession/updateMetadata 로 저장한 presence 메타(재연결 재동기화에 보존, M-1). */
|
|
124
263
|
this.metadata = undefined
|
|
264
|
+
/** @type {number} 협상된 envelope 프로토콜 버전(기본 v1) — driveWsConnection 이 핸드셰이크 결과로 설정. */
|
|
265
|
+
this.protocolVersion = 1
|
|
125
266
|
}
|
|
126
267
|
|
|
127
268
|
/** 하위 raw `ws` WebSocket (escape hatch — 바이너리/직접 제어용). */
|
|
@@ -154,6 +295,10 @@ export class MegaWsConnection {
|
|
|
154
295
|
}
|
|
155
296
|
return
|
|
156
297
|
}
|
|
298
|
+
// 프로세스 합산 budget 스윕(ADR-215) — per-conn cap 아래의 "다수의 느린 소비자" 합산 OOM 방어.
|
|
299
|
+
// 스로틀이 있어 핫패스 비용은 주기당 O(N) 1회. 스윕이 이 연결을 닫았으면(가장 큰 큐) 송신 생략.
|
|
300
|
+
sweepWsSendBudget()
|
|
301
|
+
if (this._raw.readyState !== undefined && this._raw.readyState !== 1) return
|
|
157
302
|
// ns 자동 주입 (L1): 명시 안 됐고 연결 ns 가 있으면 채운다. 명시값은 덮어쓰지 않음.
|
|
158
303
|
const withNs = fields.ns === undefined && this.ns !== undefined ? { ...fields, ns: this.ns } : fields
|
|
159
304
|
const env = createWsMessage(withNs)
|
|
@@ -234,10 +379,17 @@ function buildWsPresence(app, conn, ns) {
|
|
|
234
379
|
* @param {any} args.log - request 로거 (debug/warn/error).
|
|
235
380
|
* @param {any} [args.auth] - `before` 미들웨어가 인증 후 돌려준 신원(`{ userId, sessionId, ... }`).
|
|
236
381
|
* `ctx.auth` 로 노출 — onConnect 에서 `app.joinSession(sock, { userId: ctx.auth.userId, ... })` 에 쓴다.
|
|
382
|
+
* @param {number} [args.protocolVersion] - 핸드셰이크에서 협상된 envelope 버전(`mega.v<N>` subprotocol,
|
|
383
|
+
* {@link import('./ws-message.js').negotiateWsProtocol}). 미지정 = v1(레거시). 이 연결의 envelope
|
|
384
|
+
* 검증 기준이 되고 `conn.protocolVersion`/`ctx.protocolVersion` 으로 노출된다.
|
|
237
385
|
* @returns {MegaWsConnection}
|
|
238
386
|
*/
|
|
239
|
-
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null }) {
|
|
387
|
+
export function driveWsConnection({ raw, req, route, app, codec, log, auth = null, protocolVersion = WS_PROTOCOL_VERSION }) {
|
|
240
388
|
const conn = new MegaWsConnection(raw, codec, { ns: route.ns, path: route.path })
|
|
389
|
+
// 프로세스 합산 send budget 추적(ADR-215) — 실연결만 등록, close 에서 해제(아래 'close' 핸들러).
|
|
390
|
+
trackWsSendBudget(conn)
|
|
391
|
+
// 협상된 envelope 버전 — 이 연결의 검증 기준이자 v2 도입 시 코덱/검증 분기의 기준점.
|
|
392
|
+
conn.protocolVersion = protocolVersion
|
|
241
393
|
// ChannelClass 는 부팅 시 MegaWebSocketController 상속 검증됨 (router.ws). 여기선 인스턴스화만.
|
|
242
394
|
const channel = /** @type {import('./ws-controller.js').MegaWebSocketController} */ (
|
|
243
395
|
new (/** @type {any} */ (route.ChannelClass))()
|
|
@@ -256,6 +408,7 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
|
|
|
256
408
|
path: route.path,
|
|
257
409
|
req,
|
|
258
410
|
connId: conn.id,
|
|
411
|
+
protocolVersion, // 협상된 envelope 버전(v1 기본) — 채널이 버전별 동작을 분기할 때 사용.
|
|
259
412
|
tracer: MegaTracing.tracer, // ctx.tracer.span(name, fn) — WS 핸들러에서도 사용자 직접 span(ADR-126).
|
|
260
413
|
// presence 단축 API (ADR-176) — list(클러스터 roster)/join/directToUser/broadcast 를 ns·conn 바인딩으로
|
|
261
414
|
// 노출. 클러스터 동기화는 wsCluster 가 처리하므로 채널은 비즈니스 로직만 쓴다. mock app 이면 null.
|
|
@@ -270,30 +423,88 @@ export function driveWsConnection({ raw, req, route, app, codec, log, auth = nul
|
|
|
270
423
|
|
|
271
424
|
log.debug?.({ connId: conn.id, path: route.path, ns: route.ns }, 'ws.connect enter')
|
|
272
425
|
|
|
426
|
+
// type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
|
|
427
|
+
const schemaValidators = route.schemaValidators ?? null
|
|
428
|
+
|
|
429
|
+
/** 프레임 1건 처리 시작 — fire-and-forget(각 메시지 독립 async 흐름). @param {string} frame */
|
|
430
|
+
const processFrame = (frame) => {
|
|
431
|
+
// 최외곽 가드 (L2): handleIncoming 은 내부에서 단계별 try/catch 하지만, 예기치 못한
|
|
432
|
+
// 동기 throw / reject 가 unhandledRejection 으로 새지 않도록 .catch 로 마무리한다.
|
|
433
|
+
handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaValidators, log }).catch((err) => {
|
|
434
|
+
log.warn?.({ err, connId: conn.id }, 'ws handleIncoming unexpected error')
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// onConnect 완료 전 도착한 프레임은 큐에 보관했다가 완료 후 도착 순서대로 처리한다 — 'message' 리스너는
|
|
439
|
+
// 동기 부착되고 onConnect 는 비동기라, 빠른 클라이언트의 첫 메시지가 채널 초기화(joinSession 등) 전에
|
|
440
|
+
// 디스패치되는 race 를 막는다. onConnect 실패 시 연결이 닫히므로 큐는 버린다.
|
|
441
|
+
let isConnectSettled = false
|
|
442
|
+
/** @type {string[]} */
|
|
443
|
+
let pendingFrames = []
|
|
444
|
+
|
|
273
445
|
// onConnect — 실패 시 fail-closed. 복호화와 무관한 서버 내부 오류이므로 1011 (M3). silent 금지.
|
|
274
446
|
Promise.resolve()
|
|
275
447
|
.then(() => channel.onConnect(conn, ctx))
|
|
448
|
+
.then(() => {
|
|
449
|
+
isConnectSettled = true
|
|
450
|
+
const queued = pendingFrames
|
|
451
|
+
pendingFrames = []
|
|
452
|
+
for (const frame of queued) processFrame(frame)
|
|
453
|
+
})
|
|
276
454
|
.catch((err) => {
|
|
277
455
|
log.error?.({ err, connId: conn.id }, 'ws.onConnect threw — closing (1011)')
|
|
456
|
+
pendingFrames = [] // 연결이 닫히므로 대기 프레임은 처리하지 않는다.
|
|
278
457
|
if (conn.isOpen) conn.close(CLOSE_CODE_INTERNAL_ERROR, 'onConnect failed')
|
|
279
458
|
})
|
|
280
459
|
|
|
281
|
-
// type 별 payload 스키마 검증 함수 맵 (M2, router.ws 에서 사전 컴파일). 없으면 검증 없음.
|
|
282
|
-
const schemaValidators = route.schemaValidators ?? null
|
|
283
|
-
|
|
284
460
|
raw.on('message', (data) => {
|
|
285
461
|
// ws 는 Buffer | ArrayBuffer | Buffer[] 를 줄 수 있음. 텍스트 프레임만 처리 (바이너리는 후속 BINARY 타입).
|
|
286
462
|
const frame = Array.isArray(data)
|
|
287
463
|
? Buffer.concat(data).toString('utf8')
|
|
288
464
|
: data.toString('utf8')
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
465
|
+
if (!isConnectSettled) {
|
|
466
|
+
if (pendingFrames.length >= MAX_PENDING_FRAMES_BEFORE_CONNECT) {
|
|
467
|
+
// onConnect 가 끝나기 전에 큐 상한 도달 — 더 쌓으면 메모리 abuse 라 연결을 닫는다(1013).
|
|
468
|
+
log.warn?.({ connId: conn.id, queued: pendingFrames.length }, 'ws pre-connect frame queue overflow — closing (1013)')
|
|
469
|
+
pendingFrames = []
|
|
470
|
+
if (conn.isOpen) conn.close(CLOSE_CODE_SLOW_CONSUMER, 'pre-connect queue overflow')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
pendingFrames.push(frame)
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
processFrame(frame)
|
|
294
477
|
})
|
|
295
478
|
|
|
479
|
+
// liveness(ping/pong): 주기마다 ping 을 보내고 직전 주기의 pong 이 없으면 half-open 으로 보고
|
|
480
|
+
// terminate 한다 — 'close' 이벤트가 onDisconnect/untrack 정리를 그대로 트리거한다. opts.heartbeatMs=0 으로 끔.
|
|
481
|
+
const heartbeatMs = resolveWsHeartbeatMs(/** @type {any} */ (route.opts), log)
|
|
482
|
+
if (heartbeatMs > 0) {
|
|
483
|
+
let isAlive = true
|
|
484
|
+
raw.on('pong', () => {
|
|
485
|
+
isAlive = true
|
|
486
|
+
})
|
|
487
|
+
const pingTimer = setInterval(() => {
|
|
488
|
+
if (raw.readyState !== 1) return // 닫히는 중 — 'close' 가 곧 타이머를 정리한다.
|
|
489
|
+
if (!isAlive) {
|
|
490
|
+
log.warn?.({ connId: conn.id, heartbeatMs }, 'ws heartbeat timeout — terminating half-open connection')
|
|
491
|
+
raw.terminate()
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
isAlive = false
|
|
495
|
+
try {
|
|
496
|
+
raw.ping()
|
|
497
|
+
} catch (err) {
|
|
498
|
+
// 소켓이 닫히는 중이면 ping 실패 — 비치명적, close 가 뒤따른다 (이유+로그).
|
|
499
|
+
log.debug?.({ err, connId: conn.id }, 'ws ping send failed (socket closing)')
|
|
500
|
+
}
|
|
501
|
+
}, heartbeatMs)
|
|
502
|
+
if (typeof pingTimer.unref === 'function') pingTimer.unref()
|
|
503
|
+
raw.on('close', () => clearInterval(pingTimer))
|
|
504
|
+
}
|
|
505
|
+
|
|
296
506
|
raw.on('close', (code, reasonBuf) => {
|
|
507
|
+
untrackWsSendBudget(conn)
|
|
297
508
|
app._untrackWsConn?.(conn)
|
|
298
509
|
const reason = Buffer.isBuffer(reasonBuf) ? reasonBuf.toString('utf8') : String(reasonBuf ?? '')
|
|
299
510
|
log.debug?.({ connId: conn.id, code, reason }, 'ws.disconnect')
|
|
@@ -396,11 +607,11 @@ async function handleIncoming({ conn, channel, codec, ctx, raw, frame, schemaVal
|
|
|
396
607
|
return
|
|
397
608
|
}
|
|
398
609
|
|
|
399
|
-
// 2) envelope 파싱 +
|
|
610
|
+
// 2) envelope 파싱 + 검증 — 이 연결에서 협상된 버전 기준. 실패 → error envelope 응답(연결 유지 — 비치명적).
|
|
400
611
|
/** @type {{ type: string, id: string }} */
|
|
401
612
|
let msg
|
|
402
613
|
try {
|
|
403
|
-
msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain))
|
|
614
|
+
msg = /** @type {{ type: string, id: string }} */ (parseWsMessage(plain, { version: conn.protocolVersion }))
|
|
404
615
|
} catch (err) {
|
|
405
616
|
log.warn?.({ err, connId: conn.id }, 'ws invalid envelope')
|
|
406
617
|
if (conn.isOpen) {
|
package/src/index.js
CHANGED
|
@@ -115,7 +115,7 @@ export { MegaAspDecryptError, ASP_RULES } from './lib/asp/errors.js'
|
|
|
115
115
|
export { normalizeAspConfig } from './lib/asp/config.js'
|
|
116
116
|
|
|
117
117
|
// Bridge ↔ Hub 12-타입 프로토콜 (ADR-033/059/097)
|
|
118
|
-
export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './
|
|
118
|
+
export { MegaWsHub, DEFAULT_HEARTBEAT_MS, runWsHubCli } from './lib/ws-hub.js'
|
|
119
119
|
export { MegaHubLink } from './core/hub-link.js'
|
|
120
120
|
// WS per-message deflate 압축 (ADR-078)
|
|
121
121
|
export { buildPerMessageDeflate, checkCompressionConfig, COMPRESSION_DEFAULTS } from './core/ws-compression.js'
|
package/src/lib/hub-protocol.js
CHANGED
|
@@ -61,6 +61,30 @@ export const HUB_MESSAGE_TYPES = Object.freeze({
|
|
|
61
61
|
/** 12 개 wire `type` 문자열 집합 (빠른 소속 판별용). @type {ReadonlySet<string>} */
|
|
62
62
|
export const HUB_TYPE_SET = new Set(Object.values(HUB_MESSAGE_TYPES))
|
|
63
63
|
|
|
64
|
+
/** bridge↔hub 프로토콜 현 버전. REGISTER/REGISTER_OK 의 `protocolVersion` 협상 기준(부재 = v1). */
|
|
65
|
+
export const HUB_PROTOCOL_VERSION = 1
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 이 측이 지원하는 bridge↔hub 프로토콜 버전 목록. 버전은 선형 누적 계약 — vN 지원 = v1..vN 전부 지원.
|
|
69
|
+
* @type {ReadonlyArray<number>}
|
|
70
|
+
*/
|
|
71
|
+
export const SUPPORTED_HUB_PROTOCOL_VERSIONS = Object.freeze([HUB_PROTOCOL_VERSION])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* bridge 가 REGISTER 로 보낸 최대 지원 버전과 hub 지원 버전의 상호 최고 버전을 고른다.
|
|
75
|
+
* 선형 누적 계약(vN 지원 = v1..vN 지원)이라 `min(bridgeMax, hubMax)` 가 항상 유효한 상호 버전이다
|
|
76
|
+
* (v1 이 바닥이라 협상이 실패할 수 없다 — 부재/무효 입력은 v1).
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} requestedMax - bridge 의 `protocolVersion`(최대 지원 버전).
|
|
79
|
+
* @param {ReadonlyArray<number>} [supported] - 이 측 지원 버전 목록.
|
|
80
|
+
* @returns {number} 협상된 버전(>= 1).
|
|
81
|
+
*/
|
|
82
|
+
export function negotiateHubProtocolVersion(requestedMax, supported = SUPPORTED_HUB_PROTOCOL_VERSIONS) {
|
|
83
|
+
const ourMax = Math.max(...supported)
|
|
84
|
+
if (!Number.isInteger(requestedMax) || /** @type {number} */ (requestedMax) < 1) return HUB_PROTOCOL_VERSION
|
|
85
|
+
return Math.min(/** @type {number} */ (requestedMax), ourMax)
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
/**
|
|
65
89
|
* bridge↔hub WebSocket close code 카탈로그 (ADR-098).
|
|
66
90
|
*
|
|
@@ -107,6 +131,8 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
|
|
|
107
131
|
instanceId: { type: 'string', minLength: 1 },
|
|
108
132
|
token: { type: 'string', minLength: 1 },
|
|
109
133
|
capabilities: { type: 'array', items: { type: 'string' } },
|
|
134
|
+
// 버전 협상(옵션): bridge 의 최대 지원 버전. 부재 = v1(레거시 bridge — 협상 없이 v1 고정).
|
|
135
|
+
protocolVersion: { type: 'integer', minimum: 1 },
|
|
110
136
|
},
|
|
111
137
|
additionalProperties: false,
|
|
112
138
|
},
|
|
@@ -117,6 +143,9 @@ export const HUB_PAYLOAD_SCHEMAS = Object.freeze({
|
|
|
117
143
|
hubId: { type: 'string', minLength: 1 },
|
|
118
144
|
acceptedAt: { type: 'integer' },
|
|
119
145
|
heartbeatMs: { type: 'integer', minimum: 1 },
|
|
146
|
+
// 버전 협상 결과(옵션): hub 가 채택한 버전. bridge 가 REGISTER 에 protocolVersion 을 실었을
|
|
147
|
+
// 때만 회신(echo-on-request) — 레거시 bridge(strict 스키마)가 모르는 필드를 받지 않게 한다.
|
|
148
|
+
protocolVersion: { type: 'integer', minimum: 1 },
|
|
120
149
|
},
|
|
121
150
|
additionalProperties: false,
|
|
122
151
|
},
|
|
@@ -72,8 +72,10 @@ export const CAPACITY_ERROR_CODE = 'ESEMLOCKED'
|
|
|
72
72
|
* @property {number} [resetTimeout=30000] - open 상태 유지 시간(ms). 경과 후 halfOpen 으로 1회 프로빙.
|
|
73
73
|
* @property {number} [rollingCountTimeout=10000] - 실패율 집계 롤링 윈도우 길이(ms).
|
|
74
74
|
* @property {number} [rollingCountBuckets=10] - 롤링 윈도우를 나누는 버킷 수.
|
|
75
|
-
* @property {number} [volumeThreshold=
|
|
76
|
-
* (표본
|
|
75
|
+
* @property {number} [volumeThreshold=5] - 롤링 윈도우에 이 횟수만큼 호출이 쌓이기 전엔 실패율이
|
|
76
|
+
* 높아도 open 안 함(표본 부족 조기 trip 방지). 0=비활성. ⚠️ opossum 정본값(0)과 다른 프레임워크
|
|
77
|
+
* 디폴트 — 0 이면 부팅 직후/저빈도 호출에서 **첫 실패 1건**이 실패율 100% 가 돼 즉시 30s 차단되는
|
|
78
|
+
* 풋건이라 5 로 올렸다. opossum 원 동작이 필요하면 명시적으로 0 을 지정.
|
|
77
79
|
* @property {number} [capacity] - 동시 진행(in-flight) 호출 상한. 초과분은 `ESEMLOCKED` 로 즉시 거부.
|
|
78
80
|
* 미지정=무제한.
|
|
79
81
|
* @property {(err: any, ...args: any[]) => boolean} [errorFilter] - `true` 반환 시 그 에러는 **실패로 집계하지 않음**
|
|
@@ -149,7 +151,7 @@ export class MegaCircuitBreaker {
|
|
|
149
151
|
resetTimeout = 30_000,
|
|
150
152
|
rollingCountTimeout = 10_000,
|
|
151
153
|
rollingCountBuckets = 10,
|
|
152
|
-
volumeThreshold = 0
|
|
154
|
+
volumeThreshold = 5, // opossum 정본(0)과 의도적으로 다름 — 단일 실패 즉시 open 풋건 방지.
|
|
153
155
|
capacity,
|
|
154
156
|
errorFilter,
|
|
155
157
|
name,
|
package/src/lib/mega-health.js
CHANGED
|
@@ -19,21 +19,40 @@
|
|
|
19
19
|
|
|
20
20
|
import { MegaShutdown } from './mega-shutdown.js'
|
|
21
21
|
|
|
22
|
+
/** 체크 1개의 기본 타임아웃(ms) — hung 체크가 readiness 응답을 무기한 막지 않게 한다. */
|
|
23
|
+
export const DEFAULT_CHECK_TIMEOUT_MS = 5_000
|
|
24
|
+
|
|
25
|
+
/** @type {Map<string, { fn: Function, timeoutMs: number }>} */
|
|
22
26
|
const checks = new Map()
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* 헬스 체크 등록.
|
|
26
30
|
* @param {string} name
|
|
27
31
|
* @param {() => Promise<{ ok: boolean, [key: string]: any }> | { ok: boolean }} fn
|
|
32
|
+
* @param {{ timeoutMs?: number }} [opts] - 체크별 타임아웃(양의 정수 ms, 기본 {@link DEFAULT_CHECK_TIMEOUT_MS}).
|
|
28
33
|
*/
|
|
29
|
-
export function register(name, fn) {
|
|
34
|
+
export function register(name, fn, opts = {}) {
|
|
30
35
|
if (typeof name !== 'string' || name.length === 0) {
|
|
31
36
|
throw new Error('MegaHealth.register: name is required (string)')
|
|
32
37
|
}
|
|
33
38
|
if (typeof fn !== 'function') {
|
|
34
39
|
throw new Error('MegaHealth.register: fn must be a function')
|
|
35
40
|
}
|
|
36
|
-
|
|
41
|
+
const timeoutMs =
|
|
42
|
+
Number.isInteger(opts.timeoutMs) && /** @type {number} */ (opts.timeoutMs) > 0
|
|
43
|
+
? /** @type {number} */ (opts.timeoutMs)
|
|
44
|
+
: DEFAULT_CHECK_TIMEOUT_MS
|
|
45
|
+
checks.set(name, { fn, timeoutMs })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 등록된 체크 제거(이름 기준). 닫힌 자원(예: hub link)의 체크가 stale 클로저로 남지 않게
|
|
50
|
+
* 소유자가 register 와 짝맞춰 부른다.
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {boolean} 제거됐으면 true(미등록 이름이면 false).
|
|
53
|
+
*/
|
|
54
|
+
export function unregister(name) {
|
|
55
|
+
return checks.delete(name)
|
|
37
56
|
}
|
|
38
57
|
|
|
39
58
|
/**
|
|
@@ -48,12 +67,24 @@ export async function checkAll() {
|
|
|
48
67
|
|
|
49
68
|
const entries = [...checks.entries()]
|
|
50
69
|
const results = await Promise.all(
|
|
51
|
-
entries.map(async ([name, fn]) => {
|
|
70
|
+
entries.map(async ([name, { fn, timeoutMs }]) => {
|
|
71
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
72
|
+
let timer
|
|
52
73
|
try {
|
|
53
|
-
|
|
74
|
+
// 체크별 race — hung 체크 1개가 readiness 전체를 무기한 막지 않게 timeoutMs 에서 끊는다
|
|
75
|
+
// (probe timeout 으로 인한 연쇄 재시작 방지). 타임아웃은 해당 체크만 ok:false 처리.
|
|
76
|
+
const result = await Promise.race([
|
|
77
|
+
Promise.resolve(fn()),
|
|
78
|
+
new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(new Error(`health check timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
80
|
+
timer.unref?.()
|
|
81
|
+
}),
|
|
82
|
+
])
|
|
54
83
|
return [name, result?.ok === true ? result : { ok: false, ...result }]
|
|
55
84
|
} catch (err) {
|
|
56
85
|
return [name, { ok: false, error: err?.message ?? String(err) }]
|
|
86
|
+
} finally {
|
|
87
|
+
clearTimeout(timer)
|
|
57
88
|
}
|
|
58
89
|
}),
|
|
59
90
|
)
|