sonamu 0.7.21 → 0.7.23

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 (200) hide show
  1. package/dist/ai/agents/agent.d.ts +6 -1
  2. package/dist/ai/agents/agent.d.ts.map +1 -1
  3. package/dist/ai/agents/agent.js +20 -5
  4. package/dist/api/base-frame.d.ts +4 -0
  5. package/dist/api/base-frame.d.ts.map +1 -1
  6. package/dist/api/base-frame.js +9 -1
  7. package/dist/api/caster.d.ts.map +1 -1
  8. package/dist/api/caster.js +2 -2
  9. package/dist/api/config.d.ts +35 -3
  10. package/dist/api/config.d.ts.map +1 -1
  11. package/dist/api/config.js +1 -1
  12. package/dist/api/decorators.d.ts +4 -4
  13. package/dist/api/decorators.d.ts.map +1 -1
  14. package/dist/api/decorators.js +80 -18
  15. package/dist/api/index.d.ts +1 -0
  16. package/dist/api/index.d.ts.map +1 -1
  17. package/dist/api/index.js +2 -1
  18. package/dist/api/secret.d.ts +7 -0
  19. package/dist/api/secret.d.ts.map +1 -0
  20. package/dist/api/secret.js +17 -0
  21. package/dist/api/sonamu.d.ts +17 -8
  22. package/dist/api/sonamu.d.ts.map +1 -1
  23. package/dist/api/sonamu.js +265 -47
  24. package/dist/cache/cache-manager.d.ts +11 -0
  25. package/dist/cache/cache-manager.d.ts.map +1 -0
  26. package/dist/cache/cache-manager.js +22 -0
  27. package/dist/cache/decorator.d.ts +31 -0
  28. package/dist/cache/decorator.d.ts.map +1 -0
  29. package/dist/cache/decorator.js +86 -0
  30. package/dist/cache/drivers.d.ts +33 -0
  31. package/dist/cache/drivers.d.ts.map +1 -0
  32. package/dist/cache/drivers.js +36 -0
  33. package/dist/cache/index.d.ts +4 -0
  34. package/dist/cache/index.d.ts.map +1 -0
  35. package/dist/cache/index.js +8 -0
  36. package/dist/cache/types.d.ts +28 -0
  37. package/dist/cache/types.d.ts.map +1 -0
  38. package/dist/cache/types.js +6 -0
  39. package/dist/database/base-model.d.ts +4 -2
  40. package/dist/database/base-model.d.ts.map +1 -1
  41. package/dist/database/base-model.js +9 -4
  42. package/dist/database/code-generator.d.ts +3 -1
  43. package/dist/database/code-generator.d.ts.map +1 -1
  44. package/dist/database/code-generator.js +3 -2
  45. package/dist/database/db.d.ts +1 -1
  46. package/dist/database/db.d.ts.map +1 -1
  47. package/dist/database/db.js +5 -5
  48. package/dist/database/knex.d.ts +3 -0
  49. package/dist/database/knex.d.ts.map +1 -0
  50. package/dist/database/knex.js +29 -0
  51. package/dist/database/puri.types.d.ts.map +1 -1
  52. package/dist/database/puri.types.js +1 -1
  53. package/dist/database/upsert-builder.d.ts.map +1 -1
  54. package/dist/database/upsert-builder.js +49 -5
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -1
  58. package/dist/logger/category.d.ts +4 -0
  59. package/dist/logger/category.d.ts.map +1 -0
  60. package/dist/logger/category.js +34 -0
  61. package/dist/logger/configure.d.ts +9 -0
  62. package/dist/logger/configure.d.ts.map +1 -0
  63. package/dist/logger/configure.js +115 -0
  64. package/dist/migration/code-generation.d.ts +5 -1
  65. package/dist/migration/code-generation.d.ts.map +1 -1
  66. package/dist/migration/code-generation.js +13 -7
  67. package/dist/migration/migrator.d.ts +1 -1
  68. package/dist/migration/migrator.d.ts.map +1 -1
  69. package/dist/migration/migrator.js +7 -7
  70. package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
  71. package/dist/migration/postgresql-schema-reader.js +5 -3
  72. package/dist/naite/naite.d.ts +0 -4
  73. package/dist/naite/naite.d.ts.map +1 -1
  74. package/dist/naite/naite.js +11 -19
  75. package/dist/ssr/index.d.ts +4 -0
  76. package/dist/ssr/index.d.ts.map +1 -0
  77. package/dist/ssr/index.js +4 -0
  78. package/dist/ssr/registry.d.ts +10 -0
  79. package/dist/ssr/registry.d.ts.map +1 -0
  80. package/dist/ssr/registry.js +43 -0
  81. package/dist/ssr/renderer.d.ts +6 -0
  82. package/dist/ssr/renderer.d.ts.map +1 -0
  83. package/dist/ssr/renderer.js +70 -0
  84. package/dist/ssr/types.d.ts +19 -0
  85. package/dist/ssr/types.d.ts.map +1 -0
  86. package/dist/ssr/types.js +4 -0
  87. package/dist/syncer/syncer.d.ts +1 -0
  88. package/dist/syncer/syncer.d.ts.map +1 -1
  89. package/dist/syncer/syncer.js +58 -1
  90. package/dist/tasks/decorator.d.ts +1 -0
  91. package/dist/tasks/decorator.d.ts.map +1 -1
  92. package/dist/tasks/decorator.js +9 -7
  93. package/dist/tasks/step-wrapper.d.ts +5 -0
  94. package/dist/tasks/step-wrapper.d.ts.map +1 -1
  95. package/dist/tasks/step-wrapper.js +11 -6
  96. package/dist/tasks/workflow-manager.d.ts +2 -0
  97. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  98. package/dist/tasks/workflow-manager.js +5 -2
  99. package/dist/template/implementations/entry-server.template.d.ts +17 -0
  100. package/dist/template/implementations/entry-server.template.d.ts.map +1 -0
  101. package/dist/template/implementations/entry-server.template.js +78 -0
  102. package/dist/template/implementations/model.template.d.ts.map +1 -1
  103. package/dist/template/implementations/model.template.js +5 -3
  104. package/dist/template/implementations/queries.template.d.ts +17 -0
  105. package/dist/template/implementations/queries.template.d.ts.map +1 -0
  106. package/dist/template/implementations/queries.template.js +83 -0
  107. package/dist/template/implementations/view_enums_select.template.d.ts.map +1 -1
  108. package/dist/template/implementations/view_enums_select.template.js +34 -20
  109. package/dist/template/implementations/view_form.template.d.ts +2 -1
  110. package/dist/template/implementations/view_form.template.d.ts.map +1 -1
  111. package/dist/template/implementations/view_form.template.js +301 -129
  112. package/dist/template/implementations/view_id_async_select.template.d.ts.map +1 -1
  113. package/dist/template/implementations/view_id_async_select.template.js +136 -57
  114. package/dist/template/implementations/view_list.template.d.ts +2 -0
  115. package/dist/template/implementations/view_list.template.d.ts.map +1 -1
  116. package/dist/template/implementations/view_list.template.js +392 -227
  117. package/dist/template/implementations/view_search_input.template.d.ts.map +1 -1
  118. package/dist/template/implementations/view_search_input.template.js +46 -30
  119. package/dist/template/zod-converter.d.ts.map +1 -1
  120. package/dist/template/zod-converter.js +2 -2
  121. package/dist/testing/bootstrap.d.ts +28 -0
  122. package/dist/testing/bootstrap.d.ts.map +1 -0
  123. package/dist/testing/bootstrap.js +120 -0
  124. package/dist/testing/fixture-loader.d.ts +21 -0
  125. package/dist/testing/fixture-loader.d.ts.map +1 -0
  126. package/dist/testing/fixture-loader.js +28 -0
  127. package/dist/testing/fixture-manager.d.ts +1 -1
  128. package/dist/testing/fixture-manager.d.ts.map +1 -1
  129. package/dist/testing/fixture-manager.js +7 -7
  130. package/dist/testing/index.d.ts +4 -0
  131. package/dist/testing/index.d.ts.map +1 -0
  132. package/dist/testing/index.js +5 -0
  133. package/dist/testing/naite-vitest-reporter.d.ts +12 -0
  134. package/dist/testing/naite-vitest-reporter.d.ts.map +1 -0
  135. package/dist/testing/naite-vitest-reporter.js +17 -0
  136. package/dist/types/types.d.ts +5 -6
  137. package/dist/types/types.d.ts.map +1 -1
  138. package/dist/types/types.js +7 -8
  139. package/dist/ui/ai-client.d.ts +3 -1
  140. package/dist/ui/ai-client.d.ts.map +1 -1
  141. package/dist/ui/ai-client.js +27 -8
  142. package/dist/ui-web/assets/index-CTYv3qL6.js +92 -0
  143. package/dist/ui-web/index.html +1 -1
  144. package/package.json +43 -20
  145. package/src/ai/agents/agent.ts +38 -19
  146. package/src/api/base-frame.ts +8 -0
  147. package/src/api/caster.ts +6 -1
  148. package/src/api/config.ts +38 -4
  149. package/src/api/decorators.ts +106 -20
  150. package/src/api/index.ts +1 -0
  151. package/src/api/secret.ts +23 -0
  152. package/src/api/sonamu.ts +334 -61
  153. package/src/cache/cache-manager.ts +23 -0
  154. package/src/cache/decorator.ts +116 -0
  155. package/src/cache/drivers.ts +42 -0
  156. package/src/cache/index.ts +16 -0
  157. package/src/cache/types.ts +32 -0
  158. package/src/database/base-model.ts +7 -3
  159. package/src/database/code-generator.ts +3 -1
  160. package/src/database/db.ts +5 -5
  161. package/src/database/knex.ts +34 -0
  162. package/src/database/puri.types.ts +2 -3
  163. package/src/database/upsert-builder.ts +58 -4
  164. package/src/index.ts +4 -0
  165. package/src/logger/category.ts +42 -0
  166. package/src/logger/configure.ts +132 -0
  167. package/src/migration/code-generation.ts +19 -6
  168. package/src/migration/migrator.ts +7 -6
  169. package/src/migration/postgresql-schema-reader.ts +7 -2
  170. package/src/naite/naite.ts +10 -18
  171. package/src/shared/web.shared.ts.txt +1 -1
  172. package/src/ssr/index.ts +13 -0
  173. package/src/ssr/registry.ts +52 -0
  174. package/src/ssr/renderer.ts +105 -0
  175. package/src/ssr/types.ts +20 -0
  176. package/src/syncer/syncer.ts +59 -0
  177. package/src/tasks/decorator.ts +20 -4
  178. package/src/tasks/step-wrapper.ts +14 -5
  179. package/src/tasks/workflow-manager.ts +9 -1
  180. package/src/template/implementations/entry-server.template.ts +81 -0
  181. package/src/template/implementations/model.template.ts +4 -2
  182. package/src/template/implementations/queries.template.ts +111 -0
  183. package/src/template/implementations/view_enums_select.template.ts +33 -19
  184. package/src/template/implementations/view_form.template.ts +324 -145
  185. package/src/template/implementations/view_id_async_select.template.ts +145 -56
  186. package/src/template/implementations/view_list.template.ts +446 -236
  187. package/src/template/implementations/view_search_input.template.ts +45 -29
  188. package/src/template/zod-converter.ts +4 -1
  189. package/src/testing/bootstrap.ts +176 -0
  190. package/src/testing/fixture-loader.ts +28 -0
  191. package/src/testing/fixture-manager.ts +7 -6
  192. package/src/testing/index.ts +3 -0
  193. package/src/testing/naite-vitest-reporter.ts +18 -0
  194. package/src/types/types.ts +4 -5
  195. package/src/ui/ai-client.ts +82 -50
  196. package/dist/template/implementations/view_enums_dropdown.template.d.ts +0 -17
  197. package/dist/template/implementations/view_enums_dropdown.template.d.ts.map +0 -1
  198. package/dist/template/implementations/view_enums_dropdown.template.js +0 -50
  199. package/dist/ui-web/assets/index-B87IyofX.js +0 -92
  200. package/src/template/implementations/view_enums_dropdown.template.ts +0 -53
