@fuzionx/framework 0.1.2 → 0.1.5

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/README.md ADDED
@@ -0,0 +1,496 @@
1
+ # @fuzionx/framework
2
+
3
+ > Laravel-inspired full-stack MVC framework powered by Rust N-API bridge — **500K+ RPS**
4
+
5
+ ```
6
+ @fuzionx/framework ← npm install (개발자가 설치)
7
+ └── @fuzionx/core ← HTTP 엔진 (libuv Fusion, WebSocket, 세션, 크립토)
8
+ └── @fuzionx/bridge ← Rust N-API 네이티브 모듈
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # 프로젝트 생성
17
+ npx create-fuzionx my-app
18
+ cd my-app
19
+ npm install
20
+
21
+ # 개발 서버
22
+ npx fx dev # --watch 자동 재시작
23
+
24
+ # 또는 직접 실행
25
+ node app.js
26
+ ```
27
+
28
+ 서버가 `http://localhost:49080` 에서 시작됩니다.
29
+
30
+ ---
31
+
32
+ ## 프로젝트 구조
33
+
34
+ ```
35
+ my-app/
36
+ ├── fuzionx.yaml # 서버 + DB + 세션 설정
37
+ ├── .env # 환경변수
38
+ ├── app.js # 엔트리포인트
39
+
40
+ ├── routes/ # 라우트 정의
41
+ │ ├── api.js
42
+ │ └── web.js
43
+
44
+ ├── controllers/ # HTTP 컨트롤러 (자동 스캔)
45
+ │ └── UserController.js
46
+
47
+ ├── models/ # ORM 모델 (자동 스캔)
48
+ │ └── User.js
49
+
50
+ ├── services/ # 비즈니스 로직 (자동 스캔)
51
+ │ └── UserService.js
52
+
53
+ ├── middleware/ # 미들웨어 클래스 (자동 스캔)
54
+ │ └── AuthMiddleware.js
55
+
56
+ ├── ws/ # WebSocket 핸들러 (자동 스캔)
57
+ ├── jobs/ # 스케줄러/큐 Job (자동 스캔)
58
+ ├── events/ # 이벤트 리스너
59
+
60
+ ├── views/ # 템플릿 (Tera SSR / Bridge 렌더링)
61
+ │ └── default/
62
+ │ ├── layouts/
63
+ │ │ └── main.html
64
+ │ └── pages/
65
+ │ └── home.html
66
+
67
+ ├── locales/ # i18n 번역 파일
68
+ │ ├── ko.yaml
69
+ │ └── en.yaml
70
+
71
+ ├── storage/ # 로그, 업로드
72
+ └── public/ # 정적 파일
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 엔트리포인트 (app.js)
78
+
79
+ ```javascript
80
+ import { Application } from '@fuzionx/framework';
81
+ import apiRoutes from './routes/api.js';
82
+ import webRoutes from './routes/web.js';
83
+
84
+ const app = new Application({ configPath: './fuzionx.yaml' });
85
+
86
+ // 라우트 등록
87
+ app.routes(apiRoutes);
88
+ app.routes(webRoutes);
89
+
90
+ // 서비스 등록 (Lazy 싱글톤)
91
+ app.register('mailer', () => new MailService(app.config.get('app.mail')));
92
+
93
+ // 라이프사이클 훅
94
+ app.on('booted', async () => {
95
+ console.log(`DB 연결 완료`);
96
+ });
97
+
98
+ // 서버 시작
99
+ app.listen(49080, () => {
100
+ console.log('🚀 FuzionX running on http://localhost:49080');
101
+ });
102
+ ```
103
+
104
+ ---
105
+
106
+ ## 라우팅 & 컨트롤러
107
+
108
+ ### 라우트 파일
109
+
110
+ ```javascript
111
+ // routes/api.js
112
+ import UserController from '../controllers/UserController.js';
113
+ import Joi from 'joi';
114
+
115
+ export default (r) => {
116
+ r.group('/api', { middleware: ['apiAuth'] }, (r) => {
117
+ r.group('/users', (r) => {
118
+ r.get('/', UserController.index);
119
+ r.get('/:id', UserController.show);
120
+ r.post('/', UserController.store, {
121
+ validate: { body: Joi.object({
122
+ name: Joi.string().min(2).required(),
123
+ email: Joi.string().email().required(),
124
+ }) }
125
+ });
126
+ r.put('/:id', UserController.update);
127
+ r.delete('/:id', UserController.destroy);
128
+ });
129
+ });
130
+ };
131
+ ```
132
+
133
+ ```javascript
134
+ // routes/web.js
135
+ import HomeController from '../controllers/HomeController.js';
136
+
137
+ export default (r) => {
138
+ r.get('/', HomeController.index);
139
+ r.get('/about', HomeController.about);
140
+ };
141
+ ```
142
+
143
+ ### 컨트롤러
144
+
145
+ ```javascript
146
+ // controllers/UserController.js
147
+ import { Controller } from '@fuzionx/framework';
148
+
149
+ export default class UserController extends Controller {
150
+
151
+ async index(ctx) {
152
+ const users = await this.db.User.paginate(ctx.query.page || 1, 20);
153
+ ctx.json(users);
154
+ }
155
+
156
+ async show(ctx) {
157
+ const user = await this.db.User.findOrFail(ctx.params.id);
158
+ ctx.json(user);
159
+ }
160
+
161
+ async store(ctx) {
162
+ const user = await this.service('UserService').register(ctx.body);
163
+ ctx.status(201).json(user);
164
+ }
165
+
166
+ async update(ctx) {
167
+ const user = await this.db.User.findOrFail(ctx.params.id);
168
+ await user.update(ctx.body);
169
+ ctx.json(user);
170
+ }
171
+
172
+ async destroy(ctx) {
173
+ await this.service('UserService').deactivate(ctx.params.id);
174
+ ctx.status(204).end();
175
+ }
176
+ }
177
+ ```
178
+
179
+ 미들웨어는 `글로벌 → 그룹 → 라우트별` 3단계로 적용됩니다.
180
+
181
+ ---
182
+
183
+ ## Context (`ctx`)
184
+
185
+ 모든 핸들러, 미들웨어에 전달되는 통합 객체:
186
+
187
+ ### Request
188
+
189
+ ```javascript
190
+ ctx.method // 'GET', 'POST', ...
191
+ ctx.url // '/users/42?page=1'
192
+ ctx.path // '/users/42'
193
+ ctx.params // { id: '42' }
194
+ ctx.query // { page: '1' }
195
+ ctx.body // JSON 자동 파싱된 요청 바디
196
+ ctx.headers // 요청 헤더
197
+ ctx.ip // 클라이언트 IP
198
+ ctx.user // 인증된 사용자 (미들웨어에서 설정)
199
+ ctx.session // 세션
200
+ ctx.cookies // 파싱된 쿠키 객체
201
+ ctx.locale // 현재 locale ('ko', 'en')
202
+ ctx.files // 업로드된 파일 메타데이터 배열
203
+
204
+ ctx.get(header) // 헤더 값
205
+ ctx.is(type) // Content-Type 확인
206
+ ctx.accepts('json','html') // Accept 협상
207
+ ctx.t(key, vars) // i18n 번역
208
+ ```
209
+
210
+ ### Response
211
+
212
+ ```javascript
213
+ ctx.json(data) // JSON 응답
214
+ ctx.status(201).json(data) // 상태 코드 + JSON
215
+ ctx.send(html) // HTML 응답
216
+ ctx.text(string) // text/plain
217
+ ctx.render('home', { title }) // 템플릿 렌더링 (Rust Bridge SSR)
218
+ ctx.redirect('/login') // 302 리다이렉트
219
+ ctx.redirect('/new', 301) // 301 영구 리다이렉트
220
+ ctx.back() // Referer로 리다이렉트
221
+ ctx.error(404, 'Not found') // 에러 응답
222
+ ctx.download(filePath) // 파일 다운로드
223
+ ctx.stream(readable) // 스트림 응답
224
+ ctx.setHeader(key, value) // 헤더 설정
225
+ ctx.cookie(name, value, opts) // 쿠키 설정
226
+ ctx.end() // 응답 종료
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Model (ORM)
232
+
233
+ ```javascript
234
+ // models/User.js
235
+ import { Model } from '@fuzionx/framework';
236
+
237
+ export default class User extends Model {
238
+ static table = 'users';
239
+ static timestamps = true;
240
+ static hidden = ['password'];
241
+
242
+ static columns = {
243
+ id: { type: 'increments' },
244
+ name: { type: 'string', length: 100 },
245
+ email: { type: 'string', length: 150, unique: true },
246
+ password: { type: 'string', length: 255 },
247
+ active: { type: 'boolean', default: true },
248
+ };
249
+
250
+ posts() { return this.hasMany('Post'); }
251
+ profile() { return this.hasOne('Profile'); }
252
+
253
+ static findByEmail(email) {
254
+ return this.where('email', email).first();
255
+ }
256
+ }
257
+ ```
258
+
259
+ **지원 드라이버**: SQLite · MariaDB · PostgreSQL · MongoDB
260
+
261
+ ---
262
+
263
+ ## Service
264
+
265
+ ```javascript
266
+ // services/UserService.js
267
+ import { Service } from '@fuzionx/framework';
268
+
269
+ export default class UserService extends Service {
270
+
271
+ async register(data) {
272
+ if (await this.db.User.findByEmail(data.email)) {
273
+ throw this.error('이미 존재하는 이메일');
274
+ }
275
+ const user = await this.transaction(async (trx) => {
276
+ const user = await this.db.User.create(data, { trx });
277
+ await this.db.Profile.create({ userId: user.id }, { trx });
278
+ return user;
279
+ });
280
+ this.emit('user:created', user);
281
+ return user;
282
+ }
283
+ }
284
+ ```
285
+
286
+ ---
287
+
288
+ ## Middleware
289
+
290
+ ```javascript
291
+ // middleware/AuthMiddleware.js
292
+ import { Middleware } from '@fuzionx/framework';
293
+
294
+ export default class AuthMiddleware extends Middleware {
295
+ static name = 'auth';
296
+
297
+ handle(ctx, next) {
298
+ const token = ctx.headers.authorization?.replace('Bearer ', '');
299
+ if (!token) return ctx.error(401, 'Unauthorized');
300
+ ctx.user = this.service('AuthService').verify(token);
301
+ next();
302
+ }
303
+ }
304
+ ```
305
+
306
+ ### 내장 미들웨어
307
+
308
+ | 이름 | 기능 | 처리 |
309
+ |------|------|------|
310
+ | `bodyParser` | JSON/Form 바디 파싱 | JS |
311
+ | `cors` | CORS 헤더 | Rust |
312
+ | `csrf` | CSRF 토큰 검증 | JS |
313
+ | `session` | 세션 로드/저장 | Rust |
314
+ | `rateLimit` | 요청 제한 | Rust |
315
+ | `static` | 정적 파일 서빙 | Rust |
316
+
317
+ ---
318
+
319
+ ## WebSocket
320
+
321
+ ```javascript
322
+ // ws/ChatHandler.js
323
+ import { WsHandler } from '@fuzionx/framework';
324
+
325
+ export default class ChatHandler extends WsHandler {
326
+ static namespace = '/chat';
327
+ static middleware = ['auth'];
328
+
329
+ static events(e) {
330
+ e.on('chat', this.handleChat);
331
+ e.on('typing', this.handleTyping);
332
+ }
333
+
334
+ handleChat(socket, data) {
335
+ socket.to(`room:${data.roomId}`).send({
336
+ type: 'chat',
337
+ data: { user: socket.user, message: data.message },
338
+ });
339
+ }
340
+ }
341
+ ```
342
+
343
+ ---
344
+
345
+ ## 설정 (`fuzionx.yaml`)
346
+
347
+ ```yaml
348
+ bridge:
349
+ port: 49080
350
+ workers: 4 # 0 = CPU 수 자동
351
+ cors:
352
+ enabled: true
353
+ origins: ["*"]
354
+ rate_limit:
355
+ enabled: true
356
+ per_ip: 1000
357
+ session:
358
+ enabled: true
359
+ store: memory # memory | redis
360
+ ttl: 3600
361
+ logging:
362
+ level: info
363
+
364
+ database:
365
+ main:
366
+ driver: sqlite
367
+ path: ./storage/database.sqlite
368
+
369
+ app:
370
+ name: 'My App'
371
+ environment: ${NODE_ENV:development}
372
+ auth:
373
+ secret: ${JWT_SECRET:change-me}
374
+ accessTtl: '15m'
375
+ i18n:
376
+ default_locale: 'ko'
377
+ ```
378
+
379
+ ### 환경변수 치환
380
+
381
+ ```yaml
382
+ host: ${DB_HOST:127.0.0.1} # 없으면 기본값 사용
383
+ password: ${DB_PASSWORD} # 필수 — 없으면 부트 시 에러
384
+ redis_url: ${REDIS_URL:} # 선택 — 없으면 빈 문자열
385
+ ```
386
+
387
+ ### Config 접근
388
+
389
+ ```javascript
390
+ app.config.get('bridge.port') // 49080
391
+ app.config.get('app.auth.secret') // JWT secret
392
+ app.config.get('app.payment.api_key', null) // 커스텀 설정 + 기본값
393
+ ```
394
+
395
+ ---
396
+
397
+ ## CLI
398
+
399
+ ### 프로젝트 생성 (`create-fuzionx`)
400
+
401
+ ```bash
402
+ npx create-fuzionx my-app # 새 프로젝트 스캐폴딩
403
+ cd my-app
404
+ npm install
405
+ ```
406
+
407
+ ### 프로젝트 내 명령어 (`fx`)
408
+
409
+ ```bash
410
+ npx fx make:controller User # CRUD 컨트롤러
411
+ npx fx make:model User # ORM 모델
412
+ npx fx make:service User # 서비스
413
+ npx fx make:middleware Auth # 미들웨어
414
+ npx fx make:job Cleanup # 스케줄 Job
415
+ npx fx make:task SendEmail # 큐 Task
416
+ npx fx make:ws Chat # WebSocket 핸들러
417
+ npx fx make:event user # 이벤트 핸들러
418
+ npx fx make:worker Heavy # Worker (worker_threads)
419
+ npx fx make:test User # 테스트
420
+
421
+ # DB
422
+ npx fx db:sync # 모델 ↔ DB 스키마 diff
423
+ npx fx db:sync --apply # 안전 변경 적용
424
+
425
+ # 개발
426
+ npx fx dev # 개발 서버 (--watch)
427
+ npx fx dev --port=4000 # 포트 지정
428
+ npx fx test # 테스트 실행 (vitest)
429
+ npx fx routes # 라우트 테이블 출력
430
+ npx fx config # 설정 파싱 결과 출력
431
+ ```
432
+
433
+ ---
434
+
435
+ ## 부트스트랩 순서
436
+
437
+ ```
438
+ app.js 실행
439
+ ├── 1. Config 로드 fuzionx.yaml 파싱
440
+ ├── 2. .env 로드 환경변수 주입
441
+ ├── 3. Bridge 부트 Rust 네이티브 초기화
442
+ │ ★ emit('booting')
443
+ ├── 4. DB 연결 Knex/Mongoose
444
+ ├── 5. 모델 로드 models/ 자동 스캔
445
+ │ ★ emit('booted')
446
+ ├── 6. i18n 로드 locales/ 번역 파일
447
+ ├── 7~12. 자동 스캔 services, middleware, controllers,
448
+ │ ws, jobs, events
449
+ │ ★ emit('ready')
450
+ └── 13. 서버 시작 startFusionServer(port)
451
+ ★ emit('listening')
452
+ ```
453
+
454
+ ### 라이프사이클 훅
455
+
456
+ | 훅 | 시점 | 용도 |
457
+ |---|------|------|
458
+ | `booting` | Config 로드 직후 | 서비스 등록, 환경 설정 |
459
+ | `booted` | DB + 모델 로드 완료 | 캐시 워밍 |
460
+ | `ready` | 모든 초기화 완료 | 준비 로그 |
461
+ | `listening` | 서버 시작 완료 | 포트 표시 |
462
+ | `shutdown` | SIGTERM/SIGINT | 리소스 정리 |
463
+
464
+ ---
465
+
466
+ ## 에러 처리
467
+
468
+ | 에러 타입 | HTTP | JSON | HTML |
469
+ |-----------|:----:|------|------|
470
+ | ValidationError | 422 | `{ error, fields }` | flash + redirect |
471
+ | ServiceError | 400/403/404 | `{ error }` | 테마 에러 페이지 |
472
+ | Error (dev) | 500 | `{ error, stack }` | 에러 페이지 + 스택 |
473
+ | Error (prod) | 500 | `{ error }` | 에러 페이지 |
474
+
475
+ ---
476
+
477
+ ## 주요 특징
478
+
479
+ - ⚡ **500K+ RPS** — Rust N-API Bridge (libuv + SO_REUSEPORT)
480
+ - 🦀 **Rust 네이티브** — HTTP, 세션, CORS, Rate Limit, 암호화 모두 Rust 처리
481
+ - 🎯 **MVC 아키텍처** — Controller, Service, Model 분리
482
+ - 📦 **자동 스캔** — controllers/, models/, services/ 등 자동 로드
483
+ - 🔌 **WebSocket** — 네임스페이스 기반 이벤트 라우팅
484
+ - 🗄️ **Multi-DB ORM** — SQLite, MariaDB, PostgreSQL, MongoDB
485
+ - 🔐 **Auth & Session** — JWT + 세션 (Memory/Redis)
486
+ - 📡 **EventBus** — 도메인 이벤트 + Hub 연동 (멀티서버)
487
+ - ⏰ **Scheduler & Queue** — Job (cron), Task (큐)
488
+ - 🌍 **i18n** — 다국어 지원, `ctx.t()`, locale 자동 감지
489
+ - 🎨 **Tera SSR** — Rust 기반 템플릿 렌더링
490
+ - 📄 **OpenAPI** — 자동 Swagger 문서 생성
491
+
492
+ ---
493
+
494
+ ## License
495
+
496
+ MIT
@@ -34,7 +34,10 @@ let FuzionXApp;
34
34
  try {
35
35
  const core = await import('@fuzionx/core');
36
36
  FuzionXApp = core.FuzionXApp || core.RuxyApp;
37
- } catch {}
37
+ } catch (e) {
38
+ console.error('[fuzionx-framework] ⚠️ @fuzionx/core 로드 실패 — Bridge 없이 테스트 모드로 실행됩니다:',
39
+ e.message);
40
+ }
38
41
 
