sonamu 0.4.12 → 0.4.14

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 (46) hide show
  1. package/.pnp.cjs +5 -5
  2. package/dist/{base-model-BvVra-8f.d.mts → base-model-CEB0H0aO.d.mts} +1 -1
  3. package/dist/{base-model-Br6krkwK.d.ts → base-model-CrqDMYhI.d.ts} +1 -1
  4. package/dist/bin/cli.js +51 -51
  5. package/dist/bin/cli.mjs +2 -2
  6. package/dist/{chunk-ZLFDB43J.js → chunk-2WAC2GER.js} +147 -96
  7. package/dist/chunk-2WAC2GER.js.map +1 -0
  8. package/dist/{chunk-FKZK27YL.mjs → chunk-C3IPIF6O.mjs} +2 -2
  9. package/dist/{chunk-INTZUNZ6.js → chunk-EXHKSVTE.js} +7 -7
  10. package/dist/{chunk-LNZTU4JC.mjs → chunk-FCERKIIF.mjs} +104 -53
  11. package/dist/chunk-FCERKIIF.mjs.map +1 -0
  12. package/dist/{chunk-JQJTQQ7D.mjs → chunk-HGIBJYOU.mjs} +2 -2
  13. package/dist/{chunk-NPLUHS5L.mjs → chunk-JKSOJRQA.mjs} +2 -2
  14. package/dist/{chunk-FYLFH3Q6.js → chunk-OTKKFP3Y.js} +100 -100
  15. package/dist/{chunk-IEMX4VPN.js → chunk-UZ2IY5VE.js} +4 -4
  16. package/dist/database/drivers/knex/base-model.d.mts +2 -2
  17. package/dist/database/drivers/knex/base-model.d.ts +2 -2
  18. package/dist/database/drivers/knex/base-model.js +8 -8
  19. package/dist/database/drivers/knex/base-model.mjs +3 -3
  20. package/dist/database/drivers/kysely/base-model.d.mts +2 -2
  21. package/dist/database/drivers/kysely/base-model.d.ts +2 -2
  22. package/dist/database/drivers/kysely/base-model.js +9 -9
  23. package/dist/database/drivers/kysely/base-model.mjs +3 -3
  24. package/dist/index.d.mts +17 -4
  25. package/dist/index.d.ts +17 -4
  26. package/dist/index.js +13 -9
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +7 -3
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/src/api/caster.ts +2 -2
  32. package/src/api/code-converters.ts +7 -0
  33. package/src/api/decorators.ts +36 -4
  34. package/src/shared/web.shared.ts.txt +225 -0
  35. package/src/syncer/syncer.ts +7 -1
  36. package/src/templates/service.template.ts +50 -9
  37. package/dist/chunk-LNZTU4JC.mjs.map +0 -1
  38. package/dist/chunk-ZLFDB43J.js.map +0 -1
  39. package/dist/{chunk-FKZK27YL.mjs.map → chunk-C3IPIF6O.mjs.map} +0 -0
  40. package/dist/{chunk-INTZUNZ6.js.map → chunk-EXHKSVTE.js.map} +0 -0
  41. package/dist/{chunk-JQJTQQ7D.mjs.map → chunk-HGIBJYOU.mjs.map} +0 -0
  42. package/dist/{chunk-NPLUHS5L.mjs.map → chunk-JKSOJRQA.mjs.map} +0 -0
  43. package/dist/{chunk-FYLFH3Q6.js.map → chunk-OTKKFP3Y.js.map} +0 -0
  44. package/dist/{chunk-IEMX4VPN.js.map → chunk-UZ2IY5VE.js.map} +0 -0
  45. package/dist/{model-DWoinpJ7.d.mts → model-aFgomcdc.d.mts} +4 -4
  46. package/dist/{model-DWoinpJ7.d.ts → model-aFgomcdc.d.ts} +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "typescript",
@@ -59,7 +59,7 @@
59
59
  "qs": "^6.11.0",
60
60
  "tsicli": "^1.0.5",
61
61
  "uuid": "^8.3.2",