package/src/api/sonamu.ts CHANGED
@@ -1,12 +1,15 @@
1
+ import { dispose as logtapeDispose } from "@logtape/logtape";
1
2
  import assert from "assert";
2
3
  import { AsyncLocalStorage } from "async_hooks";
3
4
  import type { FSWatcher } from "chokidar";
4
5
  import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
6
+ import fs from "fs";
5
7
  import type { IncomingMessage, Server, ServerResponse } from "http";
6
8
  import os from "os";
7
9
  import path from "path";
8
10
  import type { ZodObject } from "zod";
9
11
  import { createMockSSEFactory, DB, isDaemonServer } from "..";
12
+ import type { CacheConfig, CacheManager } from "../cache/types";
10
13
  import type { SonamuDBConfig } from "../database/db";
11
14
  import { Naite } from "../naite/naite";
12
15
  import type { StorageManager } from "../storage/storage-manager";
@@ -17,12 +20,8 @@ import type { AbsolutePath } from "../utils/path-utils";
17
20
  import type { SonamuConfig, SonamuServerOptions, SonamuTaskOptions } from "./config";
18
21
  import type { AuthContext, Context, UploadContext } from "./context";
19
22
  import type { ExtendedApi } from "./decorators";
23
+ import { getSecrets, type SonamuSecrets } from "./secret";
20
24
 
