@fuzionx/framework 0.1.66 → 0.1.69

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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.66",
12
+ "@fuzionx/player": "^0.1.69",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -11,6 +11,8 @@ export default defineConfig({
11
11
  },
12
12
  server: {
13
13
  port: 5173,
14
+ /** 리버스 프록시 경유 시 cross-origin 요청 허용 */
15
+ cors: true,
14
16
  proxy: {
15
17
  "/api": "http://127.0.0.1:49080",
16
18
  "/wasm": "http://127.0.0.1:49080",
package/index.js CHANGED
@@ -65,4 +65,4 @@ export { default as OpenAPI } from './lib/view/OpenAPI.js';
65
65
  export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
66
66
 
67
67
  // ── Built-in Middleware ──
68
- export { bodyParser, cors, auth, apiAuth, csrf, session, theme, loadUser, roleGuard } from './lib/middleware/index.js';
68
+ export { bodyParser, cors, auth, apiAuth, csrf, session, loadUser } from './lib/middleware/index.js';
@@ -103,6 +103,7 @@ export default class Application {
103
103
  fallback: this.config.get('app.i18n.fallback', 'en'),
104
104
  dir: this.config.get('app.i18n.dir', './locales'),
105
105
  bridge: this._bridge,
106
+ autoComplete: this.config.get('app.i18n.auto_complete', false),
106
107
  });
107
108
  this.storage = null;
108
109
  this._scheduler = null;
@@ -290,6 +291,7 @@ export default class Application {
290
291
  this.storage = new Storage({
291
292
  driver: this.config.get('storage.driver', 'local'),
292
293
  basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
294
+ s3: this.config.get('storage.s3') || null,
293
295
  fileHelper: this.file,
294
296
  });
295
297
 
@@ -366,10 +368,16 @@ export default class Application {
366
368
  this._booted = true;
367
369
 
368
370
  // hostname → appName 매핑 사전 계산 (매 요청 O(1) 조회)
371
+ // host:port 키와 hostname-only 키 모두 저장 (포트 제거 조회 호환)
369
372
  this._hostAppMap = new Map();
370
373
  const appsConfig = this.config.get('apps') || {};
371
- for (const [hostname, appName] of Object.entries(appsConfig)) {
372
- this._hostAppMap.set(hostname, appName);
374
+ for (const [hostKey, appName] of Object.entries(appsConfig)) {
375
+ this._hostAppMap.set(hostKey, appName);
376
+ /** 포트 제거한 hostname도 등록 (중복 시 마지막 우선) */
377
+ const hostnameOnly = hostKey.split(':')[0];
378
+ if (hostnameOnly !== hostKey) {
379
+ this._hostAppMap.set(hostnameOnly, appName);
380
+ }
373
381
  }
374
382
 
375
383
  await this.emit('booted');
@@ -401,23 +409,28 @@ export default class Application {
401
409
  /**
402
410
  * 호스트 → 앱 이름 결정
403
411
  * reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
412
+ * 매핑되지 않은 도메인은 null 반환 (접근 차단).
404
413
  * @param {string} host - 요청 Host 헤더 (port 포함 가능)
405
414
  * @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
406
- * @returns {string} 앱 이름
415
+ * @returns {string|null} 앱 이름 또는 null (미등록 도메인)
407
416
  */
408
417
  _resolveApp(host, headers) {
409
418
  // reverse proxy: X-Forwarded-Host > X-Original-Host > Host
410
419
  const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
411
420
  || headers?.['x-original-host'] || headers?.['X-Original-Host'];
412
421
  const rawHost = forwardedHost || host || '';
413
- const hostname = rawHost.split(':')[0]; // 포트 제거
414
422
 
415
- // 사전 계산된 Map O(1) 조회
416
- const mapped = this._hostAppMap?.get(hostname);
417
- if (mapped) return mapped;
423
+ // 1차: host:port 포함 전체 키로 조회
424
+ const fullMatch = this._hostAppMap?.get(rawHost);
425
+ if (fullMatch) return fullMatch;
426
+
427
+ // 2차: 포트 제거 hostname-only 조회
428
+ const hostname = rawHost.split(':')[0];
429
+ const hostMatch = this._hostAppMap?.get(hostname);
430
+ if (hostMatch) return hostMatch;
418
431
 
419
- // 매칭 없으면 기본
420
- return this._getDefaultAppName();
432
+ // 매칭 없음 null (미등록 도메인 접근 차단)
433
+ return null;
421
434
  }
422
435
 
423
436
  /**
@@ -752,17 +765,35 @@ export default class Application {
752
765
  return { status: 404, body: '{"error":"Not Found"}', headers: { 'Content-Type': 'application/json' } };
753
766
  }
754
767
 
768
+ // ── 도메인 검증 (모든 요청에 대해 수행) ──
769
+ const host = rawReq.headers?.host || '';
770
+ const resolvedAppName = this._resolveApp(host, rawReq.headers);
771
+
772
+ // 미등록 도메인 → 403 차단
773
+ if (!resolvedAppName) {
774
+ const reqHost = rawReq.headers?.host || 'unknown';
775
+ return {
776
+ status: 403,
777
+ body: JSON.stringify({ error: 'Forbidden', message: `도메인 '${reqHost}'은(는) 등록되지 않았습니다.` }),
778
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
779
+ };
780
+ }
781
+
755
782
  // ── 앱 결정 (단일 앱: O(0), 멀티앱: Host 기반) ──
756
783
  let appName, entry;
757
784
  if (dispatch.isSingleApp) {
758
785
  appName = dispatch.singleAppName;
759
786
  entry = dispatch.singleEntry;
760
787
  } else {
761
- const host = rawReq.headers?.host || '';
762
- appName = this._resolveApp(host, rawReq.headers);
788
+ appName = resolvedAppName;
763
789
  entry = dispatch.appMap.get(appName);
764
- if (!entry) entry = dispatch.appMap.get(this._getDefaultAppName());
765
- if (!entry) entry = dispatch.appMap.values().next().value;
790
+ if (!entry) {
791
+ return {
792
+ status: 403,
793
+ body: JSON.stringify({ error: 'Forbidden', message: `앱 '${appName}'을(를) 찾을 수 없습니다.` }),
794
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
795
+ };
796
+ }
766
797
  }
767
798
 
768
799
  const { middlewareFns, resolvedHandler } = entry;
@@ -87,7 +87,7 @@ export default class AutoLoader {
87
87
  const mod = await import(pathToFileURL(file).href);
88
88
  const ModelClass = mod.default;
89
89
  if (!ModelClass) continue;
90
- const name = extractName(file);
90
+ const name = extractName(file, 'Model');
91
91
  if (this.app.db) {
92
92
  this.app.db.register(name, ModelClass);
93
93
  }
@@ -104,7 +104,7 @@ export default class Model {
104
104
  }
105
105
 
106
106
  /**
107
- * JSON 직렬화 — hidden 필드 제외
107
+ * JSON 직렬화 — hidden 필드 제외 + 로드된 관계 + 동적 필드 포함
108
108
  * @returns {object}
109
109
  */
110
110
  toJSON() {
@@ -113,9 +113,43 @@ export default class Model {
113
113
  for (const key of hidden) {
114
114
  delete obj[key];
115
115
  }
116
+
117
+ // with()로 로드된 관계 데이터 포함
118
+ const relations = this.constructor.relations || {};
119
+ for (const relName of Object.keys(relations)) {
120
+ if (this[relName] !== undefined) {
121
+ const val = this[relName];
122
+ if (Array.isArray(val)) {
123
+ // hasMany: 배열 내 각 모델 인스턴스를 직렬화
124
+ obj[relName] = val.map(v => v?.toJSON ? v.toJSON() : v);
125
+ } else if (val?.toJSON) {
126
+ // hasOne/belongsTo: 단일 모델 인스턴스 직렬화
127
+ obj[relName] = val.toJSON();
128
+ } else {
129
+ obj[relName] = val;
130
+ }
131
+ }
132
+ }
133
+
134
+ // 동적으로 추가된 필드 포함 (e.g. sub_reseller_count, sub_member_count)
135
+ if (this._extras) {
136
+ Object.assign(obj, this._extras);
137
+ }
138
+
116
139
  return obj;
117
140
  }
118
141
 
142
+ /**
143
+ * 동적 필드 추가 — toJSON() 직렬화에 포함됨
144
+ *
145
+ * @param {string} key - 필드명
146
+ * @param {*} value - 값
147
+ */
148
+ setExtra(key, value) {
149
+ if (!this._extras) this._extras = {};
150
+ this._extras[key] = value;
151
+ }
152
+
119
153
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
154
  // 관계 (스텁 — 서브클래스에서 오버라이드)
121
155
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -350,21 +350,23 @@ export default class SqlQueryBuilder extends QueryBuilder {
350
350
  */
351
351
  async _loadRelations(results) {
352
352
  const modelRelations = this._model.relations || {};
353
-
353
+
354
354
  for (const relName of this._withs) {
355
355
  const rel = modelRelations[relName];
356
- if (!rel) continue; // 정의되지 않은 관계는 무시
356
+ if (!rel) {
357
+ continue;
358
+ }
357
359
 
358
360
  // 관계 대상 모델 클래스 resolve (문자열 → ModelRegistry에서 찾기)
359
361
  let RelatedModel = rel.model;
360
362
  if (typeof RelatedModel === 'string') {
361
363
  // SqlModel._modelRegistry를 통해 모델 클래스 resolve
362
364
  const registry = this._model._modelRegistry || this._model.constructor._modelRegistry;
363
- if (registry) {
364
- RelatedModel = registry.get(rel.model);
365
+ if (registry) {
366
+ RelatedModel = registry.get(rel.model);
365
367
  }
366
- if (!RelatedModel || typeof RelatedModel === 'string') {
367
- continue; // resolve 실패 시 스킵
368
+ if (!RelatedModel || typeof RelatedModel === 'string') {
369
+ continue;
368
370
  }
369
371
  }
370
372
 
@@ -4,12 +4,18 @@
4
4
  * Bridge의 i18NTranslate(locale, key) 직접 호출.
5
5
  * Bridge 없으면 JS 파일 기반 폴백.
6
6
  *
7
+ * auto_complete 활성 시 fs.watch로 locale 파일 변경을 감지하여
8
+ * 디바운스 후 자동 동기화한다. (매 요청마다 파일 I/O 없음)
9
+ *
7
10
  * @see docs/framework/18-i18n.md
8
11
  * @see packages/fuzionx/lib/i18n.js (Core 래퍼 참조)
9
12
  */
10
- import { promises as fs } from 'node:fs';
13
+ import { promises as fs, readFileSync, watch as fsWatch, existsSync } from 'node:fs';
11
14
  import path from 'node:path';
12
15
 
16
+ /** 디바운스 기본 대기 시간 (ms) */
17
+ const RELOAD_DEBOUNCE_MS = 500;
18
+
13
19
  export default class I18nHelper {
14
20
  /**
15
21
  * @param {object} [opts]
@@ -17,18 +23,24 @@ export default class I18nHelper {
17
23
  * @param {string} [opts.fallback='en']
18
24
  * @param {string} [opts.dir='./locales']
19
25
  * @param {object} [opts.bridge] - Bridge N-API 인스턴스
26
+ * @param {boolean} [opts.autoComplete=false] - auto_complete 활성 시 파일 감시
20
27
  */
21
28
  constructor(opts = {}) {
22
29
  this.defaultLocale = opts.defaultLocale || 'ko';
23
30
  this.fallback = opts.fallback || 'en';
24
31
  this.dir = opts.dir || './locales';
25
32
  this._bridge = opts.bridge || null;
26
- this._messages = new Map(); // locale → { flat key: value }
33
+ this._autoComplete = !!opts.autoComplete;
34
+ this._messages = new Map(); // locale → { flat key: value }
27
35
  this._loaded = false;
36
+ this._watcher = null; // fs.watch 인스턴스
37
+ this._reloadTimer = null; // 디바운스 타이머
38
+ this._dirtyLocales = new Set(); // 변경된 locale 큐
28
39
  }
29
40
 
30
41
  /**
31
- * 번역 파일 로드 (JS 폴백용)
42
+ * 번역 파일 로드 (JS 폴백용).
43
+ * auto_complete가 활성이면 locale 디렉토리 감시 시작.
32
44
  */
33
45
  async load() {
34
46
  if (this._loaded) return;
@@ -45,6 +57,11 @@ export default class I18nHelper {
45
57
  }
46
58
  } catch {} // locales/ 없으면 무시
47
59
  this._loaded = true;
60
+
61
+ // auto_complete 활성 시 파일 감시 시작
62
+ if (this._autoComplete) {
63
+ this._startWatcher();
64
+ }
48
65
  }
49
66
 
50
67
  /**
@@ -86,12 +103,30 @@ export default class I18nHelper {
86
103
  }
87
104
 
88
105
  /**
89
- * 전체 locale 데이터 반환 (뷰 주입용)
106
+ * 전체 locale 데이터 반환 (뷰 주입용).
107
+ *
108
+ * Bridge가 있으면 i18NGetTranslations N-API로 최신 데이터를 직접 반환.
109
+ * Bridge 없으면 JS _messages 폴백.
110
+ *
90
111
  * @param {string} locale
91
- * @returns {object}
112
+ * @returns {object} flat dot-notation 번역 데이터
92
113
  */
93
114
  all(locale) {
94
- const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
115
+ const target = locale || this.defaultLocale;
116
+
117
+ /** Bridge 우선 — 메모리에서 직접 최신 데이터 반환 */
118
+ if (this._bridge && typeof this._bridge.i18NGetTranslations === 'function') {
119
+ try {
120
+ const json = this._bridge.i18NGetTranslations(target);
121
+ if (json) {
122
+ const data = JSON.parse(json);
123
+ return this._flatten(data); // nested → flat dot-notation
124
+ }
125
+ } catch {} // Bridge 실패 시 JS 폴백
126
+ }
127
+
128
+ /** JS 폴백 — _messages 캐시 사용 */
129
+ const messages = this._messages.get(target) || this._messages.get(this.defaultLocale);
95
130
  if (!messages) return {};
96
131
  return { ...messages };
97
132
  }
@@ -121,6 +156,75 @@ export default class I18nHelper {
121
156
  }
122
157
  }
123
158
 
159
+ // ──────────────────────────────────────────
160
+ // 파일 감시 & 디바운스 동기화
161
+ // ──────────────────────────────────────────
162
+
163
+ /**
164
+ * locale 디렉토리를 fs.watch로 감시 시작.
165
+ * JSON 파일 변경 감지 시 해당 locale을 dirty 큐에 넣고
166
+ * 디바운스 타이머 후 일괄 동기화.
167
+ * @private
168
+ */
169
+ _startWatcher() {
170
+ if (!existsSync(this.dir)) return;
171
+ try {
172
+ this._watcher = fsWatch(this.dir, (eventType, filename) => {
173
+ // .json 파일 변경만 감시
174
+ if (!filename || !filename.endsWith('.json')) return;
175
+ const locale = path.basename(filename, '.json');
176
+ this._dirtyLocales.add(locale); // 큐에 추가
177
+
178
+ // 디바운스: 기존 타이머 취소 → 새로 시작
179
+ clearTimeout(this._reloadTimer);
180
+ this._reloadTimer = setTimeout(() => {
181
+ this._flushDirty();
182
+ }, RELOAD_DEBOUNCE_MS);
183
+ });
184
+ } catch {} // 감시 실패 시 무시 (기존 동작 유지)
185
+ }
186
+
187
+ /**
188
+ * dirty 큐에 쌓인 locale 파일들을 일괄 동기 재로드.
189
+ * @private
190
+ */
191
+ _flushDirty() {
192
+ if (this._dirtyLocales.size === 0) return;
193
+ for (const locale of this._dirtyLocales) {
194
+ this._syncReload(locale);
195
+ }
196
+ this._dirtyLocales.clear();
197
+ }
198
+
199
+ /**
200
+ * 단일 locale 파일을 동기적으로 재로드.
201
+ * @param {string} locale - 로드할 locale (예: 'ko', 'en')
202
+ * @private
203
+ */
204
+ _syncReload(locale) {
205
+ try {
206
+ const jsonPath = path.join(this.dir, `${locale}.json`);
207
+ const content = readFileSync(jsonPath, 'utf-8');
208
+ const data = JSON.parse(content);
209
+ this._messages.set(locale, this._flatten(data));
210
+ } catch {} // 파일 없으면 무시
211
+ }
212
+
213
+ /**
214
+ * 감시자 정리 (graceful shutdown 용)
215
+ */
216
+ destroy() {
217
+ if (this._watcher) {
218
+ this._watcher.close();
219
+ this._watcher = null;
220
+ }
221
+ clearTimeout(this._reloadTimer);
222
+ }
223
+
224
+ // ──────────────────────────────────────────
225
+ // 파서 유틸리티
226
+ // ──────────────────────────────────────────
227
+
124
228
  /** {field} → value 치환 */
125
229
  _substitute(template, vars) {
126
230
  if (!vars || typeof template !== 'string') return template;
@@ -1,5 +1,8 @@
1
1
  /**
2
- * 내장 미들웨어 — 배럴 re-export
2
+ * 내장 미들웨어 — 배럴 re-export (공용)
3
+ *
4
+ * 앱 전용 미들웨어(theme, roleGuard 등)는
5
+ * 각 앱의 middleware/ 폴더에서 직접 구현합니다.
3
6
  *
4
7
  * @see docs/framework/12-middleware.md
5
8
  * @see docs/framework/14-authentication.md
@@ -10,6 +13,4 @@ export { auth } from './auth.js';
10
13
  export { apiAuth } from './apiAuth.js';
11
14
  export { csrf } from './csrf.js';
12
15
  export { session } from './session.js';
13
- export { theme } from './theme.js';
14
16
  export { loadUser } from './loadUser.js';
15
- export { roleGuard } from './roleGuard.js';
@@ -1,29 +1,70 @@
1
1
  /**
2
- * Storage — 파일 저장 추상화 (Local / S3)
2
+ * Storage — 파일 저장 추상화 (Local / S3 / R2)
3
3
  *
4
4
  * put(path, tempPath) — 임시파일 → 최종 위치 이동.
5
5
  * Local: file.move(tempPath, fullPath)
6
- * S3: Node.js 스트리밍 업로드 (큐 Task 권장)
6
+ * S3/R2: Node.js 스트리밍 업로드
7
+ *
8
+ * S3 호환 스토리지 지원:
9
+ * - AWS S3
10
+ * - Cloudflare R2 (egress 무료, endpoint 지정 필요)
11
+ * - MinIO, DigitalOcean Spaces 등
7
12
  *
8
13
  * @see docs/framework/15-file-upload.md
9
14
  * @see docs/framework/class-design.mm.md (Storage)
10
15
  */
11
- import { promises as fs } from 'node:fs';
16
+ import { promises as fs, createReadStream } from 'node:fs';
12
17
  import path from 'node:path';
13
18
 
19
+ /**
20
+ * @aws-sdk/client-s3 — Optional Dependency
21
+ * S3 호환 스토리지(AWS S3, Cloudflare R2, MinIO 등) 사용 시에만 필요.
22
+ * 로컬 드라이버만 사용하면 설치할 필요 없음.
23
+ */
24
+ import {
25
+ S3Client,
26
+ PutObjectCommand,
27
+ GetObjectCommand,
28
+ DeleteObjectCommand,
29
+ HeadObjectCommand,
30
+ } from '@aws-sdk/client-s3';
31
+
14
32
  export default class Storage {
15
33
  /**
16
34
  * @param {object} opts
17
35
  * @param {string} [opts.driver='local'] - 'local' | 's3'
18
36
  * @param {string} [opts.basePath='./storage'] - 로컬 저장 경로
19
- * @param {object} [opts.s3] - S3 설정 { bucket, region, credentials }
37
+ * @param {object} [opts.s3] - S3/R2 설정
38
+ * @param {string} opts.s3.bucket - 버킷명
39
+ * @param {string} [opts.s3.region='auto'] - 리전 (R2는 'auto')
40
+ * @param {string} [opts.s3.endpoint] - 커스텀 엔드포인트 (R2: https://<ACCOUNT_ID>.r2.cloudflarestorage.com)
41
+ * @param {string} [opts.s3.publicUrl] - 퍼블릭 액세스 URL (R2 커스텀 도메인 등)
42
+ * @param {object} [opts.s3.credentials] - { accessKeyId, secretAccessKey }
20
43
  * @param {import('./FileHelper.js').default} [opts.fileHelper] - FileHelper 인스턴스
21
44
  */
22
45
  constructor(opts = {}) {
23
46
  this.driver = opts.driver || 'local';
24
47
  this.basePath = opts.basePath || './storage';
25
- this._s3 = opts.s3 || null;
26
48
  this._file = opts.fileHelper || null;
49
+
50
+ // YAML snake_case → JS camelCase 정규화
51
+ const s3Raw = opts.s3 || null;
52
+ if (s3Raw) {
53
+ const creds = s3Raw.credentials || null;
54
+ this._s3 = {
55
+ bucket: s3Raw.bucket,
56
+ region: s3Raw.region || 'auto',
57
+ endpoint: s3Raw.endpoint || null,
58
+ publicUrl: s3Raw.public_url || s3Raw.publicUrl || null,
59
+ forcePathStyle: s3Raw.force_path_style ?? s3Raw.forcePathStyle ?? false,
60
+ credentials: creds ? {
61
+ accessKeyId: creds.access_key_id || creds.accessKeyId,
62
+ secretAccessKey: creds.secret_access_key || creds.secretAccessKey,
63
+ } : null,
64
+ };
65
+ } else {
66
+ this._s3 = null;
67
+ }
27
68
  }
28
69
 
29
70
  /**
@@ -99,14 +140,136 @@ export default class Storage {
99
140
  */
100
141
  url(filePath) {
101
142
  if (this.driver === 's3' && this._s3) {
102
- return `https://${this._s3.bucket}.s3.${this._s3.region}.amazonaws.com/${filePath}`;
143
+ // 커스텀 퍼블릭 URL (R2 커스텀 도메인, CDN 등)
144
+ if (this._s3.publicUrl) {
145
+ return `${this._s3.publicUrl.replace(/\/$/, '')}/${filePath}`;
146
+ }
147
+ // AWS S3 기본 URL
148
+ return `https://${this._s3.bucket}.s3.${this._s3.region || 'us-east-1'}.amazonaws.com/${filePath}`;
103
149
  }
104
150
  return `/storage/${filePath}`;
105
151
  }
106
152
 
107
- // ── S3 스텁 (Phase 5+ 구현) ──
108
- async _s3Put(filePath, tempPath) { throw new Error('S3 driver not implemented'); }
109
- async _s3Get(filePath) { throw new Error('S3 driver not implemented'); }
110
- async _s3Delete(filePath) { throw new Error('S3 driver not implemented'); }
111
- async _s3Exists(filePath) { throw new Error('S3 driver not implemented'); }
153
+ // ── S3 호환 스토리지 구현 (@aws-sdk/client-s3) ──
154
+ // AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces 지원
155
+
156
+ /**
157
+ * S3Client 지연 초기화.
158
+ * 최초 호출 시 Client 생성, 이후 캐싱.
159
+ *
160
+ * 지원 스토리지:
161
+ * - AWS S3: region만 지정
162
+ * - Cloudflare R2: endpoint + region='auto'
163
+ * - MinIO/Spaces: endpoint + forcePathStyle=true
164
+ *
165
+ * @returns {S3Client}
166
+ * @private
167
+ */
168
+ _getClient() {
169
+ if (this._s3Client) return this._s3Client;
170
+ if (!this._s3) throw new Error('S3 설정이 없습니다. opts.s3 = { bucket, region, credentials }');
171
+
172
+ /** @type {import('@aws-sdk/client-s3').S3ClientConfig} 클라이언트 설정 */
173
+ const config = {
174
+ region: this._s3.region || 'auto', // R2는 'auto', AWS는 'ap-northeast-2' 등
175
+ credentials: this._s3.credentials || undefined, // IAM Role이면 생략
176
+ };
177
+
178
+ // 커스텀 엔드포인트 (R2, MinIO, DigitalOcean Spaces 등)
179
+ if (this._s3.endpoint) {
180
+ config.endpoint = this._s3.endpoint;
181
+ config.forcePathStyle = this._s3.forcePathStyle ?? false; // R2=false, MinIO=true
182
+ }
183
+
184
+ this._s3Client = new S3Client(config);
185
+ return this._s3Client;
186
+ }
187
+
188
+ /**
189
+ * 파일 업로드 (임시파일 → Object Storage).
190
+ *
191
+ * @param {string} filePath - Object Key (e.g. 'uploads/avatar.jpg')
192
+ * @param {string} tempPath - 로컬 임시 파일 경로
193
+ * @returns {Promise<string>} - 저장된 URL
194
+ * @private
195
+ */
196
+ async _s3Put(filePath, tempPath) {
197
+ const client = this._getClient();
198
+
199
+ const stream = createReadStream(tempPath); // 파일 스트림으로 업로드 (메모리 절약)
200
+ await client.send(new PutObjectCommand({
201
+ Bucket: this._s3.bucket,
202
+ Key: filePath,
203
+ Body: stream,
204
+ }));
205
+
206
+ // 업로드 완료 후 임시파일 삭제
207
+ await fs.unlink(tempPath).catch(() => {});
208
+
209
+ return this.url(filePath);
210
+ }
211
+
212
+ /**
213
+ * 파일 읽기 (Object Storage → Buffer).
214
+ *
215
+ * @param {string} filePath - Object Key
216
+ * @returns {Promise<Buffer>}
217
+ * @private
218
+ */
219
+ async _s3Get(filePath) {
220
+ const client = this._getClient();
221
+
222
+ const res = await client.send(new GetObjectCommand({
223
+ Bucket: this._s3.bucket,
224
+ Key: filePath,
225
+ }));
226
+
227
+ // Body는 ReadableStream — Buffer로 변환
228
+ const chunks = [];
229
+ for await (const chunk of res.Body) {
230
+ chunks.push(chunk);
231
+ }
232
+ return Buffer.concat(chunks);
233
+ }
234
+
235
+ /**
236
+ * 파일 삭제.
237
+ *
238
+ * @param {string} filePath - Object Key
239
+ * @returns {Promise<void>}
240
+ * @private
241
+ */
242
+ async _s3Delete(filePath) {
243
+ const client = this._getClient();
244
+
245
+ await client.send(new DeleteObjectCommand({
246
+ Bucket: this._s3.bucket,
247
+ Key: filePath,
248
+ }));
249
+ }
250
+
251
+ /**
252
+ * 파일 존재 여부 확인 (HeadObject).
253
+ *
254
+ * @param {string} filePath - Object Key
255
+ * @returns {Promise<boolean>}
256
+ * @private
257
+ */
258
+ async _s3Exists(filePath) {
259
+ const client = this._getClient();
260
+
261
+ try {
262
+ await client.send(new HeadObjectCommand({
263
+ Bucket: this._s3.bucket,
264
+ Key: filePath,
265
+ }));
266
+ return true;
267
+ } catch (err) {
268
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
269
+ return false;
270
+ }
271
+ throw err; // 권한 오류 등은 그대로 throw
272
+ }
273
+ }
112
274
  }
275
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.66",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -34,7 +34,8 @@
34
34
  "url": "https://github.com/saytohenry/fuzionx"
35
35
  },
36
36
  "dependencies": {
37
- "@fuzionx/core": "^0.1.66",
37
+ "@aws-sdk/client-s3": "^3.1028.0",
38
+ "@fuzionx/core": "^0.1.69",
38
39
  "better-sqlite3": "^12.8.0",
39
40
  "knex": "^3.2.5",
40
41
  "mongoose": "^9.3.2",