62
- "zod": "^3.18.0"
62
+ "zod": "3.25.76"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@types/fs-extra": "^9.0.13",
package/src/api/caster.ts CHANGED
@@ -15,8 +15,8 @@ function isZodNumberAnyway(zodType: z.ZodType<any>) {
15
15
  ) {
16
16
  } else if (
17
17
  zodType instanceof z.ZodOptional &&
18
- zodType._def.innerType instanceof z.ZodOptional &&
19
- zodType._type.def.innerType instanceof z.ZodNumber
18
+ zodType._def?.innerType instanceof z.ZodOptional &&
19
+ zodType._type?.def?.innerType instanceof z.ZodNumber
20
20
  ) {
21
21
  return true;
22
22
  }
@@ -415,6 +415,13 @@ export function apiParamToTsCode(
415
415
  .join(", ");
416
416
  }
417
417
 
418
+ export function apiParamToTsCodeAsObject(
419
+ params: ApiParam[],
420
+ injectImportKeys: string[]
421
+ ): string {
422
+ return `{ ${params.map((param) => `${param.name}${param.optional ? "?" : ""}: ${apiParamTypeToTsType(param.type, injectImportKeys)}${param.defaultDef ? `= ${param.defaultDef}` : ""}`).join(", ")} }`;
423
+ }
424
+
418
425
  export function apiParamTypeToTsType(
419
426
  paramType: ApiParamType,
420
427
  injectImportKeys: string[]
@@ -1,12 +1,12 @@
1
1
  import { HTTPMethods } from "fastify";
2
2
  import inflection from "inflection";
3
3
  import { ApiParam, ApiParamType } from "../types/types";
4
+ import { z } from "zod";
4
5
 
5
6
  export type ServiceClient =
6
7
  | "axios"
7
8
  | "axios-multipart"
8
9
  | "swr"
9
- | "socketio"
10
10
  | "window-fetch";
11
11
  export type ApiDecoratorOptions = {
12
12
  httpMethod?: HTTPMethods;
@@ -22,17 +22,27 @@ export type ApiDecoratorOptions = {
22
22
  guards?: string[];
23
23
  description?: string;
24
24
  };
25
+ export type StreamDecoratorOptions = {
26
+ type: "sse"; // | 'ws
27
+ events: z.ZodObject<any>;
28
+ path?: string;
29
+ resourceName?: string;
30
+ guards?: string[];
31
+ description?: string;
32
+ };
25
33
  export const registeredApis: {
26
34
  modelName: string;
27
35
  methodName: string;
28
36
  path: string;
29
37
  options: ApiDecoratorOptions;
38
+ streamOptions?: StreamDecoratorOptions;
30
39
  }[] = [];
31
40
  export type ExtendedApi = {
32
41
  modelName: string;
33
42
  methodName: string;
34
43
  path: string;
35
44
  options: ApiDecoratorOptions;
45
+ streamOptions?: StreamDecoratorOptions;
36
46
  typeParameters: ApiParamType.TypeParam[];
37
47
  parameters: ApiParam[];
38
48
  returnType: ApiParamType;
@@ -55,12 +65,34 @@ export function api(options: ApiDecoratorOptions = {}) {
55
65
  true
56
66
  )}/${inflection.camelize(propertyKey, true)}`;
57
67
 
58
- const api = {
68
+ registeredApis.push({
59
69
  modelName,
60
70
  methodName,
61
71
  path: options.path ?? defaultPath,
62
72
  options,
63
- };
64
- registeredApis.push(api);
73
+ });
74
+ };
75
+ }
76
+
77
+ export function stream(options: StreamDecoratorOptions) {
78
+ return function (target: Object, propertyKey: string) {
79
+ const modelName = target.constructor.name.match(/(.+)Class$/)![1];
80
+ const methodName = propertyKey;
81
+
82
+ const defaultPath = `/${inflection.camelize(
83
+ modelName.replace(/Model$/, "").replace(/Frame$/, ""),
84
+ true
85
+ )}/${inflection.camelize(propertyKey, true)}`;
86
+
87
+ registeredApis.push({
88
+ modelName,
89
+ methodName,
90
+ path: options.path ?? defaultPath,
91
+ options: {
92
+ ...options,
93
+ httpMethod: "GET",
94
+ },
95
+ streamOptions: options,
96
+ });
65
97
  };
66
98
  }
@@ -126,3 +126,228 @@ export const SQLDateTimeString = z
126
126
  .max(19)
127
127
  .describe("SQLDateTimeString");
128
128
  export type SQLDateTimeString = z.infer<typeof SQLDateTimeString>;
129
+
130
+ /*
131
+ Stream
132
+ */
133
+ export type SSEStreamOptions = {
134
+ enabled?: boolean;
135
+ retry?: number;
136
+ retryInterval?: number;
137
+ };
138
+ export type SSEStreamState = {
139
+ isConnected: boolean;
140
+ error: string | null;
141
+ retryCount: number;
142
+ isEnded: boolean;
143
+ };
144
+ export type EventHandlers<T> = {
145
+ [K in keyof T]: (data: T[K]) => void;
146
+ };
147
+ import { useEffect, useRef, useState } from "react";
148
+
149
+ export function useSSEStream<T extends Record<string, any>>(
150
+ url: string,
151
+ params: Record<string, any>,
152
+ handlers: {
153
+ [K in keyof T]?: (data: T[K]) => void;
154
+ },
155
+ options: SSEStreamOptions = {}
156
+ ): SSEStreamState {
157
+ const { enabled = true, retry = 3, retryInterval = 3000 } = options;
158
+
159
+ const [state, setState] = useState<SSEStreamState>({
160
+ isConnected: false,
161
+ error: null,
162
+ retryCount: 0,
163
+ isEnded: false,
164
+ });
165
+
166
+ const eventSourceRef = useRef<EventSource | null>(null);
167
+ const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
168
+ const handlersRef = useRef(handlers);
169
+
170
+ // handlers를 ref로 관리해서 재연결 없이 업데이트
171
+ useEffect(() => {
172
+ handlersRef.current = handlers;
173
+ }, [handlers]);
174
+
175
+ // 연결 함수
176
+ const connect = () => {
177
+ if (!enabled) return;
178
+
179
+ try {
180
+ // 기존 연결이 있으면 정리
181
+ if (eventSourceRef.current) {
182
+ eventSourceRef.current.close();
183
+ eventSourceRef.current = null;
184
+ }
185
+
186
+ // 재시도 타이머 정리
187
+ if (retryTimeoutRef.current) {
188
+ clearTimeout(retryTimeoutRef.current);
189
+ retryTimeoutRef.current = null;
190
+ }
191
+
192
+ // URL에 파라미터 추가
193
+ const queryString = qs.stringify(params);
194
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
195
+
196
+ const eventSource = new EventSource(fullUrl);
197
+ eventSourceRef.current = eventSource;
198
+
199
+ // 연결 시도 중 상태 표시
200
+ setState((prev) => ({
201
+ ...prev,
202
+ isConnected: false,
203
+ error: null,
204
+ isEnded: false,
205
+ }));
206
+
207
+ eventSource.onopen = () => {
208
+ setState((prev) => ({
209
+ ...prev,
210
+ isConnected: true,
211
+ error: null,
212
+ retryCount: 0,
213
+ isEnded: false,
214
+ }));
215
+ };
216
+
217
+ eventSource.onerror = (event) => {
218
+ // 이미 다른 연결로 교체되었는지 확인
219
+ if (eventSourceRef.current !== eventSource) {
220
+ return; // 이미 새로운 연결이 있으면 무시
221
+ }
222
+
223
+ setState((prev) => ({
224
+ ...prev,
225
+ isConnected: false,
226
+ error: "Connection failed",
227
+ isEnded: false,
228
+ }));
229
+
230
+ // 자동 재연결 시도
231
+ if (state.retryCount < retry) {
232
+ retryTimeoutRef.current = setTimeout(() => {
233
+ // 여전히 같은 연결인지 확인
234
+ if (eventSourceRef.current === eventSource) {
235
+ setState((prev) => ({
236
+ ...prev,
237
+ retryCount: prev.retryCount + 1,
238
+ isEnded: false,
239
+ }));
240
+ connect();
241
+ }
242
+ }, retryInterval);
243
+ } else {
244
+ setState((prev) => ({
245
+ ...prev,
246
+ error: `Connection failed after ${retry} attempts`,
247
+ }));
248
+ }
249
+ };
250
+
251
+ // 공통 'end' 이벤트 처리 (사용자 정의 이벤트와 별도)
252
+ eventSource.addEventListener("end", () => {
253
+ console.log("SSE 연결 정상종료");
254
+ if (eventSourceRef.current === eventSource) {
255
+ eventSource.close();
256
+ eventSourceRef.current = null;
257
+ setState((prev) => ({
258
+ ...prev,
259
+ isConnected: false,
260
+ error: null, // 정상 종료
261
+ isEnded: true,
262
+ }));
263
+
264
+ if (handlersRef.current.end) {
265
+ const endHandler = handlersRef.current.end;
266
+ endHandler("end" as T[string]);
267
+ }
268
+ }
269
+ });
270
+
271
+ // 각 이벤트 타입별 리스너 등록
272
+ Object.keys(handlersRef.current).forEach((eventType) => {
273
+ const handler = handlersRef.current[eventType as keyof T];
274
+ if (handler) {
275
+ eventSource.addEventListener(eventType, (event) => {
276
+ // 여전히 현재 연결인지 확인
277
+ if (eventSourceRef.current !== eventSource) {
278
+ return; // 이미 새로운 연결로 교체되었으면 무시
279
+ }
280
+
281
+ try {
282
+ const data = JSON.parse(event.data);
283
+ handler(data);
284
+ } catch (error) {
285
+ console.error(
286
+ `Failed to parse SSE data for event ${eventType}:`,
287
+ error
288
+ );
289
+ }
290
+ setState((prev) => ({
291
+ ...prev,
292
+ isEnded: false,
293
+ }));
294
+ });
295
+ }
296
+ });
297
+
298
+ // 기본 message 이벤트 처리 (event 타입이 없는 경우)
299
+ eventSource.onmessage = (event) => {
300
+ // 여전히 현재 연결인지 확인
301
+ if (eventSourceRef.current !== eventSource) {
302
+ return;
303
+ }
304
+
305
+ try {
306
+ const data = JSON.parse(event.data);
307
+ // 'message' 핸들러가 있으면 호출
308
+ const messageHandler = handlersRef.current["message" as keyof T];
309
+ if (messageHandler) {
310
+ messageHandler(data);
311
+ }
312
+ } catch (error) {
313
+ console.error("Failed to parse SSE message:", error);
314
+ }
315
+ };
316
+ } catch (error) {
317
+ setState((prev) => ({
318
+ ...prev,
319
+ error: error instanceof Error ? error.message : "Unknown error",
320
+ isConnected: false,
321
+ isEnded: false,
322
+ }));
323
+ }
324
+ };
325
+
326
+ // 연결 시작
327
+ useEffect(() => {
328
+ if (enabled) {
329
+ connect();
330
+ }
331
+
332
+ return () => {
333
+ // cleanup
334
+ if (eventSourceRef.current) {
335
+ eventSourceRef.current.close();
336
+ eventSourceRef.current = null;
337
+ }
338
+ if (retryTimeoutRef.current) {
339
+ clearTimeout(retryTimeoutRef.current);
340
+ retryTimeoutRef.current = null;
341
+ }
342
+ };
343
+ }, [url, JSON.stringify(params), enabled]);
344
+
345
+ // 파라미터가 변경되면 재연결
346
+ useEffect(() => {
347
+ if (enabled && eventSourceRef.current) {
348
+ connect();
349
+ }
350
+ }, [JSON.stringify(params)]);
351
+
352
+ return state;
353
+ }
@@ -130,11 +130,16 @@ export class Syncer {
130
130
  async sync(): Promise<void> {
131
131
  const { targets } = Sonamu.config.sync;
132
132
 
133
+ // 번들러 여부에 따라 현재 디렉토리가 바뀌므로
134
+ const currentDirname = __dirname.endsWith("/syncer")
135
+ ? __dirname
136
+ : path.join(__dirname, "./syncer");
137
+
133
138
  // 트리거와 무관하게 shared 분배
134
139
  await Promise.all(
135
140
  targets.map(async (target) => {
136
141
  const srcCodePath = path
137
- .join(__dirname, `../shared/${target}.shared.ts.txt`)
142
+ .join(currentDirname, `../shared/${target}.shared.ts.txt`)
138
143
  .replace("/dist/", "/src/");
139
144
  if (!fs.existsSync(srcCodePath)) {
140
145
  return;
@@ -158,6 +163,7 @@ export class Syncer {
158
163
  return;
159
164
  }
160
165
  fs.writeFileSync(dstCodePath, fs.readFileSync(srcCodePath));
166
+ console.log(chalk.blue("shared.ts is synced"));
161
167
  })
162
168
  );
163
169
 
@@ -7,6 +7,8 @@ import {
7
7
  apiParamTypeToTsType,
8
8
  apiParamToTsCode,
9
9
  unwrapPromiseOnce,
10
+ zodTypeToTsTypeDef,
11
+ apiParamToTsCodeAsObject,
10
12
  } from "../api/code-converters";
11
13
  import { ExtendedApi } from "../api/decorators";
12
14
  import { Template } from "./base-template";
@@ -43,7 +45,7 @@ export class Template__service extends Template {
43
45
  `import { z } from 'zod';`,
44
46
  `import qs from "qs";`,
45
47
  `import useSWR, { SWRResponse } from "swr";`,
46
- `import { fetch, ListResult, SWRError, SwrOptions, handleConditional, swrPostFetcher } from '../sonamu.shared';`,
48
+ `import { fetch, ListResult, SWRError, SwrOptions, handleConditional, swrPostFetcher, EventHandlers, SSEStreamOptions, useSSEStream } from '../sonamu.shared';`,
47
49
  ...(hasAxiosProgressEvent
48
50
  ? [`import { AxiosProgressEvent } from 'axios';`]
49
51
  : []),
@@ -91,6 +93,12 @@ export class Template__service extends Template {
91
93
  importKeys
92
94
  );
93
95
 
96
+ // 파라미터 정의 (객체 형태)
97
+ const paramsDefAsObject = apiParamToTsCodeAsObject(
98
+ paramsWithoutContext,
99
+ importKeys
100
+ );
101
+
94
102
  // 리턴 타입 정의
95
103
  const returnTypeDef = apiParamTypeToTsType(
96
104
  unwrapPromiseOnce(api.returnType),
@@ -102,11 +110,14 @@ export class Template__service extends Template {
102
110
  .map((param) => param.name)
103
111
  .join(", ")} }`;
104
112
 
105
- return _.sortBy(api.options.clients, (client) =>
106
- client === "swr" ? 0 : 1
107
- )
108
- .map((client) => {
109
- const apiBaseUrl = `${Sonamu.config.route.prefix}${api.path}`;
113
+ // 기본 URL
114
+ const apiBaseUrl = `${Sonamu.config.route.prefix}${api.path}`;
115
+
116
+ return [
117
+ // 클라이언트별로 생성
118
+ ..._.sortBy(api.options.clients, (client) =>
119
+ client === "swr" ? 0 : 1
120
+ ).map((client) => {
110
121
  switch (client) {
111
122
  case "axios":
112
123
  return this.renderAxios(
@@ -143,12 +154,15 @@ export class Template__service extends Template {
143
154
  paramsDef,
144
155
  payloadDef
145
156
  );
146
- case "socketio":
147
157
  default:
148
158
  return `// Not supported ${inflection.camelize(client, true)} yet.`;
149
159
  }
150
- })
151
- .join("\n");
160
+ }),
161
+ // 스트리밍인 경우
162
+ ...(api.streamOptions
163
+ ? [this.renderStream(api, apiBaseUrl, paramsDefAsObject)]
164
+ : []),
165
+ ].join("\n");
152
166
  })
153
167
  .join("\n\n");
154
168
 
@@ -274,4 +288,31 @@ export async function ${api.methodName}${typeParamsDef}(${paramsDef}): Promise<R
274
288
  }
275
289
  `.trim();
276
290
  }
291
+
292
+ renderStream(
293
+ api: ExtendedApi,
294
+ apiBaseUrl: string,
295
+ paramsDefAsObject: string
296
+ ) {
297
+ if (!api.streamOptions) {
298
+ return "// streamOptions not found";
299
+ }
300
+
301
+ const methodNameStream = api.options.resourceName
302
+ ? "use" + inflection.camelize(api.options.resourceName)
303
+ : "use" + inflection.camelize(api.methodName);
304
+ const methodNameStreamCamelized = inflection.camelize(
305
+ methodNameStream,
306
+ true
307
+ );
308
+
309
+ const eventsTypeDef = zodTypeToTsTypeDef(api.streamOptions.events);
310
+
311
+ return ` export function ${methodNameStreamCamelized}(
312
+ params: ${paramsDefAsObject},
313
+ handlers: EventHandlers<${eventsTypeDef} & { end?: () => void }>,
314
+ options: SSEStreamOptions) {
315
+ return useSSEStream<${eventsTypeDef}>(\`${apiBaseUrl}\`, params, handlers, options);
316
+ }`;
317
+ }
277
318
  }