21
- export type SonamuSecrets = {
22
- anthropic_api_key?: string;
23
- voyage_api_key?: string;
24
- openai_api_key?: string;
25
- };
26
25
  class SonamuClass {
27
26
  public isInitialized: boolean = false;
28
27
  public asyncLocalStorage: AsyncLocalStorage<{
@@ -109,13 +108,7 @@ class SonamuClass {
109
108
  return this._config;
110
109
  }
111
110
 
112
- private _secrets: SonamuSecrets | null = null;
113
- set secrets(secrets: SonamuSecrets) {
114
- this._secrets = secrets;
115
- }
116
- get secrets(): SonamuSecrets | null {
117
- return this._secrets;
118
- }
111
+ public readonly secrets: SonamuSecrets = getSecrets();
119
112
 
120
113
  private _storage: StorageManager | null = null;
121
114
  /**
@@ -128,6 +121,17 @@ class SonamuClass {
128
121
  return this._storage;
129
122
  }
130
123
 
124
+ private _cache: CacheManager | null = null;
125
+ /**
126
+ * CacheManager 인스턴스 (BentoCache)
127
+ */
128
+ get cache(): CacheManager {
129
+ if (!this._cache) {
130
+ throw new Error("Cache has not been initialized. Check cache config in sonamu.config.ts.");
131
+ }
132
+ return this._cache;
133
+ }
134
+
131
135
  private _workflows: WorkflowManager | null = null;
132
136
  get workflows(): WorkflowManager {
133
137
  if (this._workflows === null) {
@@ -167,24 +171,19 @@ class SonamuClass {
167
171
  const { findApiRootPath } = await import("../utils/utils");
168
172
  this.apiRootPath = apiRootPath ?? findApiRootPath();
169
173
 
174
+ // 설정을 로딩하는 것부터 시작
170
175
  const { loadConfig } = await import("./config");
171
176
  this.config = await loadConfig(this.apiRootPath);
172
177
  // sonamu.config.ts 기본값 설정
173
- this.config.database.database = this.config.database.database ?? "postgresql";
174
-
175
- // API 키 환경변수 로드
176
- const secrets: SonamuSecrets = {};
177
- if (process.env.ANTHROPIC_API_KEY) {
178
- secrets.anthropic_api_key = process.env.ANTHROPIC_API_KEY;
179
- }
180
- if (process.env.VOYAGE_API_KEY) {
181
- secrets.voyage_api_key = process.env.VOYAGE_API_KEY;
182
- }
183
- if (process.env.OPENAI_API_KEY) {
184
- secrets.openai_api_key = process.env.OPENAI_API_KEY;
185
- }
186
- if (Object.keys(secrets).length > 0) {
187
- this.secrets = secrets;
178
+ this.config.database.database = this.config.database.database ?? "pg";
179
+ this.config.database.defaultOptions.client = this.config.database.database ?? "pg";
180
+
181
+ // 로깅 설정
182
+ const { configureLogTape } = await import("../logger/configure");
183
+ if (this.config.logging !== false) {
184
+ await configureLogTape({
185
+ ...this.config.logging,
186
+ });
188
187
  }
189
188
 
190
189
  // DB 로드
@@ -201,6 +200,9 @@ class SonamuClass {
201
200
  const { EntityManager } = await import("../entity/entity-manager");
202
201
  await EntityManager.autoload(doSilent);
203
202
 
203
+ // Cache 초기화
204
+ await this.initializeCache(this.config.server.cache, forTesting);
205
+
204
206
  // 테스팅인 경우 싱크 없이 중단
205
207
  if (forTesting) {
206
208
  this.isInitialized = true;
@@ -214,14 +216,14 @@ class SonamuClass {
214
216
  const { Syncer } = await import("../syncer/syncer");
215
217
  this.syncer = new Syncer();
216
218
 
217
- // Autoload: Models / Types / APIs
219
+ // Autoload: Models / Types / APIs / Workflows / Templates / SSR Routes
218
220
  await this.syncer.autoloadTypes();
219
221
  await this.syncer.autoloadModels();
220
222
  await this.syncer.autoloadApis();
221
223
  await this.syncer.autoloadWorkflows();
222
-
223
224
  const { TemplateManager } = await import("../template");
224
225
  await TemplateManager.autoload();
226
+ await this.syncer.autoloadSSRRoutes();
225
227
 
226
228
  const { isLocal, isTest } = await import("../utils/controller");
227
229
  if (isLocal()) {
@@ -232,7 +234,6 @@ class SonamuClass {
232
234
  const { isHotReloadServer } = await import("../utils/controller");
233
235
  if (isLocal() && !isTest() && isHotReloadServer() && enableSync) {
234
236
  await this.syncer.sync();
235
-
236
237
  await this.startWatcher();
237
238
  }
238
239
 
@@ -249,8 +250,17 @@ class SonamuClass {
249
250
  }
250
251
 
251
252
  const options = this.config.server;
252
- const fastify = (await import("fastify")).default;
253
- const server = fastify(options.fastify);
253
+ const { default: fastify } = await import("fastify");
254
+ const { getLogTapeFastifyLogger } = await import("@logtape/fastify");
255
+ const server = fastify({
256
+ ...options.fastify,
257
+ logger:
258
+ this.config.logging !== false
259
+ ? getLogTapeFastifyLogger({
260
+ category: this.config.logging?.fastifyCategory ?? ["fastify"],
261
+ })
262
+ : undefined,
263
+ });
254
264
  this.server = server;
255
265
 
256
266
  // Storage 설정 → StorageManager 생성
@@ -352,49 +362,254 @@ class SonamuClass {
352
362
  const { sonamuUIApiPlugin } = await import("../ui/api");
353
363
  server.register(sonamuUIApiPlugin);
354
364
 
355
- // API 라우팅 (로컬HMR 상태와 구분)
365
+ // 로컬/프로덕션 환경 분기
356
366
  const { isLocal } = await import("../utils/controller");
357
- if (isLocal()) {
358
- server.all("*", async (request, reply) => {
359
- // Sonamu UI
360
- if (request.url.startsWith("/sonamu-ui")) {
361
- return;
362
- }
363
-
364
- const found = this.syncer.apis.find(
365
- (api) =>
366
- this.config.api.route.prefix + api.path === request.url.split("?")[0] &&
367
- (api.options.httpMethod ?? "GET") === request.method.toUpperCase(),
368
- );
369
- if (found) {
370
- return this.createApiHandler(found, config)(request, reply);
371
- }
372
-
373
- if (request.url.startsWith("/api/")) {
374
- const { NotFoundException } = await import("../exceptions/so-exceptions");
375
- throw new NotFoundException(`존재하지 않는 API 접근입니다. ${request.url}`);
376
- }
367
+ const webPath = path.join(this.appRootPath, "web");
368
+ const hasWeb = fs.existsSync(webPath);
377
369
 
378
- // 일반 파일 접근시 별도의 에러 출력하지 않음
379
- return;
380
- });
370
+ if (isLocal()) {
371
+ // 로컬 개발 환경: Vite Dev Server + 통합 핸들러
372
+ if (hasWeb) {
373
+ await this.setupViteDevServer(server, webPath, config);
374
+ }
381
375
  } else {
376
+ // 프로덕션 환경: 개별 API 라우트 + 정적 파일 서빙
382
377
  for (const api of this.syncer.apis) {
383
- // model
384
378
  if (this.syncer.models[api.modelName] === undefined) {
385
379
  throw new Error(`정의되지 않은 모델에 접근 ${api.modelName}`);
386
380
  }
387
381
 
388
- // route
389
382
  server.route({
390
383
  method: api.options.httpMethod ?? "GET",
391
384
  url: this.config.api.route.prefix + api.path,
392
385
  handler: this.createApiHandler(api, config),
393
- }); // END server.route
386
+ });
387
+ }
388
+
389
+ if (hasWeb) {
390
+ await this.setupStaticWebServer(server, webPath, config);
394
391
  }
395
392
  }
396
393
  }
397
394
 
395
+ // biome-ignore lint/suspicious/noExplicitAny: ViteDevServer 타입을 동적으로 로드해야 함
396
+ private viteServer: any = null;
397
+
398
+ private async setupViteDevServer(
399
+ server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
400
+ webPath: string,
401
+ config: SonamuFastifyConfig,
402
+ ): Promise<void> {
403
+ // @fastify/middie 등록 (Connect-style middleware 지원)
404
+ await server.register((await import("@fastify/middie")).default);
405
+
406
+ const vite = await import("vite");
407
+
408
+ this.viteServer = await vite.createServer({
409
+ root: webPath,
410
+ server: {
411
+ middlewareMode: true,
412
+ hmr: {
413
+ server: server.server,
414
+ },
415
+ },
416
+ appType: "custom",
417
+ });
418
+
419
+ // Vite middleware 등록 (Vite 에셋 처리)
420
+ server.use((req, res, next) => {
421
+ // API와 Sonamu UI는 Fastify 라우트가 처리하도록 skip
422
+ if (req.url?.startsWith(this.config.api.route.prefix) || req.url?.startsWith("/sonamu-ui")) {
423
+ return next();
424
+ }
425
+ // 나머지는 Vite middleware로 전달
426
+ return this.viteServer.middlewares(req, res, next);
427
+ });
428
+
429
+ // API 동적 라우팅 (catch-all 전에 등록)
430
+ for (const api of this.syncer.apis) {
431
+ if (this.syncer.models[api.modelName] === undefined) {
432
+ throw new Error(`정의되지 않은 모델에 접근 ${api.modelName}`);
433
+ }
434
+
435
+ server.route({
436
+ method: api.options.httpMethod ?? "GET",
437
+ url: this.config.api.route.prefix + api.path,
438
+ handler: this.createApiHandler(api, config),
439
+ });
440
+ }
441
+
442
+ // Catch-all 핸들러: SSR + CSR fallback
443
+ server.setNotFoundHandler(async (request, reply) => {
444
+ const url = request.url;
445
+
446
+ // SSR 라우트 체크
447
+ const { matchSSRRoute } = await import("../ssr");
448
+ const match = matchSSRRoute(url);
449
+
450
+ if (match) {
451
+ console.log(`[SSR] Matched route: ${match.route.path}`);
452
+ // SSR 렌더링
453
+ try {
454
+ const { renderSSR } = await import("../ssr");
455
+ const html = await renderSSR(
456
+ url,
457
+ match.route,
458
+ match.params,
459
+ request,
460
+ reply,
461
+ config,
462
+ this.viteServer,
463
+ );
464
+ reply.type("text/html").send(html);
465
+ return;
466
+ } catch (e) {
467
+ console.error("SSR Error:", e);
468
+ console.log("Falling back to CSR...");
469
+ // fallback to CSR (아래 로직 실행)
470
+ }
471
+ }
472
+
473
+ // CSR fallback
474
+ try {
475
+ const fs = await import("node:fs/promises");
476
+ let template = await fs.readFile(
477
+ path.join(this.viteServer.config.root, "index.html"),
478
+ "utf-8",
479
+ );
480
+ template = await this.viteServer.transformIndexHtml(url, template);
481
+
482
+ reply.type("text/html").send(template);
483
+ return;
484
+ } catch (e) {
485
+ this.viteServer.ssrFixStacktrace(e as Error);
486
+ console.error(e);
487
+ reply.status(500).send((e as Error).message);
488
+ return;
489
+ }
490
+ });
491
+
492
+ // 서버 종료 시 Vite도 종료
493
+ server.addHook("onClose", async () => {
494
+ await this.viteServer.close();
495
+ });
496
+
497
+ console.log("✓ Vite dev server integrated");
498
+ }
499
+
500
+ private async setupStaticWebServer(
501
+ server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
502
+ _webPath: string,
503
+ config: SonamuFastifyConfig,
504
+ ): Promise<void> {
505
+ // 경로 명확화: api/public/web, api/dist/ssr
506
+ const webDistPath = path.join(this.apiRootPath, "public", "web");
507
+ const ssrPath = path.join(this.apiRootPath, "dist", "ssr");
508
+
509
+ if (!fs.existsSync(webDistPath)) {
510
+ console.warn(`⚠ Web dist not found: ${webDistPath}`);
511
+ return;
512
+ }
513
+
514
+ // SSR entry 존재 여부 확인
515
+ const ssrEntryPath = path.join(ssrPath, "entry-server.generated.js");
516
+ const ssrAvailable = fs.existsSync(ssrEntryPath);
517
+
518
+ if (!ssrAvailable) {
519
+ console.warn(`⚠ SSR entry not found: ${ssrEntryPath}`);
520
+ console.warn(" SSR will be disabled. Only CSR will work.");
521
+ }
522
+
523
+ // SSR 라우트 로드 (production에서만, 사용자 프로젝트의 ssr/routes.ts)
524
+ if (ssrAvailable) {
525
+ const ssrRoutesPath = path.join(this.apiRootPath, "dist", "ssr", "routes.js");
526
+ if (fs.existsSync(ssrRoutesPath)) {
527
+ await import(ssrRoutesPath);
528
+ console.log("✓ SSR routes loaded");
529
+ } else {
530
+ console.warn(`⚠ SSR routes not found: ${ssrRoutesPath}`);
531
+ }
532
+ }
533
+
534
+ // 롤링 업데이트 대응: asset hash 불일치 시 현재 버전 직접 서빙
535
+ server.get("/assets/:filename", async (request, reply) => {
536
+ const requestedFile = (request.params as { filename: string }).filename;
537
+ const assetsDir = path.join(webDistPath, "assets");
538
+
539
+ // index-*.js 또는 index-*.css 요청인 경우
540
+ if (/^index-[a-f0-9]+\.(js|css)$/.test(requestedFile)) {
541
+ const ext = requestedFile.split(".").pop();
542
+ const files = fs.readdirSync(assetsDir);
543
+ const currentFile = files.find((f) => f.startsWith("index-") && f.endsWith(`.${ext}`));
544
+
545
+ if (currentFile) {
546
+ const filePath = path.join(assetsDir, currentFile);
547
+ const content = fs.readFileSync(filePath);
548
+ reply.type(ext === "js" ? "application/javascript" : "text/css");
549
+ reply.header("Cache-Control", "public, max-age=31536000, immutable");
550
+ return reply.send(content);
551
+ }
552
+ }
553
+
554
+ // 일반 파일 서빙
555
+ const filePath = path.join(assetsDir, requestedFile);
556
+ if (fs.existsSync(filePath)) {
557
+ const content = fs.readFileSync(filePath);
558
+ const ext = requestedFile.split(".").pop();
559
+ reply.type(ext === "js" ? "application/javascript" : ext === "css" ? "text/css" : "");
560
+ if (requestedFile.includes("-")) {
561
+ reply.header("Cache-Control", "public, max-age=31536000, immutable");
562
+ }
563
+ return reply.send(content);
564
+ }
565
+
566
+ reply.code(404).send("Not found");
567
+ });
568
+
569
+ // SPA/SSR 라우팅
570
+ server.setNotFoundHandler(async (request, reply) => {
571
+ // /api, /sonamu-ui는 404 그대로
572
+ if (request.url.startsWith("/api") || request.url.startsWith("/sonamu-ui")) {
573
+ reply.code(404).send({ error: "Not Found" });
574
+ return;
575
+ }
576
+
577
+ const url = request.url;
578
+
579
+ // SSR 라우트 체크
580
+ if (ssrAvailable) {
581
+ const { matchSSRRoute } = await import("../ssr");
582
+ const match = matchSSRRoute(url);
583
+
584
+ if (match) {
585
+ try {
586
+ // renderSSR 재사용 (vite 없이 호출 = production 모드)
587
+ const { renderSSR } = await import("../ssr/renderer");
588
+ const html = await renderSSR(url, match.route, match.params, request, reply, config);
589
+ reply.type("text/html").send(html);
590
+ console.log(`[SSR] Matched route: ${match.route.path}`);
591
+ return;
592
+ } catch (e) {
593
+ console.error("[SSR Error]", {
594
+ url: request.url,
595
+ route: match.route.path,
596
+ error: e instanceof Error ? e.message : String(e),
597
+ timestamp: new Date().toISOString(),
598
+ });
599
+ // CSR로 fallback
600
+ }
601
+ }
602
+ }
603
+
604
+ // CSR fallback (SSR 실패 시 또는 SSR 라우트가 아닌 경우)
605
+ const indexPath = path.join(webDistPath, "index.html");
606
+ const html = fs.readFileSync(indexPath, "utf-8");
607
+ reply.type("text/html").send(html);
608
+ });
609
+
610
+ console.log(`✓ Static web server configured with ${ssrAvailable ? "SSR" : "CSR only"} support`);
611
+ }
612
+
398
613
  createApiHandler(
399
614
  api: ExtendedApi,
400
615
  config: SonamuFastifyConfig,
@@ -450,6 +665,35 @@ class SonamuClass {
450
665
  };
451
666
  }
452
667
 
668
+ /**
669
+ * SSR용 API 호출 (HTTP 오버헤드 없이 직접 호출)
670
+ * createApiHandler의 로직을 재사용하되, request 파싱 대신 params 직접 사용
671
+ */
672
+ async invokeApiForSSR(
673
+ api: ExtendedApi,
674
+ // biome-ignore lint/suspicious/noExplicitAny: SSR에서 다양한 타입의 params를 받아야 함
675
+ params: any[],
676
+ config: SonamuFastifyConfig,
677
+ request: FastifyRequest,
678
+ reply: FastifyReply,
679
+ ): Promise<unknown> {
680
+ // Context 생성 (기존 메소드 재사용)
681
+ const context = await this.createContext(config, request, reply);
682
+
683
+ // args 생성: Context 파라미터는 주입, 나머지는 params에서 가져오기
684
+ const { ApiParamType } = await import("../types/types");
685
+ let paramsIndex = 0;
686
+ const args = api.parameters.map((param) => {
687
+ if (ApiParamType.isContext(param.type)) {
688
+ return context;
689
+ }
690
+ return params[paramsIndex++];
691
+ });
692
+
693
+ // 모델 메서드 호출 (기존 메서드 재사용)
694
+ return this.invokeModelMethod(api, args, context, reply);
695
+ }
696
+
453
697
  async invokeModelMethod(
454
698
  api: ExtendedApi,
455
699
  args: unknown[],
@@ -614,6 +858,29 @@ class SonamuClass {
614
858
  }
615
859
  }
616
860
 
861
+ private async initializeCache(config: CacheConfig | undefined, forTesting: boolean) {
862
+ const { setCacheManagerRef } = await import("../cache/decorator");
863
+
864
+ // 테스트 환경에서 메모리 드라이버 자동 사용
865
+ if (forTesting) {
866
+ const { createTestCacheManager } = await import("../cache/cache-manager");
867
+ this._cache = createTestCacheManager();
868
+ setCacheManagerRef(this._cache);
869
+ return;
870
+ }
871
+
872
+ // 설정이 없으면 캐시 비활성화
873
+ if (!config) {
874
+ setCacheManagerRef(null);
875
+ return;
876
+ }
877
+
878
+ // 설정에 따라 CacheManager 생성
879
+ const { createCacheManager } = await import("../cache/cache-manager");
880
+ this._cache = createCacheManager(config);
881
+ setCacheManagerRef(this._cache);
882
+ }
883
+
617
884
  private async initializeWorkflows(options: SonamuTaskOptions | undefined) {
618
885
  const { WorkflowManager } = await import("../tasks/workflow-manager");
619
886
  // NOTE: @sonamu-kit/tasks 안에선 knex config를 수정하기 때문에 connection이 아닌 config 째로 보냅니다.
@@ -715,9 +982,15 @@ class SonamuClass {
715
982
 
716
983
  async destroy(): Promise<void> {
717
984
  const { BaseModel } = await import("../database/base-model");
985
+ // 먼저 처리해야함.
718
986
  await BaseModel.destroy();
719
- await this._workflows?.destroy();
720
- await this.watcher?.close();
987
+ await Promise.allSettled([
988
+ this._workflows?.destroy() ?? Promise.resolve(),
989
+ this._cache?.disconnect() ?? Promise.resolve(),
990
+ this.watcher?.close() ?? Promise.resolve(),
991
+ logtapeDispose(),
992
+ ]);
721
993
  }
722
994
  }
995
+
723
996
  export const Sonamu = new SonamuClass();
@@ -0,0 +1,23 @@
1
+ import { BentoCache, bentostore } from "bentocache";
2
+ import { memoryDriver } from "bentocache/drivers/memory";
3
+ import type { CacheConfig, CacheManager } from "./types";
4
+
5
+ /**
6
+ * BentoCache 인스턴스를 생성합니다.
7
+ */
8
+ export function createCacheManager(config: CacheConfig): CacheManager {
9
+ return new BentoCache(config);
10
+ }
11
+
12
+ /**
13
+ * 테스트 환경용 기본 CacheManager를 생성합니다.
14
+ * 메모리 드라이버만 사용하는 간단한 설정
15
+ */
16
+ export function createTestCacheManager(): CacheManager {
17
+ return new BentoCache({
18
+ default: "memory",
19
+ stores: {
20
+ memory: bentostore().useL1Layer(memoryDriver({ maxItems: 1000 })),
21
+ },
22
+ });
23
+ }
@@ -0,0 +1,116 @@
1
+ import type { BaseFrameClass } from "../api/base-frame";
2
+ import { BaseModelClass } from "../database/base-model";
3
+ import type { CacheDecoratorOptions, CacheManager } from "./types";
4
+
5
+ type DecoratorTarget = { constructor: { name: string } };
6
+
7
+ // 캐시 매니저 참조 (Sonamu.init에서 설정됨)
8
+ let cacheManagerRef: CacheManager | null = null;
9
+
10
+ /**
11
+ * 캐시 매니저 참조 설정 (내부 사용)
12
+ */
13
+ export function setCacheManagerRef(manager: CacheManager | null): void {
14
+ cacheManagerRef = manager;
15
+ }
16
+
17
+ /**
18
+ * 캐시 매니저 참조 가져오기 (내부 사용)
19
+ */
20
+ export function getCacheManagerRef(): CacheManager | null {
21
+ return cacheManagerRef;
22
+ }
23
+
24
+ /**
25
+ * 캐시 키 생성
26
+ */
27
+ function generateCacheKey(
28
+ modelName: string,
29
+ methodName: string,
30
+ args: unknown[],
31
+ keyOption?: CacheDecoratorOptions["key"],
32
+ ): string {
33
+ // 커스텀 키 함수 사용
34
+ if (typeof keyOption === "function") {
35
+ return keyOption(...args);
36
+ }
37
+
38
+ // 문자열 키 + args suffix
39
+ if (typeof keyOption === "string") {
40
+ const argsSuffix = serializeArgs(args);
41
+ return argsSuffix ? `${keyOption}:${argsSuffix}` : keyOption;
42
+ }
43
+
44
+ // 자동 생성: ModelName.methodName:serializedArgs
45
+ const baseKey = `${modelName}.${methodName}`;
46
+ const argsSuffix = serializeArgs(args);
47
+ return argsSuffix ? `${baseKey}:${argsSuffix}` : baseKey;
48
+ }
49
+
50
+ /**
51
+ * 인자 직렬화
52
+ */
53
+ function serializeArgs(args: unknown[]): string {
54
+ if (args.length === 0) return "";
55
+
56
+ // 단일 primitive 값
57
+ if (args.length === 1) {
58
+ const arg = args[0];
59
+ if (arg === null || arg === undefined) return "";
60
+ if (typeof arg === "string" || typeof arg === "number" || typeof arg === "boolean") {
61
+ return String(arg);
62
+ }
63
+ }
64
+
65
+ // 복잡한 값은 JSON 직렬화
66
+ try {
67
+ return JSON.stringify(args);
68
+ } catch {
69
+ // 직렬화 실패 시 toString 사용
70
+ return args.map((arg) => String(arg)).join(":");
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @cache 데코레이터
76
+ *
77
+ * 메서드의 결과를 캐싱합니다.
78
+ *
79
+ * @example
80
+ * class UserModelClass extends BaseModel {
81
+ * @cache({ ttl: '10m', tags: ['user'] })
82
+ * @api()
83
+ * async findById(id: number) {
84
+ * return this.findOne(['id', id]);
85
+ * }
86
+ * }
87
+ */
88
+ export function cache(options: CacheDecoratorOptions = {}) {
89
+ return (_target: DecoratorTarget, propertyKey: string, descriptor: PropertyDescriptor) => {
90
+ const originalMethod = descriptor.value;
91
+
92
+ descriptor.value = async function (this: BaseModelClass | BaseFrameClass, ...args: unknown[]) {
93
+ const manager = cacheManagerRef;
94
+
95
+ if (!manager) {
96
+ throw new Error(
97
+ "CacheManager is not initialized. Please configure 'cache' in sonamu.config.ts.",
98
+ );
99
+ }
100
+
101
+ const modelName = this instanceof BaseModelClass ? this.modelName : this.frameName;
102
+ const methodName = propertyKey;
103
+
104
+ const cacheKey = generateCacheKey(modelName, methodName, args, options.key);
105
+ const store = options.store ? manager.use(options.store) : manager;
106
+
107
+ return store.getOrSet({
108
+ ...options,
109
+ key: cacheKey,
110
+ factory: () => originalMethod.apply(this, args),
111
+ });
112
+ };
113
+
114
+ return descriptor;
115
+ };
116
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * BentoCache 드라이버 re-export
3
+ *
4
+ * @example
5
+ * import { drivers, store } from 'sonamu/cache';
6
+ *
7
+ * cache: {
8
+ * stores: {
9
+ * main: store()
10
+ * .useL1Layer(drivers.memory({ maxSize: '100mb' }))
11
+ * .useL2Layer(drivers.redis({ connection }))
12
+ * .useBus(drivers.redisBus({ connection }))
13
+ * }
14
+ * }
15
+ */
16
+
17
+ // Store builder
18
+ export { bentostore as store } from "bentocache";
19
+
20
+ import { fileDriver as _fileDriver } from "bentocache/drivers/file";
21
+ import { knexDriver as _knexDriver } from "bentocache/drivers/knex";
22
+ import { memoryDriver as _memoryDriver } from "bentocache/drivers/memory";
23
+ import {
24
+ redisBusDriver as _redisBusDriver,
25
+ redisDriver as _redisDriver,
26
+ } from "bentocache/drivers/redis";
27
+
28
+ // 개별 드라이버 export
29
+ export const memoryDriver = _memoryDriver;
30
+ export const fileDriver = _fileDriver;
31
+ export const redisDriver = _redisDriver;
32
+ export const redisBusDriver = _redisBusDriver;
33
+ export const knexDriver = _knexDriver;
34
+
35
+ // 편의를 위한 drivers 객체
36
+ export const drivers = {
37
+ memory: _memoryDriver,
38
+ file: _fileDriver,
39
+ redis: _redisDriver,
40
+ redisBus: _redisBusDriver,
41
+ knex: _knexDriver,
42
+ };