@fuzionx/framework 0.1.29 → 0.1.31

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 (63) hide show
  1. package/cli/index.js +230 -114
  2. package/cli/templates/{app/fuzionx → make/app}/controllers/HomeController.js +1 -0
  3. package/cli/templates/{app/tester → make/app}/views/default/errors/404.html +0 -4
  4. package/cli/templates/{app/fuzionx/views/default/errors/404.html → make/app/views/default/errors/500.html} +1 -2
  5. package/cli/templates/make/app/views/default/pages/home.html +11 -0
  6. package/index.js +3 -0
  7. package/lib/core/Application.js +31 -6
  8. package/lib/core/Context.js +30 -1
  9. package/lib/helpers/I18nHelper.js +10 -6
  10. package/lib/middleware/apiAuth.js +79 -0
  11. package/lib/middleware/auth.js +42 -0
  12. package/lib/middleware/bodyParser.js +19 -0
  13. package/lib/middleware/cors.js +47 -0
  14. package/lib/middleware/csrf.js +32 -0
  15. package/lib/middleware/index.js +8 -277
  16. package/lib/middleware/session.js +27 -0
  17. package/lib/middleware/theme.js +20 -0
  18. package/lib/schedule/Job.js +4 -0
  19. package/lib/schedule/Queue.js +20 -8
  20. package/lib/schedule/Scheduler.js +84 -75
  21. package/lib/utilities/ArrUtil.js +112 -0
  22. package/lib/utilities/DateUtil.js +98 -0
  23. package/lib/utilities/FunctionUtil.js +119 -0
  24. package/lib/utilities/NumUtil.js +75 -0
  25. package/lib/utilities/ObjectUtil.js +170 -0
  26. package/lib/utilities/PaginationUtil.js +81 -0
  27. package/lib/utilities/StrUtil.js +105 -0
  28. package/lib/utilities/index.js +18 -0
  29. package/package.json +2 -2
  30. package/cli/templates/app/.env.example.tpl +0 -14
  31. package/cli/templates/app/.gitignore.tpl +0 -4
  32. package/cli/templates/app/app.js.tpl +0 -6
  33. package/cli/templates/app/database/models/User.js +0 -9
  34. package/cli/templates/app/fuzionx/views/default/errors/500.html +0 -14
  35. package/cli/templates/app/fuzionx/views/default/pages/home.html +0 -188
  36. package/cli/templates/app/fuzionx.yaml.tpl +0 -202
  37. package/cli/templates/app/locales/en.json +0 -52
  38. package/cli/templates/app/locales/ko.json +0 -52
  39. package/cli/templates/app/package.json.tpl +0 -16
  40. package/cli/templates/app/shared/events/userEvents.js +0 -10
  41. package/cli/templates/app/shared/jobs/CleanupJob.js +0 -18
  42. package/cli/templates/app/shared/jobs/EmailTask.js +0 -17
  43. package/cli/templates/app/shared/jobs/VideoPreviewTask.js +0 -47
  44. package/cli/templates/app/shared/workers/heavy.js +0 -18
  45. package/cli/templates/app/tester/controllers/FileController.js +0 -288
  46. package/cli/templates/app/tester/controllers/HomeController.js +0 -36
  47. package/cli/templates/app/tester/controllers/UserController.js +0 -43
  48. package/cli/templates/app/tester/middleware/RequestLogger.js +0 -13
  49. package/cli/templates/app/tester/routes/api.js +0 -397
  50. package/cli/templates/app/tester/routes/web.js +0 -8
  51. package/cli/templates/app/tester/services/UserService.js +0 -52
  52. package/cli/templates/app/tester/views/default/errors/500.html +0 -14
  53. package/cli/templates/app/tester/views/default/layouts/main.html +0 -82
  54. package/cli/templates/app/tester/views/default/pages/home.html +0 -56
  55. package/cli/templates/app/tester/views/default/pages/i18n.html +0 -104
  56. package/cli/templates/app/tester/views/default/pages/upload.html +0 -149
  57. package/cli/templates/app/tester/views/default/pages/websocket.html +0 -239
  58. package/cli/templates/app/tester/views/default/partials/footer.html +0 -8
  59. package/cli/templates/app/tester/views/default/partials/header.html +0 -20
  60. package/cli/templates/app/tester/ws/ChatHandler.js +0 -98
  61. /package/cli/templates/{app/fuzionx/routes/api.js.tpl → make/app/routes/api.js} +0 -0
  62. /package/cli/templates/{app/fuzionx/routes/web.js.tpl → make/app/routes/web.js} +0 -0
  63. /package/cli/templates/{app/fuzionx → make/app}/views/default/layouts/main.html +0 -0
