@fuzionx/framework 0.1.30 → 0.1.32
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/cli/index.js +146 -11
- package/index.js +3 -0
- package/lib/core/Application.js +27 -5
- package/lib/core/Context.js +31 -1
- package/lib/helpers/I18nHelper.js +10 -6
- package/lib/middleware/apiAuth.js +79 -0
- package/lib/middleware/auth.js +42 -0
- package/lib/middleware/bodyParser.js +19 -0
- package/lib/middleware/cors.js +47 -0
- package/lib/middleware/csrf.js +32 -0
- package/lib/middleware/index.js +8 -277
- package/lib/middleware/session.js +27 -0
- package/lib/middleware/theme.js +20 -0
- package/lib/schedule/Job.js +4 -0
- package/lib/schedule/Queue.js +20 -8
- package/lib/schedule/Scheduler.js +84 -75
- package/lib/utilities/ArrUtil.js +112 -0
- package/lib/utilities/DateUtil.js +98 -0
- package/lib/utilities/FunctionUtil.js +119 -0
- package/lib/utilities/NumUtil.js +75 -0
- package/lib/utilities/ObjectUtil.js +170 -0
- package/lib/utilities/PaginationUtil.js +81 -0
- package/lib/utilities/StrUtil.js +105 -0
- package/lib/utilities/index.js +18 -0
- package/package.json +2 -2
package/lib/middleware/index.js
CHANGED
|
@@ -1,282 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 내장 미들웨어 —
|
|
2
|
+
* 내장 미들웨어 — 배럴 re-export
|
|
3
3
|
*
|
|
4
4
|
* @see docs/framework/12-middleware.md
|
|
5
5
|
* @see docs/framework/14-authentication.md
|
|
6
6
|
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
14
|
-
return async (ctx, next) => {
|
|
15
|
-
// Bridge가 body를 이미 파싱했으므로 ctx.body는 rawReq.body에서 가져옴
|
|
16
|
-
// 추가 파싱이 필요하면 여기서 처리
|
|
17
|
-
if (typeof ctx.body === 'string' && ctx.body) {
|
|
18
|
-
const ct = ctx.get('content-type') || '';
|
|
19
|
-
if (ct.includes('application/json')) {
|
|
20
|
-
try { ctx.body = JSON.parse(ctx.body); } catch {}
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
await next();
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* CORS 미들웨어
|
|
29
|
-
* @param {object} [opts]
|
|
30
|
-
* @param {string|string[]} [opts.origin='*']
|
|
31
|
-
* @param {string} [opts.methods='GET,POST,PUT,PATCH,DELETE,OPTIONS']
|
|
32
|
-
* @param {string} [opts.headers='Content-Type,Authorization']
|
|
33
|
-
* @param {boolean} [opts.credentials=false]
|
|
34
|
-
* @param {number} [opts.maxAge=86400]
|
|
35
|
-
*/
|
|
36
|
-
export function cors(opts = {}) {
|
|
37
|
-
const origin = opts.origin || '*';
|
|
38
|
-
const methods = opts.methods || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
|
39
|
-
const headers = opts.headers || 'Content-Type,Authorization';
|
|
40
|
-
const credentials = opts.credentials || false;
|
|
41
|
-
const maxAge = opts.maxAge || 86400;
|
|
42
|
-
|
|
43
|
-
return async (ctx, next) => {
|
|
44
|
-
const reqOrigin = ctx.get('origin') || '*';
|
|
45
|
-
let allowOrigin;
|
|
46
|
-
|
|
47
|
-
if (credentials && origin === '*') {
|
|
48
|
-
// W3C 스펙: credentials:true일 때 origin:'*' 사용 불가 → request origin 미러링
|
|
49
|
-
allowOrigin = reqOrigin !== '*' ? reqOrigin : '';
|
|
50
|
-
} else {
|
|
51
|
-
allowOrigin = origin === '*' ? '*' : (
|
|
52
|
-
Array.isArray(origin)
|
|
53
|
-
? (origin.includes(reqOrigin) ? reqOrigin : origin[0])
|
|
54
|
-
: origin
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
ctx.header('Access-Control-Allow-Origin', allowOrigin);
|
|
59
|
-
ctx.header('Access-Control-Allow-Methods', methods);
|
|
60
|
-
ctx.header('Access-Control-Allow-Headers', headers);
|
|
61
|
-
if (credentials) ctx.header('Access-Control-Allow-Credentials', 'true');
|
|
62
|
-
|
|
63
|
-
// Preflight OPTIONS
|
|
64
|
-
if (ctx.method === 'OPTIONS') {
|
|
65
|
-
ctx.header('Access-Control-Max-Age', String(maxAge));
|
|
66
|
-
ctx.status(204).end();
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
await next();
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 세션 인증 미들웨어
|
|
76
|
-
* ctx.session.userId → db.User.find() → ctx.user
|
|
77
|
-
* @param {object} [opts]
|
|
78
|
-
* @param {string} [opts.sessionKey='userId']
|
|
79
|
-
* @param {string} [opts.model='User']
|
|
80
|
-
* @param {string} [opts.redirectTo='/login']
|
|
81
|
-
*/
|
|
82
|
-
export function auth(opts = {}) {
|
|
83
|
-
const sessionKey = opts.sessionKey || 'userId';
|
|
84
|
-
const modelName = opts.model || 'User';
|
|
85
|
-
const redirectTo = opts.redirectTo || null;
|
|
86
|
-
|
|
87
|
-
return async (ctx, next) => {
|
|
88
|
-
const userId = ctx._rawSession?.[sessionKey] || ctx.get('x-test-user-id');
|
|
89
|
-
|
|
90
|
-
if (!userId) {
|
|
91
|
-
if (redirectTo) {
|
|
92
|
-
ctx.redirect(redirectTo);
|
|
93
|
-
} else {
|
|
94
|
-
ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
|
|
95
|
-
}
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// DB 조회 (ModelRegistry 사용)
|
|
100
|
-
if (ctx.app?.db?.[modelName]) {
|
|
101
|
-
try {
|
|
102
|
-
ctx.user = await ctx.app.db[modelName].find(userId);
|
|
103
|
-
} catch {
|
|
104
|
-
ctx.user = { id: userId };
|
|
105
|
-
}
|
|
106
|
-
} else {
|
|
107
|
-
ctx.user = { id: userId };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
await next();
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* JWT Bearer 토큰 인증 미들웨어
|
|
116
|
-
* Authorization: Bearer <token> → 검증 → ctx.user
|
|
117
|
-
* @param {object} [opts]
|
|
118
|
-
* @param {string} [opts.secret] - JWT 시크릿 (config에서도 읽음)
|
|
119
|
-
* @param {string} [opts.model='User']
|
|
120
|
-
*/
|
|
121
|
-
export function apiAuth(opts = {}) {
|
|
122
|
-
const modelName = opts.model || 'User';
|
|
123
|
-
|
|
124
|
-
return async (ctx, next) => {
|
|
125
|
-
const authHeader = ctx.get('authorization') || '';
|
|
126
|
-
|
|
127
|
-
if (!authHeader.startsWith('Bearer ')) {
|
|
128
|
-
ctx.status(401).json({ error: { message: 'Token required', status: 401 } });
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const token = authHeader.slice(7);
|
|
133
|
-
|
|
134
|
-
// JWT 검증 — Bridge crypto 사용 가능 시 위임
|
|
135
|
-
try {
|
|
136
|
-
const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
|
|
137
|
-
const payload = decodeJwtPayload(token, secret);
|
|
138
|
-
|
|
139
|
-
if (!payload || !payload.sub) {
|
|
140
|
-
ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// DB에서 유저 조회
|
|
145
|
-
if (ctx.app?.db?.[modelName]) {
|
|
146
|
-
ctx.user = await ctx.app.db[modelName].find(payload.sub);
|
|
147
|
-
} else {
|
|
148
|
-
ctx.user = { id: payload.sub, ...payload };
|
|
149
|
-
}
|
|
150
|
-
} catch {
|
|
151
|
-
ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
await next();
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* CSRF 보호 미들웨어
|
|
161
|
-
* GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증
|
|
162
|
-
* @param {object} [opts]
|
|
163
|
-
* @param {string} [opts.headerName='x-csrf-token']
|
|
164
|
-
* @param {string} [opts.sessionKey='_csrfToken']
|
|
165
|
-
*/
|
|
166
|
-
export function csrf(opts = {}) {
|
|
167
|
-
const headerName = opts.headerName || 'x-csrf-token';
|
|
168
|
-
const sessionKey = opts.sessionKey || '_csrfToken';
|
|
169
|
-
|
|
170
|
-
return async (ctx, next) => {
|
|
171
|
-
// 안전한 메서드는 통과
|
|
172
|
-
if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) {
|
|
173
|
-
await next();
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const token = ctx.get(headerName);
|
|
178
|
-
const expected = ctx._rawSession?.[sessionKey];
|
|
179
|
-
|
|
180
|
-
if (!token || !expected || token !== expected) {
|
|
181
|
-
ctx.status(403).json({ error: { message: 'CSRF token mismatch', status: 403 } });
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
await next();
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* JWT 디코드 + HMAC-SHA256 서명 검증
|
|
191
|
-
* @param {string} token - JWT 토큰
|
|
192
|
-
* @param {string} secret - HMAC 시크릿 키
|
|
193
|
-
* @returns {object|null} - 검증된 payload 또는 null
|
|
194
|
-
* @private
|
|
195
|
-
*/
|
|
196
|
-
function decodeJwtPayload(token, secret) {
|
|
197
|
-
try {
|
|
198
|
-
const parts = token.split('.');
|
|
199
|
-
if (parts.length !== 3) return null;
|
|
200
|
-
|
|
201
|
-
const [headerB64, payloadB64, signatureB64] = parts;
|
|
202
|
-
|
|
203
|
-
// ── HMAC-SHA256 서명 검증 ──
|
|
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
|
-
|
|
274
|
-
return async (ctx, next) => {
|
|
275
|
-
// 앱별 테마 결정 — 도메인→앱 라우팅은 Application._resolveApp()에서 처리
|
|
276
|
-
const configDefault = ctx.app?.config?.get('app.themes.default') || defaultTheme;
|
|
277
|
-
ctx.theme = configDefault;
|
|
278
|
-
|
|
279
|
-
await next();
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
7
|
+
export { bodyParser } from './bodyParser.js';
|
|
8
|
+
export { cors } from './cors.js';
|
|
9
|
+
export { auth } from './auth.js';
|
|
10
|
+
export { apiAuth } from './apiAuth.js';
|
|
11
|
+
export { csrf } from './csrf.js';
|
|
12
|
+
export { session } from './session.js';
|
|
13
|
+
export { theme } from './theme.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session — 세션 로드/저장 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* Bridge sessionGet/sessionSet 사용.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/12-middleware.md — "session: 세션 로드/저장"
|
|
7
|
+
*/
|
|
8
|
+
export function session(opts = {}) {
|
|
9
|
+
return async (ctx, next) => {
|
|
10
|
+
// Session은 Context 생성 시 rawReq.session에서 이미 로드됨
|
|
11
|
+
// Bridge가 rawReq에 session 데이터를 포함하여 전달
|
|
12
|
+
|
|
13
|
+
await next();
|
|
14
|
+
|
|
15
|
+
// 요청 완료 후 — 소비된 flash 데이터 정리
|
|
16
|
+
const bridge = ctx.app?._bridge;
|
|
17
|
+
const sessionId = ctx._sessionId;
|
|
18
|
+
if (sessionId && bridge?.sessionSet && ctx._rawSession) {
|
|
19
|
+
if (ctx._rawSession._flash && Object.keys(ctx._rawSession._flash).length === 0) {
|
|
20
|
+
delete ctx._rawSession._flash;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
bridge.sessionSet(sessionId, ctx._rawSession);
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme — 도메인 → 테마 매핑 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/03-views-templates.md
|
|
5
|
+
* @see docs/framework/12-middleware.md — "theme: 도메인 → 테마 매핑"
|
|
6
|
+
*
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {string} [opts.default='default'] - 기본 테마
|
|
9
|
+
* @param {object} [opts.mapping] - { 'domain.com': 'theme1' }
|
|
10
|
+
*/
|
|
11
|
+
export function theme(opts = {}) {
|
|
12
|
+
const defaultTheme = opts.default || 'default';
|
|
13
|
+
|
|
14
|
+
return async (ctx, next) => {
|
|
15
|
+
const configDefault = ctx.app?.config?.get('app.themes.default') || defaultTheme;
|
|
16
|
+
ctx.theme = configDefault;
|
|
17
|
+
|
|
18
|
+
await next();
|
|
19
|
+
};
|
|
20
|
+
}
|
package/lib/schedule/Job.js
CHANGED
|
@@ -18,6 +18,10 @@ export default class Job extends Base {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Job 실행 (서브클래스에서 오버라이드)
|
|
21
|
+
*
|
|
22
|
+
* 메인 스레드에서 실행되므로 this.db, this.service() 등 접근 가능.
|
|
23
|
+
* CPU-intensive 작업은 this.worker.run() 또는 this.worker.exec()으로 위임.
|
|
24
|
+
*
|
|
21
25
|
* @returns {Promise<void>}
|
|
22
26
|
*/
|
|
23
27
|
async handle() {
|
package/lib/schedule/Queue.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Queue — Task 큐 관리 (Memory/Redis)
|
|
3
3
|
*
|
|
4
|
+
* AutoLoader가 shared/jobs/ 에서 Task 클래스를 자동 스캔·등록.
|
|
5
|
+
* handle(data)은 메인 스레드에서 실행되므로 this.db, this.service() 등
|
|
6
|
+
* 앱 컨텍스트에 접근 가능합니다.
|
|
7
|
+
*
|
|
8
|
+
* CPU-intensive 작업은 handle() 내에서 this.worker.run()으로 위임.
|
|
9
|
+
*
|
|
4
10
|
* @see docs/framework/10-scheduler-queue.md
|
|
5
|
-
* @see
|
|
11
|
+
* @see lib/schedule/WorkerPool.js
|
|
6
12
|
*/
|
|
7
13
|
export default class Queue {
|
|
8
14
|
/**
|
|
9
|
-
* @param {import('
|
|
15
|
+
* @param {import('../core/Application.js').default} app
|
|
10
16
|
* @param {object} [opts]
|
|
11
17
|
* @param {string} [opts.driver='memory'] - 'memory' | 'redis'
|
|
12
18
|
*/
|
|
13
19
|
constructor(app, opts = {}) {
|
|
14
20
|
this.app = app;
|
|
15
21
|
this.driver = opts.driver || 'memory';
|
|
16
|
-
this._tasks = new Map();
|
|
17
|
-
this._queue = [];
|
|
22
|
+
this._tasks = new Map(); // name → TaskClass
|
|
23
|
+
this._queue = [];
|
|
18
24
|
this._processing = false;
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -55,24 +61,30 @@ export default class Queue {
|
|
|
55
61
|
const TaskClass = this._tasks.get(job.name);
|
|
56
62
|
|
|
57
63
|
if (!TaskClass) {
|
|
58
|
-
this.app?.logger?.error?.(`Task '${job.name}' not registered`);
|
|
64
|
+
this.app?.logger?.error?.(`[Queue] Task '${job.name}' not registered`);
|
|
59
65
|
continue;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
const timeout = TaskClass.timeout || 30000;
|
|
62
69
|
const task = new TaskClass(this.app);
|
|
70
|
+
|
|
63
71
|
try {
|
|
64
|
-
await
|
|
72
|
+
await Promise.race([
|
|
73
|
+
task.handle(job.data),
|
|
74
|
+
new Promise((_, reject) =>
|
|
75
|
+
setTimeout(() => reject(new Error(`Task timeout (${timeout}ms)`)), timeout)
|
|
76
|
+
),
|
|
77
|
+
]);
|
|
65
78
|
} catch (err) {
|
|
66
79
|
job.retries++;
|
|
67
80
|
if (job.retries < (TaskClass.retries || 3)) {
|
|
68
|
-
// 재시도
|
|
69
81
|
const delay = TaskClass.retryDelay || 1000;
|
|
70
82
|
setTimeout(() => {
|
|
71
83
|
this._queue.push(job);
|
|
72
84
|
if (!this._processing) this._process();
|
|
73
85
|
}, delay);
|
|
74
86
|
} else {
|
|
75
|
-
await task.failed(job.data, err);
|
|
87
|
+
try { await task.failed(job.data, err); } catch {}
|
|
76
88
|
}
|
|
77
89
|
}
|
|
78
90
|
}
|
|
@@ -1,38 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scheduler —
|
|
2
|
+
* Scheduler — 정기 실행 작업 관리 (cron 스케줄)
|
|
3
|
+
*
|
|
4
|
+
* AutoLoader가 shared/jobs/ 에서 Job 클래스를 자동 스캔·등록.
|
|
5
|
+
* handle()은 메인 스레드에서 실행되므로 this.db, this.service() 등
|
|
6
|
+
* 앱 컨텍스트에 접근 가능합니다.
|
|
7
|
+
*
|
|
8
|
+
* CPU-intensive 작업은 handle() 내에서 this.worker.run()으로 위임.
|
|
3
9
|
*
|
|
4
10
|
* @see docs/framework/10-scheduler-queue.md
|
|
5
|
-
* @see
|
|
11
|
+
* @see lib/schedule/WorkerPool.js
|
|
6
12
|
*/
|
|
7
13
|
export default class Scheduler {
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('../core/Application.js').default} app
|
|
16
|
+
*/
|
|
8
17
|
constructor(app) {
|
|
9
18
|
this.app = app;
|
|
19
|
+
/** @type {Array<{JobClass: typeof import('./Job.js').default}>} */
|
|
10
20
|
this._jobs = [];
|
|
21
|
+
/** @type {Array<NodeJS.Timeout>} */
|
|
11
22
|
this._timers = [];
|
|
12
|
-
this._running = false;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Job 등록
|
|
26
|
+
* Job 클래스 등록 (AutoLoader에서 호출)
|
|
17
27
|
* @param {typeof import('./Job.js').default} JobClass
|
|
18
28
|
*/
|
|
19
29
|
register(JobClass) {
|
|
20
|
-
|
|
30
|
+
if (!JobClass.schedule) return;
|
|
31
|
+
if (JobClass.enabled === false) return;
|
|
32
|
+
this._jobs.push({ JobClass });
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
* 모든
|
|
36
|
+
* 등록된 모든 Job 시작
|
|
25
37
|
*/
|
|
26
38
|
start() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const interval = this._parseCron(JobClass.schedule);
|
|
35
|
-
if (!interval) continue;
|
|
39
|
+
for (const { JobClass } of this._jobs) {
|
|
40
|
+
const interval = this._parseSchedule(JobClass.schedule);
|
|
41
|
+
if (!interval) {
|
|
42
|
+
this.app?.logger?.warn?.(`[Scheduler] Invalid schedule: ${JobClass.name} → '${JobClass.schedule}'`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
36
45
|
|
|
37
46
|
const timeout = JobClass.timeout || 30000;
|
|
38
47
|
|
|
@@ -46,7 +55,11 @@ export default class Scheduler {
|
|
|
46
55
|
),
|
|
47
56
|
]);
|
|
48
57
|
} catch (err) {
|
|
49
|
-
|
|
58
|
+
if (typeof job.onError === 'function') {
|
|
59
|
+
try { await job.onError(err); } catch {}
|
|
60
|
+
} else {
|
|
61
|
+
this.app?.logger?.error?.(`[Scheduler] ${JobClass.name} failed: ${err.message}`);
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
};
|
|
52
65
|
|
|
@@ -62,12 +75,18 @@ export default class Scheduler {
|
|
|
62
75
|
} else {
|
|
63
76
|
const timer = setInterval(runJob, interval);
|
|
64
77
|
this._timers.push(timer);
|
|
78
|
+
// 즉시 첫 실행
|
|
79
|
+
runJob();
|
|
65
80
|
}
|
|
81
|
+
|
|
82
|
+
this.app?.logger?.info?.(
|
|
83
|
+
`[Scheduler] ${JobClass.name} registered (${JobClass.schedule}, interval=${interval}ms)`
|
|
84
|
+
);
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
/**
|
|
70
|
-
* 모든
|
|
89
|
+
* 모든 타이머 정지
|
|
71
90
|
*/
|
|
72
91
|
stop() {
|
|
73
92
|
for (const timer of this._timers) {
|
|
@@ -75,87 +94,77 @@ export default class Scheduler {
|
|
|
75
94
|
clearTimeout(timer);
|
|
76
95
|
}
|
|
77
96
|
this._timers = [];
|
|
78
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Schedule 파싱 ──
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 간편 스케줄 → ms 변환
|
|
103
|
+
*
|
|
104
|
+
* 지원 포맷:
|
|
105
|
+
* 'every:5s' → 5000ms
|
|
106
|
+
* 'every:5m' → 300000ms
|
|
107
|
+
* 'every:1h' → 3600000ms
|
|
108
|
+
* 'daily:HH:MM' → 24h (첫 실행은 _calcInitialDelay)
|
|
109
|
+
* 'weekly:DAY:HH:MM' → 7d (첫 실행은 _calcInitialDelay)
|
|
110
|
+
*/
|
|
111
|
+
_parseSchedule(schedule) {
|
|
112
|
+
if (!schedule) return null;
|
|
113
|
+
|
|
114
|
+
// every:Ns/Nm/Nh
|
|
115
|
+
const everyMatch = schedule.match(/^every:(\d+)(s|m|h)$/i);
|
|
116
|
+
if (everyMatch) {
|
|
117
|
+
const val = parseInt(everyMatch[1], 10);
|
|
118
|
+
const unit = everyMatch[2].toLowerCase();
|
|
119
|
+
if (unit === 's') return val * 1000;
|
|
120
|
+
if (unit === 'm') return val * 60000;
|
|
121
|
+
if (unit === 'h') return val * 3600000;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// daily:HH:MM → 24시간 간격
|
|
125
|
+
if (/^daily:\d{2}:\d{2}$/.test(schedule)) {
|
|
126
|
+
return 24 * 60 * 60 * 1000;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// weekly:DAY:HH:MM → 7일 간격
|
|
130
|
+
if (/^weekly:\w+:\d{2}:\d{2}$/.test(schedule)) {
|
|
131
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
/**
|
|
82
|
-
* daily/weekly 스케줄의 첫
|
|
83
|
-
* @param {string} schedule
|
|
84
|
-
* @returns {number} 0이면 즉시 setInterval, >0이면 setTimeout 필요
|
|
85
|
-
* @private
|
|
138
|
+
* daily/weekly 스케줄의 첫 실행 지연시간 계산
|
|
86
139
|
*/
|
|
87
140
|
_calcInitialDelay(schedule) {
|
|
88
141
|
const now = new Date();
|
|
89
142
|
|
|
90
|
-
//
|
|
143
|
+
// daily:HH:MM
|
|
91
144
|
const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
|
|
92
145
|
if (dailyMatch) {
|
|
93
|
-
const [, h, m] = dailyMatch;
|
|
94
146
|
const target = new Date(now);
|
|
95
|
-
target.setHours(
|
|
147
|
+
target.setHours(parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), 0, 0);
|
|
96
148
|
if (target <= now) target.setDate(target.getDate() + 1);
|
|
97
149
|
return target - now;
|
|
98
150
|
}
|
|
99
151
|
|
|
100
|
-
//
|
|
152
|
+
// weekly:DAY:HH:MM
|
|
101
153
|
const weeklyMatch = schedule.match(/^weekly:(\w+):(\d{2}):(\d{2})$/);
|
|
102
154
|
if (weeklyMatch) {
|
|
103
155
|
const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
104
|
-
const
|
|
105
|
-
|
|
156
|
+
const targetDay = days[weeklyMatch[1].toLowerCase()];
|
|
157
|
+
if (targetDay === undefined) return 0;
|
|
158
|
+
|
|
106
159
|
const target = new Date(now);
|
|
107
|
-
target.setHours(
|
|
160
|
+
target.setHours(parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), 0, 0);
|
|
161
|
+
|
|
108
162
|
let diff = targetDay - now.getDay();
|
|
109
163
|
if (diff < 0 || (diff === 0 && target <= now)) diff += 7;
|
|
110
164
|
target.setDate(target.getDate() + diff);
|
|
111
165
|
return target - now;
|
|
112
166
|
}
|
|
113
167
|
|
|
114
|
-
return 0; // every
|
|
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;
|
|
168
|
+
return 0; // every:* → 즉시 시작
|
|
160
169
|
}
|
|
161
170
|
}
|