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.
- package/dist/ai/providers/rtzr/utils.js +2 -2
- package/dist/api/config.d.ts +13 -2
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/context.d.ts +17 -7
- package/dist/api/context.d.ts.map +1 -1
- package/dist/api/context.js +1 -1
- package/dist/api/decorators.d.ts +18 -0
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +54 -3
- package/dist/api/index.js +8 -3
- package/dist/api/sonamu.d.ts +24 -9
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +365 -79
- package/dist/api/websocket-helpers.d.ts +24 -0
- package/dist/api/websocket-helpers.d.ts.map +1 -0
- package/dist/api/websocket-helpers.js +77 -0
- package/dist/bin/cli.js +12 -4
- package/dist/database/upsert-builder.js +4 -4
- package/dist/dict/sonamu-dictionary.js +6 -6
- package/dist/entity/entity-manager.js +1 -1
- package/dist/entity/entity.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/migration/code-generation.d.ts.map +1 -1
- package/dist/migration/code-generation.js +8 -9
- package/dist/stream/index.d.ts +6 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +13 -2
- package/dist/stream/ws-audience-resolver.d.ts +15 -0
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
- package/dist/stream/ws-audience-resolver.js +31 -0
- package/dist/stream/ws-audience.d.ts +28 -0
- package/dist/stream/ws-audience.d.ts.map +1 -0
- package/dist/stream/ws-audience.js +46 -0
- package/dist/stream/ws-cluster-bus.d.ts +23 -0
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
- package/dist/stream/ws-cluster-bus.js +18 -0
- package/dist/stream/ws-core.d.ts +15 -0
- package/dist/stream/ws-core.d.ts.map +1 -0
- package/dist/stream/ws-core.js +1 -0
- package/dist/stream/ws-delivery.d.ts +24 -0
- package/dist/stream/ws-delivery.d.ts.map +1 -0
- package/dist/stream/ws-delivery.js +103 -0
- package/dist/stream/ws-local-connection-store.d.ts +10 -0
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
- package/dist/stream/ws-local-connection-store.js +44 -0
- package/dist/stream/ws-presence-store.d.ts +61 -0
- package/dist/stream/ws-presence-store.d.ts.map +1 -0
- package/dist/stream/ws-presence-store.js +236 -0
- package/dist/stream/ws-registry.d.ts +42 -0
- package/dist/stream/ws-registry.d.ts.map +1 -0
- package/dist/stream/ws-registry.js +108 -0
- package/dist/stream/ws.d.ts +52 -0
- package/dist/stream/ws.d.ts.map +1 -0
- package/dist/stream/ws.js +397 -0
- package/dist/syncer/api-parser.d.ts.map +1 -1
- package/dist/syncer/api-parser.js +72 -2
- package/dist/syncer/checksum.d.ts.map +1 -1
- package/dist/syncer/checksum.js +13 -12
- package/dist/syncer/code-generator.d.ts.map +1 -1
- package/dist/syncer/code-generator.js +7 -4
- package/dist/syncer/event-batcher.d.ts +27 -0
- package/dist/syncer/event-batcher.d.ts.map +1 -0
- package/dist/syncer/event-batcher.js +69 -0
- package/dist/syncer/file-patterns.d.ts +48 -26
- package/dist/syncer/file-patterns.d.ts.map +1 -1
- package/dist/syncer/file-patterns.js +71 -23
- package/dist/syncer/file-tracking.d.ts +13 -0
- package/dist/syncer/file-tracking.d.ts.map +1 -0
- package/dist/syncer/file-tracking.js +33 -0
- package/dist/syncer/index.js +2 -2
- package/dist/syncer/module-loader.d.ts +2 -11
- package/dist/syncer/module-loader.d.ts.map +1 -1
- package/dist/syncer/module-loader.js +3 -3
- package/dist/syncer/syncer-actions.d.ts +39 -6
- package/dist/syncer/syncer-actions.d.ts.map +1 -1
- package/dist/syncer/syncer-actions.js +125 -10
- package/dist/syncer/syncer.d.ts +33 -19
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +168 -168
- package/dist/syncer/watcher.d.ts +8 -0
- package/dist/syncer/watcher.d.ts.map +1 -0
- package/dist/syncer/watcher.js +105 -0
- package/dist/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +2 -1
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +36 -1
- package/dist/testing/bootstrap.d.ts.map +1 -1
- package/dist/testing/bootstrap.js +8 -1
- package/dist/testing/data-explorer.d.ts.map +1 -1
- package/dist/testing/data-explorer.js +5 -3
- package/dist/testing/fixture-manager.js +1 -1
- package/dist/types/types.d.ts +2 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -2
- package/dist/ui/api.d.ts.map +1 -1
- package/dist/ui/api.js +4 -3
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-C5KUjXm0.js → index-BmThfg-s.js} +39 -39
- package/dist/ui-web/assets/index-D4rYm-Xz.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/dist/utils/async-utils.d.ts +27 -3
- package/dist/utils/async-utils.d.ts.map +1 -1
- package/dist/utils/async-utils.js +56 -6
- package/dist/utils/formatter.d.ts +7 -1
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +95 -60
- package/dist/utils/fs-utils.d.ts +2 -0
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +10 -2
- package/dist/utils/process-utils.d.ts +6 -0
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +16 -3
- package/dist/utils/utils.d.ts +1 -0
- package/dist/utils/utils.d.ts.map +1 -1
- package/dist/utils/utils.js +2 -2
- package/package.json +7 -5
- package/src/ai/providers/rtzr/utils.ts +1 -1
- package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
- package/src/api/__tests__/websocket-context.types.test.ts +58 -0
- package/src/api/config.ts +28 -2
- package/src/api/context.ts +21 -7
- package/src/api/decorators.ts +101 -1
- package/src/api/sonamu.ts +529 -127
- package/src/api/websocket-helpers.ts +122 -0
- package/src/bin/cli.ts +10 -2
- package/src/database/upsert-builder.ts +3 -3
- package/src/dict/sonamu-dictionary.ts +3 -3
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +6 -11
- package/src/shared/app.shared.ts.txt +312 -4
- package/src/shared/web.shared.ts.txt +340 -4
- package/src/stream/__tests__/ws-contracts.test.ts +381 -0
- package/src/stream/__tests__/ws.test.ts +449 -0
- package/src/stream/index.ts +6 -0
- package/src/stream/ws-audience-resolver.ts +35 -0
- package/src/stream/ws-audience.ts +62 -0
- package/src/stream/ws-cluster-bus.ts +32 -0
- package/src/stream/ws-core.ts +16 -0
- package/src/stream/ws-delivery.ts +138 -0
- package/src/stream/ws-local-connection-store.ts +44 -0
- package/src/stream/ws-presence-store.ts +326 -0
- package/src/stream/ws-registry.ts +138 -0
- package/src/stream/ws.ts +591 -0
- package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
- package/src/syncer/api-parser.ts +112 -1
- package/src/syncer/checksum.ts +23 -29
- package/src/syncer/code-generator.ts +4 -1
- package/src/syncer/event-batcher.ts +72 -0
- package/src/syncer/file-patterns.ts +98 -30
- package/src/syncer/file-tracking.ts +27 -0
- package/src/syncer/module-loader.ts +5 -12
- package/src/syncer/syncer-actions.ts +179 -17
- package/src/syncer/syncer.ts +250 -287
- package/src/syncer/watcher.ts +128 -0
- package/src/tasks/workflow-manager.ts +1 -0
- package/src/template/__tests__/services.template.websocket.test.ts +79 -0
- package/src/template/implementations/services.template.ts +69 -0
- package/src/testing/bootstrap.ts +8 -1
- package/src/testing/data-explorer.ts +3 -2
- package/src/types/types.ts +20 -2
- package/src/ui/api.ts +10 -1
- package/src/utils/async-utils.ts +71 -4
- package/src/utils/formatter.ts +114 -75
- package/src/utils/fs-utils.ts +9 -0
- package/src/utils/process-utils.ts +17 -0
- package/src/utils/utils.ts +1 -1
- 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:
|
|
65
|
+
context: RuntimeContext;
|
|
50
66
|
}> = new AsyncLocalStorage();
|
|
51
67
|
|
|
52
|
-
public getContext():
|
|
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
|
-
|
|
82
|
+
locale: "",
|
|
83
|
+
user: null,
|
|
84
|
+
session: null,
|
|
66
85
|
naiteStore: new Map<string, any>(),
|
|
67
|
-
} as unknown as
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
528
|
+
return undefined;
|
|
468
529
|
}
|
|
469
530
|
|
|
470
|
-
|
|
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:
|
|
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
|
-
//
|
|
551
|
-
// 개발 환경에서는 라우트별 compress 옵션을 포기하고 HMR 이점을 취합니다.
|
|
622
|
+
// WS upgrade 경로(GET)와 일반 HTTP 메서드를 별도 route로 분리해 websocket route가 HTML fallback에 먹히지 않도록 함
|
|
552
623
|
server.route({
|
|
553
|
-
method:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
(
|
|
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
|
|
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
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
+
}
|