@@ -1,38 +1,47 @@
1
1
  /**
2
- * Scheduler — Job 스케줄 관리 (마스터 프로세스)
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 docs/framework/class-design.mm.md (Scheduler)
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
- this._jobs.push(JobClass);
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
- 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;
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
- await job.onError(err);
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
- this._running = false;
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 스케줄의 첫 실행까지 지연(ms) 계산
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
- // 'daily:HH:MM'
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(Number(h), Number(m), 0, 0);
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
- // 'weekly:day:HH:MM'
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 [, day, h, m] = weeklyMatch;
105
- const targetDay = days[day.toLowerCase()] ?? 0;
156
+ const targetDay = days[weeklyMatch[1].toLowerCase()];
157
+ if (targetDay === undefined) return 0;
158
+
106
159
  const target = new Date(now);
107
- target.setHours(Number(h), Number(m), 0, 0);
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:*, 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;
168
+ return 0; // every:* → 즉시 시작
160
169
  }
161
170
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ArrUtil — 배열 순수 유틸리티
3
+ */
4
+
5
+ /**
6
+ * 배열을 n개씩 분할
7
+ * @param {Array} arr
8
+ * @param {number} size
9
+ * @returns {Array<Array>}
10
+ *
11
+ * @example chunk([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
12
+ */
13
+ export function chunk(arr, size) {
14
+ const result = [];
15
+ for (let i = 0; i < arr.length; i += size) {
16
+ result.push(arr.slice(i, i + size));
17
+ }
18
+ return result;
19
+ }
20
+
21
+ /**
22
+ * 중복 제거
23
+ * @param {Array} arr
24
+ * @param {Function} [keyFn] - 비교 키 추출 함수
25
+ * @returns {Array}
26
+ */
27
+ export function unique(arr, keyFn) {
28
+ if (!keyFn) return [...new Set(arr)];
29
+ const seen = new Set();
30
+ return arr.filter(item => {
31
+ const key = keyFn(item);
32
+ if (seen.has(key)) return false;
33
+ seen.add(key);
34
+ return true;
35
+ });
36
+ }
37
+
38
+ /**
39
+ * 키 기준 그룹핑
40
+ * @param {Array<object>} arr
41
+ * @param {string|Function} key
42
+ * @returns {object}
43
+ *
44
+ * @example groupBy([{a:1,b:'x'},{a:2,b:'x'},{a:3,b:'y'}], 'b') → { x: [...], y: [...] }
45
+ */
46
+ export function groupBy(arr, key) {
47
+ const fn = typeof key === 'function' ? key : (item) => item[key];
48
+ return arr.reduce((acc, item) => {
49
+ const k = fn(item);
50
+ (acc[k] = acc[k] || []).push(item);
51
+ return acc;
52
+ }, {});
53
+ }
54
+
55
+ /**
56
+ * 특정 키 값만 추출
57
+ * @param {Array<object>} arr
58
+ * @param {string} key
59
+ * @returns {Array}
60
+ *
61
+ * @example pluck([{id:1,name:'a'},{id:2,name:'b'}], 'name') → ['a', 'b']
62
+ */
63
+ export function pluck(arr, key) {
64
+ return arr.map(item => item[key]);
65
+ }
66
+
67
+ /**
68
+ * 키맵 생성 (배열 → 객체)
69
+ * @param {Array<object>} arr
70
+ * @param {string} key
71
+ * @returns {object}
72
+ *
73
+ * @example keyBy([{id:1,name:'a'},{id:2,name:'b'}], 'id') → { 1: {id:1,...}, 2: {id:2,...} }
74
+ */
75
+ export function keyBy(arr, key) {
76
+ return arr.reduce((acc, item) => {
77
+ acc[item[key]] = item;
78
+ return acc;
79
+ }, {});
80
+ }
81
+
82
+ /**
83
+ * 배열 셔플 (Fisher-Yates — JS 폴백용, 프로덕션은 WASM FuzionXUtil.shuffle 사용)
84
+ * @param {Array} arr
85
+ * @returns {Array} 새 배열 (원본 미변경)
86
+ */
87
+ export function shuffle(arr) {
88
+ const result = [...arr];
89
+ for (let i = result.length - 1; i > 0; i--) {
90
+ const j = Math.floor(Math.random() * (i + 1));
91
+ [result[i], result[j]] = [result[j], result[i]];
92
+ }
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * 배열 flatten (1 depth)
98
+ * @param {Array} arr
99
+ * @returns {Array}
100
+ */
101
+ export function flatten(arr) {
102
+ return arr.flat();
103
+ }
104
+
105
+ /**
106
+ * 배열이 비어있는지 확인
107
+ * @param {*} arr
108
+ * @returns {boolean}
109
+ */
110
+ export function isEmpty(arr) {
111
+ return !Array.isArray(arr) || arr.length === 0;
112
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * DateUtil — 날짜 순수 유틸리티
3
+ */
4
+
5
+ /**
6
+ * ISO → 'YYYY-MM-DD HH:mm:ss' 포맷
7
+ * @param {Date|string|number} date
8
+ * @param {string} [fmt='YYYY-MM-DD HH:mm:ss']
9
+ * @returns {string}
10
+ */
11
+ export function format(date, fmt = 'YYYY-MM-DD HH:mm:ss') {
12
+ const d = date instanceof Date ? date : new Date(date);
13
+ if (isNaN(d.getTime())) return '';
14
+
15
+ const pad = (n) => String(n).padStart(2, '0');
16
+ const tokens = {
17
+ YYYY: d.getFullYear(),
18
+ MM: pad(d.getMonth() + 1),
19
+ DD: pad(d.getDate()),
20
+ HH: pad(d.getHours()),
21
+ mm: pad(d.getMinutes()),
22
+ ss: pad(d.getSeconds()),
23
+ };
24
+
25
+ let result = fmt;
26
+ for (const [token, value] of Object.entries(tokens)) {
27
+ result = result.replace(token, value);
28
+ }
29
+ return result;
30
+ }
31
+
32
+ /**
33
+ * 상대 시간 (n분 전, n시간 전, n일 전)
34
+ * @param {Date|string|number} date
35
+ * @param {string} [locale='ko']
36
+ * @returns {string}
37
+ */
38
+ export function ago(date, locale = 'ko') {
39
+ const d = date instanceof Date ? date : new Date(date);
40
+ const now = Date.now();
41
+ const diffMs = now - d.getTime();
42
+ const diffSec = Math.floor(diffMs / 1000);
43
+
44
+ const units = locale === 'ko'
45
+ ? { just: '방금 전', sec: '초 전', min: '분 전', hour: '시간 전', day: '일 전', month: '개월 전', year: '년 전' }
46
+ : { just: 'just now', sec: 's ago', min: 'm ago', hour: 'h ago', day: 'd ago', month: 'mo ago', year: 'y ago' };
47
+
48
+ if (diffSec < 10) return units.just;
49
+ if (diffSec < 60) return `${diffSec}${units.sec}`;
50
+ if (diffSec < 3600) return `${Math.floor(diffSec / 60)}${units.min}`;
51
+ if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}${units.hour}`;
52
+ if (diffSec < 2592000) return `${Math.floor(diffSec / 86400)}${units.day}`;
53
+ if (diffSec < 31536000) return `${Math.floor(diffSec / 2592000)}${units.month}`;
54
+ return `${Math.floor(diffSec / 31536000)}${units.year}`;
55
+ }
56
+
57
+ /**
58
+ * 오늘인지 확인
59
+ * @param {Date|string|number} date
60
+ * @returns {boolean}
61
+ */
62
+ export function isToday(date) {
63
+ const d = date instanceof Date ? date : new Date(date);
64
+ const now = new Date();
65
+ return d.getFullYear() === now.getFullYear()
66
+ && d.getMonth() === now.getMonth()
67
+ && d.getDate() === now.getDate();
68
+ }
69
+
70
+ /**
71
+ * 두 날짜 간 일수 차이
72
+ * @param {Date|string|number} a
73
+ * @param {Date|string|number} b
74
+ * @returns {number}
75
+ */
76
+ export function diffDays(a, b) {
77
+ const da = a instanceof Date ? a : new Date(a);
78
+ const db = b instanceof Date ? b : new Date(b);
79
+ return Math.round((da.getTime() - db.getTime()) / 86400000);
80
+ }
81
+
82
+ /**
83
+ * 날짜 범위 배열 생성
84
+ * @param {Date|string} start
85
+ * @param {Date|string} end
86
+ * @returns {Date[]}
87
+ */
88
+ export function range(start, end) {
89
+ const s = new Date(start);
90
+ const e = new Date(end);
91
+ const dates = [];
92
+ const d = new Date(s);
93
+ while (d <= e) {
94
+ dates.push(new Date(d));
95
+ d.setDate(d.getDate() + 1);
96
+ }
97
+ return dates;
98
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * FunctionUtil — 함수 순수 유틸리티
3
+ */
4
+
5
+ /**
6
+ * Debounce — 마지막 호출 후 delay ms 뒤 실행
7
+ * @param {Function} fn
8
+ * @param {number} delay - ms
9
+ * @returns {Function}
10
+ */
11
+ export function debounce(fn, delay = 300) {
12
+ let timer;
13
+ const debounced = (...args) => {
14
+ clearTimeout(timer);
15
+ timer = setTimeout(() => fn(...args), delay);
16
+ };
17
+ debounced.cancel = () => clearTimeout(timer);
18
+ return debounced;
19
+ }
20
+
21
+ /**
22
+ * Throttle — delay ms 간격으로 최대 1회 실행
23
+ * @param {Function} fn
24
+ * @param {number} delay - ms
25
+ * @returns {Function}
26
+ */
27
+ export function throttle(fn, delay = 300) {
28
+ let last = 0;
29
+ let timer;
30
+ return (...args) => {
31
+ const now = Date.now();
32
+ const remaining = delay - (now - last);
33
+ clearTimeout(timer);
34
+ if (remaining <= 0) {
35
+ last = now;
36
+ fn(...args);
37
+ } else {
38
+ timer = setTimeout(() => {
39
+ last = Date.now();
40
+ fn(...args);
41
+ }, remaining);
42
+ }
43
+ };
44
+ }
45
+
46
+ /**
47
+ * 한 번만 실행
48
+ * @param {Function} fn
49
+ * @returns {Function}
50
+ */
51
+ export function once(fn) {
52
+ let called = false;
53
+ let result;
54
+ return (...args) => {
55
+ if (called) return result;
56
+ called = true;
57
+ result = fn(...args);
58
+ return result;
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Promise 타임아웃 래퍼
64
+ * @param {Promise} promise
65
+ * @param {number} ms
66
+ * @param {string} [message='Timeout']
67
+ * @returns {Promise}
68
+ */
69
+ export function timeout(promise, ms, message = 'Timeout') {
70
+ return Promise.race([
71
+ promise,
72
+ new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms)),
73
+ ]);
74
+ }
75
+
76
+ /**
77
+ * 딜레이 (async sleep)
78
+ * @param {number} ms
79
+ * @returns {Promise<void>}
80
+ */
81
+ export function sleep(ms) {
82
+ return new Promise(resolve => setTimeout(resolve, ms));
83
+ }
84
+
85
+ /**
86
+ * 함수 파이프라인 (왼→오) — 순차 실행
87
+ * @param {...Function} fns
88
+ * @returns {Function}
89
+ *
90
+ * @example pipe(addOne, double, square)(2) → square(double(addOne(2)))
91
+ */
92
+ export function pipe(...fns) {
93
+ return (x) => fns.reduce((v, fn) => fn(v), x);
94
+ }
95
+
96
+ /**
97
+ * 메모이제이션 — 결과 캐싱
98
+ * @param {Function} fn
99
+ * @param {Function} [keyFn] - 캐시 키 생성 함수 (기본: 첫 인자)
100
+ * @returns {Function}
101
+ */
102
+ export function memoize(fn, keyFn) {
103
+ const cache = new Map();
104
+ const memoized = (...args) => {
105
+ const key = keyFn ? keyFn(...args) : args[0];
106
+ if (cache.has(key)) return cache.get(key);
107
+ const result = fn(...args);
108
+ cache.set(key, result);
109
+ return result;
110
+ };
111
+ memoized.cache = cache;
112
+ memoized.clear = () => cache.clear();
113
+ return memoized;
114
+ }
115
+
116
+ /**
117
+ * noop — 아무것도 하지 않는 함수
118
+ */
119
+ export function noop() {}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * NumUtil — 숫자 순수 유틸리티
3
+ */
4
+
5
+ /**
6
+ * 범위 제한 (clamp)
7
+ * @param {number} n
8
+ * @param {number} min
9
+ * @param {number} max
10
+ * @returns {number}
11
+ */
12
+ export function clamp(n, min, max) {
13
+ return Math.min(Math.max(n, min), max);
14
+ }
15
+
16
+ /**
17
+ * 범위 내 랜덤 정수
18
+ * @param {number} min
19
+ * @param {number} max
20
+ * @returns {number}
21
+ */
22
+ export function random(min = 0, max = 100) {
23
+ return Math.floor(Math.random() * (max - min + 1)) + min;
24
+ }
25
+
26
+ /**
27
+ * 숫자 포맷 (로케일)
28
+ * @param {number} n
29
+ * @param {string} [locale='ko-KR']
30
+ * @returns {string}
31
+ *
32
+ * @example format(1234567) → '1,234,567'
33
+ */
34
+ export function format(n, locale = 'ko-KR') {
35
+ return new Intl.NumberFormat(locale).format(n);
36
+ }
37
+
38
+ /**
39
+ * 바이트 → 읽기 쉬운 단위 변환
40
+ * @param {number} bytes
41
+ * @param {number} [decimals=2]
42
+ * @returns {string}
43
+ *
44
+ * @example fileSize(1536) → '1.50 KB'
45
+ */
46
+ export function fileSize(bytes, decimals = 2) {
47
+ if (bytes === 0) return '0 B';
48
+ const k = 1024;
49
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
50
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
51
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${units[i]}`;
52
+ }
53
+
54
+ /**
55
+ * 퍼센트 계산
56
+ * @param {number} part
57
+ * @param {number} total
58
+ * @param {number} [decimals=1]
59
+ * @returns {number}
60
+ */
61
+ export function percent(part, total, decimals = 1) {
62
+ if (total === 0) return 0;
63
+ return parseFloat(((part / total) * 100).toFixed(decimals));
64
+ }
65
+
66
+ /**
67
+ * 안전한 정수 파싱
68
+ * @param {*} value
69
+ * @param {number} [fallback=0]
70
+ * @returns {number}
71
+ */
72
+ export function toInt(value, fallback = 0) {
73
+ const n = parseInt(value, 10);
74
+ return isNaN(n) ? fallback : n;
75
+ }