@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.
Files changed (45) hide show
  1. package/bin/fx.js +12 -0
  2. package/index.js +64 -0
  3. package/lib/core/AppError.js +46 -0
  4. package/lib/core/Application.js +553 -0
  5. package/lib/core/AutoLoader.js +162 -0
  6. package/lib/core/Base.js +64 -0
  7. package/lib/core/Config.js +122 -0
  8. package/lib/core/Context.js +429 -0
  9. package/lib/database/ConnectionManager.js +192 -0
  10. package/lib/database/MariaModel.js +29 -0
  11. package/lib/database/Model.js +247 -0
  12. package/lib/database/ModelRegistry.js +72 -0
  13. package/lib/database/MongoModel.js +232 -0
  14. package/lib/database/Pagination.js +37 -0
  15. package/lib/database/PostgreModel.js +29 -0
  16. package/lib/database/QueryBuilder.js +172 -0
  17. package/lib/database/SQLiteModel.js +27 -0
  18. package/lib/database/SqlModel.js +252 -0
  19. package/lib/database/SqlQueryBuilder.js +309 -0
  20. package/lib/helpers/CryptoHelper.js +48 -0
  21. package/lib/helpers/FileHelper.js +61 -0
  22. package/lib/helpers/HashHelper.js +39 -0
  23. package/lib/helpers/I18nHelper.js +170 -0
  24. package/lib/helpers/Logger.js +105 -0
  25. package/lib/helpers/MediaHelper.js +38 -0
  26. package/lib/http/Controller.js +34 -0
  27. package/lib/http/ErrorHandler.js +135 -0
  28. package/lib/http/Middleware.js +43 -0
  29. package/lib/http/Router.js +109 -0
  30. package/lib/http/Validation.js +124 -0
  31. package/lib/middleware/index.js +286 -0
  32. package/lib/realtime/RoomManager.js +85 -0
  33. package/lib/realtime/WsHandler.js +107 -0
  34. package/lib/schedule/Job.js +34 -0
  35. package/lib/schedule/Queue.js +90 -0
  36. package/lib/schedule/Scheduler.js +161 -0
  37. package/lib/schedule/Task.js +39 -0
  38. package/lib/schedule/WorkerPool.js +225 -0
  39. package/lib/services/EventBus.js +94 -0
  40. package/lib/services/Service.js +261 -0
  41. package/lib/services/Storage.js +112 -0
  42. package/lib/view/OpenAPI.js +231 -0
  43. package/lib/view/View.js +72 -0
  44. package/package.json +52 -0
  45. 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
+ }