39
42
  export default class Application {
40
43
  /**
@@ -304,6 +307,17 @@ export default class Application {
304
307
  : undefined,
305
308
  port,
306
309
  });
310
+ // FuzionXApp 생성 후 Bridge 전파 — boot() 시점에는 null이었음
311
+ this._bridge = this._coreApp._bridge;
312
+ if (this._bridge) {
313
+ if (this._view) this._view._bridge = this._bridge;
314
+ if (this.logger) this.logger._bridge = this._bridge;
315
+ if (this.crypto) this.crypto._bridge = this._bridge;
316
+ if (this.hash) this.hash._bridge = this._bridge;
317
+ if (this.media) this.media._bridge = this._bridge;
318
+ if (this.file) this.file._bridge = this._bridge;
319
+ if (this.i18n) this.i18n._bridge = this._bridge;
320
+ }
307
321
  }
308
322
  this._registerBridgeRoutes(this._coreApp);
309
323
  this._coreApp.listen(port, callback);
package/lib/view/View.js CHANGED
@@ -1,24 +1,24 @@
1
1
  /**
2
- * View — 뷰 렌더링 (테마 + Tera SSR)
2
+ * View — 뷰 렌더링 (Bridge SSR)
3
3
  *
4
4
  * ctx.theme에 따른 테마 경로 해석.
5
- * Bridge Tera SSR 또는 JS 폴백.
5
+ * Bridge renderTemplateFile N-API로 렌더링.
6
6
  *
7
7
  * @see docs/framework/03-views-templates.md
8
8
  */
9
+ import { join } from 'node:path';
10
+
9
11
  export default class View {
10
12
  /**
11
13
  * @param {object} opts
12
14
  * @param {string} opts.viewsPath - 뷰 파일 루트 경로
13
15
  * @param {string} [opts.theme='default'] - 기본 테마
14
- * @param {object} [opts.bridge] - Bridge SSR 인스턴스 (Tera)
15
- * @param {object} [opts.globals] - 모든 뷰에 주입되는 전역 변수
16
+ * @param {object} [opts.bridge] - Bridge N-API 인스턴스
16
17
  */
17
18
  constructor(opts = {}) {
18
19
  this.viewsPath = opts.viewsPath || '';
19
20
  this.theme = opts.theme || 'default';
20
21
  this._bridge = opts.bridge || null;
21
- this._globals = opts.globals || {};
22
22
  }
23
23
 
24
24
  /**
@@ -27,46 +27,46 @@ export default class View {
27
27
  * @param {*} value
28
28
  */
29
29
  share(key, value) {
30
+ this._globals = this._globals || {};
30
31
  this._globals[key] = value;
31
32
  }
32
33
 
33
34
  /**
34
- * 템플릿 렌더링
35
+ * 템플릿 렌더링 — Bridge renderTemplateFile N-API 사용
35
36
  *
36
37
  * 테마 경로 해석 (03-views-templates.md):
37
- * 'pages/users/index' → {theme}/pages/users/index.html
38
+ * 'home' → {viewsPath}/{theme}/pages/home.html
38
39
  *
39
- * @param {string} template - 'users/index' 등
40
+ * @param {string} template - 'home', 'users/index' 등
40
41
  * @param {object} [data] - 템플릿 변수
41
- * @param {string} [theme] - 테마 오버라이드 (ctx.theme에서 전달)
42
+ * @param {string} [theme] - 테마 오버라이드
42
43
  * @returns {string} - 렌더링된 HTML
43
44
  */
44
45
  render(template, data = {}, theme) {
45
46
  const activeTheme = theme || data.theme || this.theme;
46
47
  const mergedData = { ...this._globals, ...data, theme: activeTheme };
47
48
 
48
- // Bridge Tera SSR이 있으면 위임
49
- if (this._bridge) {
50
- const templatePath = `${activeTheme}/${template}.html`;
51
- try {
52
- return this._bridge.render(templatePath, mergedData);
53
- } catch {
54
- // SSR 실패 시 폴백
55
- }
56
- }
49
+ // 파일 경로 해석: pages/{name}.html 우선
50
+ const candidates = [
51
+ join(this.viewsPath, activeTheme, 'pages', `${template}.html`),
52
+ join(this.viewsPath, activeTheme, `${template}.html`),
53
+ ];
57
54
 
58
- // 폴백: 간단한 변수 치환
59
- return this._simpleRender(template, mergedData);
60
- }
55
+ // Bridge renderTemplateFile N-API 호출
56
+ if (this._bridge && typeof this._bridge.renderTemplateFile === 'function') {
57
+ const contextJson = JSON.stringify(mergedData);
61
58
 
62
- /**
63
- * @private 간단 치환 (Bridge 없을 때)
64
- */
65
- _simpleRender(template, data) {
66
- let html = `<!-- template: ${template} -->`;
67
- for (const [key, value] of Object.entries(data)) {
68
- html += `\n<!-- ${key}: ${JSON.stringify(value)} -->`;
59
+ for (const filePath of candidates) {
60
+ try {
61
+ return this._bridge.renderTemplateFile(filePath, contextJson);
62
+ } catch {
63
+ // 파일 없음 다음 후보
64
+ }
65
+ }
66
+
67
+ throw new Error(`View '${template}' not found. Searched: ${candidates.join(', ')}`);
69
68
  }
70
- return html;
69
+
70
+ throw new Error(`Bridge not available — cannot render view '${template}'`);
71
71
  }
72
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
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,7 @@
34
34
  "url": "https://github.com/saytohenry/fuzionx"
35
35
  },
36
36
  "dependencies": {
37
- "@fuzionx/core": "^0.1.2",
37
+ "@fuzionx/core": "^0.1.5",
38
38
  "better-sqlite3": "^12.8.0",
39
39
  "knex": "^3.2.5",
40
40
  "mongoose": "^9.3.2",