sonamu 0.9.4 → 0.9.6

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 (171) hide show
  1. package/dist/ai/providers/rtzr/utils.js +2 -2
  2. package/dist/api/config.d.ts +13 -2
  3. package/dist/api/config.d.ts.map +1 -1
  4. package/dist/api/config.js +1 -1
  5. package/dist/api/context.d.ts +17 -7
  6. package/dist/api/context.d.ts.map +1 -1
  7. package/dist/api/context.js +1 -1
  8. package/dist/api/decorators.d.ts +18 -0
  9. package/dist/api/decorators.d.ts.map +1 -1
  10. package/dist/api/decorators.js +54 -3
  11. package/dist/api/index.js +8 -3
  12. package/dist/api/sonamu.d.ts +24 -9
  13. package/dist/api/sonamu.d.ts.map +1 -1
  14. package/dist/api/sonamu.js +365 -79
  15. package/dist/api/websocket-helpers.d.ts +24 -0
  16. package/dist/api/websocket-helpers.d.ts.map +1 -0
  17. package/dist/api/websocket-helpers.js +77 -0
  18. package/dist/bin/cli.js +12 -4
  19. package/dist/database/upsert-builder.js +4 -4
  20. package/dist/dict/sonamu-dictionary.js +6 -6
  21. package/dist/entity/entity-manager.js +1 -1
  22. package/dist/entity/entity.js +3 -3
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +16 -4
  26. package/dist/migration/code-generation.d.ts.map +1 -1
  27. package/dist/migration/code-generation.js +8 -9
  28. package/dist/stream/index.d.ts +6 -0
  29. package/dist/stream/index.d.ts.map +1 -1
  30. package/dist/stream/index.js +13 -2
  31. package/dist/stream/ws-audience-resolver.d.ts +15 -0
  32. package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
  33. package/dist/stream/ws-audience-resolver.js +31 -0
  34. package/dist/stream/ws-audience.d.ts +28 -0
  35. package/dist/stream/ws-audience.d.ts.map +1 -0
  36. package/dist/stream/ws-audience.js +46 -0
  37. package/dist/stream/ws-cluster-bus.d.ts +23 -0
  38. package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
  39. package/dist/stream/ws-cluster-bus.js +18 -0
  40. package/dist/stream/ws-core.d.ts +15 -0
  41. package/dist/stream/ws-core.d.ts.map +1 -0
  42. package/dist/stream/ws-core.js +1 -0
  43. package/dist/stream/ws-delivery.d.ts +24 -0
  44. package/dist/stream/ws-delivery.d.ts.map +1 -0
  45. package/dist/stream/ws-delivery.js +103 -0
  46. package/dist/stream/ws-local-connection-store.d.ts +10 -0
  47. package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
  48. package/dist/stream/ws-local-connection-store.js +44 -0
  49. package/dist/stream/ws-presence-store.d.ts +61 -0
  50. package/dist/stream/ws-presence-store.d.ts.map +1 -0
  51. package/dist/stream/ws-presence-store.js +236 -0
  52. package/dist/stream/ws-registry.d.ts +42 -0
  53. package/dist/stream/ws-registry.d.ts.map +1 -0
  54. package/dist/stream/ws-registry.js +108 -0
  55. package/dist/stream/ws.d.ts +52 -0
  56. package/dist/stream/ws.d.ts.map +1 -0
  57. package/dist/stream/ws.js +397 -0
  58. package/dist/syncer/api-parser.d.ts.map +1 -1
  59. package/dist/syncer/api-parser.js +72 -2
  60. package/dist/syncer/checksum.d.ts.map +1 -1
  61. package/dist/syncer/checksum.js +13 -12
  62. package/dist/syncer/code-generator.d.ts.map +1 -1
  63. package/dist/syncer/code-generator.js +7 -4
  64. package/dist/syncer/event-batcher.d.ts +27 -0
  65. package/dist/syncer/event-batcher.d.ts.map +1 -0
  66. package/dist/syncer/event-batcher.js +69 -0
  67. package/dist/syncer/file-patterns.d.ts +48 -26
  68. package/dist/syncer/file-patterns.d.ts.map +1 -1
  69. package/dist/syncer/file-patterns.js +71 -23
  70. package/dist/syncer/file-tracking.d.ts +13 -0
  71. package/dist/syncer/file-tracking.d.ts.map +1 -0
  72. package/dist/syncer/file-tracking.js +33 -0
  73. package/dist/syncer/index.js +2 -2
  74. package/dist/syncer/module-loader.d.ts +2 -11
  75. package/dist/syncer/module-loader.d.ts.map +1 -1
  76. package/dist/syncer/module-loader.js +3 -3
  77. package/dist/syncer/syncer-actions.d.ts +39 -6
  78. package/dist/syncer/syncer-actions.d.ts.map +1 -1
  79. package/dist/syncer/syncer-actions.js +125 -10
  80. package/dist/syncer/syncer.d.ts +33 -19
  81. package/dist/syncer/syncer.d.ts.map +1 -1
  82. package/dist/syncer/syncer.js +168 -168
  83. package/dist/syncer/watcher.d.ts +8 -0
  84. package/dist/syncer/watcher.d.ts.map +1 -0
  85. package/dist/syncer/watcher.js +105 -0
  86. package/dist/tasks/workflow-manager.d.ts.map +1 -1
  87. package/dist/tasks/workflow-manager.js +2 -1
  88. package/dist/template/implementations/services.template.d.ts.map +1 -1
  89. package/dist/template/implementations/services.template.js +36 -1
  90. package/dist/testing/bootstrap.d.ts.map +1 -1
  91. package/dist/testing/bootstrap.js +8 -1
  92. package/dist/testing/data-explorer.d.ts.map +1 -1
  93. package/dist/testing/data-explorer.js +5 -3
  94. package/dist/testing/fixture-manager.js +1 -1
  95. package/dist/types/types.d.ts +2 -1
  96. package/dist/types/types.d.ts.map +1 -1
  97. package/dist/types/types.js +2 -2
  98. package/dist/ui/api.d.ts.map +1 -1
  99. package/dist/ui/api.js +4 -3
  100. package/dist/ui/cdd-service.js +1 -1
  101. package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
  102. package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
  103. package/dist/ui-web/index.html +2 -2
  104. package/dist/utils/async-utils.d.ts +27 -3
  105. package/dist/utils/async-utils.d.ts.map +1 -1
  106. package/dist/utils/async-utils.js +56 -6
  107. package/dist/utils/formatter.d.ts +7 -1
  108. package/dist/utils/formatter.d.ts.map +1 -1
  109. package/dist/utils/formatter.js +95 -60
  110. package/dist/utils/fs-utils.d.ts +2 -0
  111. package/dist/utils/fs-utils.d.ts.map +1 -1
  112. package/dist/utils/fs-utils.js +10 -2
  113. package/dist/utils/process-utils.d.ts +6 -0
  114. package/dist/utils/process-utils.d.ts.map +1 -1
  115. package/dist/utils/process-utils.js +16 -3
  116. package/dist/utils/utils.d.ts +1 -0
  117. package/dist/utils/utils.d.ts.map +1 -1
  118. package/dist/utils/utils.js +2 -2
  119. package/package.json +7 -5
  120. package/src/ai/providers/rtzr/utils.ts +1 -1
  121. package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
  122. package/src/api/__tests__/websocket-context.types.test.ts +58 -0
  123. package/src/api/config.ts +28 -2
  124. package/src/api/context.ts +21 -7
  125. package/src/api/decorators.ts +101 -1
  126. package/src/api/sonamu.ts +529 -127
  127. package/src/api/websocket-helpers.ts +122 -0
  128. package/src/bin/cli.ts +10 -2
  129. package/src/database/upsert-builder.ts +3 -3
  130. package/src/dict/sonamu-dictionary.ts +3 -3
  131. package/src/entity/entity.ts +1 -1
  132. package/src/index.ts +6 -0
  133. package/src/migration/code-generation.ts +6 -11
  134. package/src/shared/app.shared.ts.txt +312 -4
  135. package/src/shared/web.shared.ts.txt +340 -4
  136. package/src/stream/__tests__/ws-contracts.test.ts +381 -0
  137. package/src/stream/__tests__/ws.test.ts +449 -0
  138. package/src/stream/index.ts +6 -0
  139. package/src/stream/ws-audience-resolver.ts +35 -0
  140. package/src/stream/ws-audience.ts +62 -0
  141. package/src/stream/ws-cluster-bus.ts +32 -0
  142. package/src/stream/ws-core.ts +16 -0
  143. package/src/stream/ws-delivery.ts +138 -0
  144. package/src/stream/ws-local-connection-store.ts +44 -0
  145. package/src/stream/ws-presence-store.ts +326 -0
  146. package/src/stream/ws-registry.ts +138 -0
  147. package/src/stream/ws.ts +591 -0
  148. package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
  149. package/src/syncer/api-parser.ts +112 -1
  150. package/src/syncer/checksum.ts +23 -29
  151. package/src/syncer/code-generator.ts +4 -1
  152. package/src/syncer/event-batcher.ts +72 -0
  153. package/src/syncer/file-patterns.ts +98 -30
  154. package/src/syncer/file-tracking.ts +27 -0
  155. package/src/syncer/module-loader.ts +5 -12
  156. package/src/syncer/syncer-actions.ts +179 -17
  157. package/src/syncer/syncer.ts +250 -287
  158. package/src/syncer/watcher.ts +128 -0
  159. package/src/tasks/workflow-manager.ts +1 -0
  160. package/src/template/__tests__/services.template.websocket.test.ts +79 -0
  161. package/src/template/implementations/services.template.ts +69 -0
  162. package/src/testing/bootstrap.ts +8 -1
  163. package/src/testing/data-explorer.ts +3 -2
  164. package/src/types/types.ts +20 -2
  165. package/src/ui/api.ts +10 -1
  166. package/src/utils/async-utils.ts +71 -4
  167. package/src/utils/formatter.ts +114 -75
  168. package/src/utils/fs-utils.ts +9 -0
  169. package/src/utils/process-utils.ts +17 -0
  170. package/src/utils/utils.ts +1 -1
  171. package/dist/ui-web/assets/index-Dr8pRJC_.css +0 -1
