@fuzionx/framework 0.1.2
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/fx.js +12 -0
- package/index.js +64 -0
- package/lib/core/AppError.js +46 -0
- package/lib/core/Application.js +553 -0
- package/lib/core/AutoLoader.js +162 -0
- package/lib/core/Base.js +64 -0
- package/lib/core/Config.js +122 -0
- package/lib/core/Context.js +429 -0
- package/lib/database/ConnectionManager.js +192 -0
- package/lib/database/MariaModel.js +29 -0
- package/lib/database/Model.js +247 -0
- package/lib/database/ModelRegistry.js +72 -0
- package/lib/database/MongoModel.js +232 -0
- package/lib/database/Pagination.js +37 -0
- package/lib/database/PostgreModel.js +29 -0
- package/lib/database/QueryBuilder.js +172 -0
- package/lib/database/SQLiteModel.js +27 -0
- package/lib/database/SqlModel.js +252 -0
- package/lib/database/SqlQueryBuilder.js +309 -0
- package/lib/helpers/CryptoHelper.js +48 -0
- package/lib/helpers/FileHelper.js +61 -0
- package/lib/helpers/HashHelper.js +39 -0
- package/lib/helpers/I18nHelper.js +170 -0
- package/lib/helpers/Logger.js +105 -0
- package/lib/helpers/MediaHelper.js +38 -0
- package/lib/http/Controller.js +34 -0
- package/lib/http/ErrorHandler.js +135 -0
- package/lib/http/Middleware.js +43 -0
- package/lib/http/Router.js +109 -0
- package/lib/http/Validation.js +124 -0
- package/lib/middleware/index.js +286 -0
- package/lib/realtime/RoomManager.js +85 -0
- package/lib/realtime/WsHandler.js +107 -0
- package/lib/schedule/Job.js +34 -0
- package/lib/schedule/Queue.js +90 -0
- package/lib/schedule/Scheduler.js +161 -0
- package/lib/schedule/Task.js +39 -0
- package/lib/schedule/WorkerPool.js +225 -0
- package/lib/services/EventBus.js +94 -0
- package/lib/services/Service.js +261 -0
- package/lib/services/Storage.js +112 -0
- package/lib/view/OpenAPI.js +231 -0
- package/lib/view/View.js +72 -0
- package/package.json +52 -0
- package/testing/index.js +232 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — Job 스케줄 관리 (마스터 프로세스)
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/10-scheduler-queue.md
|
|
5
|
+
* @see docs/framework/class-design.mm.md (Scheduler)
|
|
6
|
+
*/
|
|
7
|
+
export default class Scheduler {
|
|
8
|
+
constructor(app) {
|
|
9
|
+
this.app = app;
|
|
10
|
+
this._jobs = [];
|
|
11
|
+
this._timers = [];
|
|
12
|
+
this._running = false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Job 등록
|
|
17
|
+
* @param {typeof import('./Job.js').default} JobClass
|
|
18
|
+
*/
|
|
19
|
+
register(JobClass) {
|
|
20
|
+
this._jobs.push(JobClass);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 모든 스케줄 시작
|
|
25
|
+
*/
|
|
26
|
+
start() {
|
|
27
|
+
if (this._running) return;
|
|
28
|
+
this._running = true;
|
|
29
|
+
|
|
30
|
+
for (const JobClass of this._jobs) {
|
|
31
|
+
// enabled === false면 스킵 (10-scheduler-queue.md)
|
|
32
|
+
if (JobClass.enabled === false) continue;
|
|
33
|
+
|
|
34
|
+
const interval = this._parseCron(JobClass.schedule);
|
|
35
|
+
if (!interval) continue;
|
|
36
|
+
|
|
37
|
+
const timeout = JobClass.timeout || 30000;
|
|
38
|
+
|
|
39
|
+
const runJob = async () => {
|
|
40
|
+
const job = new JobClass(this.app);
|
|
41
|
+
try {
|
|
42
|
+
await Promise.race([
|
|
43
|
+
job.handle(),
|
|
44
|
+
new Promise((_, reject) =>
|
|
45
|
+
setTimeout(() => reject(new Error(`Job timeout (${timeout}ms)`)), timeout)
|
|
46
|
+
),
|
|
47
|
+
]);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
await job.onError(err);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// daily/weekly → 첫 실행까지 지연 계산
|
|
54
|
+
const initialDelay = this._calcInitialDelay(JobClass.schedule);
|
|
55
|
+
if (initialDelay > 0) {
|
|
56
|
+
const delayed = setTimeout(() => {
|
|
57
|
+
runJob();
|
|
58
|
+
const timer = setInterval(runJob, interval);
|
|
59
|
+
this._timers.push(timer);
|
|
60
|
+
}, initialDelay);
|
|
61
|
+
this._timers.push(delayed);
|
|
62
|
+
} else {
|
|
63
|
+
const timer = setInterval(runJob, interval);
|
|
64
|
+
this._timers.push(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 모든 스케줄 정지
|
|
71
|
+
*/
|
|
72
|
+
stop() {
|
|
73
|
+
for (const timer of this._timers) {
|
|
74
|
+
clearInterval(timer);
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
this._timers = [];
|
|
78
|
+
this._running = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* daily/weekly 스케줄의 첫 실행까지 지연(ms) 계산
|
|
83
|
+
* @param {string} schedule
|
|
84
|
+
* @returns {number} 0이면 즉시 setInterval, >0이면 setTimeout 필요
|
|
85
|
+
* @private
|
|
86
|
+
*/
|
|
87
|
+
_calcInitialDelay(schedule) {
|
|
88
|
+
const now = new Date();
|
|
89
|
+
|
|
90
|
+
// 'daily:HH:MM'
|
|
91
|
+
const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
|
|
92
|
+
if (dailyMatch) {
|
|
93
|
+
const [, h, m] = dailyMatch;
|
|
94
|
+
const target = new Date(now);
|
|
95
|
+
target.setHours(Number(h), Number(m), 0, 0);
|
|
96
|
+
if (target <= now) target.setDate(target.getDate() + 1);
|
|
97
|
+
return target - now;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 'weekly:day:HH:MM'
|
|
101
|
+
const weeklyMatch = schedule.match(/^weekly:(\w+):(\d{2}):(\d{2})$/);
|
|
102
|
+
if (weeklyMatch) {
|
|
103
|
+
const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
104
|
+
const [, day, h, m] = weeklyMatch;
|
|
105
|
+
const targetDay = days[day.toLowerCase()] ?? 0;
|
|
106
|
+
const target = new Date(now);
|
|
107
|
+
target.setHours(Number(h), Number(m), 0, 0);
|
|
108
|
+
let diff = targetDay - now.getDay();
|
|
109
|
+
if (diff < 0 || (diff === 0 && target <= now)) diff += 7;
|
|
110
|
+
target.setDate(target.getDate() + diff);
|
|
111
|
+
return target - now;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return 0; // every:*, cron → 즉시 setInterval
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 간단 cron 파서 — 간격(ms) 또는 다음 실행까지 지연(ms) 반환
|
|
119
|
+
* 지원:
|
|
120
|
+
* 'every:30s' / 'every:5m' / 'every:1h'
|
|
121
|
+
* 'daily:02:00' — 매일 HH:MM
|
|
122
|
+
* 'weekly:mon:09:00' — 매주 요일 HH:MM
|
|
123
|
+
* '* * * * *' — 매분 (60초)
|
|
124
|
+
* 5-field cron — 분 단위 간격 근사
|
|
125
|
+
* @private
|
|
126
|
+
*/
|
|
127
|
+
_parseCron(schedule) {
|
|
128
|
+
if (!schedule) return null;
|
|
129
|
+
|
|
130
|
+
// 'every:30s' → 30000ms
|
|
131
|
+
const everyMatch = schedule.match(/^every:(\d+)([smh])$/);
|
|
132
|
+
if (everyMatch) {
|
|
133
|
+
const [, num, unit] = everyMatch;
|
|
134
|
+
const multiplier = { s: 1000, m: 60000, h: 3600000 };
|
|
135
|
+
return Number(num) * multiplier[unit];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 'daily:HH:MM' → 24h interval
|
|
139
|
+
const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
|
|
140
|
+
if (dailyMatch) {
|
|
141
|
+
return 86400000;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 'weekly:day:HH:MM' → 7일 간격
|
|
145
|
+
const weeklyMatch = schedule.match(/^weekly:\w+:\d{2}:\d{2}$/);
|
|
146
|
+
if (weeklyMatch) {
|
|
147
|
+
return 604800000;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 5-field cron: '*/5 * * * *' → 분 단위 추출
|
|
151
|
+
const cronMatch = schedule.match(/^\*\/(\d+)\s/);
|
|
152
|
+
if (cronMatch) {
|
|
153
|
+
return Number(cronMatch[1]) * 60000;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// '* * * * *' → 60000ms (매분)
|
|
157
|
+
if (schedule.match(/^[\d*\/,-]+(\s[\d*\/,-]+){4}$/)) return 60000;
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task — 비동기 위임 작업 (큐)
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/10-scheduler-queue.md
|
|
5
|
+
* @see docs/framework/class-design.mm.md (Task)
|
|
6
|
+
*/
|
|
7
|
+
import Job from './Job.js';
|
|
8
|
+
|
|
9
|
+
export default class Task extends Job {
|
|
10
|
+
/** @type {string} 큐 이름 */
|
|
11
|
+
static queue = 'default';
|
|
12
|
+
|
|
13
|
+
/** @type {number} 최대 재시도 횟수 */
|
|
14
|
+
static retries = 3;
|
|
15
|
+
|
|
16
|
+
/** @type {number} 재시도 딜레이 (ms) */
|
|
17
|
+
static retryDelay = 5000;
|
|
18
|
+
|
|
19
|
+
/** @type {number} 타임아웃 (ms) */
|
|
20
|
+
static timeout = 30000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Task 실행 (서브클래스에서 오버라이드)
|
|
24
|
+
* @param {object} data - dispatch() 시 전달된 데이터
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
async handle(data) {
|
|
28
|
+
throw new Error(`${this.constructor.name}.handle() must be implemented`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 최종 실패 시 호출 (모든 재시도 소진)
|
|
33
|
+
* @param {object} data
|
|
34
|
+
* @param {Error} error
|
|
35
|
+
*/
|
|
36
|
+
async failed(data, error) {
|
|
37
|
+
this.logger.error(`Task ${this.constructor.name} failed permanently:`, error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkerPool — CPU 격리 워커 관리 (worker_threads)
|
|
3
|
+
*
|
|
4
|
+
* Scheduler("언제"), Queue("나중에")와 함께 "별도로" 실행을 담당.
|
|
5
|
+
* CPU-heavy 작업을 별도 스레드에서 실행하여 메인 이벤트 루프 보호.
|
|
6
|
+
*
|
|
7
|
+
* workers/ 폴더 컨벤션:
|
|
8
|
+
* workers/
|
|
9
|
+
* ├── csv-parser.js
|
|
10
|
+
* ├── image-resize.js
|
|
11
|
+
* └── excel/
|
|
12
|
+
* └── csv-parser.js
|
|
13
|
+
*
|
|
14
|
+
* @see docs/framework/10-scheduler-queue.md
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // 단축 이름 (workers/ 폴더 기준)
|
|
18
|
+
* const result = await app.worker.run('csv-parser', { path: '/tmp/data.csv' });
|
|
19
|
+
* const result = await app.worker.run('excel/csv-parser', { path: '/tmp/sheet.xlsx' });
|
|
20
|
+
*
|
|
21
|
+
* // 절대경로도 가능
|
|
22
|
+
* const result = await app.worker.run('/opt/workers/custom.js', data);
|
|
23
|
+
*
|
|
24
|
+
* // Queue Task 내에서 조합
|
|
25
|
+
* class HeavyTask extends Task {
|
|
26
|
+
* async handle(data) {
|
|
27
|
+
* const result = await this.worker.run('file-processor', data);
|
|
28
|
+
* await this.db.Report.create(result);
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
import { Worker, isMainThread } from 'node:worker_threads';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
|
|
36
|
+
export default class WorkerPool {
|
|
37
|
+
/**
|
|
38
|
+
* @param {import('../core/Application.js').default} app
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {number} [opts.timeout=30000] - 기본 타임아웃 (ms)
|
|
41
|
+
*/
|
|
42
|
+
constructor(app, opts = {}) {
|
|
43
|
+
this.app = app;
|
|
44
|
+
this._defaultTimeout = opts.timeout || 30000;
|
|
45
|
+
/** @type {Set<Worker>} 활성 워커 추적 */
|
|
46
|
+
this._activeWorkers = new Set();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 워커 스크립트 실행
|
|
51
|
+
*
|
|
52
|
+
* 이름 해석 규칙:
|
|
53
|
+
* - 절대경로 → 그대로 사용
|
|
54
|
+
* - 확장자 포함 (`./xxx.js`) → baseDir 상대경로
|
|
55
|
+
* - 단축 이름 (`csv-parser`, `excel/csv-parser`) → workers/ 폴더에서 .js/.mjs 자동 탐색
|
|
56
|
+
*
|
|
57
|
+
* @param {string} name - 워커 이름 또는 경로
|
|
58
|
+
* @param {*} [data] - workerData로 전달
|
|
59
|
+
* @param {object} [opts]
|
|
60
|
+
* @param {number} [opts.timeout] - 타임아웃 (ms)
|
|
61
|
+
* @param {Array} [opts.transferList] - ArrayBuffer 등 zero-copy 전송
|
|
62
|
+
* @returns {Promise<*>} 워커 결과
|
|
63
|
+
*/
|
|
64
|
+
run(name, data, opts = {}) {
|
|
65
|
+
const timeout = opts.timeout ?? this._defaultTimeout;
|
|
66
|
+
const resolved = this._resolve(name);
|
|
67
|
+
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const worker = new Worker(resolved, {
|
|
70
|
+
workerData: data,
|
|
71
|
+
transferList: opts.transferList,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this._activeWorkers.add(worker);
|
|
75
|
+
|
|
76
|
+
let timer;
|
|
77
|
+
let settled = false;
|
|
78
|
+
|
|
79
|
+
const settle = (fn, value) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
this._activeWorkers.delete(worker);
|
|
84
|
+
fn(value);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
worker.on('message', (result) => settle(resolve, result));
|
|
88
|
+
worker.on('error', (err) => settle(reject, err));
|
|
89
|
+
worker.on('exit', (code) => {
|
|
90
|
+
if (!settled) {
|
|
91
|
+
settle(reject, new Error(`Worker exited with code ${code}`));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (timeout > 0) {
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
worker.terminate();
|
|
98
|
+
settle(reject, new Error(`Worker timeout (${timeout}ms)`));
|
|
99
|
+
}, timeout);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 인라인 순수 함수를 워커에서 실행
|
|
106
|
+
*
|
|
107
|
+
* 함수는 직렬화 가능해야 함 (클로저/외부 참조 불가).
|
|
108
|
+
* 워커 내에서 require() 사용 가능.
|
|
109
|
+
*
|
|
110
|
+
* @param {Function} fn - (data) => result 형태의 순수 함수
|
|
111
|
+
* @param {*} [data] - 함수에 전달할 데이터
|
|
112
|
+
* @param {object} [opts]
|
|
113
|
+
* @param {number} [opts.timeout] - 타임아웃 (ms)
|
|
114
|
+
* @returns {Promise<*>} 함수 실행 결과
|
|
115
|
+
*/
|
|
116
|
+
exec(fn, data, opts = {}) {
|
|
117
|
+
const timeout = opts.timeout ?? this._defaultTimeout;
|
|
118
|
+
const fnStr = fn.toString();
|
|
119
|
+
|
|
120
|
+
// 인라인 워커 코드
|
|
121
|
+
const workerCode = `
|
|
122
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
123
|
+
const fn = ${fnStr};
|
|
124
|
+
(async () => {
|
|
125
|
+
try {
|
|
126
|
+
const result = await fn(workerData);
|
|
127
|
+
parentPort.postMessage(result);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
})();
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const worker = new Worker(workerCode, {
|
|
136
|
+
eval: true,
|
|
137
|
+
workerData: data,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
this._activeWorkers.add(worker);
|
|
141
|
+
|
|
142
|
+
let timer;
|
|
143
|
+
let settled = false;
|
|
144
|
+
|
|
145
|
+
const settle = (fn, value) => {
|
|
146
|
+
if (settled) return;
|
|
147
|
+
settled = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this._activeWorkers.delete(worker);
|
|
150
|
+
fn(value);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
worker.on('message', (result) => settle(resolve, result));
|
|
154
|
+
worker.on('error', (err) => settle(reject, err));
|
|
155
|
+
worker.on('exit', (code) => {
|
|
156
|
+
if (!settled) {
|
|
157
|
+
settle(reject, new Error(`Worker exited with code ${code}`));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (timeout > 0) {
|
|
162
|
+
timer = setTimeout(() => {
|
|
163
|
+
worker.terminate();
|
|
164
|
+
settle(reject, new Error(`Worker timeout (${timeout}ms)`));
|
|
165
|
+
}, timeout);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 활성 워커 수
|
|
172
|
+
* @returns {number}
|
|
173
|
+
*/
|
|
174
|
+
get active() {
|
|
175
|
+
return this._activeWorkers.size;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 모든 활성 워커 종료 (graceful shutdown)
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async terminate() {
|
|
183
|
+
const terminations = [];
|
|
184
|
+
for (const worker of this._activeWorkers) {
|
|
185
|
+
terminations.push(worker.terminate());
|
|
186
|
+
}
|
|
187
|
+
await Promise.allSettled(terminations);
|
|
188
|
+
this._activeWorkers.clear();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 워커 이름 → 절대경로 해석
|
|
193
|
+
*
|
|
194
|
+
* 규칙:
|
|
195
|
+
* 1) 절대경로 → 그대로
|
|
196
|
+
* 2) 확장자 포함 (./foo.js, ../foo.mjs) → baseDir 기준 상대경로
|
|
197
|
+
* 3) 단축 이름 (csv-parser, excel/csv-parser) → workers/ 폴더에서 탐색
|
|
198
|
+
* 탐색 순서: .js → .mjs
|
|
199
|
+
*
|
|
200
|
+
* @param {string} name
|
|
201
|
+
* @returns {string} 절대경로
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
_resolve(name) {
|
|
205
|
+
// 1) 절대경로
|
|
206
|
+
if (path.isAbsolute(name)) return name;
|
|
207
|
+
|
|
208
|
+
const baseDir = this.app?.baseDir || '.';
|
|
209
|
+
|
|
210
|
+
// 2) 확장자 포함 → baseDir 상대경로
|
|
211
|
+
if (/\.\w+$/.test(name)) {
|
|
212
|
+
return path.resolve(baseDir, name);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3) 단축 이름 → workers/ 폴더 탐색
|
|
216
|
+
const workersDir = path.resolve(baseDir, 'workers');
|
|
217
|
+
for (const ext of ['.js', '.mjs']) {
|
|
218
|
+
const candidate = path.join(workersDir, name + ext);
|
|
219
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 기본값: workers/{name}.js (존재하지 않아도 Worker 생성 시 에러)
|
|
223
|
+
return path.join(workersDir, name + '.js');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EventBus — 앱 내 + Hub 경유 이벤트 시스템
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/05-eventbus.md
|
|
5
|
+
*/
|
|
6
|
+
export default class EventBus {
|
|
7
|
+
/**
|
|
8
|
+
* @param {import('./Application.js').default} app
|
|
9
|
+
*/
|
|
10
|
+
constructor(app) {
|
|
11
|
+
this.app = app;
|
|
12
|
+
this._handlers = new Map();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 이벤트 구독
|
|
17
|
+
* @param {string} event
|
|
18
|
+
* @param {Function} handler - (data, meta) => void
|
|
19
|
+
*/
|
|
20
|
+
on(event, handler) {
|
|
21
|
+
if (!this._handlers.has(event)) {
|
|
22
|
+
this._handlers.set(event, []);
|
|
23
|
+
}
|
|
24
|
+
this._handlers.get(event).push(handler);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 이벤트 발행 (로컬)
|
|
29
|
+
* @param {string} event
|
|
30
|
+
* @param {*} data
|
|
31
|
+
* @param {object} [opts] - { hub: true } → Hub 전파
|
|
32
|
+
*/
|
|
33
|
+
async emit(event, data, opts = {}) {
|
|
34
|
+
const meta = {
|
|
35
|
+
remote: false,
|
|
36
|
+
server: this.app?.config?.get('app.name', 'fuzionx') || 'fuzionx',
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handlers = this._handlers.get(event);
|
|
41
|
+
if (handlers) {
|
|
42
|
+
for (const handler of handlers) {
|
|
43
|
+
try { await handler(data, meta); } catch (err) {
|
|
44
|
+
this.app?.logger?.error?.(`Event '${event}' handler error:`, err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Hub 전파 (Hub 연동 시 구현)
|
|
50
|
+
if (opts.hub && this.app?.ws?.hub) {
|
|
51
|
+
// this.app.ws.hub.publish(event, data);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 원격 이벤트 수신 (Hub에서 호출)
|
|
57
|
+
* @param {string} event
|
|
58
|
+
* @param {*} data
|
|
59
|
+
* @param {object} remoteMeta
|
|
60
|
+
*/
|
|
61
|
+
async onRemoteEvent(event, data, remoteMeta) {
|
|
62
|
+
const handlers = this._handlers.get(event);
|
|
63
|
+
if (!handlers) return;
|
|
64
|
+
|
|
65
|
+
const meta = { ...remoteMeta, remote: true };
|
|
66
|
+
for (const handler of handlers) {
|
|
67
|
+
await handler(data, meta);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 구독 해제
|
|
73
|
+
* @param {string} event
|
|
74
|
+
* @param {Function} [handler] - 없으면 이벤트의 모든 핸들러 해제
|
|
75
|
+
*/
|
|
76
|
+
off(event, handler) {
|
|
77
|
+
if (!handler) {
|
|
78
|
+
this._handlers.delete(event);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const handlers = this._handlers.get(event);
|
|
82
|
+
if (handlers) {
|
|
83
|
+
const idx = handlers.indexOf(handler);
|
|
84
|
+
if (idx >= 0) handlers.splice(idx, 1);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 모든 구독 해제
|
|
90
|
+
*/
|
|
91
|
+
clear() {
|
|
92
|
+
this._handlers.clear();
|
|
93
|
+
}
|
|
94
|
+
}
|