@simplysm/service-server 13.0.69 → 13.0.70

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 (49) hide show
  1. package/README.md +48 -249
  2. package/dist/auth/jwt-manager.js +2 -2
  3. package/dist/auth/jwt-manager.js.map +1 -1
  4. package/dist/core/define-service.js +2 -2
  5. package/dist/core/define-service.js.map +1 -1
  6. package/dist/core/service-executor.js +5 -5
  7. package/dist/core/service-executor.js.map +1 -1
  8. package/dist/legacy/v1-auto-update-handler.d.ts +2 -2
  9. package/dist/legacy/v1-auto-update-handler.js +2 -2
  10. package/dist/legacy/v1-auto-update-handler.js.map +1 -1
  11. package/dist/service-server.js +11 -11
  12. package/dist/service-server.js.map +1 -1
  13. package/dist/services/auto-update-service.js +1 -1
  14. package/dist/services/auto-update-service.js.map +1 -1
  15. package/dist/services/orm-service.js +6 -6
  16. package/dist/services/orm-service.js.map +1 -1
  17. package/dist/transport/http/http-request-handler.js +1 -1
  18. package/dist/transport/http/http-request-handler.js.map +1 -1
  19. package/dist/transport/http/static-file-handler.js +3 -3
  20. package/dist/transport/http/upload-handler.js +2 -2
  21. package/dist/transport/http/upload-handler.js.map +1 -1
  22. package/dist/transport/socket/service-socket.js +2 -2
  23. package/dist/transport/socket/service-socket.js.map +1 -1
  24. package/dist/transport/socket/websocket-handler.d.ts.map +1 -1
  25. package/dist/transport/socket/websocket-handler.js +11 -9
  26. package/dist/transport/socket/websocket-handler.js.map +1 -1
  27. package/dist/utils/config-manager.js +7 -7
  28. package/dist/utils/config-manager.js.map +1 -1
  29. package/package.json +9 -9
  30. package/src/auth/jwt-manager.ts +2 -2
  31. package/src/core/define-service.ts +2 -2
  32. package/src/core/service-executor.ts +13 -13
  33. package/src/legacy/v1-auto-update-handler.ts +8 -8
  34. package/src/service-server.ts +28 -28
  35. package/src/services/auto-update-service.ts +1 -1
  36. package/src/services/orm-service.ts +6 -6
  37. package/src/transport/http/http-request-handler.ts +5 -5
  38. package/src/transport/http/static-file-handler.ts +7 -7
  39. package/src/transport/http/upload-handler.ts +3 -3
  40. package/src/transport/socket/service-socket.ts +4 -4
  41. package/src/transport/socket/websocket-handler.ts +12 -10
  42. package/src/utils/config-manager.ts +11 -11
  43. package/tests/define-service.spec.ts +85 -0
  44. package/tests/orm-service.spec.ts +83 -0
  45. package/tests/service-executor.spec.ts +114 -0
  46. package/docs/authentication.md +0 -114
  47. package/docs/built-in-services.md +0 -100
  48. package/docs/server.md +0 -374
  49. package/docs/transport.md +0 -273
@@ -37,8 +37,8 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
37
37
  constructor(readonly options: ServiceServerOptions) {
38
38
  super();
39
39
 
40
- // SSL 설정 (동기)
41
- // Note: Fastify HTTPS 설정은 Buffer 타입을 요구함 (Uint8Array 직접 사용 불가)
40
+ // SSL configuration (synchronous)
41
+ // Note: Fastify HTTPS requires Buffer type (Uint8Array not directly usable)
42
42
  const httpsConf = options.ssl
43
43
  ? { pfx: Buffer.from(options.ssl.pfxBytes), passphrase: options.ssl.passphrase }
44
44
  : null;
@@ -52,12 +52,12 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
52
52
  }
53
53
 
