sonamu 0.9.5 → 0.9.7
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/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 +55 -4
- 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/dict/sonamu-dictionary.js +5 -5
- 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.js +7 -7
- 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/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.js +1 -1
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-DzZ7vBk4.js → index-BmThfg-s.js} +37 -37
- package/dist/ui-web/index.html +1 -1
- 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 +93 -59
- 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 +10 -4
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +20 -7
- 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 +3 -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 +103 -3
- package/src/api/sonamu.ts +529 -127
- package/src/api/websocket-helpers.ts +122 -0
- package/src/bin/cli.ts +10 -2
- package/src/dict/sonamu-dictionary.ts +2 -2
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +5 -5
- package/src/shared/app.shared.ts.txt +254 -1
- package/src/shared/web.shared.ts.txt +282 -1
- 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/types/types.ts +20 -2
- package/src/utils/async-utils.ts +71 -4
- package/src/utils/formatter.ts +111 -74
- package/src/utils/fs-utils.ts +9 -0
- package/src/utils/process-utils.ts +21 -4
- package/src/utils/utils.ts +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { strict as assert } from "assert";
|
|
2
|
+
import { type Stats } from "node:fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import chokidar, { type FSWatcher } from "chokidar";
|
|
7
|
+
import { minimatch } from "minimatch";
|
|
8
|
+
|
|
9
|
+
import { Sonamu } from "../api";
|
|
10
|
+
import { type AbsolutePath } from "../utils/path-utils";
|
|
11
|
+
import { createFileEventBatcher } from "./event-batcher";
|
|
12
|
+
import { getChecksumPatternGroupInAbsolutePath } from "./file-patterns";
|
|
13
|
+
import { isLastChangedByMe } from "./file-tracking";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Watcher를 설정합니다.
|
|
17
|
+
* 이 친구는 진짜로 syncer가 받아야 할 변경 이벤트들만 추려서 batch로 전달해줍니다.
|
|
18
|
+
*/
|
|
19
|
+
export async function setupWatcher(
|
|
20
|
+
onFileEvents: (fileEvents: Map<AbsolutePath, "change" | "add">) => Promise<void>,
|
|
21
|
+
): Promise<FSWatcher> {
|
|
22
|
+
// api 본인뿐 아니라 sync target들의 src도 봅니다.
|
|
23
|
+
// target 산출물(sonamu.generated, services.generated, i18n copy 등)이 외부에서
|
|
24
|
+
// 변경되는 경우를 drift로 잡아 워닝을 띄우기 위함입니다.
|
|
25
|
+
const watcher = chokidar.watch(apiAndTargetsSrcPaths(), {
|
|
26
|
+
ignored: ignoreIfExtensionIsNotOneOf(".ts", ".json", ".http"),
|
|
27
|
+
persistent: true,
|
|
28
|
+
ignoreInitial: true,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// 100ms 안에 들어온 변경들을 한 batch로 모아 한 사이클로 처리합니다.
|
|
32
|
+
const pushFileEvent = createFileEventBatcher<"change" | "add">({
|
|
33
|
+
delayMs: 100,
|
|
34
|
+
onFlush: async (fileEvents) => {
|
|
35
|
+
const realChanges = new Map<AbsolutePath, "change" | "add">();
|
|
36
|
+
for (const [p, e] of fileEvents) {
|
|
37
|
+
// self-write echo는 flush 시점에 거릅니다.
|
|
38
|
+
// watcher.on에서 즉시 거르면 너무 이릅니다.
|
|
39
|
+
// 여기에서는 파일이 디스크에 쓰이고 나서 약간의 딜레이가 있기 때문에
|
|
40
|
+
// "디스크에 썼지만 아직 trackWritten이 완료되기 전 상태" 같은 문제가 사라집니다.
|
|
41
|
+
if (!(await isLastChangedByMe(p))) {
|
|
42
|
+
realChanges.set(p, e);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (realChanges.size > 0) {
|
|
46
|
+
await onFileEvents(realChanges);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
watcher.on("all", (event: string, filePath: string) => {
|
|
52
|
+
const absolutePath = filePath as AbsolutePath;
|
|
53
|
+
assert(
|
|
54
|
+
absolutePath.startsWith(Sonamu.appRootPath),
|
|
55
|
+
"File path is not within the app root path",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!isWantedEvent(event)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isConfigChange(absolutePath)) {
|
|
63
|
+
triggerSelfRestart(event, filePath);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isOutOfScope(absolutePath)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 여기까지 왔다면 syncer가 진짜로 처리해야 할 변경사항이라고 보면 됩니다.
|
|
72
|
+
// 이 호출은 이벤트를 batcher에 쌓습니다.
|
|
73
|
+
// 이후 때가 되면 이렇게 쌓인 이벤트를 들고 onFileEvents 콜백이 호출됩니다.
|
|
74
|
+
pushFileEvent(absolutePath, event);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return watcher;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function apiAndTargetsSrcPaths() {
|
|
81
|
+
return [
|
|
82
|
+
path.join(Sonamu.apiRootPath, "src"),
|
|
83
|
+
...Sonamu.config.sync.targets.map((t) => path.join(Sonamu.appRootPath, t, "src")),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* chokidar의 `ignored` 옵션에 박는 헬퍼.
|
|
89
|
+
* 주어진 확장자가 *아닌* 파일은 무시합니다 — 즉 주어진 확장자만 watch 대상.
|
|
90
|
+
*
|
|
91
|
+
* chokidar `ignored`는 `true`를 반환하면 해당 경로를 무시합니다.
|
|
92
|
+
* 따라서 "이 확장자만 허용"을 표현하려면 *다른 확장자에 대해 true*를 반환해야 합니다.
|
|
93
|
+
*/
|
|
94
|
+
function ignoreIfExtensionIsNotOneOf(...allowedExtensions: `.${string}`[]) {
|
|
95
|
+
const isAllowed = (ext: string) => (allowedExtensions as string[]).includes(ext);
|
|
96
|
+
|
|
97
|
+
return (p: string, stats?: Stats) => !!stats?.isFile() && !isAllowed(path.extname(p));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isWantedEvent(event: string): event is "change" | "add" {
|
|
101
|
+
return event === "change" || event === "add";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isConfigChange(filePath: AbsolutePath): boolean {
|
|
105
|
+
return filePath === path.join(Sonamu.apiRootPath, "src", "sonamu.config.ts");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function triggerSelfRestart(event: string, filePath: string): void {
|
|
109
|
+
const relativePath = filePath.replace(Sonamu.apiRootPath, "api");
|
|
110
|
+
console.log(chalk.bold(`Detected(${event}): ${chalk.blue(relativePath)} - Restarting...`));
|
|
111
|
+
process.kill(process.pid, "SIGUSR2");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 스코프 정의:
|
|
116
|
+
* - api/src 안의 모든 변경은 스코프 안 (HMR 대상이 될 수 있으므로)
|
|
117
|
+
* - api/src 밖이라면 checksumPatternGroup 패턴에 매칭되는 경로만 스코프 안
|
|
118
|
+
*
|
|
119
|
+
* 그 외(예: web/src/App.tsx 같은 target의 비추적 파일)는 스코프 밖이라 무시합니다.
|
|
120
|
+
*/
|
|
121
|
+
function isOutOfScope(filePath: AbsolutePath): boolean {
|
|
122
|
+
const apiSrc = path.join(Sonamu.apiRootPath, "src");
|
|
123
|
+
if (filePath.startsWith(apiSrc)) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const checkPatternGroup = getChecksumPatternGroupInAbsolutePath();
|
|
127
|
+
return !Object.values(checkPatternGroup).some((pattern) => minimatch(filePath, pattern));
|
|
128
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import { Sonamu } from "../../api";
|
|
5
|
+
import { type ExtendedApi } from "../../api/decorators";
|
|
6
|
+
import { EntityManager } from "../../entity/entity-manager";
|
|
7
|
+
import { Template__services } from "../implementations/services.template";
|
|
8
|
+
|
|
9
|
+
describe("Template__services websocket event refs", () => {
|
|
10
|
+
let originalConfig: unknown;
|
|
11
|
+
let originalSyncer: unknown;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
originalConfig = Reflect.get(Sonamu, "_config");
|
|
15
|
+
originalSyncer = Reflect.get(Sonamu, "_syncer");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
Reflect.set(Sonamu, "_config", originalConfig);
|
|
20
|
+
Reflect.set(Sonamu, "_syncer", originalSyncer);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("reuses websocket event type names when they are importable", () => {
|
|
24
|
+
Reflect.set(Sonamu, "_config", {
|
|
25
|
+
api: {
|
|
26
|
+
route: {
|
|
27
|
+
prefix: "/api",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const apis: ExtendedApi[] = [
|
|
33
|
+
{
|
|
34
|
+
modelName: "ChatFrame",
|
|
35
|
+
methodName: "subscribeChat",
|
|
36
|
+
path: "/chat/subscribeChat",
|
|
37
|
+
options: {
|
|
38
|
+
httpMethod: "GET",
|
|
39
|
+
},
|
|
40
|
+
websocketOptions: {
|
|
41
|
+
outEvents: z.object({
|
|
42
|
+
ready: z.object({
|
|
43
|
+
ok: z.boolean(),
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
inEvents: z.object({
|
|
47
|
+
ping: z.object({
|
|
48
|
+
at: z.string(),
|
|
49
|
+
}),
|
|
50
|
+
}),
|
|
51
|
+
outEventsTypeRef: {
|
|
52
|
+
t: "ref",
|
|
53
|
+
id: "ChatOutEvents",
|
|
54
|
+
},
|
|
55
|
+
inEventsTypeRef: {
|
|
56
|
+
t: "ref",
|
|
57
|
+
id: "ChatInEvents",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
typeParameters: [],
|
|
61
|
+
parameters: [],
|
|
62
|
+
returnType: "void",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
Reflect.set(Sonamu, "_syncer", {
|
|
66
|
+
apis,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
EntityManager.setModulePath("ChatOutEvents", "chat/chat.types");
|
|
70
|
+
EntityManager.setModulePath("ChatInEvents", "chat/chat.types");
|
|
71
|
+
|
|
72
|
+
const template = new Template__services();
|
|
73
|
+
const rendered = template.render({});
|
|
74
|
+
|
|
75
|
+
expect(rendered.body).toContain("handlers: EventHandlers<ChatOutEvents>");
|
|
76
|
+
expect(rendered.body).toContain("useWebSocketChannel<ChatOutEvents, ChatInEvents>");
|
|
77
|
+
expect(rendered.importKeys).toEqual(expect.arrayContaining(["ChatOutEvents", "ChatInEvents"]));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -43,6 +43,27 @@ export class Template__services extends Template {
|
|
|
43
43
|
const importKeys: string[] = [];
|
|
44
44
|
const namespaces: string[] = [];
|
|
45
45
|
let typeParamNames: string[] = [];
|
|
46
|
+
const resolveWebSocketEventTypeDef = (
|
|
47
|
+
typeRef: ApiParamType.Ref | undefined,
|
|
48
|
+
schema: Parameters<typeof zodTypeToTsTypeDef>[0],
|
|
49
|
+
): string => {
|
|
50
|
+
if (typeRef) {
|
|
51
|
+
const candidateImportKeys: string[] = [];
|
|
52
|
+
const candidateTypeDef = apiParamTypeToTsType(typeRef, candidateImportKeys);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
for (const key of unique(candidateImportKeys)) {
|
|
56
|
+
EntityManager.getModulePath(key);
|
|
57
|
+
}
|
|
58
|
+
importKeys.push(...candidateImportKeys);
|
|
59
|
+
return candidateTypeDef;
|
|
60
|
+
} catch {
|
|
61
|
+
// 로컬 const 등 import 가능한 심볼이 아니면 기존 inline 생성으로 후퇴
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return zodTypeToTsTypeDef(schema);
|
|
66
|
+
};
|
|
46
67
|
|
|
47
68
|
for (const [modelName, modelApis] of apisByModel) {
|
|
48
69
|
const functions: string[] = [];
|
|
@@ -86,6 +107,52 @@ export function ${methodNameStreamCamelized}(
|
|
|
86
107
|
continue;
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
if (api.websocketOptions) {
|
|
111
|
+
// websocket surface는 fetch 함수 대신 typed hook을 생성함
|
|
112
|
+
const paramsWithoutContext = api.parameters.filter(
|
|
113
|
+
(param) =>
|
|
114
|
+
!ApiParamType.isContext(param.type) &&
|
|
115
|
+
!ApiParamType.isRefKnex(param.type) &&
|
|
116
|
+
!(param.optional && param.name.startsWith("_")),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const apiBaseUrl = `${Sonamu.config.api.route.prefix}${api.path}`;
|
|
120
|
+
|
|
121
|
+
const methodNameWebSocket = api.options.resourceName
|
|
122
|
+
? `use${inflection.camelize(api.options.resourceName)}`
|
|
123
|
+
: `use${inflection.camelize(api.methodName)}`;
|
|
124
|
+
const methodNameWebSocketCamelized = inflection.camelize(methodNameWebSocket, true);
|
|
125
|
+
|
|
126
|
+
// outEvents는 수신 타입, inEvents는 send() 입력 타입으로 사용함
|
|
127
|
+
const outEventsTypeDef = resolveWebSocketEventTypeDef(
|
|
128
|
+
api.websocketOptions.outEventsTypeRef,
|
|
129
|
+
api.websocketOptions.outEvents,
|
|
130
|
+
);
|
|
131
|
+
const inEventsTypeDef = resolveWebSocketEventTypeDef(
|
|
132
|
+
api.websocketOptions.inEventsTypeRef,
|
|
133
|
+
api.websocketOptions.inEvents,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// context/refKnex/internal optional 파라미터는 클라이언트 입력에서 제외함
|
|
137
|
+
const paramsDefAsObject =
|
|
138
|
+
paramsWithoutContext.length > 0
|
|
139
|
+
? `{ ${paramsWithoutContext.map((p) => `${p.name}: ${apiParamTypeToTsType(p.type, importKeys)}`).join(", ")} }`
|
|
140
|
+
: "{}";
|
|
141
|
+
|
|
142
|
+
functions.push(
|
|
143
|
+
`
|
|
144
|
+
export function ${methodNameWebSocketCamelized}(
|
|
145
|
+
params: ${paramsDefAsObject},
|
|
146
|
+
handlers: EventHandlers<${outEventsTypeDef}>,
|
|
147
|
+
options: WebSocketChannelOptions = {}
|
|
148
|
+
) {
|
|
149
|
+
return useWebSocketChannel<${outEventsTypeDef}, ${inEventsTypeDef}>(\`${apiBaseUrl}\`, params, handlers, options);
|
|
150
|
+
}
|
|
151
|
+
`.trim(),
|
|
152
|
+
);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
89
156
|
// Context 제외한 파라미터
|
|
90
157
|
const paramsWithoutContext = api.parameters.filter(
|
|
91
158
|
(param) =>
|
|
@@ -397,7 +464,9 @@ export const ${names.capital}AsyncIdConfig: AsyncIdConfig<${names.capital}Subset
|
|
|
397
464
|
"fetch",
|
|
398
465
|
"type EventHandlers",
|
|
399
466
|
"type SSEStreamOptions",
|
|
467
|
+
"type WebSocketChannelOptions",
|
|
400
468
|
"useSSEStream",
|
|
469
|
+
"useWebSocketChannel",
|
|
401
470
|
"toFormData",
|
|
402
471
|
...(needsDedupeAndFlatten ? ["dedupeAndFlatten"] : []),
|
|
403
472
|
...(needsUseRefreshable ? ["useRefreshable"] : []),
|
package/src/testing/bootstrap.ts
CHANGED
|
@@ -58,11 +58,18 @@ export function bootstrap(vi: VitestUtils, options?: BootstrapOptions) {
|
|
|
58
58
|
|
|
59
59
|
function getMockContext(): Context {
|
|
60
60
|
return {
|
|
61
|
+
transport: "http",
|
|
62
|
+
request: null as unknown as Context["request"],
|
|
63
|
+
reply: null as unknown as Context["reply"],
|
|
64
|
+
headers: {},
|
|
65
|
+
createSSE: (() => {
|
|
66
|
+
throw new Error("createSSE is not available in mock context");
|
|
67
|
+
}) as Context["createSSE"],
|
|
61
68
|
session: null,
|
|
62
69
|
user: null,
|
|
63
70
|
naiteStore: Naite.createStore(),
|
|
64
71
|
locale: "",
|
|
65
|
-
}
|
|
72
|
+
};
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
export async function runWithContext(context: Context | null, fn: () => Promise<void>) {
|
package/src/types/types.ts
CHANGED
|
@@ -992,7 +992,8 @@ export namespace ApiParamType {
|
|
|
992
992
|
typeof v === "object" &&
|
|
993
993
|
v !== null &&
|
|
994
994
|
(v as { t?: unknown }).t === "ref" &&
|
|
995
|
-
(v as { id?: unknown }).id === "Context"
|
|
995
|
+
((v as { id?: unknown }).id === "Context" ||
|
|
996
|
+
(v as { id?: unknown }).id === "WebSocketContext")
|
|
996
997
|
);
|
|
997
998
|
}
|
|
998
999
|
export function isRefKnex(v: unknown): v is ApiParamType.Ref {
|
|
@@ -1774,11 +1775,28 @@ export type SonamuFastifyConfig = {
|
|
|
1774
1775
|
contextProvider: (
|
|
1775
1776
|
defaultContext: Pick<
|
|
1776
1777
|
Context,
|
|
1777
|
-
|
|
1778
|
+
| "transport"
|
|
1779
|
+
| "request"
|
|
1780
|
+
| "reply"
|
|
1781
|
+
| "headers"
|
|
1782
|
+
| "createSSE"
|
|
1783
|
+
| "naiteStore"
|
|
1784
|
+
| "locale"
|
|
1785
|
+
| "user"
|
|
1786
|
+
| "session"
|
|
1778
1787
|
>,
|
|
1779
1788
|
request: FastifyRequest,
|
|
1780
1789
|
reply: FastifyReply,
|
|
1781
1790
|
) => Context | Promise<Context>;
|
|
1791
|
+
websocketContextProvider?: (
|
|
1792
|
+
defaultContext: Pick<
|
|
1793
|
+
import("../api/context").WebSocketContext,
|
|
1794
|
+
"transport" | "request" | "headers" | "ws" | "naiteStore" | "locale" | "user" | "session"
|
|
1795
|
+
>,
|
|
1796
|
+
request: FastifyRequest,
|
|
1797
|
+
) =>
|
|
1798
|
+
| import("../api/context").WebSocketContext
|
|
1799
|
+
| Promise<import("../api/context").WebSocketContext>;
|
|
1782
1800
|
guardHandler: (
|
|
1783
1801
|
guard: GuardKey,
|
|
1784
1802
|
request: FastifyRequest,
|
package/src/utils/async-utils.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { glob } from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
|
|
4
|
+
import { minimatch } from "minimatch";
|
|
5
|
+
import { debounce } from "radashi";
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* 비동기 조건으로 배열을 필터링합니다
|
|
6
9
|
* @example
|
|
@@ -65,13 +68,77 @@ export async function reduceAsync<T, U>(
|
|
|
65
68
|
/**
|
|
66
69
|
* 비동기 glob 함수입니다.
|
|
67
70
|
* AsyncIterableIterator로 날아오는 glob의 반환을 받아 끝까지 돌아서 배열로 반환합니다.
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
71
|
+
*
|
|
72
|
+
* @param pathPattern glob 패턴 (절대 경로 또는 상대 경로)
|
|
73
|
+
* @param options.exclude 매치에서 제외할 패턴 목록 (예: `["**\/node_modules/**"]`).
|
|
74
|
+
* alternation을 포함하는 패턴이 의도치 않게 빌드 산출물 디렉토리를 휘말리게 하는 것을 막는 안전망.
|
|
70
75
|
*/
|
|
71
|
-
export async function globAsync(
|
|
76
|
+
export async function globAsync(
|
|
77
|
+
pathPattern: string,
|
|
78
|
+
options?: { exclude?: string[] },
|
|
79
|
+
): Promise<string[]> {
|
|
72
80
|
const files: string[] = [];
|
|
73
|
-
|
|
81
|
+
const excludePatterns = options?.exclude;
|
|
82
|
+
const iter = excludePatterns
|
|
83
|
+
? glob(path.resolve(pathPattern), {
|
|
84
|
+
exclude: (filePath: string) =>
|
|
85
|
+
excludePatterns.some((pat) => minimatch(filePath, pat, { dot: true })),
|
|
86
|
+
})
|
|
87
|
+
: glob(path.resolve(pathPattern));
|
|
88
|
+
for await (const file of iter) {
|
|
74
89
|
files.push(file);
|
|
75
90
|
}
|
|
76
91
|
return files;
|
|
77
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 키별로 trailing-edge debounce. 같은 key에 대해 delay 안에 여러 번 호출되면 마지막 args만
|
|
96
|
+
* delay 후에 fn 호출. key가 다르면 timer 독립적.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* const debounced = debounceByKey<string>(100, (path) => handleFileChange(path));
|
|
100
|
+
* debounced("a.ts"); debounced("a.ts"); // 100ms 후 한 번만 호출
|
|
101
|
+
* debounced("b.ts"); // a.ts와 별개로 100ms 후 호출
|
|
102
|
+
*/
|
|
103
|
+
export function debounceByKey<K, A extends unknown[]>(
|
|
104
|
+
delay: number,
|
|
105
|
+
fn: (key: K, ...args: A) => void | Promise<void>,
|
|
106
|
+
): (key: K, ...args: A) => void {
|
|
107
|
+
const debouncersByKey = new Map<K, (key: K, ...args: A) => void>();
|
|
108
|
+
return (key, ...args) => {
|
|
109
|
+
let debounced = debouncersByKey.get(key);
|
|
110
|
+
if (!debounced) {
|
|
111
|
+
debounced = debounce({ delay }, (k: K, ...a: A) => {
|
|
112
|
+
void fn(k, ...a);
|
|
113
|
+
});
|
|
114
|
+
debouncersByKey.set(key, debounced);
|
|
115
|
+
}
|
|
116
|
+
debounced(key, ...args);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 같은 key에 대한 호출 결과를 캐시. 같은 key로 다시 호출되면 fn 실행 없이 캐시값 반환.
|
|
122
|
+
* 무효화는 프로세스 재시작 (또는 외부에서 invalidate). 호출자가 keyFn으로 args → string 매핑.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* const cachedFmt = cached(formatCodeInternal, (code, filePath) => `${ext(filePath)}:${sha1(code)}`);
|
|
126
|
+
* await cachedFmt(code, "a.ts"); // 실제 호출
|
|
127
|
+
* await cachedFmt(code, "a.ts"); // 캐시 hit
|
|
128
|
+
*/
|
|
129
|
+
export function cached<A extends unknown[], R>(
|
|
130
|
+
fn: (...args: A) => Promise<R>,
|
|
131
|
+
keyFn: (...args: A) => string,
|
|
132
|
+
): (...args: A) => Promise<R> {
|
|
133
|
+
const cache = new Map<string, R>();
|
|
134
|
+
return async (...args) => {
|
|
135
|
+
const key = keyFn(...args);
|
|
136
|
+
const hit = cache.get(key);
|
|
137
|
+
if (hit !== undefined) {
|
|
138
|
+
return hit;
|
|
139
|
+
}
|
|
140
|
+
const result = await fn(...args);
|
|
141
|
+
cache.set(key, result);
|
|
142
|
+
return result;
|
|
143
|
+
};
|
|
144
|
+
}
|
package/src/utils/formatter.ts
CHANGED
|
@@ -1,108 +1,145 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
// 테스트 환경에서는 fs/promises가 mock되지만, 아래 runOxlint이 isTest 가드로 안 도니까
|
|
3
|
+
// 그냥 fs/promises 그대로 사용. (production에서만 임시파일 흐름이 돕니다.)
|
|
4
|
+
import { readFile, unlink, writeFile } from "fs/promises";
|
|
3
5
|
import { createRequire } from "module";
|
|
4
|
-
import { join } from "path";
|
|
6
|
+
import path, { dirname, join } from "path";
|
|
5
7
|
|
|
6
|
-
import { format } from "oxfmt";
|
|
8
|
+
import { format, type FormatConfig } from "oxfmt";
|
|
7
9
|
|
|
10
|
+
import { cached } from "./async-utils";
|
|
8
11
|
import { isTest } from "./controller";
|
|
12
|
+
import { execute } from "./process-utils";
|
|
9
13
|
|
|
10
14
|
const _require = createRequire(import.meta.url);
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sortImports: true,
|
|
23
|
-
};
|
|
16
|
+
/**
|
|
17
|
+
* 코드를 프로젝트의 oxfmt + oxlint 설정에 맞춰 포매팅한 문자열을 반환합니다.
|
|
18
|
+
*
|
|
19
|
+
* 캐싱도 있어요 ㅎㅎ 똑같은 입력에 대해서 캐시 커버됩니다.
|
|
20
|
+
* 수명은 프로세스 죽을때까지 ㅋ
|
|
21
|
+
*/
|
|
22
|
+
export const formatCode = cached(formatCodeInternal, (code, filePath) => {
|
|
23
|
+
const ext = filePath.endsWith(".tsx") ? "tsx" : filePath.endsWith(".json") ? "json" : "ts";
|
|
24
|
+
return `${ext}:${createHash("sha1").update(code).digest("hex")}`;
|
|
25
|
+
});
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 캐시 없는 포맷함수 엔트리.
|
|
29
|
+
*/
|
|
30
|
+
async function formatCodeInternal(code: string, filePath: string): Promise<string> {
|
|
31
|
+
// json은 포맷만 하면 됩니다.
|
|
32
|
+
if (filePath.endsWith(".json")) {
|
|
33
|
+
return runOxfmt(code, filePath);
|
|
30
34
|
}
|
|
35
|
+
|
|
36
|
+
// 린트 먼저 한 다음에 포맷으로 마무리해요.
|
|
37
|
+
return runOxfmt(await runOxlint(code), filePath);
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 프로젝트 설정을 찾아서 이에 맞춰서 코드를 포맷합니다.
|
|
42
|
+
*/
|
|
43
|
+
async function runOxfmt(code: string, filePath: string): Promise<string> {
|
|
44
|
+
const result = await format(path.basename(filePath), code, await loadOxfmtConfig());
|
|
45
|
+
|
|
46
|
+
const errors = result.errors.filter((e) => e.severity === "Error");
|
|
47
|
+
if (errors.length > 0) {
|
|
48
|
+
if (!isTest()) {
|
|
49
|
+
console.error(`oxfmt errors (${filePath}):`);
|
|
50
|
+
for (const err of errors) {
|
|
51
|
+
const label = err.labels[0];
|
|
52
|
+
if (label) {
|
|
53
|
+
const before = code.slice(Math.max(0, label.start - 80), label.start);
|
|
54
|
+
const at = code.slice(label.start, label.end);
|
|
55
|
+
const after = code.slice(label.end, Math.min(code.length, label.end + 80));
|
|
56
|
+
console.error(` - ${err.message} (offset ${label.start}-${label.end})`);
|
|
57
|
+
console.error(` around: ...${before}»${at}«${after}...`);
|
|
58
|
+
} else {
|
|
59
|
+
console.error(` - ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return code;
|
|
64
|
+
}
|
|
65
|
+
return result.code;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let cachedOxfmtConfig: FormatConfig | null = null;
|
|
69
|
+
async function loadOxfmtConfig(): Promise<FormatConfig> {
|
|
70
|
+
if (cachedOxfmtConfig !== null) {
|
|
71
|
+
return cachedOxfmtConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let dir = process.cwd();
|
|
75
|
+
while (true) {
|
|
76
|
+
const candidate = join(dir, ".oxfmtrc.json");
|
|
77
|
+
try {
|
|
78
|
+
cachedOxfmtConfig = JSON.parse(await readFile(candidate, "utf-8")) as FormatConfig;
|
|
79
|
+
return cachedOxfmtConfig;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
82
|
+
!isTest() && console.error(`Failed to load ${candidate}:`, e);
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
49
85
|
}
|
|
86
|
+
const parent = dirname(dir);
|
|
87
|
+
if (parent === dir) break;
|
|
88
|
+
dir = parent;
|
|
50
89
|
}
|
|
51
90
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
91
|
+
cachedOxfmtConfig = {};
|
|
92
|
+
return cachedOxfmtConfig;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 프로젝트 설정에 맞춰 코드를 lint합니다.
|
|
97
|
+
*
|
|
98
|
+
* 프로젝트 설정을 적용받는 oxlint cli를 찾아 띄워서,
|
|
99
|
+
* 임시 파일에 in-place로 써서 그 결과를 빼오는 방식으로 작동합니다.
|
|
100
|
+
* 왜 이렇게 하느냐? oxlint가 node api도 안 주고 cli에서 stdin 옵션도 안 주기 때문...
|
|
101
|
+
*/
|
|
102
|
+
async function runOxlint(code: string): Promise<string> {
|
|
103
|
+
if (isTest()) {
|
|
104
|
+
// 테스트 환경에서는 느려지기만 하고 검증할 가치도 없어서 안 합니다.
|
|
105
|
+
// GitHub Actions 환경에서 lint가 오래 걸려서 뻗기도 했어요. (https://github.com/cartanova-ai/sonamu/actions/runs/25267214027/job/74083630169)
|
|
106
|
+
return code;
|
|
55
107
|
}
|
|
56
108
|
|
|
57
|
-
// TypeScript: oxlint --fix로 lint fix 수행 (unused import 제거, type import 변환 등)
|
|
58
|
-
// cwd 아래에 생성해야 nested config(.oxlintrc.json)과 tsconfig를 찾을 수 있음
|
|
59
109
|
const tmpFile = join(
|
|
110
|
+
// 타겟 파일이 루트 아래에 있어야 해요. 그래서 tmp 디렉토리같은거 안 씁니다!
|
|
60
111
|
process.cwd(),
|
|
61
112
|
`.sonamu-fmt-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
|
|
62
113
|
);
|
|
114
|
+
|
|
63
115
|
try {
|
|
64
|
-
|
|
116
|
+
await writeFile(tmpFile, code, "utf-8");
|
|
65
117
|
|
|
66
|
-
const oxlintBin = resolveOxlintBin();
|
|
67
118
|
try {
|
|
68
|
-
|
|
69
|
-
stdio: "pipe",
|
|
119
|
+
await execute(resolveOxlintBin(), ["--fix", "--fix-suggestions", "--type-aware", tmpFile], {
|
|
70
120
|
timeout: 10000,
|
|
71
121
|
});
|
|
72
|
-
} catch (
|
|
73
|
-
//
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
if (typeof errObj.status === "number") {
|
|
77
|
-
void errObj.status;
|
|
78
|
-
} else {
|
|
79
|
-
throw execError;
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
throw execError;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const lintFixed = readFileSync(tmpFile, "utf-8");
|
|
87
|
-
|
|
88
|
-
// lint fix 후 재포맷 (import 구문 변경으로 인한 정렬 등)
|
|
89
|
-
const reformatted = await format(fileName, lintFixed, OXFMT_OPTIONS);
|
|
90
|
-
if (reformatted.errors.length > 0) {
|
|
91
|
-
const errorMessages = reformatted.errors
|
|
92
|
-
.filter((e) => e.severity === "Error")
|
|
93
|
-
.map((e) => e.message);
|
|
94
|
-
if (errorMessages.length > 0) {
|
|
95
|
-
!isTest() && console.error("oxfmt reformat errors:", errorMessages);
|
|
96
|
-
throw new Error(`oxfmt reformat error: ${errorMessages.join(", ")}`);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
// lint 위반 시 exit code != 0이지만 --fix는 적용됨. exec 자체 실패만 throw.
|
|
124
|
+
if (typeof (e as Error & { code?: number }).code !== "number") {
|
|
125
|
+
throw e;
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
128
|
|
|
100
|
-
return
|
|
129
|
+
return await readFile(tmpFile, "utf-8");
|
|
101
130
|
} finally {
|
|
102
131
|
try {
|
|
103
|
-
|
|
132
|
+
await unlink(tmpFile);
|
|
104
133
|
} catch {
|
|
105
|
-
//
|
|
134
|
+
// 삭제 실패해도 어차피 ignore됨.
|
|
106
135
|
}
|
|
107
136
|
}
|
|
108
137
|
}
|
|
138
|
+
|
|
139
|
+
function resolveOxlintBin(): string {
|
|
140
|
+
try {
|
|
141
|
+
return _require.resolve("oxlint/bin/oxlint");
|
|
142
|
+
} catch {
|
|
143
|
+
return "oxlint";
|
|
144
|
+
}
|
|
145
|
+
}
|