mega-framework 0.1.5 → 0.1.7
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/bin/mega-ws-hub.js +2 -2
- package/package.json +32 -8
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- 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/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 +63 -3
- package/sample/crud/package.json +3 -3
- package/sample/crud/scripts/start-ws-hub.sh +2 -2
- package/sample/simple/package.json +2 -2
- package/src/adapters/adapter-manager.js +2 -1
- package/src/adapters/adapter-options.js +30 -0
- package/src/adapters/maria-adapter.js +26 -3
- package/src/adapters/mega-db-adapter.js +7 -1
- package/src/adapters/mongo-adapter.js +19 -1
- package/src/adapters/postgres-adapter.js +25 -2
- package/src/adapters/sqlite-adapter.js +20 -1
- package/src/cli/commands/new.js +13 -3
- package/src/cli/commands/scaffold.js +137 -33
- package/src/cli/generators/index.js +82 -2
- package/src/cli/index.js +478 -104
- package/src/core/ajv-mapper.js +27 -2
- package/src/core/boot.js +485 -237
- package/src/core/cluster-metrics.js +13 -4
- package/src/core/config-validator.js +25 -0
- package/src/core/ctx-builder.js +6 -2
- package/src/core/envelope.js +112 -12
- package/src/core/hub-link.js +65 -4
- package/src/core/i18n.js +11 -1
- package/src/core/index.js +6 -2
- package/src/core/mega-app.js +223 -481
- package/src/core/mega-cluster.js +54 -13
- 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 +129 -0
- package/src/core/router.js +70 -65
- package/src/core/scope-registry.js +0 -1
- package/src/core/security.js +67 -9
- 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 +624 -0
- package/src/core/ws-roster.js +4 -1
- package/src/core/ws-upgrade.js +118 -12
- package/src/index.js +1 -1
- package/src/lib/hub-protocol.js +29 -0
- package/src/lib/mega-health.js +25 -4
- package/src/lib/mega-job-queue.js +98 -21
- package/src/lib/mega-job.js +29 -0
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-metrics.js +3 -12
- package/src/lib/mega-plugin.js +34 -3
- package/src/lib/mega-schedule.js +40 -22
- package/src/lib/mega-shutdown.js +162 -49
- package/src/lib/mega-tracing.js +66 -19
- package/src/lib/mega-worker.js +5 -1
- package/src/lib/otel-resource.js +36 -0
- package/src/{cli → lib}/ws-hub.js +51 -8
- 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/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 +91 -0
- package/types/adapters/file-adapter.d.ts +94 -0
- package/types/adapters/file-session-adapter.d.ts +101 -0
- package/types/adapters/index.d.ts +20 -0
- package/types/adapters/maria-adapter.d.ts +115 -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 +47 -0
- package/types/adapters/mega-db-adapter.d.ts +47 -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 +139 -0
- package/types/adapters/nats-adapter.d.ts +108 -0
- package/types/adapters/postgres-adapter.d.ts +139 -0
- package/types/adapters/redis-adapter.d.ts +70 -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 +106 -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 +112 -0
- package/types/cli/index.d.ts +249 -0
- package/types/cli/template-engine.d.ts +40 -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 +80 -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 +264 -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 +92 -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 +18 -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 +96 -0
- package/types/core/ws-upgrade.d.ts +231 -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 +241 -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 +41 -0
- package/types/lib/mega-job-queue.d.ts +176 -0
- package/types/lib/mega-job-worker.d.ts +130 -0
- package/types/lib/mega-job.d.ts +138 -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 +127 -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 +234 -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
|
@@ -250,20 +250,29 @@ export function collectCluster({ timeoutMs = 2500, _cluster = cluster, _proc = p
|
|
|
250
250
|
}
|
|
251
251
|
const timer = setTimeout(() => {
|
|
252
252
|
cleanup()
|
|
253
|
-
// 마스터 무응답 → 로컬 폴백(이 워커 메트릭만이라도).
|
|
253
|
+
// 마스터 무응답 → 로컬 폴백(이 워커 메트릭만이라도). 폴백마저 실패하면 빈 응답으로 강등하되
|
|
254
|
+
// 묵히지 않고 알린다 — 스크레이프가 조용히 빈 값이 되는 원인을 추적 가능하게.
|
|
254
255
|
Promise.resolve(MegaMetrics.collect())
|
|
255
256
|
.then(resolve)
|
|
256
|
-
.catch(() =>
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
console.warn('[mega-cluster-metrics] local metrics fallback failed (returning empty):', /** @type {any} */ (err)?.message ?? err)
|
|
259
|
+
resolve('')
|
|
260
|
+
})
|
|
257
261
|
}, timeoutMs)
|
|
258
262
|
timer.unref?.()
|
|
259
263
|
_proc.on('message', onMsg)
|
|
260
264
|
try {
|
|
261
265
|
_proc.send({ type: MSG_REQUEST, id })
|
|
262
|
-
} catch {
|
|
266
|
+
} catch (sendErr) {
|
|
263
267
|
cleanup()
|
|
268
|
+
// 마스터 IPC 채널 단절 — 로컬 폴백으로 강등(원인을 묵히지 않는다).
|
|
269
|
+
console.warn('[mega-cluster-metrics] request to primary failed (falling back to local):', /** @type {any} */ (sendErr)?.message ?? sendErr)
|
|
264
270
|
Promise.resolve(MegaMetrics.collect())
|
|
265
271
|
.then(resolve)
|
|
266
|
-
.catch(() =>
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
console.warn('[mega-cluster-metrics] local metrics fallback failed (returning empty):', /** @type {any} */ (err)?.message ?? err)
|
|
274
|
+
resolve('')
|
|
275
|
+
})
|
|
267
276
|
}
|
|
268
277
|
})
|
|
269
278
|
}
|
|
@@ -94,6 +94,31 @@ export function validateGlobalConfig(globalConfig) {
|
|
|
94
94
|
// 앱에서 registerSession 이 fail-fast 로 잡으므로(여긴 server.sessionSecret 가 "정의됐을 때만"
|
|
95
95
|
// 강도를 검증) — 정의됐다면 (a) 기본 placeholder 거부, (b) ≥32자 강제(부팅 fail-fast, ADR-062).
|
|
96
96
|
validateSessionSecret(globalConfig.server?.sessionSecret)
|
|
97
|
+
|
|
98
|
+
// 7) server 런타임 타임아웃(ADR-181) — 정의됐다면 음 아닌 정수만(Fastify requestTimeout/keepAliveTimeout).
|
|
99
|
+
validateServerTimeouts(globalConfig.server)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `server.timeouts.requestMs` · `server.keepAliveMs` 검증(ADR-181, Fastify 인스턴스 옵션으로 매핑).
|
|
104
|
+
* 정의됐다면 음 아닌 정수(ms)만 허용 — 잘못된 값은 부팅 fail-fast. `trustProxy`/`trustedProxies` 는
|
|
105
|
+
* 타입 유연(boolean/number/string/array)이라 Fastify 에 위임(여기서 검증 안 함). 미정의는 통과(선택 키).
|
|
106
|
+
* @param {any} server - `globalConfig.server`.
|
|
107
|
+
* @throws {MegaConfigError} `server.invalid_timeout`.
|
|
108
|
+
*/
|
|
109
|
+
function validateServerTimeouts(server) {
|
|
110
|
+
const s = server ?? {}
|
|
111
|
+
/** @param {string} name @param {unknown} v */
|
|
112
|
+
const check = (name, v) => {
|
|
113
|
+
if (v === undefined) return
|
|
114
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) {
|
|
115
|
+
throw new MegaConfigError('server.invalid_timeout', `${name} must be a non-negative integer (ms). Got ${JSON.stringify(v)}.`, {
|
|
116
|
+
details: { value: v },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
check('server.timeouts.requestMs', s.timeouts?.requestMs)
|
|
121
|
+
check('server.keepAliveMs', s.keepAliveMs)
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
/** sessionSecret 강도 검증의 최소 길이(바이트 수가 아닌 문자 길이 — base64url 32바이트 ≈ 43자). */
|
package/src/core/ctx-builder.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { MegaConfigError } from '../errors/config-error.js'
|
|
20
20
|
import * as AdapterManager from '../adapters/adapter-manager.js'
|
|
21
|
-
import { contextProxy as workersContext } from './workers-manager.js'
|
|
21
|
+
import { contextProxy as workersContext, PROXY_PROTOCOL_KEYS } from './workers-manager.js'
|
|
22
22
|
import { tracer as megaTracer } from '../lib/mega-tracing.js'
|
|
23
23
|
|
|
24
24
|
/** ctx 도메인 함수 ↔ app.config 별명 맵 키. */
|
|
@@ -94,7 +94,11 @@ function buildServicesProxy(ctx, app) {
|
|
|
94
94
|
{},
|
|
95
95
|
{
|
|
96
96
|
get(_t, prop) {
|
|
97
|
-
if (typeof prop !== 'string') return undefined // Symbol(
|
|
97
|
+
if (typeof prop !== 'string') return undefined // Symbol 키(toStringTag 등) — 해석 대상 아님.
|
|
98
|
+
// 'then'/'toJSON' 등은 await·JSON.stringify·inspect 가 **string 키**로 암묵 조회한다 — 미등록
|
|
99
|
+
// 상태에서 throw 하면 `await ctx.services` 같은 정상 코드가 not_registered 로 죽는다. 등록된
|
|
100
|
+
// 이름이면(우연히 겹쳐도) 그대로 해석한다.
|
|
101
|
+
if (!registry.has(prop) && PROXY_PROTOCOL_KEYS.has(prop)) return undefined
|
|
98
102
|
return resolve(prop)
|
|
99
103
|
},
|
|
100
104
|
has(_t, prop) {
|
package/src/core/envelope.js
CHANGED
|
@@ -12,6 +12,26 @@
|
|
|
12
12
|
/** request 시작 시간 기록용 symbol (meta.took_ms 계산). reply 객체에 부착. */
|
|
13
13
|
export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 프레임워크가 만든 envelope 임을 표시하는 비열거 마킹 symbol.
|
|
17
|
+
*
|
|
18
|
+
* 이중 wrap 방지 판별을 키 모양 추측(duck-typing)이 아니라 이 마킹으로 한다 — 핸들러가 우연히
|
|
19
|
+
* `{ ok, data }` 모양의 도메인 데이터(예: 외부 API 프록시 응답)를 반환해도 정상적으로 data 로
|
|
20
|
+
* 감싸진다. 비열거라 JSON 직렬화·spread 에는 나타나지 않는다.
|
|
21
|
+
*/
|
|
22
|
+
export const ENVELOPE_MARK = Symbol('mega.envelope')
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* envelope 객체에 비열거 마킹을 부착한다.
|
|
26
|
+
* @template {Record<string, any>} T
|
|
27
|
+
* @param {T} env
|
|
28
|
+
* @returns {T} 같은 객체 (마킹됨).
|
|
29
|
+
*/
|
|
30
|
+
function markEnvelope(env) {
|
|
31
|
+
Object.defineProperty(env, ENVELOPE_MARK, { value: true, enumerable: false })
|
|
32
|
+
return env
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
/**
|
|
16
36
|
* 자동 envelope (ADR-018). preSerialization 훅의 마지막 단계에서 호출되어
|
|
17
37
|
* raw data → `{ ok:true, data, meta }`.
|
|
@@ -29,11 +49,11 @@ export const REPLY_START_SYMBOL = Symbol('mega.reply.start')
|
|
|
29
49
|
*/
|
|
30
50
|
export function wrapEnvelope(req, reply, payload) {
|
|
31
51
|
if (isEnvelope(payload)) return payload
|
|
32
|
-
return {
|
|
52
|
+
return markEnvelope({
|
|
33
53
|
ok: true,
|
|
34
54
|
data: payload,
|
|
35
55
|
meta: buildMeta(req, reply),
|
|
36
|
-
}
|
|
56
|
+
})
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
/**
|
|
@@ -44,7 +64,7 @@ export function wrapEnvelope(req, reply, payload) {
|
|
|
44
64
|
* @returns {{ ok: false, error: { code: string, message: string, details?: any }, meta: Object }}
|
|
45
65
|
*/
|
|
46
66
|
export function errorEnvelope(req, reply, error) {
|
|
47
|
-
return {
|
|
67
|
+
return markEnvelope({
|
|
48
68
|
ok: false,
|
|
49
69
|
error: {
|
|
50
70
|
code: error.code,
|
|
@@ -52,22 +72,102 @@ export function errorEnvelope(req, reply, error) {
|
|
|
52
72
|
...(error.details !== undefined ? { details: error.details } : {}),
|
|
53
73
|
},
|
|
54
74
|
meta: buildMeta(req, reply),
|
|
55
|
-
}
|
|
75
|
+
})
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
/**
|
|
59
|
-
* 이미 envelope
|
|
79
|
+
* 이미 envelope 인지 판별.
|
|
80
|
+
*
|
|
81
|
+
* 1차 = {@link ENVELOPE_MARK} 마킹(프레임워크 산출물 — wrapEnvelope/errorEnvelope 만 부착).
|
|
82
|
+
* 2차 = **수동 error envelope 하위호환**: `before` 가드 등에서 `reply.send({ ok:false, error })` 로
|
|
83
|
+
* 직접 보낸 에러 응답만 모양으로 통과시킨다. 성공 모양(`{ ok:true, data }`)의 duck-typing 통과는
|
|
84
|
+
* 제거 — 그런 모양의 도메인 데이터(외부 API 프록시 등)는 이제 정상적으로 data 로 감싸진다(오탐 제거).
|
|
85
|
+
*
|
|
60
86
|
* @param {any} payload
|
|
61
87
|
* @returns {boolean}
|
|
62
88
|
*/
|
|
63
89
|
function isEnvelope(payload) {
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) return false
|
|
91
|
+
if (payload[ENVELOPE_MARK] === true) return true
|
|
92
|
+
return payload.ok === false && 'error' in payload
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** envelope `error` 필드의 JSON Schema (ADR-014/075 — details 는 object | array 자유형). */
|
|
96
|
+
const ERROR_FIELD_SCHEMA = Object.freeze({
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: { code: { type: 'string' }, message: { type: 'string' }, details: {} },
|
|
99
|
+
additionalProperties: true,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/** envelope `meta` 필드의 JSON Schema (향후 meta 확장에 닫히지 않게 additionalProperties 허용). */
|
|
103
|
+
const META_FIELD_SCHEMA = Object.freeze({
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: { request_id: { type: 'string' }, took_ms: { type: 'number' } },
|
|
106
|
+
additionalProperties: true,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 라우트 `schema.response` 를 envelope 모양으로 합성한다.
|
|
111
|
+
*
|
|
112
|
+
* 배경: 자동 envelope(ADR-018)는 preSerialization 마지막에 raw data 를 `{ ok, data, meta }` 로
|
|
113
|
+
* 감싸는데, 사용자가 raw data 모양의 response schema 를 선언하면 Fastify 직렬화기가 **envelope 를
|
|
114
|
+
* 그 raw 스키마로 직렬화해 ok/data/meta 가 통째로 제거**된다(silent 데이터 소실). 그래서 등록
|
|
115
|
+
* 시점(onRoute)에 각 상태코드 스키마를 `{ ok, data: <사용자 스키마>, error, meta }` 로 재작성한다 —
|
|
116
|
+
* 사용자는 계속 raw data 모양만 선언하고(ADR-091), strict 직렬화(ADR-020: 선언 외 필드 제거)는
|
|
117
|
+
* data 안에서 그대로 동작하며, OpenAPI 명세(@fastify/swagger 가 schema 수집)도 실제 와이어 모양과
|
|
118
|
+
* 일치하게 된다.
|
|
119
|
+
*
|
|
120
|
+
* 합성 제외(원본 유지) 3종:
|
|
121
|
+
* - `type: 'string'` 스키마 — 문자열 payload 는 Fastify 가 preSerialization(envelope wrap) 자체를
|
|
122
|
+
* 건너뛰므로(raw 전송, 예: /metrics) envelope 모양으로 바꾸면 오히려 직렬화가 깨진다.
|
|
123
|
+
* - 이미 envelope 모양(`properties.ok` 보유) — 사용자가 envelope 전체를 직접 선언한 경우.
|
|
124
|
+
* - 스키마가 object 가 아닌 항목 — 알 수 없는 형식은 건드리지 않는다(fail-safe).
|
|
125
|
+
*
|
|
126
|
+
* Fastify v5 의 media-type 형식(`{ content: { 'application/json': { schema } } }`)은 내부 schema
|
|
127
|
+
* 에 같은 규칙을 적용한다.
|
|
128
|
+
*
|
|
129
|
+
* @param {Record<string, any>} response - 라우트 `schema.response` (상태코드/패턴 → JSON Schema).
|
|
130
|
+
* @returns {Record<string, any>} envelope 모양으로 합성된 새 response 객체 (원본 불변).
|
|
131
|
+
*/
|
|
132
|
+
export function synthesizeEnvelopeResponseSchema(response) {
|
|
133
|
+
/** @type {Record<string, any>} */
|
|
134
|
+
const out = {}
|
|
135
|
+
for (const [code, entry] of Object.entries(response)) {
|
|
136
|
+
out[code] = synthesizeEntry(entry)
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* response 항목 1개를 envelope 모양으로 합성 (제외 규칙은 {@link synthesizeEnvelopeResponseSchema}).
|
|
143
|
+
* @param {any} entry - 상태코드별 스키마 또는 `{ content }` media-type 형식.
|
|
144
|
+
* @returns {any}
|
|
145
|
+
*/
|
|
146
|
+
function synthesizeEntry(entry) {
|
|
147
|
+
if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) return entry
|
|
148
|
+
// Fastify v5 media-type 형식 — content 의 각 schema 에 동일 규칙 적용.
|
|
149
|
+
if (entry.content && typeof entry.content === 'object') {
|
|
150
|
+
/** @type {Record<string, any>} */
|
|
151
|
+
const content = {}
|
|
152
|
+
for (const [mediaType, def] of Object.entries(entry.content)) {
|
|
153
|
+
content[mediaType] =
|
|
154
|
+
def && typeof def === 'object' && 'schema' in def ? { ...def, schema: synthesizeEntry(def.schema) } : def
|
|
155
|
+
}
|
|
156
|
+
return { ...entry, content }
|
|
157
|
+
}
|
|
158
|
+
if (entry.type === 'string') return entry // 문자열 payload 는 envelope 우회(raw 전송) — 원본 유지.
|
|
159
|
+
if (entry.properties && typeof entry.properties === 'object' && 'ok' in entry.properties) return entry // 이미 envelope 모양.
|
|
160
|
+
return {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
ok: { type: 'boolean' },
|
|
164
|
+
data: entry,
|
|
165
|
+
error: ERROR_FIELD_SCHEMA,
|
|
166
|
+
meta: META_FIELD_SCHEMA,
|
|
167
|
+
},
|
|
168
|
+
required: ['ok'],
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
}
|
|
71
171
|
}
|
|
72
172
|
|
|
73
173
|
/**
|
package/src/core/hub-link.js
CHANGED
|
@@ -28,6 +28,7 @@ import { WebSocket } from 'ws'
|
|
|
28
28
|
import {
|
|
29
29
|
HUB_MESSAGE_TYPES,
|
|
30
30
|
CLOSE_CODE_DRAIN,
|
|
31
|
+
HUB_PROTOCOL_VERSION,
|
|
31
32
|
createHubMessage,
|
|
32
33
|
parseHubMessage,
|
|
33
34
|
} from '../lib/hub-protocol.js'
|
|
@@ -84,8 +85,11 @@ export class MegaHubLink {
|
|
|
84
85
|
* 스키마 — hub 서버와 양쪽이 협상해야 활성(RFC 7692). 디폴트 OFF. 잘못된 값은 즉시 throw.
|
|
85
86
|
* @param {{ warn?: Function, debug?: Function, info?: Function, error?: Function }} [opts.logger]
|
|
86
87
|
* @param {typeof import('ws').WebSocket} [opts.WebSocketCtor] - 테스트 주입용 ws 생성자.
|
|
88
|
+
* @param {number} [opts.maxBufferedBytes=16777216] - 송신 버퍼(bufferedAmount) 상한(바이트). 초과 시
|
|
89
|
+
* 백프레셔로 보고 소켓을 닫고(1013) 송신을 명시 실패시킨다 — hub 가 느릴 때 bridge 힙이 무한
|
|
90
|
+
* 적재되는 것을 막는다. 재연결(retry) 설정 시 자동 회복.
|
|
87
91
|
*/
|
|
88
|
-
constructor({ url, token, bridgeId, instanceId, capabilities, retry, connectTimeoutMs, compression, logger, WebSocketCtor } = {}) {
|
|
92
|
+
constructor({ url, token, bridgeId, instanceId, capabilities, retry, connectTimeoutMs, compression, logger, WebSocketCtor, maxBufferedBytes } = {}) {
|
|
89
93
|
if (typeof url !== 'string' || url.length === 0) {
|
|
90
94
|
throw new Error('MegaHubLink: url is required (MegaBridgeHubConfig.url).')
|
|
91
95
|
}
|
|
@@ -106,6 +110,8 @@ export class MegaHubLink {
|
|
|
106
110
|
this._retry = retry && typeof retry === 'object' ? retry : null
|
|
107
111
|
/** 한 attempt 의 register_ok 대기 한도(M4). reconnect 에서도 동일 적용. @type {number} */
|
|
108
112
|
this._connectTimeoutMs = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : 10_000
|
|
113
|
+
/** 송신 버퍼(bufferedAmount) 상한 — 초과 시 백프레셔로 소켓을 닫는다(기본 16 MiB). @type {number} */
|
|
114
|
+
this._maxBufferedBytes = Number.isFinite(maxBufferedBytes) && Number(maxBufferedBytes) > 0 ? Number(maxBufferedBytes) : 16 * 1024 * 1024
|
|
109
115
|
// Bridge↔Hub link 압축(ADR-078). enabled=false → false. 잘못된 값은 생성자에서 즉시 throw.
|
|
110
116
|
/** @type {false | Object} ws 클라이언트 perMessageDeflate 로 전달(_connectOnce). */
|
|
111
117
|
this._perMessageDeflate = buildPerMessageDeflate(compression, 'wsHub.compression')
|
|
@@ -128,6 +134,14 @@ export class MegaHubLink {
|
|
|
128
134
|
this._isReconnecting = false
|
|
129
135
|
/** close() 가 진행 중인 재시도(백오프 대기)를 취소하기 위한 컨트롤러. @type {AbortController | null} */
|
|
130
136
|
this._abort = null
|
|
137
|
+
/** register_ok 로 협상된 hub 프로토콜 버전(등록 전 null, 레거시 hub 면 1). @type {number | null} */
|
|
138
|
+
this._negotiatedProtocolVersion = null
|
|
139
|
+
/**
|
|
140
|
+
* 레거시 hub 모드 — REGISTER 의 `protocolVersion` 필드를 모르는 구버전 hub(strict 스키마가
|
|
141
|
+
* `hub.invalid_message` 로 거부)로 판정되면 true. 이후 register 는 필드 없이(v1) 보낸다.
|
|
142
|
+
* @type {boolean}
|
|
143
|
+
*/
|
|
144
|
+
this._isLegacyHub = false
|
|
131
145
|
}
|
|
132
146
|
|
|
133
147
|
/** hub 가 부여한 hubId (register 전엔 null). */
|
|
@@ -140,6 +154,11 @@ export class MegaHubLink {
|
|
|
140
154
|
return this._isRegistered
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
/** 협상된 hub 프로토콜 버전 (register 전엔 null, 레거시 hub 면 1). */
|
|
158
|
+
get negotiatedProtocolVersion() {
|
|
159
|
+
return this._negotiatedProtocolVersion
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
/** 소켓이 OPEN(=1) 인지. */
|
|
144
163
|
get isOpen() {
|
|
145
164
|
return this._ws?.readyState === 1
|
|
@@ -184,7 +203,16 @@ export class MegaHubLink {
|
|
|
184
203
|
const timeout = Number.isFinite(connectTimeoutMs) ? Number(connectTimeoutMs) : this._connectTimeoutMs
|
|
185
204
|
this._connectTimeoutMs = timeout
|
|
186
205
|
if (!this._retry) {
|
|
187
|
-
|
|
206
|
+
try {
|
|
207
|
+
return await this._connectOnce({ connectTimeoutMs: timeout })
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// 레거시 hub 폴백(1회) — protocolVersion 필드 거부 판정 후 v1 register 로 즉시 재시도.
|
|
210
|
+
// retry 미설정 경로 전용(백오프 경로는 withRetry 가 non-fatal 을 자동 재시도).
|
|
211
|
+
if (err instanceof Error && err.message === 'hub.legacy_register_fallback') {
|
|
212
|
+
return this._connectOnce({ connectTimeoutMs: timeout })
|
|
213
|
+
}
|
|
214
|
+
throw err
|
|
215
|
+
}
|
|
188
216
|
}
|
|
189
217
|
// retry 활성 — 최초 연결도 백오프 재시도. AbortError(인증 실패 등) 는 재시도하지 않는다.
|
|
190
218
|
this._abort = new AbortController()
|
|
@@ -246,7 +274,7 @@ export class MegaHubLink {
|
|
|
246
274
|
}
|
|
247
275
|
|
|
248
276
|
ws.on('open', () => {
|
|
249
|
-
this._log?.debug?.({ bridgeId: this._bridgeId, url: this._url }, 'hub-link open → REGISTER')
|
|
277
|
+
this._log?.debug?.({ bridgeId: this._bridgeId, url: this._url, legacyHub: this._isLegacyHub }, 'hub-link open → REGISTER')
|
|
250
278
|
try {
|
|
251
279
|
this._sendEnvelope(
|
|
252
280
|
createHubMessage({
|
|
@@ -255,6 +283,9 @@ export class MegaHubLink {
|
|
|
255
283
|
instanceId: this._instanceId,
|
|
256
284
|
token: this._token,
|
|
257
285
|
capabilities: this._capabilities,
|
|
286
|
+
// 버전 협상 — 최대 지원 버전을 싣는다. 레거시 hub(필드를 모르는 strict 스키마)로
|
|
287
|
+
// 판정된 뒤에는 싣지 않는다(v1 고정 — 아래 hub.invalid_message 폴백 참조).
|
|
288
|
+
...(this._isLegacyHub ? {} : { protocolVersion: HUB_PROTOCOL_VERSION }),
|
|
258
289
|
},
|
|
259
290
|
}),
|
|
260
291
|
)
|
|
@@ -281,13 +312,32 @@ export class MegaHubLink {
|
|
|
281
312
|
didRegister = true
|
|
282
313
|
this._hubId = msg.payload.hubId
|
|
283
314
|
this._heartbeatMs = msg.payload.heartbeatMs
|
|
315
|
+
// 협상 결과 — hub 가 채택한 버전(echo-on-request). 부재 = v1(레거시 hub 또는 v1 고정 register).
|
|
316
|
+
this._negotiatedProtocolVersion = msg.payload.protocolVersion ?? 1
|
|
284
317
|
this._startHeartbeat()
|
|
285
|
-
this._log?.info?.(
|
|
318
|
+
this._log?.info?.(
|
|
319
|
+
{ bridgeId: this._bridgeId, hubId: this._hubId, protocolVersion: this._negotiatedProtocolVersion },
|
|
320
|
+
'hub-link registered',
|
|
321
|
+
)
|
|
286
322
|
finish(resolve, { hubId: this._hubId, heartbeatMs: this._heartbeatMs })
|
|
287
323
|
return
|
|
288
324
|
}
|
|
289
325
|
if (msg.type === T.ERROR) {
|
|
290
326
|
const code = msg.error?.code ?? 'hub.error'
|
|
327
|
+
// 레거시 hub 폴백: protocolVersion 필드를 모르는 구버전 hub 는 strict 스키마가 register 를
|
|
328
|
+
// `hub.invalid_message` 로 거부한다(연결은 유지하지만 attempt 는 종료). 한 번만 레거시로
|
|
329
|
+
// 전환해 필드 없는 v1 register 로 재시도한다 — non-fatal reject 라 retry 경로(withRetry)가
|
|
330
|
+
// 자동 재시도하고, retry 미설정 connect() 도 1회 폴백 재시도한다.
|
|
331
|
+
if (code === 'hub.invalid_message' && !this._isLegacyHub) {
|
|
332
|
+
this._isLegacyHub = true
|
|
333
|
+
this._log?.info?.(
|
|
334
|
+
{ bridgeId: this._bridgeId },
|
|
335
|
+
'hub-link register rejected as invalid — assuming legacy hub (no protocolVersion), falling back to v1 register',
|
|
336
|
+
)
|
|
337
|
+
finish(reject, new Error('hub.legacy_register_fallback'))
|
|
338
|
+
ws.close()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
291
341
|
this._log?.warn?.({ bridgeId: this._bridgeId, code }, 'hub-link registration rejected')
|
|
292
342
|
// 인증 실패 등 등록 거부는 재시도해도 같은 결과 — fatal 마킹으로 백오프 중단(ADR-098).
|
|
293
343
|
finish(reject, new HubRegistrationError(`hub registration failed: ${code}`))
|
|
@@ -484,6 +534,17 @@ export class MegaHubLink {
|
|
|
484
534
|
if (this._ws?.readyState !== 1) {
|
|
485
535
|
throw new Error('MegaHubLink: socket is not open.')
|
|
486
536
|
}
|
|
537
|
+
// 백프레셔 가드: hub 가 느려 송신 버퍼가 상한을 넘으면 더 쌓지 않는다 — bridge 힙이 무한 증가해
|
|
538
|
+
// OOM 으로 가는 것을 막는다. 소켓을 닫아 재연결(retry 설정 시) 경로로 회복시키고, 이번 송신은
|
|
539
|
+
// 명시 실패로 알린다(silent drop 금지 — 호출부 broadcast/join 이 warn 으로 표면화).
|
|
540
|
+
if (this._ws.bufferedAmount > this._maxBufferedBytes) {
|
|
541
|
+
this._log?.warn?.(
|
|
542
|
+
{ bufferedAmount: this._ws.bufferedAmount, max: this._maxBufferedBytes },
|
|
543
|
+
'hub-link send buffer exceeded — closing socket (backpressure)',
|
|
544
|
+
)
|
|
545
|
+
this._ws.close(1013, 'backpressure: send buffer exceeded')
|
|
546
|
+
throw new Error('MegaHubLink: send buffer exceeded (backpressure) — socket closed for recovery.')
|
|
547
|
+
}
|
|
487
548
|
this._ws.send(JSON.stringify(envelope))
|
|
488
549
|
}
|
|
489
550
|
|
package/src/core/i18n.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* 한 언어에만 있는 키(예: en 만 있고 ko 없음)를 나머지 언어에 **fallback 값 우선**으로 채운다 — i18next 가
|
|
35
35
|
* fallback 으로 찾아 missingKeyHandler 가 안 터지는 공백을 메운다. **자동 기입은 `NODE_ENV==='development'`
|
|
36
36
|
* 에서만** — test/CI/prod/미설정에선 off 라 추적 locale 파일을 디스크 기입하지 않는다(ADR-164, 테스트 오염 방지).
|
|
37
|
+
* production 은 명시 `autoComplete.enabled:true` 도 **강제 off + warn**(ADR-186) — dev config 복사 사고 차단.
|
|
37
38
|
*
|
|
38
39
|
* # 트레이싱·메트릭 (ADR-126 / ADR-131 인프라 재사용)
|
|
39
40
|
* - 트레이싱: onRequest 가 활성 HTTP span 에 `mega.i18n.lang` 기록. 옵트인 OFF 면 0 비용 no-op.
|
|
@@ -123,7 +124,10 @@ export function normalizeI18n(i18n) {
|
|
|
123
124
|
// 자동 기입은 **명시적 개발 모드**(`NODE_ENV==='development'`)에서만 — test/CI/미설정(undefined)에서 켜면
|
|
124
125
|
// vitest 부팅이 추적된 locale 파일을 디스크 기입해 오염시킨다(ADR-164). 샘플은 (a)로 dev=`NODE_ENV=development`,
|
|
125
126
|
// start=`production` 이라 정확히 맞물린다. 명시 `autoComplete.enabled` 가 있으면 그것을 우선.
|
|
126
|
-
|
|
127
|
+
// 단 **production 은 강제 off**(ADR-186) — dev config 가 prod 로 복사돼 명시 `enabled:true` 가 남아도
|
|
128
|
+
// saveMissing 디스크 기입(ADR-163 locale 오염 함정)이 켜지지 않는다. 덮어쓴 사실은 registerI18n 이 warn.
|
|
129
|
+
const requestedAuto = ac.enabled !== undefined ? ac.enabled === true : process.env.NODE_ENV === 'development'
|
|
130
|
+
const autoEnabled = process.env.NODE_ENV === 'production' ? false : requestedAuto
|
|
127
131
|
const debounceMs = Number.isInteger(ac.debounceMs) && ac.debounceMs >= 0 ? ac.debounceMs : DEFAULT_DEBOUNCE_MS
|
|
128
132
|
|
|
129
133
|
// localesDir: 명시 우선, 없으면 autoComplete.dir. 디렉터리 로딩·saveMissing 쓰기의 기준 경로.
|
|
@@ -473,6 +477,12 @@ export function registerI18n(fastify, { i18n, appName = '(unknown)', logger } =
|
|
|
473
477
|
return { enabled: false, default: 'en', fallback: 'en', available: [], cookieName: DEFAULT_I18N_COOKIE, autoComplete: false, instance: null }
|
|
474
478
|
}
|
|
475
479
|
|
|
480
|
+
// prod 강제 off 표면화(ADR-186) — 명시 `autoComplete.enabled:true` 를 normalizeI18n 이 production 에서
|
|
481
|
+
// 덮어썼으면 조용히 무시하지 않고 warn 으로 드러낸다(오설정 인지 — P4 정합).
|
|
482
|
+
if (process.env.NODE_ENV === 'production' && /** @type {any} */ (i18n)?.autoComplete?.enabled === true) {
|
|
483
|
+
logger?.warn?.({ app: appName }, 'i18n.autoComplete is forced off in production — saveMissing writes locale files to disk and is dev-only (ADR-164/186). Remove autoComplete.enabled:true from the production config.')
|
|
484
|
+
}
|
|
485
|
+
|
|
476
486
|
// saveMissing writer — dev + localesDir 있을 때만(쓸 디렉터리 없으면 무의미). prod/디렉터리 없으면 null.
|
|
477
487
|
const writer = cfg.autoComplete.enabled && cfg.localesDir ? createMissingKeyWriter({ dir: cfg.localesDir, debounceMs: cfg.autoComplete.debounceMs, logger }) : null
|
|
478
488
|
|
package/src/core/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
export { MegaApp } from './mega-app.js'
|
|
3
|
+
// WS presence/hub 협력자 — MegaApp 이 위임하는 연결 인덱스·hub link·cluster/roster 동기화 전담
|
|
4
|
+
export { MegaWsPresence } from './ws-presence.js'
|
|
3
5
|
export { MegaServer } from './mega-server.js'
|
|
4
6
|
export { Router, MegaRouteError } from './router.js'
|
|
5
7
|
export { MegaService } from './mega-service.js'
|
|
@@ -7,9 +9,11 @@ export { MegaCluster } from './mega-cluster.js'
|
|
|
7
9
|
export { loadRoutes } from './routes-loader.js'
|
|
8
10
|
// 중앙 부팅 orchestrator (ADR-123)
|
|
9
11
|
export { bootApp, buildBootContext } from './boot.js'
|
|
10
|
-
export { wrapEnvelope, errorEnvelope } from './envelope.js'
|
|
12
|
+
export { wrapEnvelope, errorEnvelope, synthesizeEnvelopeResponseSchema, ENVELOPE_MARK } from './envelope.js'
|
|
13
|
+
// HTTP 라이프사이클 Pipeline — before/transform/after 합성 정본 (ADR-185)
|
|
14
|
+
export { buildHttpPipeline, wrapPreHandler, composeTransform, composeAfter } from './pipeline.js'
|
|
11
15
|
export { buildErrorHandler } from './error-mapper.js'
|
|
12
|
-
export { ajvErrorToValidationError } from './ajv-mapper.js'
|
|
16
|
+
export { ajvErrorToValidationError, MAX_VALIDATION_DETAILS } from './ajv-mapper.js'
|
|
13
17
|
export { loadAndValidateConfig } from './config-loader.js'
|
|
14
18
|
// 요청 ctx 빌더 — db/cache/bus 접근자 배선 (ADR-102)
|
|
15
19
|
export { buildHttpCtx, getHttpCtx, buildAdapterAccessors } from './ctx-builder.js'
|