@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,286 @@
1
+ /**
2
+ * 내장 미들웨어 — bodyParser, cors, auth, apiAuth, csrf
3
+ *
4
+ * @see docs/framework/12-middleware.md
5
+ * @see docs/framework/14-authentication.md
6
+ */
7
+
8
+ /**
9
+ * JSON/Form body 파싱 미들웨어
10
+ * Bridge가 이미 파싱한 body를 보장. 추가 파싱이 필요한 경우 처리.
11
+ */
12
+ export function bodyParser() {
13
+ return async (ctx, next) => {
14
+ // Bridge가 body를 이미 파싱했으므로 ctx.body는 rawReq.body에서 가져옴
15
+ // 추가 파싱이 필요하면 여기서 처리
16
+ if (typeof ctx.body === 'string' && ctx.body) {
17
+ const ct = ctx.get('content-type') || '';
18
+ if (ct.includes('application/json')) {
19
+ try { ctx.body = JSON.parse(ctx.body); } catch {}
20
+ }
21
+ }
22
+ await next();
23
+ };
24
+ }
25
+
26
+ /**
27
+ * CORS 미들웨어
28
+ * @param {object} [opts]
29
+ * @param {string|string[]} [opts.origin='*']
30
+ * @param {string} [opts.methods='GET,POST,PUT,PATCH,DELETE,OPTIONS']
31
+ * @param {string} [opts.headers='Content-Type,Authorization']
32
+ * @param {boolean} [opts.credentials=false]
33
+ * @param {number} [opts.maxAge=86400]
34
+ */
35
+ export function cors(opts = {}) {
36
+ const origin = opts.origin || '*';
37
+ const methods = opts.methods || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
38
+ const headers = opts.headers || 'Content-Type,Authorization';
39
+ const credentials = opts.credentials || false;
40
+ const maxAge = opts.maxAge || 86400;
41
+
42
+ return async (ctx, next) => {
43
+ const reqOrigin = ctx.get('origin') || '*';
44
+ let allowOrigin;
45
+
46
+ if (credentials && origin === '*') {
47
+ // W3C 스펙: credentials:true일 때 origin:'*' 사용 불가 → request origin 미러링
48
+ allowOrigin = reqOrigin !== '*' ? reqOrigin : '';
49
+ } else {
50
+ allowOrigin = origin === '*' ? '*' : (
51
+ Array.isArray(origin)
52
+ ? (origin.includes(reqOrigin) ? reqOrigin : origin[0])
53
+ : origin
54
+ );
55
+ }
56
+
57
+ ctx.header('Access-Control-Allow-Origin', allowOrigin);
58
+ ctx.header('Access-Control-Allow-Methods', methods);
59
+ ctx.header('Access-Control-Allow-Headers', headers);
60
+ if (credentials) ctx.header('Access-Control-Allow-Credentials', 'true');
61
+
62
+ // Preflight OPTIONS
63
+ if (ctx.method === 'OPTIONS') {
64
+ ctx.header('Access-Control-Max-Age', String(maxAge));
65
+ ctx.status(204).end();
66
+ return;
67
+ }
68
+
69
+ await next();
70
+ };
71
+ }
72
+
73
+ /**
74
+ * 세션 인증 미들웨어
75
+ * ctx.session.userId → db.User.find() → ctx.user
76
+ * @param {object} [opts]
77
+ * @param {string} [opts.sessionKey='userId']
78
+ * @param {string} [opts.model='User']
79
+ * @param {string} [opts.redirectTo='/login']
80
+ */
81
+ export function auth(opts = {}) {
82
+ const sessionKey = opts.sessionKey || 'userId';
83
+ const modelName = opts.model || 'User';
84
+ const redirectTo = opts.redirectTo || null;
85
+
86
+ return async (ctx, next) => {
87
+ const userId = ctx._rawSession?.[sessionKey] || ctx.get('x-test-user-id');
88
+
89
+ if (!userId) {
90
+ if (redirectTo) {
91
+ ctx.redirect(redirectTo);
92
+ } else {
93
+ ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
94
+ }
95
+ return;
96
+ }
97
+
98
+ // DB 조회 (ModelRegistry 사용)
99
+ if (ctx.app?.db?.[modelName]) {
100
+ try {
101
+ ctx.user = await ctx.app.db[modelName].find(userId);
102
+ } catch {
103
+ ctx.user = { id: userId };
104
+ }
105
+ } else {
106
+ ctx.user = { id: userId };
107
+ }
108
+
109
+ await next();
110
+ };
111
+ }
112
+
113
+ /**
114
+ * JWT Bearer 토큰 인증 미들웨어
115
+ * Authorization: Bearer <token> → 검증 → ctx.user
116
+ * @param {object} [opts]
117
+ * @param {string} [opts.secret] - JWT 시크릿 (config에서도 읽음)
118
+ * @param {string} [opts.model='User']
119
+ */
120
+ export function apiAuth(opts = {}) {
121
+ const modelName = opts.model || 'User';
122
+
123
+ return async (ctx, next) => {
124
+ const authHeader = ctx.get('authorization') || '';
125
+
126
+ if (!authHeader.startsWith('Bearer ')) {
127
+ ctx.status(401).json({ error: { message: 'Token required', status: 401 } });
128
+ return;
129
+ }
130
+
131
+ const token = authHeader.slice(7);
132
+
133
+ // JWT 검증 — Bridge crypto 사용 가능 시 위임
134
+ try {
135
+ const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
136
+ const payload = decodeJwtPayload(token, secret);
137
+
138
+ if (!payload || !payload.sub) {
139
+ ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
140
+ return;
141
+ }
142
+
143
+ // DB에서 유저 조회
144
+ if (ctx.app?.db?.[modelName]) {
145
+ ctx.user = await ctx.app.db[modelName].find(payload.sub);
146
+ } else {
147
+ ctx.user = { id: payload.sub, ...payload };
148
+ }
149
+ } catch {
150
+ ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
151
+ return;
152
+ }
153
+
154
+ await next();
155
+ };
156
+ }
157
+
158
+ /**
159
+ * CSRF 보호 미들웨어
160
+ * GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증
161
+ * @param {object} [opts]
162
+ * @param {string} [opts.headerName='x-csrf-token']
163
+ * @param {string} [opts.sessionKey='_csrfToken']
164
+ */
165
+ export function csrf(opts = {}) {
166
+ const headerName = opts.headerName || 'x-csrf-token';
167
+ const sessionKey = opts.sessionKey || '_csrfToken';
168
+
169
+ return async (ctx, next) => {
170
+ // 안전한 메서드는 통과
171
+ if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) {
172
+ await next();
173
+ return;
174
+ }
175
+
176
+ const token = ctx.get(headerName);
177
+ const expected = ctx._rawSession?.[sessionKey];
178
+
179
+ if (!token || !expected || token !== expected) {
180
+ ctx.status(403).json({ error: { message: 'CSRF token mismatch', status: 403 } });
181
+ return;
182
+ }
183
+
184
+ await next();
185
+ };
186
+ }
187
+
188
+ /**
189
+ * JWT 디코드 + HMAC-SHA256 서명 검증
190
+ * @param {string} token - JWT 토큰
191
+ * @param {string} secret - HMAC 시크릿 키
192
+ * @returns {object|null} - 검증된 payload 또는 null
193
+ * @private
194
+ */
195
+ function decodeJwtPayload(token, secret) {
196
+ try {
197
+ const parts = token.split('.');
198
+ if (parts.length !== 3) return null;
199
+
200
+ const [headerB64, payloadB64, signatureB64] = parts;
201
+
202
+ // ── HMAC-SHA256 서명 검증 ──
203
+ const { createHmac, timingSafeEqual } = require('node:crypto');
204
+ const signingInput = `${headerB64}.${payloadB64}`;
205
+ const expectedSig = createHmac('sha256', secret)
206
+ .update(signingInput)
207
+ .digest();
208
+
209
+ // base64url → Buffer
210
+ const actualSig = Buffer.from(signatureB64, 'base64url');
211
+
212
+ // 길이 불일치 시 즉시 거부 (timingSafeEqual은 길이 같아야 함)
213
+ if (expectedSig.length !== actualSig.length) return null;
214
+ if (!timingSafeEqual(expectedSig, actualSig)) return null;
215
+
216
+ // ── payload 디코드 ──
217
+ const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
218
+
219
+ // 만료 체크
220
+ if (payload.exp !== undefined && Date.now() / 1000 > payload.exp) return null;
221
+
222
+ // nbf (Not Before) 체크
223
+ if (payload.nbf !== undefined && Date.now() / 1000 < payload.nbf) return null;
224
+
225
+ return payload;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * 세션 로드/저장 미들웨어
233
+ * Bridge sessionGet/sessionSet 사용.
234
+ *
235
+ * @see docs/framework/12-middleware.md — "session: 세션 로드/저장"
236
+ */
237
+ export function session(opts = {}) {
238
+ return async (ctx, next) => {
239
+ // Session은 Context 생성 시 rawReq.session에서 이미 로드됨
240
+ // Bridge가 rawReq에 session 데이터를 포함하여 전달
241
+
242
+ await next();
243
+
244
+ // 요청 완료 후 — 소비된 flash 데이터 정리
245
+ // flash는 getFlash() 호출 시 로컬에서 삭제되지만,
246
+ // Bridge 세션 저장소에서도 제거해야 함
247
+ const bridge = ctx.app?._bridge;
248
+ const sessionId = ctx._sessionId;
249
+ if (sessionId && bridge?.sessionSet && ctx._rawSession) {
250
+ // _flash가 비었으면 세션에서 제거
251
+ if (ctx._rawSession._flash && Object.keys(ctx._rawSession._flash).length === 0) {
252
+ delete ctx._rawSession._flash;
253
+ }
254
+ try {
255
+ bridge.sessionSet(sessionId, ctx._rawSession);
256
+ } catch {}
257
+ }
258
+ };
259
+ }
260
+
261
+ /**
262
+ * 테마 미들웨어 — 도메인 → 테마 매핑
263
+ *
264
+ * @see docs/framework/03-views-templates.md
265
+ * @see docs/framework/12-middleware.md — "theme: 도메인 → 테마 매핑"
266
+ *
267
+ * @param {object} [opts]
268
+ * @param {string} [opts.default='default'] - 기본 테마
269
+ * @param {object} [opts.mapping] - { 'domain.com': 'theme1' }
270
+ */
271
+ export function theme(opts = {}) {
272
+ const defaultTheme = opts.default || 'default';
273
+ const mapping = opts.mapping || {};
274
+
275
+ return async (ctx, next) => {
276
+ // YAML 설정에서 매핑 읽기
277
+ const configMapping = ctx.app?.config?.get('themes.mapping') || mapping;
278
+ const configDefault = ctx.app?.config?.get('themes.default') || defaultTheme;
279
+
280
+ const host = ctx.host?.split(':')[0] || ''; // 포트 제거
281
+ ctx.theme = configMapping[host] || configDefault;
282
+
283
+ await next();
284
+ };
285
+ }
286
+
@@ -0,0 +1,85 @@
1
+ /**
2
+ * RoomManager — 전체 룸 상태 관리 (Primary 프로세스)
3
+ *
4
+ * @see docs/framework/class-design.mm.md (RoomManager)
5
+ */
6
+ export default class RoomManager {
7
+ constructor() {
8
+ /** @type {Map<string, Set<string>>} roomName → Set<socketId> */
9
+ this._rooms = new Map();
10
+ }
11
+
12
+ /**
13
+ * 룸에 참가
14
+ * @param {string} room
15
+ * @param {string} socketId
16
+ */
17
+ join(room, socketId) {
18
+ if (!this._rooms.has(room)) {
19
+ this._rooms.set(room, new Set());
20
+ }
21
+ this._rooms.get(room).add(socketId);
22
+ }
23
+
24
+ /**
25
+ * 룸에서 퇴장
26
+ * @param {string} room
27
+ * @param {string} socketId
28
+ */
29
+ leave(room, socketId) {
30
+ const members = this._rooms.get(room);
31
+ if (!members) return;
32
+ members.delete(socketId);
33
+ if (members.size === 0) {
34
+ this._rooms.delete(room);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 소켓이 속한 모든 룸에서 퇴장
40
+ * @param {string} socketId
41
+ */
42
+ leaveAll(socketId) {
43
+ for (const [room, members] of this._rooms) {
44
+ members.delete(socketId);
45
+ if (members.size === 0) {
46
+ this._rooms.delete(room);
47
+ }
48
+ }
49
+ }
50
+
51
+ /**
52
+ * 룸 멤버 조회
53
+ * @param {string} room
54
+ * @returns {string[]}
55
+ */
56
+ members(room) {
57
+ const members = this._rooms.get(room);
58
+ return members ? [...members] : [];
59
+ }
60
+
61
+ /**
62
+ * 룸 존재 여부
63
+ * @param {string} room
64
+ * @returns {boolean}
65
+ */
66
+ has(room) {
67
+ return this._rooms.has(room) && this._rooms.get(room).size > 0;
68
+ }
69
+
70
+ /**
71
+ * 전체 룸 목록
72
+ * @returns {string[]}
73
+ */
74
+ rooms() {
75
+ return [...this._rooms.keys()];
76
+ }
77
+
78
+ /**
79
+ * 룸 수
80
+ * @returns {number}
81
+ */
82
+ get size() {
83
+ return this._rooms.size;
84
+ }
85
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * WsHandler — WebSocket 네임스페이스 핸들러
3
+ *
4
+ * @see docs/framework/11-websocket.md
5
+ * @see docs/framework/class-design.mm.md (WsHandler + EventBuilder)
6
+ */
7
+ import Base from '../core/Base.js';
8
+
9
+ /**
10
+ * EventBuilder — static events(e) DSL에서 사용
11
+ *
12
+ * @example
13
+ * static events(e) {
14
+ * e.on('chat', this.handleChat);
15
+ * e.on('typing', this.handleTyping, ['rateLimit:10']);
16
+ * }
17
+ */
18
+ export class EventBuilder {
19
+ constructor() {
20
+ this._events = [];
21
+ }
22
+
23
+ /**
24
+ * 이벤트 핸들러 등록
25
+ * @param {string} type - 이벤트 타입
26
+ * @param {Function|object} handler - 핸들러 메서드 레퍼런스
27
+ * @param {string[]} [middleware] - 이벤트별 미들웨어
28
+ */
29
+ on(type, handler, middleware = []) {
30
+ this._events.push({ type, handler, middleware });
31
+ }
32
+ }
33
+
34
+ export default class WsHandler extends Base {
35
+ /** @type {string} 네임스페이스 경로 (e.g. '/chat') */
36
+ static namespace = '/';
37
+
38
+ /** @type {string[]} 핸드셰이크 미들웨어 */
39
+ static middleware = [];
40
+
41
+ /**
42
+ * 이벤트 라우팅 선언 (서브클래스에서 오버라이드)
43
+ * @param {EventBuilder} e
44
+ */
45
+ static events(e) {
46
+ // 서브클래스에서:
47
+ // e.on('chat', this.handleChat);
48
+ // e.on('typing', this.handleTyping, ['rateLimit:10']);
49
+ }
50
+
51
+ /**
52
+ * 이벤트 라우팅 맵 빌드
53
+ * @returns {Map<string, {handler, middleware}>}
54
+ */
55
+ static buildEventMap() {
56
+ const builder = new EventBuilder();
57
+ this.events(builder);
58
+ const map = new Map();
59
+ for (const { type, handler, middleware } of builder._events) {
60
+ map.set(type, { handler, middleware });
61
+ }
62
+ return map;
63
+ }
64
+
65
+ // ── 라이프사이클 훅 ──
66
+
67
+ /**
68
+ * 연결 시 (서브클래스에서 오버라이드)
69
+ * @param {object} socket
70
+ */
71
+ async onConnect(socket) {}
72
+
73
+ /**
74
+ * 이벤트 핸들러 (서브클래스에서 오버라이드)
75
+ * @param {object} socket
76
+ * @param {string} event
77
+ * @param {*} data
78
+ */
79
+ async onEvent(socket, event, data) {}
80
+
81
+ /**
82
+ * 연결 해제 시 (서브클래스에서 오버라이드)
83
+ * @param {object} socket
84
+ * @param {number} code
85
+ * @param {string} reason
86
+ */
87
+ async onDisconnect(socket, code, reason) {}
88
+
89
+ /**
90
+ * 미등록 이벤트 수신 시 (서브클래스에서 오버라이드)
91
+ * @param {object} socket
92
+ * @param {string} type
93
+ * @param {*} data
94
+ */
95
+ async onUnknownEvent(socket, type, data) {
96
+ // 기본: 무시 (서브클래스에서 오버라이드 가능)
97
+ }
98
+
99
+ /**
100
+ * 에러 시 (서브클래스에서 오버라이드)
101
+ * @param {object} socket
102
+ * @param {Error} error
103
+ */
104
+ async onError(socket, error) {
105
+ this.logger?.error(`WS error in ${this.constructor.namespace}:`, error);
106
+ }
107
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Job — 정기 실행 작업 (cron 스케줄)
3
+ *
4
+ * @see docs/framework/10-scheduler-queue.md
5
+ * @see docs/framework/class-design.mm.md (Job)
6
+ */
7
+ import Base from '../core/Base.js';
8
+
9
+ export default class Job extends Base {
10
+ /** @type {string} cron 표현식 또는 간편 표현 (every:5m, daily:02:00) */
11
+ static schedule = '';
12
+
13
+ /** @type {number} 타임아웃 (ms), 기본 30초 */
14
+ static timeout = 30000;
15
+
16
+ /** @type {boolean} false면 스킵 */
17
+ static enabled = true;
18
+
19
+ /**
20
+ * Job 실행 (서브클래스에서 오버라이드)
21
+ * @returns {Promise<void>}
22
+ */
23
+ async handle() {
24
+ throw new Error(`${this.constructor.name}.handle() must be implemented`);
25
+ }
26
+
27
+ /**
28
+ * Job 실패 시 호출 (서브클래스에서 오버라이드)
29
+ * @param {Error} error
30
+ */
31
+ async onError(error) {
32
+ this.logger.error(`Job ${this.constructor.name} failed:`, error);
33
+ }
34
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Queue — Task 큐 관리 (Memory/Redis)
3
+ *
4
+ * @see docs/framework/10-scheduler-queue.md
5
+ * @see docs/framework/class-design.mm.md (Queue)
6
+ */
7
+ export default class Queue {
8
+ /**
9
+ * @param {import('./Application.js').default} app
10
+ * @param {object} [opts]
11
+ * @param {string} [opts.driver='memory'] - 'memory' | 'redis'
12
+ */
13
+ constructor(app, opts = {}) {
14
+ this.app = app;
15
+ this.driver = opts.driver || 'memory';
16
+ this._tasks = new Map(); // 등록된 Task 클래스
17
+ this._queue = []; // 메모리 큐
18
+ this._processing = false;
19
+ }
20
+
21
+ /**
22
+ * Task 클래스 등록
23
+ * @param {string} name
24
+ * @param {typeof import('./Task.js').default} TaskClass
25
+ */
26
+ register(name, TaskClass) {
27
+ this._tasks.set(name, TaskClass);
28
+ }
29
+
30
+ /**
31
+ * Task 디스패치 (큐에 추가)
32
+ * @param {string|Function} taskOrName
33
+ * @param {object} data
34
+ * @param {object} [opts]
35
+ */
36
+ dispatch(taskOrName, data, opts = {}) {
37
+ const name = typeof taskOrName === 'string' ? taskOrName : taskOrName.name;
38
+ this._queue.push({ name, data, opts, retries: 0 });
39
+
40
+ // auto-process (비동기)
41
+ if (!this._processing) {
42
+ this._process();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 큐 처리 루프
48
+ * @private
49
+ */
50
+ async _process() {
51
+ this._processing = true;
52
+
53
+ while (this._queue.length > 0) {
54
+ const job = this._queue.shift();
55
+ const TaskClass = this._tasks.get(job.name);
56
+
57
+ if (!TaskClass) {
58
+ this.app?.logger?.error?.(`Task '${job.name}' not registered`);
59
+ continue;
60
+ }
61
+
62
+ const task = new TaskClass(this.app);
63
+ try {
64
+ await task.handle(job.data);
65
+ } catch (err) {
66
+ job.retries++;
67
+ if (job.retries < (TaskClass.retries || 3)) {
68
+ // 재시도
69
+ const delay = TaskClass.retryDelay || 1000;
70
+ setTimeout(() => {
71
+ this._queue.push(job);
72
+ if (!this._processing) this._process();
73
+ }, delay);
74
+ } else {
75
+ await task.failed(job.data, err);
76
+ }
77
+ }
78
+ }
79
+
80
+ this._processing = false;
81
+ }
82
+
83
+ /**
84
+ * 남은 큐 수
85
+ * @returns {number}
86
+ */
87
+ get pending() {
88
+ return this._queue.length;
89
+ }
90
+ }