54
54
  async listen(): Promise<void> {
55
- logger.info(`서버 시작... ${env.VER ?? ""}`);
55
+ logger.info(`Server starting... ${env.VER ?? ""}`);
56
56
 
57
- // Websocket 플러그인
57
+ // WebSocket plugin
58
58
  await this.fastify.register(fastifyWebsocket);
59
59
 
60
- // 보안 플러그인
60
+ // Security plugin
61
61
  await this.fastify.register(fastifyHelmet, {
62
62
  global: true,
63
63
  contentSecurityPolicy: {
@@ -78,17 +78,17 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
78
78
  originAgentCluster: false,
79
79
  });
80
80
 
81
- // 업로드 플러그인
81
+ // Upload plugin
82
82
  await this.fastify.register(fastifyMultipart);
83
83
 
84
- // @fastify/static 등록
84
+ // Register @fastify/static
85
85
  await this.fastify.register(fastifyStatic, {
86
86
  root: path.resolve(this.options.rootPath, "www"),
87
87
  wildcard: false,
88
88
  serve: false,
89
89
  });
90
90
 
91
- // CORS 설정
91
+ // CORS configuration
92
92
  await this.fastify.register(fastifyCors, {
93
93
  origin: (_origin, cb) => {
94
94
  cb(null, true);
@@ -97,7 +97,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
97
97
  exposedHeaders: ["Content-Disposition", "Content-Length"],
98
98
  });
99
99
 
100
- // JSON 파서
100
+ // JSON parser
101
101
  this.fastify.addContentTypeParser(
102
102
  "application/json",
103
103
  { parseAs: "string" },
@@ -113,22 +113,22 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
113
113
  },
114
114
  );
115
115
 
116
- // JSON 생성기
116
+ // JSON serializer
117
117
  this.fastify.setSerializerCompiler(() => (data) => jsonStringify(data));
118
118
 
119
- // API 라우트
119
+ // API routes
120
120
  this.fastify.all("/api/:service/:method", async (req, reply) => {
121
121
  await handleHttpRequest(req, reply, this.options.auth?.jwtSecret, (def) =>
122
122
  runServiceMethod(this, def),
123
123
  );
124
124
  });
125
125
 
126
- // 업로드 라우트
126
+ // Upload route
127
127
  this.fastify.all("/upload", async (req, reply) => {
128
128
  await handleUpload(req, reply, this.options.rootPath, this.options.auth?.jwtSecret);
129
129
  });
130
130
 
131
- // WebSocket 라우트
131
+ // WebSocket route
132
132
  const onWebSocketConnected = (socket: WebSocket, req: FastifyRequest) => {
133
133
  const { ver, clientId, clientName } = req.query as {
134
134
  ver: string | undefined;
@@ -143,7 +143,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
143
143
  }
144
144
  this._wsHandler.addSocket(socket, clientId, clientName, req);
145
145
  } else {
146
- // V1 레거시 지원 (auto-update)
146
+ // V1 legacy support (auto-update only)
147
147
  const autoUpdateDef = this.options.services.find((s) => s.name === "AutoUpdate");
148
148
  if (autoUpdateDef == null) {
149
149
  socket.close(1008, "AutoUpdate service not configured");
@@ -163,7 +163,7 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
163
163
  this.fastify.get("/", { websocket: true }, onWebSocketConnected.bind(this));
164
164
  this.fastify.get("/ws", { websocket: true }, onWebSocketConnected.bind(this));
165
165
 
166
- // 정적 파일 와일드카드 핸들러
166
+ // Static file wildcard handler
167
167
  this.fastify.route({
168
168
  method: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD"],
169
169
  url: "/*",
@@ -175,19 +175,19 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
175
175
  },
176
176
  });
177
177
 
178
- // HTTP 서버 수준의 에러 핸들링
178
+ // HTTP server-level error handling
179
179
  this.fastify.server.on("error", (err) => {
180
- logger.error("HTTP 서버 오류 발생", err);
180
+ logger.error("HTTP server error", err);
181
181
  });
182
182
 
183
- // 리슨
183
+ // Listen
184
184
  await this.fastify.listen({ port: this.options.port, host: "0.0.0.0" });
185
185
 
186
- // Graceful Shutdown 핸들러 등록
186
+ // Register graceful shutdown handler
187
187
  this._registerGracefulShutdown();
188
188
 
189
189
  this.isOpen = true;
190
- logger.info(`서버 시작됨 (port: ${this.options.port})`);
190
+ logger.info(`Server started (port: ${this.options.port})`);
191
191
  this.emit("ready");
192
192
  }
193
193
 
@@ -196,12 +196,12 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
196
196
  await this.fastify.close();
197
197
 
198
198
  this.isOpen = false;
199
- logger.debug("서버 종료됨");
199
+ logger.debug("Server closed");
200
200
  this.emit("close");
201
201
  }
202
202
 
203
203
  async broadcastReload(clientName: string | undefined, changedFileSet: Set<string>) {
204
- logger.debug("서버내 모든 클라이언트 RELOAD 명령 전송");
204
+ logger.debug("Broadcasting RELOAD to all server clients");
205
205
  await this._wsHandler.broadcastReload(clientName, changedFileSet);
206
206
  }
207
207
 
@@ -215,24 +215,24 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
215
215
 
216
216
  async generateAuthToken(payload: AuthTokenPayload<TAuthInfo>) {
217
217
  const jwtSecret = this.options.auth?.jwtSecret;
218
- if (jwtSecret == null) throw new Error("JWT Secret 정의되지 않았습니다.");
218
+ if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
219
219
 
220
220
  return signJwt(jwtSecret, payload);
221
221
  }
222
222
 
223
223
  async verifyAuthToken(token: string): Promise<AuthTokenPayload<TAuthInfo>> {
224
224
  const jwtSecret = this.options.auth?.jwtSecret;
225
- if (jwtSecret == null) throw new Error("JWT Secret 정의되지 않았습니다.");
225
+ if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
226
226
 
227
227
  return verifyJwt(jwtSecret, token);
228
228
  }
229
229
 
230
230
  private _registerGracefulShutdown() {
231
231
  const shutdownHandler = async (signal: string) => {
232
- logger.info(`${signal} 시그널 감지. 서버 종료 프로세스 시작...`);
232
+ logger.info(`${signal} signal received. Starting server shutdown...`);
233
233
 
234
234
  const forceExitTimer = setTimeout(() => {
235
- logger.error("서버 종료 시간 초과 (10초). 강제 종료합니다.");
235
+ logger.error("Server shutdown timed out (10s). Forcing exit.");
236
236
  process.exit(1);
237
237
  }, 10000);
238
238
 
@@ -240,11 +240,11 @@ export class ServiceServer<TAuthInfo = unknown> extends EventEmitter<{
240
240
  if (this.isOpen) {
241
241
  await this.close();
242
242
  }
243
- logger.info("서버가 안전하게 종료되었습니다.");
243
+ logger.info("Server shut down gracefully.");
244
244
  clearTimeout(forceExitTimer);
245
245
  process.exit(0);
246
246
  } catch (err) {
247
- logger.error("서버 종료 오류 발생", err);
247
+ logger.error("Error during server shutdown", err);
248
248
  process.exit(1);
249
249
  }
250
250
  };
@@ -12,7 +12,7 @@ export const AutoUpdateService = defineService("AutoUpdate", (ctx) => ({
12
12
  | undefined
13
13
  > {
14
14
  const clientPath = ctx.clientPath;
15
- if (clientPath == null) throw new Error("클라이언트 경로를 찾을 수 없습니다.");
15
+ if (clientPath == null) throw new Error("Client path not found.");
16
16
 
17
17
  if (!(await fsExists(path.resolve(clientPath, platform, "updates")))) return undefined;
18
18
 
@@ -24,7 +24,7 @@ export const OrmService = defineService(
24
24
  const sock = (): ServiceSocket => {
25
25
  const socket = ctx.socket;
26
26
  if (socket == null) {
27
- throw new Error("WebSocket 연결이 필요합니다. HTTP로는 ORM 서비스를 사용할 없습니다.");
27
+ throw new Error("WebSocket connection is required. ORM service cannot be used over HTTP.");
28
28
  }
29
29
  return socket;
30
30
  };
@@ -34,7 +34,7 @@ export const OrmService = defineService(
34
34
  opt.configName
35
35
  ];
36
36
  if (config == null) {
37
- throw new Error(`ORM 설정을 찾을 없습니다: ${opt.configName}`);
37
+ throw new Error(`ORM configuration not found: ${opt.configName}`);
38
38
  }
39
39
  return { ...config, ...opt.config } as DbConnConfig;
40
40
  };
@@ -43,7 +43,7 @@ export const OrmService = defineService(
43
43
  const myConns = socketConns.get(sock());
44
44
  const conn = myConns?.get(connId);
45
45
  if (conn == null) {
46
- throw new Error("DB에 연결되어있지 않습니다. (Invalid Connection ID)");
46
+ throw new Error("Not connected to database. (Invalid Connection ID)");
47
47
  }
48
48
  return conn;
49
49
  };
@@ -71,7 +71,7 @@ export const OrmService = defineService(
71
71
  sock().on("close", async () => {
72
72
  if (myConns == null) return;
73
73
 
74
- logger.debug("소켓 연결 종료 감지: 열려있는 모든 DB 연결을 정리합니다.");
74
+ logger.debug("Socket close detected: cleaning up all open DB connections.");
75
75
  const conns = Array.from(myConns.values());
76
76
 
77
77
  await Promise.all(
@@ -81,7 +81,7 @@ export const OrmService = defineService(
81
81
  await conn.close();
82
82
  }
83
83
  } catch (err) {
84
- logger.warn("DB 연결 강제 종료 오류 무시됨", err);
84
+ logger.warn("Error ignored during forced DB connection close", err);
85
85
  }
86
86
  }),
87
87
  );
@@ -110,7 +110,7 @@ export const OrmService = defineService(
110
110
  const conn = getConn(connId);
111
111
  await conn.close();
112
112
  } catch (err) {
113
- logger.warn("DB 연결 종료 오류 무시됨", err);
113
+ logger.warn("Error ignored during DB connection close", err);
114
114
  }
115
115
  },
116
116
 
@@ -16,16 +16,16 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
16
16
  ): Promise<void> {
17
17
  const { service, method } = req.params as { service: string; method: string };
18
18
 
19
- // ClientName 헤더
19
+ // ClientName header
20
20
  const clientName = req.headers["x-sd-client-name"] as string | undefined;
21
21
  if (clientName == null) throw new Error("ClientName header is required");
22
22
 
23
- // Authorization 헤더 파싱 검증
23
+ // Parse and verify Authorization header
24
24
  let authTokenPayload: AuthTokenPayload<TAuthInfo> | undefined;
25
25
  try {
26
26
  const authHeader = req.headers.authorization;
27
27
  if (authHeader != null) {
28
- if (jwtSecret == null) throw new Error("JWT Secret 정의되지 않았습니다.");
28
+ if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
29
29
 
30
30
  const token = authHeader.split(" ")[1]; // "Bearer <token>"
31
31
  authTokenPayload = await verifyJwt<TAuthInfo>(jwtSecret, token);
@@ -38,7 +38,7 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
38
38
  return;
39
39
  }
40
40
 
41
- // 파라미터 파싱
41
+ // Parse parameters
42
42
  let params: unknown[] | undefined;
43
43
  if (req.method === "GET") {
44
44
  const query = req.query as { json?: string };
@@ -58,7 +58,7 @@ export async function handleHttpRequest<TAuthInfo = unknown>(
58
58
  params = req.body as unknown[];
59
59
  }
60
60
 
61
- // 서비스 실행 응답
61
+ // Execute service and send response
62
62
  if (params != null) {
63
63
  const serviceResult = await runMethod({
64
64
  serviceName: service,
@@ -14,12 +14,12 @@ export async function handleStaticFile(
14
14
  let targetFilePath = path.resolve(rootPath, "www", urlPath);
15
15
  const allowedRootPath = path.resolve(rootPath, "www");
16
16
 
17
- // targetPath 보안 방어 (Path Traversal 방지)
17
+ // Security guard for targetPath (prevent path traversal)
18
18
  if (targetFilePath !== allowedRootPath && !pathIsChildPath(targetFilePath, allowedRootPath)) {
19
19
  throw new Error("Access denied");
20
20
  }
21
21
 
22
- // 디렉토리면 trailing slash 리다이렉트 (표준 서버 동작)
22
+ // Redirect with trailing slash for directories (standard web server behavior)
23
23
  if ((await fsExists(targetFilePath)) && (await fsStat(targetFilePath)).isDirectory()) {
24
24
  if (!urlPath.endsWith("/")) {
25
25
  const urlObj = new URL(req.raw.url!, "http://localhost");
@@ -29,15 +29,15 @@ export async function handleStaticFile(
29
29
  targetFilePath = path.resolve(targetFilePath, "index.html");
30
30
  }
31
31
 
32
- // 권한 확인 (숨김 파일 )
32
+ // Permission check (hidden files, etc.)
33
33
  if (path.basename(targetFilePath).startsWith(".")) {
34
- const errorMessage = "파일을 사용할 권한이 없습니다.";
34
+ const errorMessage = "You do not have permission to access this file.";
35
35
  responseErrorHtml(reply, 403, errorMessage);
36
36
  logger.warn(`[403] ${errorMessage} (${targetFilePath})`);
37
37
  return;
38
38
  }
39
39
 
40
- // 파일 전송
40
+ // Send file
41
41
  const filename = path.basename(targetFilePath);
42
42
  const directory = path.dirname(targetFilePath);
43
43
 
@@ -46,11 +46,11 @@ export async function handleStaticFile(
46
46
  } catch (err: unknown) {
47
47
  const error = err as { code?: string };
48
48
  if (error.code === "ENOENT") {
49
- const errorMessage = "파일을 찾을 수 없습니다.";
49
+ const errorMessage = "File not found.";
50
50
  responseErrorHtml(reply, 404, errorMessage);
51
51
  logger.warn(`[404] ${errorMessage} (${targetFilePath})`);
52
52
  } else {
53
- const errorMessage = "파일 전송 오류가 발생했습니다.";
53
+ const errorMessage = "An error occurred while sending the file.";
54
54
  responseErrorHtml(reply, 500, errorMessage);
55
55
  logger.error(`[500] ${errorMessage}`, err);
56
56
  }
@@ -21,14 +21,14 @@ export async function handleUpload(
21
21
  return;
22
22
  }
23
23
 
24
- // 인증 검사
24
+ // Auth check
25
25
  try {
26
26
  const authHeader = req.headers.authorization;
27
27
  if (authHeader == null) {
28
- throw new Error("인증 토큰이 없습니다.");
28
+ throw new Error("Authentication token is missing.");
29
29
  }
30
30
  if (jwtSecret == null) {
31
- throw new Error("JWT Secret 정의되지 않았습니다.");
31
+ throw new Error("JWT Secret is not defined.");
32
32
  }
33
33
  const token = authHeader.split(" ")[1];
34
34
  await verifyJwt(jwtSecret, token);
@@ -80,7 +80,7 @@ export function createServiceSocket(
80
80
  // State
81
81
  // -------------------------------------------------------------------
82
82
 
83
- const PING_INTERVAL = 5000; // 5초마다 전송
83
+ const PING_INTERVAL = 5000; // Send ping every 5s
84
84
  const PONG_PACKET = new Uint8Array([0x02]);
85
85
 
86
86
  const protocol = createProtocolWrapper();
@@ -125,7 +125,7 @@ export function createServiceSocket(
125
125
  // -------------------------------------------------------------------
126
126
 
127
127
  function onError(err: Error): void {
128
- logger.error("WebSocket 클라이언트 오류 발생", err);
128
+ logger.error("WebSocket client error", err);
129
129
  emitEvent("error", err);
130
130
  }
131
131
 
@@ -137,7 +137,7 @@ export function createServiceSocket(
137
137
 
138
138
  async function onMessage(msgBuffer: Bytes): Promise<void> {
139
139
  try {
140
- // ping에 대한 pong처리
140
+ // Handle pong response to ping
141
141
  if (msgBuffer.length === 1 && msgBuffer[0] === 0x01) {
142
142
  if (socket.readyState === WebSocket.OPEN) {
143
143
  socket.send(PONG_PACKET);
@@ -159,7 +159,7 @@ export function createServiceSocket(
159
159
  emitEvent("message", { uuid: decodeResult.uuid, msg });
160
160
  }
161
161
  } catch (err) {
162
- logger.error("WebSocket 메시지 처리 중 오류 발생", err);
162
+ logger.error("Error processing WebSocket message", err);
163
163
  }
164
164
  }
165
165
 
@@ -114,13 +114,13 @@ export function createWebSocketHandler(
114
114
 
115
115
  return await serviceSocket.send(uuid, { name: "response" });
116
116
  } else if (message.name === "auth") {
117
- if (jwtSecret == null) throw new Error("JWT Secret 정의되지 않았습니다.");
117
+ if (jwtSecret == null) throw new Error("JWT Secret is not defined.");
118
118
 
119
119
  const token = message.body;
120
120
  serviceSocket.authTokenPayload = await verifyJwt(jwtSecret, token);
121
121
  return await serviceSocket.send(uuid, { name: "response" });
122
122
  } else {
123
- const err = new Error("요청이 잘못되었습니다.");
123
+ const err = new Error("Invalid request.");
124
124
 
125
125
  return await serviceSocket.send(uuid, {
126
126
  name: "error",
@@ -136,7 +136,7 @@ export function createWebSocketHandler(
136
136
  const error =
137
137
  err instanceof Error
138
138
  ? err
139
- : new Error(typeof err === "string" ? err : " 없는 오류가 발생하였습니다.");
139
+ : new Error(typeof err === "string" ? err : "An unknown error has occurred.");
140
140
 
141
141
  return serviceSocket.send(uuid, {
142
142
  name: "error",
@@ -164,38 +164,40 @@ export function createWebSocketHandler(
164
164
  try {
165
165
  const serviceSocket = createServiceSocket(socket, clientId, clientName, connReq);
166
166
 
167
- // 기존 연결 끊기
167
+ // Disconnect existing connection
168
168
  const prevServiceSocket = socketMap.get(clientId);
169
169
  if (prevServiceSocket != null) {
170
170
  prevServiceSocket.close();
171
171
 
172
172
  const connectionDateTimeText =
173
173
  prevServiceSocket.connectedAtDateTime.toFormatString("yyyy:MM:dd HH:mm:ss.fff");
174
- logger.debug(`클라이언트 기존연결 끊음: ${clientId}: ${connectionDateTimeText}`);
174
+ logger.debug(
175
+ `Disconnected previous client connection: ${clientId}: ${connectionDateTimeText}`,
176
+ );
175
177
  }
176
178
 
177
179
  socketMap.set(clientId, serviceSocket);
178
180
 
179
181
  serviceSocket.on("close", (code) => {
180
- logger.debug(`클라이언트 연결 끊김: (code: ${code})`);
182
+ logger.debug(`Client disconnected: (code: ${code})`);
181
183
 
182
184
  if (socketMap.get(clientId) !== serviceSocket) return;
183
185
  socketMap.delete(clientId);
184
186
  });
185
187
 
186
188
  serviceSocket.on("message", async ({ uuid, msg }) => {
187
- logger.debug("요청 수신", msg);
189
+ logger.debug("Request received", msg);
188
190
  const sentSize = await processRequest(serviceSocket, uuid, msg);
189
- logger.debug(`응답 전송 (size: ${sentSize})`);
191
+ logger.debug(`Response sent (size: ${sentSize})`);
190
192
  });
191
193
 
192
- logger.debug("클라이언트 연결됨", {
194
+ logger.debug("Client connected", {
193
195
  clientId,
194
196
  remoteAddress: connReq.socket.remoteAddress,
195
197
  socketSize: socketMap.size,
196
198
  });
197
199
  } catch (err) {
198
- logger.error("연결 처리 중 오류 발생", err);
200
+ logger.error("Error handling connection", err);
199
201
  socket.terminate();
200
202
  }
201
203
  },
@@ -5,12 +5,12 @@ import consola from "consola";
5
5
 
6
6
  const logger = consola.withTag("service-server:ConfigManager");
7
7
 
8
- // 값: Config 객체, 키: 파일 경로
8
+ // Value: Config object, Key: file path
9
9
  const _cache = new LazyGcMap<string, unknown>({
10
- gcInterval: 10 * 60 * 1000, // 10분마다
11
- expireTime: 60 * 60 * 1000, // 1시간 만료
10
+ gcInterval: 10 * 60 * 1000, // Every 10 minutes
11
+ expireTime: 60 * 60 * 1000, // Expire after 1 hour
12
12
  onExpire: async (filePath) => {
13
- logger.debug(`설정 캐시 만료 감시 해제: ${path.basename(filePath)}`);
13
+ logger.debug(`Config cache expired and watcher released: ${path.basename(filePath)}`);
14
14
  await closeWatcher(filePath);
15
15
  },
16
16
  });
@@ -18,18 +18,18 @@ const _cache = new LazyGcMap<string, unknown>({
18
18
  const _watchers = new Map<string, FsWatcher>();
19
19
 
20
20
  export async function getConfig<TConfig>(filePath: string): Promise<TConfig | undefined> {
21
- // 1. 캐시 적중 (시간 자동 갱신)
21
+ // 1. Cache hit (time auto-renewed)
22
22
  if (_cache.has(filePath)) {
23
23
  return _cache.get(filePath) as TConfig;
24
24
  }
25
25
 
26
26
  if (!(await fsExists(filePath))) return undefined;
27
27
 
28
- // 2. 로드 캐시
28
+ // 2. Load and cache
29
29
  const config = await fsReadJson(filePath);
30
30
  _cache.set(filePath, config);
31
31
 
32
- // 3. Watcher 등록
32
+ // 3. Register watcher
33
33
  if (!_watchers.has(filePath)) {
34
34
  try {
35
35
  const watcher = await FsWatcher.watch([filePath]);
@@ -39,20 +39,20 @@ export async function getConfig<TConfig>(filePath: string): Promise<TConfig | un
39
39
  if (!(await fsExists(filePath))) {
40
40
  _cache.delete(filePath);
41
41
  await closeWatcher(filePath);
42
- logger.debug(`설정 파일 삭제됨: ${path.basename(filePath)}`);
42
+ logger.debug(`Config file deleted: ${path.basename(filePath)}`);
43
43
  return;
44
44
  }
45
45
 
46
46
  try {
47
47
  const newConfig = await fsReadJson(filePath);
48
48
  _cache.set(filePath, newConfig);
49
- logger.debug(`설정 파일 실시간 갱신: ${path.basename(filePath)}`);
49
+ logger.debug(`Config file live-reloaded: ${path.basename(filePath)}`);
50
50
  } catch (err) {
51
- logger.warn(`설정 파일 갱신 실패: ${filePath}`, err);
51
+ logger.warn(`Config file reload failed: ${filePath}`, err);
52
52
  }
53
53
  });
54
54
  } catch (err) {
55
- logger.error(`감시 실패: ${filePath}`, err);
55
+ logger.error(`Watch failed: ${filePath}`, err);
56
56
  }
57
57
  }
58
58
 
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { defineService, auth, getServiceAuthPermissions } from "@simplysm/service-server";
3
+
4
+ describe("defineService", () => {
5
+ it("creates service definition with name and factory", () => {
6
+ const svc = defineService("Health", (_ctx) => ({
7
+ check: () => "ok",
8
+ }));
9
+
10
+ expect(svc.name).toBe("Health");
11
+ expect(typeof svc.factory).toBe("function");
12
+ });
13
+
14
+ it("factory generates methods when called with context", () => {
15
+ const svc = defineService("Echo", (_ctx) => ({
16
+ echo: (msg: string) => `Echo: ${msg}`,
17
+ }));
18
+
19
+ const methods = svc.factory({} as any);
20
+ expect(methods.echo("hello")).toBe("Echo: hello");
21
+ });
22
+ });
23
+
24
+ describe("Authentication", () => {
25
+ it("marks function with empty permissions (login required only)", () => {
26
+ const fn = auth(() => "result");
27
+ expect(getServiceAuthPermissions(fn)).toEqual([]);
28
+ expect(fn()).toBe("result");
29
+ });
30
+
31
+ it("marks function with specific permissions", () => {
32
+ const fn = auth(["admin"], (id: number) => id * 2);
33
+ expect(getServiceAuthPermissions(fn)).toEqual(["admin"]);
34
+ expect(fn(5)).toBe(10);
35
+ });
36
+
37
+ it("returns undefined for unmarked function", () => {
38
+ const fn = () => "plain";
39
+ expect(getServiceAuthPermissions(fn)).toBeUndefined();
40
+ });
41
+
42
+ it("works at service level (factory wrapping)", () => {
43
+ const svc = defineService(
44
+ "User",
45
+ auth((_ctx) => ({
46
+ getProfile: () => "profile",
47
+ })),
48
+ );
49
+
50
+ expect(svc.authPermissions).toEqual([]);
51
+ });
52
+
53
+ it("works at service-level with roles", () => {
54
+ const svc = defineService(
55
+ "Admin",
56
+ auth(["admin"], (_ctx) => ({
57
+ manage: () => "managed",
58
+ })),
59
+ );
60
+
61
+ expect(svc.authPermissions).toEqual(["admin"]);
62
+ });
63
+
64
+ it("service without auth has no authPermissions", () => {
65
+ const svc = defineService("Public", (_ctx) => ({
66
+ open: () => "open",
67
+ }));
68
+
69
+ expect(svc.authPermissions).toBeUndefined();
70
+ });
71
+
72
+ it("method-level auth is readable from returned methods", () => {
73
+ const svc = defineService(
74
+ "Mixed",
75
+ auth((_ctx) => ({
76
+ normal: () => "normal",
77
+ adminOnly: auth(["admin"], () => "admin"),
78
+ })),
79
+ );
80
+
81
+ const methods = svc.factory({} as any);
82
+ expect(getServiceAuthPermissions(methods.normal)).toBeUndefined();
83
+ expect(getServiceAuthPermissions(methods.adminOnly)).toEqual(["admin"]);
84
+ });
85
+ });