package/src/api/sonamu.ts CHANGED
@@ -1,15 +1,17 @@
1
- import assert from "assert";
2
1
  import { AsyncLocalStorage } from "async_hooks";
3
2
  import fs from "fs/promises";
4
3
  import { type IncomingMessage, type Server, type ServerResponse } from "http";
5
4
  import os from "os";
6
5
  import path from "path";
7
6
 
7
+ import type {} from "@fastify/websocket";
8
8
  import { dispose as logtapeDispose } from "@logtape/logtape";
9
9
  import { type Auth, type BetterAuthOptions } from "better-auth";
10
+ import chalk from "chalk";
10
11
  import { type FSWatcher } from "chokidar";
11
12
  import { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
12
13
  import mime, { lookup as mimeLookup } from "mime-types";
14
+ import { type WebSocket } from "ws";
13
15
  import { type ZodObject } from "zod";
14
16
 
15
17
  import { BASE_FIELD_MAPPINGS } from "../auth/better-auth-entities";
@@ -28,43 +30,60 @@ import { type StorageManager } from "../storage/storage-manager";
28
30
  import { type KeyGenerator } from "../storage/types";
29
31
  import { UploadedFile } from "../storage/uploaded-file";
30
32
  import { createMockSSEFactory } from "../stream/sse";
33
+ import { WebSocketRuntime, type WebSocketConnection, type WebSocketEventMap } from "../stream/ws";
31
34
  import { type Syncer } from "../syncer/syncer";
32
35
  import { type WorkflowManager } from "../tasks/workflow-manager";
33
36
  import { type DevVitestManager } from "../testing/dev-vitest-manager";
34
37
  import { type SonamuFastifyConfig } from "../types/types";
38
+ import { centerText } from "../utils/console-util";
35
39
  import { isDaemonServer } from "../utils/controller";
36
40
  import { exists, fileExists } from "../utils/fs-utils";
37
41
  import { type AbsolutePath } from "../utils/path-utils";
38
42
  import { convertFastifyHeadersToStandard, merge } from "../utils/utils";
39
43
  import { type SonamuConfig, type SonamuServerOptions, type SonamuTaskOptions } from "./config";
40
- import { type Context } from "./context";
44
+ import { type Context, type RuntimeContext, type WebSocketContext } from "./context";
41
45
  import { type ExtendedApi } from "./decorators";
42
46
  import { getSecrets } from "./secret";
43
47
  import { type SonamuSecrets } from "./secret";
48
+ import {
49
+ createWebSocketReplyStub,
50
+ resolveWebSocketCloseDescriptor,
51
+ resolveWebSocketPluginOptions,
52
+ resolveIntegratedViteHmrOptions,
53
+ } from "./websocket-helpers";
54
+
55
+ export {
56
+ createWebSocketReplyStub,
57
+ resolveWebSocketCloseDescriptor,
58
+ resolveWebSocketPluginOptions,
59
+ } from "./websocket-helpers";
44
60
 
45
61
  class SonamuClass {
46
62
  public isInitialized: boolean = false;
47
63
  public forTesting: boolean = false;
48
64
  public asyncLocalStorage: AsyncLocalStorage<{
49
- context: Context;
65
+ context: RuntimeContext;
50
66
  }> = new AsyncLocalStorage();
51
67
 
52
- public getContext(): Context {
68
+ public getContext<T extends RuntimeContext = Context>(): T {
53
69
  const store = this.asyncLocalStorage.getStore();
54
70
  if (store?.context) {
55
- return store.context;
71
+ return store.context as T;
56
72
  }
57
73
 
58
74
  if (process.env.NODE_ENV === "test") {
59
75
  // 테스팅 환경에서 컨텍스트가 주입되지 않은 경우 빈 컨텍스트 리턴
60
76
  return {
77
+ transport: "http",
61
78
  request: null,
62
79
  reply: null,
63
80
  headers: {},
64
81
  createSSE: (schema: ZodObject) => createMockSSEFactory(schema),
65
- // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- 테스팅 환경에서 컨텍스트가 주입되지 않은 경우 빈 컨텍스트 리턴
82
+ locale: "",
83
+ user: null,
84
+ session: null,
66
85
  naiteStore: new Map<string, any>(),
67
- } as unknown as Context;
86
+ } as unknown as T;
68
87
  } else {
69
88
  throw new Error("Sonamu cannot find context");
70
89
  }
@@ -166,10 +185,24 @@ class SonamuClass {
166
185
  this._devVitestManager = manager;
167
186
  }
168
187
 
169
- // HMR 처리
188
+ // Sonamu가 runtime을 직접 소유해 registry/connection lifecycle을 애플리케이션 수명주기와 동기화함
189
+ private _websocketRuntime: WebSocketRuntime | null = null;
190
+ // 같은 Fastify 인스턴스에 @fastify/websocket을 중복 등록하는 것을 WeakSet으로 차단함
191
+ private readonly websocketPluginServers = new WeakSet<
192
+ FastifyInstance<Server, IncomingMessage, ServerResponse>
193
+ >();
194
+ get websocketRuntime(): WebSocketRuntime {
195
+ if (!this._websocketRuntime) {
196
+ throw new Error("WebSocket runtime has not been initialized.");
197
+ }
198
+ return this._websocketRuntime;
199
+ }
200
+ set websocketRuntime(runtime: WebSocketRuntime | null) {
201
+ this._websocketRuntime = runtime;
202
+ }
203
+
204
+ // HMR 처리: 파일 시스템 감시 + HMR/sync 사이클 실행은 watcher 모듈로 위임합니다.
170
205
  public watcher: FSWatcher | null = null;
171
- private pendingFiles: string[] = [];
172
- private hmrStartTime: number = 0;
173
206
 
174
207
  public server: FastifyInstance | null = null;
175
208
 
@@ -264,7 +297,7 @@ class SonamuClass {
264
297
  await this.syncer.autoloadWorkflows();
265
298
  const { TemplateManager } = await import("../template");
266
299
  await TemplateManager.autoload();
267
- await this.syncer.autoloadSSRRoutes();
300
+ await this.syncer.autoloadSsrRoutes();
268
301
 
269
302
  const { isLocal, isTest, isHotReloadServer } = await import("../utils/controller");
270
303
  if (isLocal() && !isTest() && isHotReloadServer() && enableSync) {
@@ -298,6 +331,7 @@ class SonamuClass {
298
331
  : undefined,
299
332
  });
300
333
  this.server = server;
334
+ this.websocketRuntime = new WebSocketRuntime(options.websocket);
301
335
 
302
336
  // Storage 설정 → StorageManager 생성
303
337
  if (options.storage) {
@@ -343,6 +377,7 @@ class SonamuClass {
343
377
  }
344
378
 
345
379
  this.server = server;
380
+ this.websocketRuntime ??= new WebSocketRuntime(this.config.server.websocket);
346
381
 
347
382
  // timezone 설정
348
383
  const timezone = this.config.api.timezone;
@@ -435,6 +470,17 @@ class SonamuClass {
435
470
  throw new Error(`정의되지 않은 모델에 접근 ${api.modelName}`);
436
471
  }
437
472
 
473
+ // @websocket route는 wsHandler로 등록하고, 같은 path의 일반 HTTP GET은 426 응답으로 upgrade를 강제함
474
+ if (api.websocketOptions) {
475
+ server.route({
476
+ method: "GET",
477
+ url: this.config.api.route.prefix + api.path,
478
+ handler: this.createWebSocketUpgradeRequiredHandler(),
479
+ wsHandler: this.createWebSocketHandler(api, config),
480
+ });
481
+ continue;
482
+ }
483
+
438
484
  server.route({
439
485
  method: api.options.httpMethod ?? "GET",
440
486
  url: this.config.api.route.prefix + api.path,
@@ -460,16 +506,29 @@ class SonamuClass {
460
506
  request: FastifyRequest,
461
507
  config: SonamuFastifyConfig,
462
508
  ): ((request: FastifyRequest, reply: FastifyReply) => Promise<unknown>) | null {
509
+ const matchedApi = this.findMatchedApi(request);
510
+
511
+ if (!matchedApi) {
512
+ throw new NotFoundException(SD("error.api.notFound"));
513
+ }
514
+
515
+ // websocket route를 일반 HTTP로 직접 호출한 경우 426을 돌려줘 upgrade 없이 접근하는 것을 차단함
516
+ if (matchedApi.websocketOptions) {
517
+ return this.createWebSocketUpgradeRequiredHandler();
518
+ }
519
+
520
+ return this.createApiHandler(matchedApi, config);
521
+ }
522
+
523
+ private findMatchedApi(request: FastifyRequest): ExtendedApi | undefined {
463
524
  const url = this.getPathnameFromUrl(request.url);
464
525
  const method = request.method;
465
526
 
466
527
  if (!url.startsWith(this.config.api.route.prefix)) {
467
- return null;
528
+ return undefined;
468
529
  }
469
530
 
470
- // syncer.apis path는 :param 형태를 포함할 수 있으므로 세그먼트 단위로 매칭합니다.
471
- // 정규식 생성 방식은 path 문자열 내 특수문자(., +, (, [ 등)로 오작동할 수 있어 사용하지 않습니다.
472
- const matchedApi = this.syncer.apis.find((api) => {
531
+ return this.syncer.apis.find((api) => {
473
532
  if (this.syncer.models[api.modelName] === undefined) {
474
533
  return false;
475
534
  }
@@ -479,12 +538,6 @@ class SonamuClass {
479
538
  const fullPath = this.config.api.route.prefix + api.path;
480
539
  return this.isPathPatternMatch(fullPath, url);
481
540
  });
482
-
483
- if (!matchedApi) {
484
- throw new NotFoundException(SD("error.api.notFound"));
485
- }
486
-
487
- return this.createApiHandler(matchedApi, config);
488
541
  }
489
542
 
490
543
  /**
@@ -495,8 +548,9 @@ class SonamuClass {
495
548
  server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
496
549
  config: SonamuFastifyConfig,
497
550
  ): void {
551
+ // upgrade는 실질적으로 GET에서만 성립하므로 GET + wsHandler 와 그 외 method를 별도 route로 분리함
498
552
  server.route({
499
- method: ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"],
553
+ method: "GET",
500
554
  url: `${this.config.api.route.prefix}/*`,
501
555
  handler: async (request, reply) => {
502
556
  const handler = this.handleDevApiRequest(request, config);
@@ -506,10 +560,24 @@ class SonamuClass {
506
560
  // 등록된 API와 일치하지 않는 요청에 대한 fallback입니다.
507
561
  throw new NotFoundException(SD("error.api.notFound"));
508
562
  },
563
+ wsHandler: async (connection, request) => {
564
+ await this.handleDevWebSocketRequest(connection.socket, request, config);
565
+ },
566
+ });
567
+
568
+ server.route({
569
+ method: ["HEAD", "POST", "PUT", "DELETE", "PATCH"],
570
+ url: `${this.config.api.route.prefix}/*`,
571
+ handler: async (request, reply) => {
572
+ const handler = this.handleDevApiRequest(request, config);
573
+ if (handler) {
574
+ return handler(request, reply);
575
+ }
576
+ throw new NotFoundException(SD("error.api.notFound"));
577
+ },
509
578
  });
510
579
  }
511
580
 
512
- // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- ViteDevServer 타입을 동적으로 로드해야 함
513
581
  private viteServer: any = null;
514
582
 
515
583
  /**
@@ -525,14 +593,18 @@ class SonamuClass {
525
593
  await server.register((await import("@fastify/middie")).default);
526
594
 
527
595
  const vite = await import("vite");
596
+ // @fastify/websocket 플러그인이 활성화되면 HMR websocket과 server socket이 충돌하므로 dedicated 포트로 분리함
597
+ const requiresDedicatedHmrServer = Boolean(this.config.server.plugins?.ws);
598
+ const hmr = resolveIntegratedViteHmrOptions({
599
+ httpServer: server.server,
600
+ requiresDedicatedWebSocketServer: requiresDedicatedHmrServer,
601
+ });
528
602
 
529
603
  this.viteServer = await vite.createServer({
530
604
  root: webPath,
531
605
  server: {
532
606
  middlewareMode: true,
533
- hmr: {
534
- server: server.server,
535
- },
607
+ hmr,
536
608
  },
537
609
  appType: "custom",
538
610
  });
@@ -547,21 +619,43 @@ class SonamuClass {
547
619
  return this.viteServer.middlewares(req, res, next);
548
620
  });
549
621
 
550
- // catch-all 라우트에서 동적으로 API/SSR 처리
551
- // 개발 환경에서는 라우트별 compress 옵션을 포기하고 HMR 이점을 취합니다.
622
+ // WS upgrade 경로(GET)와 일반 HTTP 메서드를 별도 route로 분리해 websocket route가 HTML fallback에 먹히지 않도록 함
552
623
  server.route({
553
- method: ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"],
554
- url: "/*",
624
+ method: "GET",
625
+ url: `${this.config.api.route.prefix}/*`,
555
626
  handler: async (request, reply) => {
556
- // 1. API 요청 처리
557
627
  const result = this.handleDevApiRequest(request, config);
558
628
  if (result) {
559
629
  return result(request, reply);
560
630
  }
631
+ throw new NotFoundException(SD("error.api.notFound"));
632
+ },
633
+ wsHandler: async (connection, request) => {
634
+ await this.handleDevWebSocketRequest(connection.socket, request, config);
635
+ },
636
+ });
561
637
 
638
+ server.route({
639
+ method: ["HEAD", "POST", "PUT", "DELETE", "PATCH"],
640
+ url: `${this.config.api.route.prefix}/*`,
641
+ handler: async (request, reply) => {
642
+ const result = this.handleDevApiRequest(request, config);
643
+ if (result) {
644
+ return result(request, reply);
645
+ }
646
+ throw new NotFoundException(SD("error.api.notFound"));
647
+ },
648
+ });
649
+
650
+ // catch-all 라우트에서 SSR/CSR 처리
651
+ // 개발 환경에서는 라우트별 compress 옵션을 포기하고 HMR 이점을 취합니다.
652
+ server.route({
653
+ method: ["GET", "HEAD"],
654
+ url: "/*",
655
+ handler: async (request, reply) => {
562
656
  const url = request.url;
563
657
 
564
- // 2. SSR 라우트 처리
658
+ // 1. SSR 라우트 처리
565
659
  const { matchSSRRoute, renderSSR } = await import("../ssr");
566
660
  const ssrMatch = matchSSRRoute(url);
567
661
  if (ssrMatch) {
@@ -579,7 +673,7 @@ class SonamuClass {
579
673
  return html;
580
674
  }
581
675
 
582
- // 3. CSR fallback
676
+ // 2. CSR fallback
583
677
  try {
584
678
  const fs = await import("node:fs/promises");
585
679
  let template = await fs.readFile(
@@ -605,6 +699,13 @@ class SonamuClass {
605
699
  });
606
700
 
607
701
  const chalk = (await import("chalk")).default;
702
+ if ("port" in hmr) {
703
+ console.log(
704
+ chalk.dim(
705
+ `✓ Vite HMR using dedicated websocket port ${hmr.port} to avoid Fastify websocket conflicts`,
706
+ ),
707
+ );
708
+ }
608
709
  console.log(chalk.dim("✓ Vite dev server integrated"));
609
710
  }
610
711
 
@@ -789,7 +890,12 @@ class SonamuClass {
789
890
 
790
891
  return this.asyncLocalStorage.run({ context }, async () => {
791
892
  // guards 처리
792
- (api.options.guards ?? []).every((guard) => config.guardHandler(guard, request, api));
893
+ runGuards({
894
+ guards: api.options.guards,
895
+ config,
896
+ request,
897
+ api,
898
+ });
793
899
 
794
900
  // 파라미터 정보로 zod 스키마 빌드
795
901
  const { getZodObjectFromApi } = await import("./code-converters");
@@ -932,6 +1038,201 @@ class SonamuClass {
932
1038
  };
933
1039
  }
934
1040
 
1041
+ // WS path를 일반 HTTP GET으로 호출한 경우 426 + Upgrade 헤더로 명시적으로 websocket 접속을 유도함
1042
+ private createWebSocketUpgradeRequiredHandler() {
1043
+ return async (_request: FastifyRequest, reply: FastifyReply): Promise<void> => {
1044
+ reply.header("connection", "Upgrade").header("upgrade", "websocket").status(426).send({
1045
+ message: "WebSocket upgrade required",
1046
+ });
1047
+ };
1048
+ }
1049
+
1050
+ // dev 모드의 catch-all wsHandler에서 실제 WS API로 디스패치함. 매칭되는 route가 없으면 1008로 닫음
1051
+ private async handleDevWebSocketRequest(
1052
+ socket: WebSocket,
1053
+ request: FastifyRequest,
1054
+ config: SonamuFastifyConfig,
1055
+ ): Promise<void> {
1056
+ const matchedApi = this.findMatchedApi(request);
1057
+ if (!matchedApi?.websocketOptions) {
1058
+ socket.close(1008, "WebSocket route not found");
1059
+ return;
1060
+ }
1061
+
1062
+ const handler = this.createWebSocketHandler(matchedApi, config);
1063
+ await handler({ socket }, request);
1064
+ }
1065
+
1066
+ // WS route 핸들러의 실행 순서를 고정함:
1067
+ // 1) guard를 connection 등록 이전에 돌려 인증 실패 시 부분 등록 상태를 남기지 않음
1068
+ // 2) query param 파싱도 activation 전에 끝내 handshake 실패가 registry에 노출되지 않게 함
1069
+ // 3) `active: false`로 먼저 등록하고, context 준비가 끝난 뒤 `activate()`해 브로드캐스트가 초기화 중간 상태를 보지 못하게 함
1070
+ // 에러 발생 시에는 resolveWebSocketCloseDescriptor 정책에 따라 close code를 매핑함
1071
+ private createWebSocketHandler(api: ExtendedApi, config: SonamuFastifyConfig) {
1072
+ return async (
1073
+ connection: {
1074
+ socket: WebSocket;
1075
+ },
1076
+ request: FastifyRequest,
1077
+ ): Promise<void> => {
1078
+ const socket = connection.socket;
1079
+ let wsContext: WebSocketContext | null = null;
1080
+ let rawWs: ReturnType<WebSocketRuntime["registerConnection"]> | null = null;
1081
+
1082
+ try {
1083
+ runGuards({
1084
+ guards: api.options.guards,
1085
+ config,
1086
+ request,
1087
+ api,
1088
+ });
1089
+
1090
+ const reqBody = await this.parseWebSocketRequestParams(api, request);
1091
+ rawWs = this.websocketRuntime.registerConnection(socket, {
1092
+ outEvents: api.websocketOptions!.outEvents,
1093
+ inEvents: api.websocketOptions!.inEvents,
1094
+ namespace: api.websocketOptions!.namespace,
1095
+ heartbeat: api.websocketOptions!.heartbeat,
1096
+ maxPayload: api.websocketOptions!.maxPayload,
1097
+ active: false,
1098
+ });
1099
+
1100
+ const scopedWs = this.createScopedWebSocketConnection(rawWs, () => wsContext);
1101
+ wsContext = await this.createWebSocketContext(config, request, scopedWs);
1102
+ this.websocketRuntime.activateConnection(rawWs.id);
1103
+
1104
+ const { ApiParamType } = await import("../types/types");
1105
+ const args = api.parameters.map((param) => {
1106
+ if (ApiParamType.isContext(param.type)) {
1107
+ return wsContext;
1108
+ }
1109
+
1110
+ return reqBody[param.name];
1111
+ });
1112
+
1113
+ await this.asyncLocalStorage.run({ context: wsContext }, async () => {
1114
+ await this.invokeModelMethod(api, args);
1115
+ });
1116
+ } catch (error) {
1117
+ const closeDescriptor = resolveWebSocketCloseDescriptor(error);
1118
+ if (rawWs) {
1119
+ rawWs.close(closeDescriptor.code, closeDescriptor.reason);
1120
+ } else if (socket.readyState < 2) {
1121
+ socket.close(closeDescriptor.code, closeDescriptor.reason);
1122
+ }
1123
+
1124
+ if (this.server?.log) {
1125
+ const payload = {
1126
+ err: error,
1127
+ modelName: api.modelName,
1128
+ methodName: api.methodName,
1129
+ path: api.path,
1130
+ };
1131
+ if (closeDescriptor.logLevel === "warn") {
1132
+ this.server.log.warn(payload, closeDescriptor.reason);
1133
+ } else {
1134
+ this.server.log.error(payload, closeDescriptor.reason);
1135
+ }
1136
+ } else {
1137
+ if (closeDescriptor.logLevel === "warn") {
1138
+ console.warn(closeDescriptor.reason, error);
1139
+ } else {
1140
+ console.error(closeDescriptor.reason, error);
1141
+ }
1142
+ }
1143
+ }
1144
+ };
1145
+ }
1146
+
1147
+ // onMessage/onClose처럼 다른 tick에서 실행되는 callback은 ALS context가 끊기므로 wrapper에서 `asyncLocalStorage.run`으로 다시 감싸 복원함
1148
+ // publish/join/leave/setUserId 같은 즉시 실행 API는 단순 위임만 하고, deferred callback에만 context 복원을 적용함
1149
+ private createScopedWebSocketConnection<
1150
+ TOut extends WebSocketEventMap,
1151
+ TIn extends WebSocketEventMap,
1152
+ >(
1153
+ ws: WebSocketConnection<TOut, TIn>,
1154
+ getContext: () => WebSocketContext | null,
1155
+ ): WebSocketConnection<TOut, TIn> {
1156
+ const runInContext = <T>(callback: () => T): T => {
1157
+ const context = getContext();
1158
+ if (!context) {
1159
+ return callback();
1160
+ }
1161
+
1162
+ return this.asyncLocalStorage.run({ context }, callback);
1163
+ };
1164
+
1165
+ return {
1166
+ get id() {
1167
+ return ws.id;
1168
+ },
1169
+ get namespace() {
1170
+ return ws.namespace;
1171
+ },
1172
+ get closed() {
1173
+ return ws.closed;
1174
+ },
1175
+ transport: "ws",
1176
+ publishUntyped(event, data) {
1177
+ ws.publishUntyped(event, data);
1178
+ },
1179
+ close(code, reason) {
1180
+ ws.close(code, reason);
1181
+ },
1182
+ onClose(callback) {
1183
+ ws.onClose(() => runInContext(callback));
1184
+ },
1185
+ onMessage(event, handler) {
1186
+ ws.onMessage(event, (data) => runInContext(() => handler(data)));
1187
+ },
1188
+ publish(event, data) {
1189
+ ws.publish(event, data);
1190
+ },
1191
+ waitForClose() {
1192
+ return ws.waitForClose();
1193
+ },
1194
+ join(roomId) {
1195
+ ws.join(roomId);
1196
+ },
1197
+ leave(roomId) {
1198
+ ws.leave(roomId);
1199
+ },
1200
+ setUserId(userId) {
1201
+ ws.setUserId(userId);
1202
+ },
1203
+ clearUserId() {
1204
+ ws.clearUserId();
1205
+ },
1206
+ };
1207
+ }
1208
+
1209
+ private async parseWebSocketRequestParams(
1210
+ api: ExtendedApi,
1211
+ request: FastifyRequest,
1212
+ ): Promise<Record<string, unknown>> {
1213
+ const { getZodObjectFromApi } = await import("./code-converters");
1214
+ const ReqType = getZodObjectFromApi(api, this.syncer.types);
1215
+
1216
+ try {
1217
+ const { fastifyCaster } = await import("./caster");
1218
+ return fastifyCaster(ReqType).parse((request.query ?? {}) as Record<string, unknown>);
1219
+ } catch (e) {
1220
+ const { ZodError } = await import("zod");
1221
+ if (e instanceof ZodError) {
1222
+ const { humanizeZodError } = await import("../utils/zod-error");
1223
+ const messages = humanizeZodError(e)
1224
+ .map((issue) => issue.message)
1225
+ .join(" ");
1226
+ const { BadRequestException } = await import("../exceptions/so-exceptions");
1227
+ throw new BadRequestException(messages as LocalizedString, {
1228
+ zodError: e,
1229
+ });
1230
+ }
1231
+
1232
+ throw e;
1233
+ }
1234
+ }
1235
+
935
1236
  /**
936
1237
  * URL에서 path params를 추출합니다.
937
1238
  * 예: pattern="/admin/companies/:companyId", url="/admin/companies/123" → { companyId: "123" }
@@ -1029,7 +1330,6 @@ class SonamuClass {
1029
1330
  */
1030
1331
  async invokeApiForSSR(
1031
1332
  api: ExtendedApi,
1032
- // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- SSR에서 다양한 타입의 params를 받아야 함
1033
1333
  params: any[],
1034
1334
  config: SonamuFastifyConfig,
1035
1335
  request: FastifyRequest,
@@ -1054,15 +1354,15 @@ class SonamuClass {
1054
1354
  });
1055
1355
  }
1056
1356
 
1357
+ // WS 경로에서는 HTTP reply가 없으므로 reply를 optional로 받아 공통 호출 경로를 유지함
1057
1358
  async invokeModelMethod(
1058
1359
  api: ExtendedApi,
1059
1360
  args: unknown[],
1060
- reply: FastifyReply,
1361
+ reply?: FastifyReply,
1061
1362
  ): Promise<unknown> {
1062
1363
  const model = this.syncer.models[api.modelName];
1063
- // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- model은 모델 인스턴스이므로 메서드 호출 가능
1064
1364
  const result = await (model as any)[api.methodName].apply(model, args);
1065
- reply.type(api.options.contentType ?? "application/json");
1365
+ reply?.type(api.options.contentType ?? "application/json");
1066
1366
 
1067
1367
  return result;
1068
1368
  }
@@ -1089,28 +1389,101 @@ class SonamuClass {
1089
1389
  const headers = convertFastifyHeadersToStandard(request.headers);
1090
1390
  const session = (await this._auth?.api.getSession({ headers })) ?? null;
1091
1391
 
1092
- const context: Context = {
1093
- ...(await Promise.resolve(
1094
- config.contextProvider(
1095
- {
1096
- request,
1097
- reply,
1098
- headers: request.headers,
1099
- createSSE,
1100
- naiteStore: new Map(),
1101
- locale,
1102
- // auth
1103
- user: session?.user ?? null,
1104
- session: session?.session ?? null,
1105
- },
1392
+ const context: Context = await Promise.resolve(
1393
+ config.contextProvider(
1394
+ {
1395
+ transport: "http",
1106
1396
  request,
1107
1397
  reply,
1108
- ),
1109
- )),
1110
- };
1398
+ headers: request.headers,
1399
+ createSSE,
1400
+ naiteStore: new Map(),
1401
+ locale,
1402
+ // auth
1403
+ user: session?.user ?? null,
1404
+ session: session?.session ?? null,
1405
+ },
1406
+ request,
1407
+ reply,
1408
+ ),
1409
+ );
1111
1410
  return context;
1112
1411
  }
1113
1412
 
1413
+ // session/locale/store 같은 공통 state는 HTTP context와 공유하되, reply/createSSE 같은 HTTP 전용 helper는 노출하지 않음
1414
+ // 사용자가 websocketContextProvider를 주면 그대로 위임하고, 없으면 기존 contextProvider를 reply/SSE stub과 함께 재활용함
1415
+ async createWebSocketContext(
1416
+ config: SonamuFastifyConfig,
1417
+ request: FastifyRequest,
1418
+ ws: WebSocketContext["ws"],
1419
+ ): Promise<WebSocketContext> {
1420
+ const locale =
1421
+ this.detectLocale(request.headers["accept-language"], this.config.i18n.supportedLocales) ??
1422
+ this.config.i18n.defaultLocale;
1423
+
1424
+ const headers = convertFastifyHeadersToStandard(request.headers);
1425
+ const session = (await this._auth?.api.getSession({ headers })) ?? null;
1426
+
1427
+ const defaultContext = {
1428
+ transport: "ws" as const,
1429
+ request,
1430
+ headers: request.headers,
1431
+ ws,
1432
+ naiteStore: new Map(),
1433
+ locale,
1434
+ user: session?.user ?? null,
1435
+ session: session?.session ?? null,
1436
+ };
1437
+
1438
+ if (config.websocketContextProvider) {
1439
+ return {
1440
+ ...(await Promise.resolve(config.websocketContextProvider(defaultContext, request))),
1441
+ };
1442
+ }
1443
+
1444
+ // reply/createSSE에 의존하는 contextProvider가 있으면 즉시 에러를 던져 transport misuse를 빨리 드러냄
1445
+ const replyStub = createWebSocketReplyStub();
1446
+ const createSSE = <T extends ZodObject>(_events: T) => {
1447
+ throw new Error(
1448
+ "createSSE is not available in websocket context. Define websocketContextProvider if your context setup depends on SSE helpers.",
1449
+ );
1450
+ };
1451
+ const httpLikeContext = await Promise.resolve(
1452
+ config.contextProvider(
1453
+ {
1454
+ transport: "http",
1455
+ request,
1456
+ reply: replyStub,
1457
+ headers: request.headers,
1458
+ createSSE,
1459
+ naiteStore: defaultContext.naiteStore,
1460
+ locale,
1461
+ user: defaultContext.user,
1462
+ session: defaultContext.session,
1463
+ },
1464
+ request,
1465
+ replyStub,
1466
+ ),
1467
+ );
1468
+
1469
+ const {
1470
+ transport: _transport,
1471
+ reply: _reply,
1472
+ createSSE: _createSSE,
1473
+ bufferedFiles: _bufferedFiles,
1474
+ uploadedFiles: _uploadedFiles,
1475
+ ...rest
1476
+ } = httpLikeContext;
1477
+
1478
+ return {
1479
+ ...rest,
1480
+ transport: "ws",
1481
+ request,
1482
+ headers: request.headers,
1483
+ ws,
1484
+ };
1485
+ }
1486
+
1114
1487
  /**
1115
1488
  * Accept-Language 헤더에서 지원하는 locale을 찾습니다.
1116
1489
  * @example "ko-KR,ko;q=0.9,en;q=0.8" → "ko"
@@ -1131,46 +1504,30 @@ class SonamuClass {
1131
1504
  }
1132
1505
 
1133
1506
  async startWatcher(): Promise<void> {
1134
- const watchPath = [path.join(this.apiRootPath, "src")];
1135
-
1136
- const chokidar = (await import("chokidar")).default;
1137
- this.watcher = chokidar.watch(watchPath, {
1138
- ignored: (path, stats) =>
1139
- !!stats?.isFile() && !path.endsWith(".ts") && !path.endsWith(".json"),
1140
- persistent: true,
1141
- ignoreInitial: true,
1142
- });
1507
+ // watcher 모듈은 file-patterns → Sonamu 순환을 피하기 위해 dynamic import 합니다.
1508
+ const { setupWatcher } = await import("../syncer/watcher");
1509
+ this.watcher = await setupWatcher((fileEvents) => this.runHmrSyncCycle(fileEvents));
1510
+ }
1143
1511
 
1144
- this.watcher.on("all", async (event: string, filePath: string) => {
1145
- const absolutePath = filePath as AbsolutePath;
1146
- assert(
1147
- absolutePath.startsWith(this.apiRootPath),
1148
- "File path is not within the API root path",
1149
- );
1512
+ /**
1513
+ * Watcher가 100ms batch로 모은 fileEvents 하나에 대해 한 번의 HMR/sync 사이클을 돕니다.
1514
+ * batch 큐잉 덕에 한 시점에 하나만 실행됨이 보장됩니다 (event-batcher가 직렬화).
1515
+ */
1516
+ private async runHmrSyncCycle(fileEvents: Map<AbsolutePath, "change" | "add">): Promise<void> {
1517
+ const startedAt = Date.now();
1150
1518
 
1151
- if (event !== "change" && event !== "add") {
1152
- return;
1153
- }
1519
+ for (const [filePath, event] of fileEvents) {
1520
+ const relativePath = path.relative(this.appRootPath, filePath);
1521
+ console.log(chalk.bold(`Detected(${event}): ${chalk.blue(relativePath)}`));
1522
+ }
1154
1523
 
1155
- try {
1156
- // sonamu.config.ts 변경 시 재시작
1157
- const isConfigTs = filePath === path.join(this.apiRootPath, "src", "sonamu.config.ts");
1158
-
1159
- if (isConfigTs) {
1160
- const relativePath = filePath.replace(this.apiRootPath, "api");
1161
- const chalk = (await import("chalk")).default;
1162
- console.log(
1163
- chalk.bold(`Detected(${event}): ${chalk.blue(relativePath)} - Restarting...`),
1164
- );
1165
- process.kill(process.pid, "SIGUSR2");
1166
- return;
1167
- }
1524
+ // 본체: 변경 흡수 + 체크섬 갱신.
1525
+ await this.syncer.hmrAndSync(fileEvents);
1526
+ await this.syncer.renewChecksums();
1168
1527
 
1169
- await this.handleFileChange(event, absolutePath);
1170
- } catch (e) {
1171
- console.error(e);
1172
- }
1173
- });
1528
+ const totalTime = Date.now() - startedAt;
1529
+ const msg = `HMR Done! ${chalk.bold.white(`${totalTime}ms`)}`;
1530
+ console.log(chalk.black.bgGreen(centerText(msg)));
1174
1531
  }
1175
1532
 
1176
1533
  /*
@@ -1235,11 +1592,72 @@ class SonamuClass {
1235
1592
  await registerPlugin(key as keyof typeof plugins, pluginName);
1236
1593
  }
1237
1594
 
1595
+ if (plugins.ws) {
1596
+ await this.ensureWebSocketPlugin(server);
1597
+ }
1598
+
1238
1599
  if (plugins.custom) {
1239
1600
  plugins.custom(server);
1240
1601
  }
1241
1602
  }
1242
1603
 
1604
+ // @fastify/websocket은 plugins.ws가 설정된 경우에만 등록하고, 같은 server에 중복 등록되지 않도록 WeakSet으로 기록함
1605
+ private async ensureWebSocketPlugin(
1606
+ server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
1607
+ ): Promise<void> {
1608
+ if (this.websocketPluginServers.has(server)) {
1609
+ return;
1610
+ }
1611
+
1612
+ const pluginOption = this.config.server.plugins?.ws;
1613
+ if (!pluginOption) {
1614
+ return;
1615
+ }
1616
+
1617
+ const websocketPlugin = (await import("@fastify/websocket")).default;
1618
+ const resolvedPluginOptions = resolveWebSocketPluginOptions({
1619
+ rawPluginOption: pluginOption,
1620
+ apis: this.syncer.apis,
1621
+ });
1622
+ if (resolvedPluginOptions) {
1623
+ await server.register(websocketPlugin, resolvedPluginOptions);
1624
+ } else {
1625
+ await server.register(websocketPlugin);
1626
+ }
1627
+
1628
+ this.websocketPluginServers.add(server);
1629
+ this.warnOnPotentialWebSocketTimeoutConflicts(server);
1630
+ }
1631
+
1632
+ // heartbeat interval이 Fastify keepAliveTimeout 이상이면 인프라가 먼저 idle 연결을 끊을 수 있어 경고만 남기고 넘어감
1633
+ private warnOnPotentialWebSocketTimeoutConflicts(
1634
+ server: FastifyInstance<Server, IncomingMessage, ServerResponse>,
1635
+ ): void {
1636
+ const heartbeats = this.syncer.apis
1637
+ .map((api) => api.websocketOptions?.heartbeat ?? 30000)
1638
+ .filter((heartbeat) => heartbeat > 0);
1639
+
1640
+ if (heartbeats.length === 0) {
1641
+ return;
1642
+ }
1643
+
1644
+ const keepAliveTimeout = this.config.server.fastify?.keepAliveTimeout;
1645
+ if (!keepAliveTimeout || keepAliveTimeout <= 0) {
1646
+ return;
1647
+ }
1648
+
1649
+ const largestHeartbeat = Math.max(...heartbeats);
1650
+ if (largestHeartbeat >= keepAliveTimeout) {
1651
+ server.log.warn(
1652
+ {
1653
+ keepAliveTimeout,
1654
+ largestHeartbeat,
1655
+ },
1656
+ "WebSocket heartbeat is greater than or equal to keepAliveTimeout; align infrastructure idle timeouts to avoid unexpected disconnects.",
1657
+ );
1658
+ }
1659
+ }
1660
+
1243
1661
  /**
1244
1662
  * better-auth 라우트를 등록합니다.
1245
1663
  * /api/auth/* 경로로 인증 API가 자동 등록됩니다.
@@ -1417,53 +1835,20 @@ class SonamuClass {
1417
1835
  });
1418
1836
  }
1419
1837
 
1420
- private async handleFileChange(event: string, filePath: AbsolutePath): Promise<void> {
1421
- // 첫 번째 파일이면 HMR 시작 시간 기록
1422
- if (this.pendingFiles.length === 0) {
1423
- this.hmrStartTime = Date.now();
1424
- }
1425
- this.pendingFiles.push(filePath);
1426
-
1427
- const relativePath = path.relative(this.apiRootPath, filePath);
1428
- const chalk = (await import("chalk")).default;
1429
- console.log(chalk.bold(`Detected(${event}): ${chalk.blue(relativePath)}`));
1430
-
1431
- await this.syncer.syncFromWatcher(event, filePath);
1432
-
1433
- // 처리 완료된 파일을 대기 목록에서 제거
1434
- this.pendingFiles = this.pendingFiles.slice(1);
1435
-
1436
- // 모든 파일 처리가 완료되면 최종 메시지 출력
1437
- if (this.pendingFiles.length === 0) {
1438
- await this.finishHMR();
1439
- }
1440
- }
1441
-
1442
- private async finishHMR(): Promise<void> {
1443
- await this.syncer.renewChecksums();
1444
-
1445
- const endTime = Date.now();
1446
- const totalTime = endTime - this.hmrStartTime;
1447
- const [chalk, { centerText }] = await Promise.all([
1448
- (await import("chalk")).default,
1449
- import("../utils/console-util"),
1450
- ]);
1451
- const msg = `HMR Done! ${chalk.bold.white(`${totalTime}ms`)}`;
1452
-
1453
- console.log(chalk.black.bgGreen(centerText(msg)));
1454
- }
1455
-
1456
1838
  async destroy(): Promise<void> {
1457
1839
  const { BaseModel } = await import("../database/base-model");
1458
1840
  // 먼저 처리해야함.
1459
1841
  await BaseModel.destroy();
1842
+ // 프로세스 종료 시 살아있는 WS 연결을 먼저 정리해 이후 다른 리소스 해제 과정에서 잔여 callback이 튀지 않게 함
1460
1843
  await Promise.allSettled([
1844
+ this._websocketRuntime?.shutdown() ?? Promise.resolve(),
1461
1845
  this._workflows?.destroy() ?? Promise.resolve(),
1462
1846
  this._cache?.disconnect() ?? Promise.resolve(),
1463
1847
  this._devVitestManager?.shutdown() ?? Promise.resolve(),
1464
1848
  this.watcher?.close() ?? Promise.resolve(),
1465
1849
  logtapeDispose(),
1466
1850
  ]);
1851
+ this._websocketRuntime = null;
1467
1852
  }
1468
1853
  }
1469
1854
 
@@ -1488,3 +1873,20 @@ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
1488
1873
  function isLocalHost(host: string): boolean {
1489
1874
  return LOCAL_HOSTS.has(host);
1490
1875
  }
1876
+
1877
+ // `.every()`가 첫 guard 이후 순회를 멈추는 문제가 있어 `for...of`로 모든 guard를 순서대로 실행하도록 고정함
1878
+ function runGuards({
1879
+ guards,
1880
+ config,
1881
+ request,
1882
+ api,
1883
+ }: {
1884
+ guards: ExtendedApi["options"]["guards"] | undefined;
1885
+ config: Pick<SonamuFastifyConfig, "guardHandler">;
1886
+ request: FastifyRequest;
1887
+ api: ExtendedApi;
1888
+ }): void {
1889
+ for (const guard of guards ?? []) {
1890
+ config.guardHandler(guard, request, api);
1891
+ }
1892
+ }