@simplysm/service-client 13.0.100 → 14.0.4

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 (67) hide show
  1. package/README.md +403 -89
  2. package/dist/features/event-client.d.ts.map +1 -1
  3. package/dist/features/event-client.js +75 -67
  4. package/dist/features/event-client.js.map +1 -6
  5. package/dist/features/file-client.d.ts +3 -2
  6. package/dist/features/file-client.d.ts.map +1 -1
  7. package/dist/features/file-client.js +41 -39
  8. package/dist/features/file-client.js.map +1 -6
  9. package/dist/features/orm/orm-client-connector.js +37 -38
  10. package/dist/features/orm/orm-client-connector.js.map +1 -6
  11. package/dist/features/orm/orm-client-db-context-executor.d.ts.map +1 -1
  12. package/dist/features/orm/orm-client-db-context-executor.js +60 -60
  13. package/dist/features/orm/orm-client-db-context-executor.js.map +1 -6
  14. package/dist/features/orm/orm-connect-options.js +2 -1
  15. package/dist/features/orm/orm-connect-options.js.map +1 -6
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +7 -1
  19. package/dist/index.js.map +1 -6
  20. package/dist/protocol/client-protocol-wrapper.d.ts +1 -0
  21. package/dist/protocol/client-protocol-wrapper.d.ts.map +1 -1
  22. package/dist/protocol/client-protocol-wrapper.js +93 -70
  23. package/dist/protocol/client-protocol-wrapper.js.map +1 -6
  24. package/dist/service-client.d.ts +5 -3
  25. package/dist/service-client.d.ts.map +1 -1
  26. package/dist/service-client.js +110 -111
  27. package/dist/service-client.js.map +1 -6
  28. package/dist/transport/service-transport.d.ts +0 -1
  29. package/dist/transport/service-transport.d.ts.map +1 -1
  30. package/dist/transport/service-transport.js +115 -104
  31. package/dist/transport/service-transport.js.map +1 -6
  32. package/dist/transport/socket-provider.d.ts.map +1 -1
  33. package/dist/transport/socket-provider.js +185 -155
  34. package/dist/transport/socket-provider.js.map +1 -6
  35. package/dist/types/browser-compat.d.ts +33 -0
  36. package/dist/types/browser-compat.d.ts.map +1 -0
  37. package/dist/types/browser-compat.js +17 -0
  38. package/dist/types/browser-compat.js.map +1 -0
  39. package/dist/types/connection-options.d.ts +1 -1
  40. package/dist/types/connection-options.d.ts.map +1 -1
  41. package/dist/types/connection-options.js +2 -1
  42. package/dist/types/connection-options.js.map +1 -6
  43. package/dist/types/progress.types.d.ts +1 -0
  44. package/dist/types/progress.types.d.ts.map +1 -1
  45. package/dist/types/progress.types.js +2 -1
  46. package/dist/types/progress.types.js.map +1 -6
  47. package/dist/workers/client-protocol.worker.js +38 -24
  48. package/dist/workers/client-protocol.worker.js.map +1 -6
  49. package/package.json +16 -9
  50. package/src/features/event-client.ts +19 -17
  51. package/src/features/file-client.ts +11 -10
  52. package/src/features/orm/orm-client-connector.ts +2 -2
  53. package/src/features/orm/orm-client-db-context-executor.ts +8 -7
  54. package/src/index.ts +6 -5
  55. package/src/protocol/client-protocol-wrapper.ts +37 -28
  56. package/src/service-client.ts +24 -20
  57. package/src/transport/service-transport.ts +19 -25
  58. package/src/transport/socket-provider.ts +44 -38
  59. package/src/types/browser-compat.ts +47 -0
  60. package/src/types/connection-options.ts +1 -1
  61. package/src/types/progress.types.ts +1 -0
  62. package/src/workers/client-protocol.worker.ts +10 -10
  63. package/docs/features.md +0 -143
  64. package/docs/protocol.md +0 -29
  65. package/docs/service-client.md +0 -93
  66. package/docs/transport.md +0 -96
  67. package/docs/types.md +0 -55
@@ -5,23 +5,26 @@ import type {
5
5
  ServiceMessage,
6
6
  ServiceProtocol,
7
7
  } from "@simplysm/service-common";
8
+ import type { WorkerLike } from "../types/browser-compat";
9
+ import { isWorkerSupported, createBrowserWorker } from "../types/browser-compat";
8
10
 
9
11
  export interface ClientProtocolWrapper {
10
12
  encode(uuid: string, message: ServiceMessage): Promise<{ chunks: Bytes[]; totalSize: number }>;
11
13
  decode(bytes: Bytes): Promise<ServiceMessageDecodeResult<ServiceMessage>>;
14
+ dispose(): void;
12
15
  }
13
16
 
14
- // Shared worker state (singleton pattern)
15
- let worker: Worker | undefined;
17
+ // 공유 worker 상태 (싱글턴 패턴)
18
+ let worker: WorkerLike | undefined;
16
19
  const workerResolvers = new LazyGcMap<
17
20
  string,
18
21
  { resolve: (res: unknown) => void; reject: (err: Error) => void }
19
22
  >({
20
- gcInterval: 5 * 1000, // Check for expired entries every 5s
21
- expireTime: 60 * 1000, // Expire after 60s (timeout)
23
+ gcInterval: 5 * 1000, // 5초마다 만료된 항목 확인
24
+ expireTime: 60 * 1000, // 60초 만료 (타임아웃)
22
25
  onExpire: (key, item) => {
23
- // Reject on expiry (critical for preventing memory leaks)
24
- item.reject(new Error(`Worker task timed out (uuid: ${key})`));
26
+ // 만료 reject (메모리 누수 방지에 필수)
27
+ item.reject(new Error(`Worker 작업 시간 초과 (uuid: ${key})`));
25
28
  },
26
29
  });
27
30
 
@@ -29,22 +32,24 @@ let workerAvailable: boolean | undefined;
29
32
 
30
33
  function isWorkerAvailable(): boolean {
31
34
  if (workerAvailable === undefined) {
32
- workerAvailable = typeof Worker !== "undefined";
35
+ workerAvailable = isWorkerSupported();
33
36
  }
34
37
  return workerAvailable;
35
38
  }
36
39
 
37
- function getWorker(): Worker | undefined {
40
+ function getWorker(): WorkerLike | undefined {
38
41
  if (!isWorkerAvailable()) {
39
42
  return undefined;
40
43
  }
41
44
 
42
45
  if (!worker) {
43
- // Modern bundlers (Vite/Esbuild/Webpack) use this syntax to split/load the Worker as a separate file
44
- // Note: use relative path instead of import.meta.resolve (Vite compatibility)
45
- worker = new Worker(new URL("../workers/client-protocol.worker.ts", import.meta.url), {
46
- type: "module",
47
- });
46
+ // 모던 번들러 (Vite/Esbuild/Webpack) 구문을 사용하여 Worker 별도 파일로 분리/로드함
47
+ // 참고: import.meta.resolve 대신 상대 경로 사용 (Vite 호환성)
48
+ worker = createBrowserWorker(
49
+ new URL("../workers/client-protocol.worker.ts", import.meta.url),
50
+ { type: "module" },
51
+ );
52
+ if (worker == null) return undefined;
48
53
 
49
54
  worker.onmessage = (event: MessageEvent) => {
50
55
  const { id, type, result, error } = event.data as {
@@ -59,7 +64,7 @@ function getWorker(): Worker | undefined {
59
64
  if (type === "success") {
60
65
  resolver.resolve(result);
61
66
  } else {
62
- const err = new Error(error?.message ?? "Unknown worker error");
67
+ const err = new Error(error?.message ?? " 수 없는 worker 에러");
63
68
  err.stack = error?.stack;
64
69
  resolver.reject(err);
65
70
  }
@@ -71,32 +76,32 @@ function getWorker(): Worker | undefined {
71
76
  }
72
77
 
73
78
  /**
74
- * Delegate work to Worker and await result
75
- * Note: only call when workerAvailable is true
79
+ * Worker에 작업을 위임하고 결과를 대기
80
+ * 참고: workerAvailable이 true일 때만 호출할
76
81
  */
77
82
  async function runWorker(
78
83
  type: "encode" | "decode",
79
84
  data: unknown,
80
- transferables: Transferable[] = [],
85
+ transferables: ArrayBuffer[] = [],
81
86
  ): Promise<unknown> {
82
87
  return new Promise((resolve, reject) => {
83
88
  const id = Uuid.generate().toString();
84
89
 
85
90
  workerResolvers.set(id, { resolve, reject });
86
- // Called after workerAvailable check, so worker always exists
87
- getWorker()!.postMessage({ id, type, data }, { transfer: transferables });
91
+ // workerAvailable 확인 호출되므로 worker 항상 존재
92
+ getWorker()!.postMessage({ id, type, data }, transferables);
88
93
  });
89
94
  }
90
95
 
91
96
  export function createClientProtocolWrapper(protocol: ServiceProtocol): ClientProtocolWrapper {
92
- // Threshold: 30KB
97
+ // 임계값: 30KB
93
98
  const SIZE_THRESHOLD = 30 * 1024;
94
99
 
95
100
  function shouldUseWorkerForEncode(msg: ServiceMessage): boolean {
96
101
  if (!("body" in msg)) return false;
97
102
  const body = msg.body;
98
103
 
99
- // Use worker if Uint8Array is present or array length is large
104
+ // Uint8Array 있거나 배열 길이가 경우 worker 사용
100
105
  if (body instanceof Uint8Array) return true;
101
106
  if (typeof body === "string" && body.length > SIZE_THRESHOLD) return true;
102
107
  if (Array.isArray(body)) {
@@ -110,14 +115,14 @@ export function createClientProtocolWrapper(protocol: ServiceProtocol): ClientPr
110
115
  uuid: string,
111
116
  message: ServiceMessage,
112
117
  ): Promise<{ chunks: Bytes[]; totalSize: number }> {
113
- // Process on main thread if no Worker or small data
118
+ // Worker가 없거나 데이터가 작으면 메인 스레드에서 처리
114
119
  if (!isWorkerAvailable() || !shouldUseWorkerForEncode(message)) {
115
120
  return protocol.encode(uuid, message);
116
121
  }
117
122
 
118
123
  // [Worker]
119
- // Encode requires sending an object, so Structured Clone occurs.
120
- // But the benefit of offloading JSON.stringify cost from the main thread is greater.
124
+ // 인코딩은 객체 전송이 필요하므로 Structured Clone 발생함.
125
+ // 하지만 메인 스레드에서 JSON.stringify 비용을 오프로드하는 이점이 큼.
121
126
  return (await runWorker("encode", { uuid, message })) as {
122
127
  chunks: Bytes[];
123
128
  totalSize: number;
@@ -127,21 +132,25 @@ export function createClientProtocolWrapper(protocol: ServiceProtocol): ClientPr
127
132
  async function decode(bytes: Bytes): Promise<ServiceMessageDecodeResult<ServiceMessage>> {
128
133
  const totalSize = bytes.length;
129
134
 
130
- // Process on main thread if no Worker or small data
135
+ // Worker가 없거나 데이터가 작으면 메인 스레드에서 처리
131
136
  if (!isWorkerAvailable() || totalSize <= SIZE_THRESHOLD) {
132
137
  return protocol.decode(bytes);
133
138
  }
134
139
 
135
140
  // [Worker]
136
- // Zero-copy transfer (buffer ownership moves to Worker)
137
- const rawResult = await runWorker("decode", bytes, [bytes.buffer]);
141
+ // Zero-copy 전송 (버퍼 소유권이 Worker로 이동)
142
+ const rawResult = await runWorker("decode", bytes, [bytes.buffer as ArrayBuffer]);
138
143
 
139
- // Restore class instances (DateTime, etc.) from Worker's plain object result
144
+ // Worker의 plain object 결과에서 클래스 인스턴스 복원 (DateTime 등)
140
145
  return transfer.decode(rawResult) as ServiceMessageDecodeResult<ServiceMessage>;
141
146
  }
142
147
 
143
148
  return {
144
149
  encode,
145
150
  decode,
151
+ dispose() {
152
+ protocol.dispose();
153
+ workerResolvers.dispose();
154
+ },
146
155
  };
147
156
  }
@@ -3,33 +3,35 @@ import { EventEmitter } from "@simplysm/core-common";
3
3
  import type { ServiceEventDef } from "@simplysm/service-common";
4
4
  import { createServiceProtocol } from "@simplysm/service-common";
5
5
 
6
+ import type { BlobInput, FileCollection } from "./types/browser-compat";
6
7
  import type { ServiceConnectionOptions } from "./types/connection-options";
7
8
  import type { ServiceProgress, ServiceProgressState } from "./types/progress.types";
8
9
  import { createServiceTransport, type ServiceTransport } from "./transport/service-transport";
9
10
  import { createSocketProvider, type SocketProvider } from "./transport/socket-provider";
10
11
  import { createEventClient, type EventClient } from "./features/event-client";
11
12
  import { createFileClient, type FileClient } from "./features/file-client";
12
- import { createClientProtocolWrapper } from "./protocol/client-protocol-wrapper";
13
+ import { createClientProtocolWrapper, type ClientProtocolWrapper } from "./protocol/client-protocol-wrapper";
13
14
 
14
15
  const logger = consola.withTag("service-client:ServiceClient");
15
16
 
16
17
  interface ServiceClientEvents {
17
18
  "request-progress": ServiceProgressState;
18
19
  "response-progress": ServiceProgressState;
20
+ "server-progress": ServiceProgressState;
19
21
  "state": "connected" | "closed" | "reconnecting";
20
- "reload": Set<string>;
21
22
  }
22
23
 
23
24
  export class ServiceClient extends EventEmitter<ServiceClientEvents> {
24
- // Modules
25
+ // 모듈
25
26
  private readonly _socket: SocketProvider;
26
27
  private readonly _transport: ServiceTransport;
27
28
  private readonly _eventClient: EventClient;
28
29
  private readonly _fileClient: FileClient;
29
30
 
31
+ private readonly _protocolWrapper: ClientProtocolWrapper;
30
32
  private _authToken?: string;
31
33
 
32
- // State accessors
34
+ // 상태 접근자
33
35
  get connected() {
34
36
  return this._socket.connected;
35
37
  }
@@ -47,37 +49,34 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
47
49
  const wsProtocol = options.ssl ? "wss" : "ws";
48
50
  const wsUrl = `${wsProtocol}://${options.host}:${options.port}/ws`;
49
51
 
50
- // Initialize modules
52
+ // 모듈 초기화
51
53
  this._socket = createSocketProvider(wsUrl, this.name, this.options.maxReconnectCount ?? 10);
52
54
  const protocol = createServiceProtocol();
53
- const protocolWrapper = createClientProtocolWrapper(protocol);
54
- this._transport = createServiceTransport(this._socket, protocolWrapper);
55
+ this._protocolWrapper = createClientProtocolWrapper(protocol);
56
+ this._transport = createServiceTransport(this._socket, this._protocolWrapper);
55
57
  this._eventClient = createEventClient(this._transport);
56
58
  this._fileClient = createFileClient(this.hostUrl, this.name);
57
59
 
58
- // Event bindings
60
+ // 이벤트 바인딩
59
61
  this._socket.on("state", async (state) => {
60
62
  this.emit("state", state);
61
63
 
62
- // Auto-recover event listeners on reconnect
64
+ // 재연결 이벤트 리스너 자동 복구
63
65
  if (state === "connected") {
64
66
  try {
65
67
  if (this._authToken != null) {
66
- await this.auth(this._authToken); // Re-authenticate
68
+ await this.auth(this._authToken); // 재인증
67
69
  }
68
70
  await this._eventClient.resubscribeAll();
69
71
  } catch (err) {
70
- logger.error("Failed to recover event listeners", err);
72
+ logger.error("이벤트 리스너 복구 실패", err);
71
73
  }
72
74
  }
73
75
  });
74
76
 
75
- this._transport.on("reload", (changedFiles) => {
76
- this.emit("reload", changedFiles);
77
- });
78
77
  }
79
78
 
80
- // Proxy creation method for type safety
79
+ // 타입 안전성을 위한 프록시 생성 메서드
81
80
  getService<TService>(serviceName: string): ServiceProxy<TService> {
82
81
  return new Proxy({} as ServiceProxy<TService>, {
83
82
  get: (_target, prop) => {
@@ -95,6 +94,7 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
95
94
 
96
95
  async close(): Promise<void> {
97
96
  await this._socket.close();
97
+ this._protocolWrapper.dispose();
98
98
  }
99
99
 
100
100
  async send(
@@ -117,6 +117,10 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
117
117
  this.emit("response-progress", state);
118
118
  progress?.response?.(state);
119
119
  },
120
+ server: (state) => {
121
+ this.emit("server-progress", state);
122
+ progress?.server?.(state);
123
+ },
120
124
  },
121
125
  );
122
126
  }
@@ -131,7 +135,7 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
131
135
  info: TInfo,
132
136
  cb: (data: TData) => PromiseLike<void>,
133
137
  ): Promise<string> {
134
- if (!this.connected) throw new Error("Not connected to the server.");
138
+ if (!this.connected) throw new Error("서버에 연결되지 않았습니다.");
135
139
  return this._eventClient.addListener(eventDef, info, cb);
136
140
  }
137
141
 
@@ -147,10 +151,10 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
147
151
  await this._eventClient.emit(eventDef, infoSelector, data);
148
152
  }
149
153
 
150
- async uploadFile(files: File[] | FileList | { name: string; data: BlobPart }[]) {
154
+ async uploadFile(files: File[] | FileCollection | { name: string; data: BlobInput }[]) {
151
155
  if (this._authToken == null) {
152
156
  throw new Error(
153
- "No authentication token found. Call auth() to authenticate before uploading files.",
157
+ "인증 토큰이 없습니다. 파일 업로드 전에 auth() 호출하여 인증해 주세요.",
154
158
  );
155
159
  }
156
160
  return this._fileClient.upload(files, this._authToken);
@@ -161,11 +165,11 @@ export class ServiceClient extends EventEmitter<ServiceClientEvents> {
161
165
  }
162
166
  }
163
167
 
164
- // Type transformer that wraps all method return types of TService with Promise
168
+ // TService의 모든 메서드 반환 타입을 Promise로 래핑하는 타입 변환기
165
169
  export type ServiceProxy<TService> = {
166
170
  [K in keyof TService]: TService[K] extends (...args: infer P) => infer R
167
171
  ? (...args: P) => Promise<Awaited<R>>
168
- : never; // Non-function properties are excluded
172
+ : never; // 함수가 아닌 속성은 제외
169
173
  };
170
174
 
171
175
  export function createServiceClient(name: string, options: ServiceConnectionOptions): ServiceClient {
@@ -10,7 +10,6 @@ import type { ServiceProgress } from "../types/progress.types";
10
10
  import type { SocketProvider } from "./socket-provider";
11
11
 
12
12
  export interface ServiceTransportEvents {
13
- reload: Set<string>;
14
13
  event: { keys: string[]; data: unknown };
15
14
  }
16
15
 
@@ -41,34 +40,34 @@ export function createServiceTransport(
41
40
  }
42
41
  >();
43
42
 
44
- // Store response progress totalSize (for emitting 100% on complete)
43
+ // 응답 progress totalSize 저장 (완료 100% 전송용)
45
44
  const responseProgressTotalSize = new Map<string, number>();
46
45
 
47
46
  socket.on("message", onMessage);
48
47
 
49
- // When socket disconnects, reject all pending requests to free memory
48
+ // 소켓 연결 끊김 메모리 해제를 위해 모든 대기 중인 요청을 reject
50
49
  socket.on("state", (state) => {
51
50
  if (state === "closed" || state === "reconnecting") {
52
- cancelAllRequests("Socket connection lost");
51
+ cancelAllRequests("소켓 연결이 끊어졌습니다");
53
52
  }
54
53
  });
55
54
 
56
55
  async function send(message: ServiceClientMessage, progress?: ServiceProgress): Promise<unknown> {
57
56
  const uuid = Uuid.generate().toString();
58
57
 
59
- // Start awaiting response (register listener before sending request for safety)
58
+ // 응답 대기 시작 (안전을 위해 요청 전송 전에 리스너 등록)
60
59
  const responsePromise = new Promise((resolve, reject) => {
61
60
  pendingRequests.set(uuid, { resolve, reject, progress });
62
61
  });
63
62
 
64
- // Prevent unhandled rejection when the promise is orphaned (e.g., during HMR cleanup)
63
+ // Promise가 고아가 되었을 unhandled rejection 방지 (예: 소켓 연결 끊김 시)
65
64
  responsePromise.catch(() => {});
66
65
 
67
- // Send request
66
+ // 요청 전송
68
67
  try {
69
68
  const { chunks, totalSize } = await protocol.encode(uuid, message);
70
69
 
71
- // Initialize progress
70
+ // progress 초기화
72
71
  if (chunks.length > 1) {
73
72
  progress?.request?.({
74
73
  uuid,
@@ -77,18 +76,18 @@ export function createServiceTransport(
77
76
  });
78
77
  }
79
78
 
80
- // Send
79
+ // 전송
81
80
  for (const chunk of chunks) {
82
81
  await socket.send(chunk);
83
82
  }
84
83
  } catch (err) {
85
- // Clean up immediately on send failure
84
+ // 전송 실패 즉시 정리
86
85
  pendingRequests.get(uuid)?.reject(err as Error);
87
86
  pendingRequests.delete(uuid);
88
87
  throw err;
89
88
  }
90
89
 
91
- // Return response result
90
+ // 응답 결과 반환
92
91
  return responsePromise;
93
92
  }
94
93
 
@@ -99,7 +98,7 @@ export function createServiceTransport(
99
98
 
100
99
  try {
101
100
  if (decoded.type === "progress") {
102
- // Remember totalSize (for emitting 100% on complete)
101
+ // totalSize 기억 (완료 100% 전송용)
103
102
  responseProgressTotalSize.set(decoded.uuid, decoded.totalSize);
104
103
 
105
104
  listenerInfo?.progress?.response?.({
@@ -110,13 +109,13 @@ export function createServiceTransport(
110
109
  } else {
111
110
  if (decoded.message.name === "progress") {
112
111
  const body = decoded.message.body as { totalSize: number; completedSize: number };
113
- listenerInfo?.progress?.request?.({
112
+ listenerInfo?.progress?.server?.({
114
113
  uuid: decoded.uuid,
115
114
  totalSize: body.totalSize,
116
115
  completedSize: body.completedSize,
117
116
  });
118
117
  } else if (decoded.message.name === "response") {
119
- // Emit 100% progress if it was a split message
118
+ // 분할 메시지였을 경우 100% progress 전송
120
119
  const totalSize = responseProgressTotalSize.get(decoded.uuid);
121
120
  if (totalSize != null) {
122
121
  responseProgressTotalSize.delete(decoded.uuid);
@@ -127,28 +126,23 @@ export function createServiceTransport(
127
126
  });
128
127
  }
129
128
 
130
- // Remove from Map since response received
129
+ // 응답 수신으로 Map에서 제거
131
130
  pendingRequests.delete(decoded.uuid);
132
131
 
133
132
  listenerInfo?.resolve(decoded.message.body as ServiceResponseMessage);
134
133
  } else if (decoded.message.name === "error") {
135
- // Clean up progress totalSize
134
+ // progress totalSize 정리
136
135
  responseProgressTotalSize.delete(decoded.uuid);
137
136
 
138
- // Remove from Map since error received
137
+ // 에러 수신으로 Map에서 제거
139
138
  pendingRequests.delete(decoded.uuid);
140
139
 
141
140
  listenerInfo?.reject(toError(decoded.message.body));
142
- } else if (decoded.message.name === "reload") {
143
- const body = decoded.message.body as { clientName: string; changedFileSet: Set<string> };
144
- if (socket.clientName === body.clientName) {
145
- emitter.emit("reload", body.changedFileSet);
146
- }
147
141
  } else if (decoded.message.name === "evt:on") {
148
142
  const body = decoded.message.body as { keys: string[]; data: unknown };
149
143
  emitter.emit("event", { keys: body.keys, data: body.data });
150
144
  } else {
151
- throw new Error("Invalid message received from server.");
145
+ throw new Error("서버로부터 잘못된 메시지를 수신했습니다.");
152
146
  }
153
147
  }
154
148
  } catch (err) {
@@ -156,10 +150,10 @@ export function createServiceTransport(
156
150
  }
157
151
  }
158
152
 
159
- // Cancel all pending requests
153
+ // 모든 대기 중인 요청 취소
160
154
  function cancelAllRequests(reason: string): void {
161
155
  for (const listenerInfo of pendingRequests.values()) {
162
- listenerInfo.reject(new Error(`Request canceled: ${reason}`));
156
+ listenerInfo.reject(new Error(`요청 취소됨: ${reason}`));
163
157
  }
164
158
  pendingRequests.clear();
165
159
  responseProgressTotalSize.clear();
@@ -2,6 +2,12 @@ import type { Bytes } from "@simplysm/core-common";
2
2
  import { EventEmitter, Uuid, wait } from "@simplysm/core-common";
3
3
  import consola from "consola";
4
4
 
5
+ // Node.js 환경에서 글로벌 WebSocket이 없으면 ws 패키지로 polyfill
6
+ if (typeof globalThis.WebSocket === "undefined") {
7
+ const { WebSocket } = await import("ws");
8
+ globalThis.WebSocket = WebSocket as never;
9
+ }
10
+
5
11
  const logger = consola.withTag("service-client:SocketProvider");
6
12
 
7
13
  export interface SocketProviderEvents {
@@ -30,15 +36,15 @@ export function createSocketProvider(
30
36
  clientName: string,
31
37
  maxReconnectCount: number,
32
38
  ): SocketProvider {
33
- // Configuration constants
34
- const HEARTBEAT_TIMEOUT = 30000; // Consider disconnected if no message for 30s
35
- const HEARTBEAT_INTERVAL = 5000; // Send ping every 5s
36
- const RECONNECT_DELAY = 3000; // Retry reconnect every 3s
39
+ // 설정 상수
40
+ const HEARTBEAT_TIMEOUT = 30000; // 30초 동안 메시지가 없으면 연결 끊김으로 간주
41
+ const HEARTBEAT_INTERVAL = 5000; // 5초마다 ping 전송
42
+ const RECONNECT_DELAY = 3000; // 3초마다 재연결 시도
37
43
 
38
- // Pre-allocate 1-byte buffer (saves memory)
44
+ // 1바이트 버퍼 사전 할당 (메모리 절약)
39
45
  const PING_PACKET = new Uint8Array([0x01]);
40
46
 
41
- // State
47
+ // 상태
42
48
  let ws: WebSocket | undefined;
43
49
  let isManualClose = false;
44
50
  let reconnectCount = 0;
@@ -58,10 +64,10 @@ export function createSocketProvider(
58
64
  try {
59
65
  await createSocket();
60
66
  startHeartbeat();
61
- reconnectCount = 0; // Reset count on successful connection
67
+ reconnectCount = 0; // 연결 성공 카운트 초기화
62
68
  emitter.emit("state", "connected");
63
69
  } catch (err) {
64
- // Throw on initial connection failure (so the caller can handle it)
70
+ // 초기 연결 실패 예외를 던짐 (호출자가 처리할 있도록)
65
71
  throw err;
66
72
  }
67
73
  }
@@ -72,7 +78,7 @@ export function createSocketProvider(
72
78
  const currentWs = ws;
73
79
  if (currentWs != null) {
74
80
  currentWs.close();
75
- // Wait until fully closed (graceful shutdown)
81
+ // 완전히 닫힐 때까지 대기 (정상 종료)
76
82
  await wait.until(() => currentWs.readyState === WebSocket.CLOSED, 100, 30).catch(() => {});
77
83
  }
78
84
  emitter.emit("state", "closed");
@@ -82,11 +88,11 @@ export function createSocketProvider(
82
88
  try {
83
89
  await wait.until(() => isConnected(), undefined, 50);
84
90
  } catch {
85
- throw new Error("Not connected to the server. Please check your internet connection.");
91
+ throw new Error("서버에 연결되지 않았습니다. 인터넷 연결을 확인해 주세요.");
86
92
  }
87
93
  const currentWs = ws;
88
94
  if (currentWs == null) {
89
- throw new Error("WebSocket is not connected.");
95
+ throw new Error("WebSocket 연결되지 않았습니다.");
90
96
  }
91
97
  currentWs.send(data);
92
98
  }
@@ -109,7 +115,7 @@ export function createSocketProvider(
109
115
  };
110
116
 
111
117
  newWs.onerror = (event: Event) => {
112
- // Reject on error during connection
118
+ // 연결 에러 발생 시 reject
113
119
  if (!isConnected()) {
114
120
  const errorEvent = event as ErrorEvent;
115
121
  const msg = errorEvent.message;
@@ -118,21 +124,21 @@ export function createSocketProvider(
118
124
  };
119
125
  });
120
126
 
121
- // At this point ws is always assigned (assigned in ws.onopen)
127
+ // 시점에서 ws 항상 할당됨 (ws.onopen에서 할당됨)
122
128
  const currentWs = ws;
123
129
  if (currentWs == null) {
124
- throw new Error("WebSocket initialization failed");
130
+ throw new Error("WebSocket 초기화 실패");
125
131
  }
126
132
 
127
133
  currentWs.onmessage = (event) => {
128
- lastHeartbeatTime = Date.now(); // Update heartbeat
134
+ lastHeartbeatTime = Date.now(); // 하트비트 갱신
129
135
 
130
136
  const data = event.data as ArrayBuffer;
131
137
  const bytes = new Uint8Array(data);
132
138
 
133
- // Raw Ping/Pong handling (checked first)
134
- // If 1 byte and first byte is 0x02 (Pong), ignore
135
- // (only heartbeat timestamp was updated, nothing else to do)
139
+ // Raw Ping/Pong 처리 (먼저 확인)
140
+ // 1바이트이고 번째 바이트가 0x02 (Pong)이면 무시
141
+ // (하트비트 타임스탬프만 갱신되었으므로 추가 작업 불필요)
136
142
  if (bytes.length === 1 && bytes[0] === 0x02) return;
137
143
 
138
144
  emitter.emit("message", bytes);
@@ -147,11 +153,11 @@ export function createSocketProvider(
147
153
  }
148
154
 
149
155
  async function tryReconnect(): Promise<void> {
150
- // Loop-based reconnect (used instead of recursion for stack safety)
156
+ // 루프 기반 재연결 (스택 안전성을 위해 재귀 대신 사용)
151
157
  while (reconnectCount < maxReconnectCount) {
152
158
  reconnectCount++;
153
159
  emitter.emit("state", "reconnecting");
154
- logger.warn("WebSocket disconnected. Attempting reconnect...", {
160
+ logger.warn("WebSocket 연결 끊김. 재연결 시도 중...", {
155
161
  reconnectCount,
156
162
  maxReconnectCount,
157
163
  });
@@ -162,16 +168,16 @@ export function createSocketProvider(
162
168
  await createSocket();
163
169
  startHeartbeat();
164
170
  reconnectCount = 0;
165
- emitter.emit("state", "connected"); // Notify reconnect success
166
- logger.info("WebSocket reconnected successfully");
167
- return; // Exit on successful reconnect
171
+ emitter.emit("state", "connected"); // 재연결 성공 알림
172
+ logger.info("WebSocket 재연결 성공");
173
+ return; // 재연결 성공 루프 종료
168
174
  } catch {
169
- // Continue loop on failure
175
+ // 실패 루프 계속
170
176
  }
171
177
  }
172
178
 
173
- // Max retry count exceeded
174
- logger.error("Reconnect retry limit exceeded. Giving up.");
179
+ // 최대 재시도 횟수 초과
180
+ logger.error("재연결 재시도 한도 초과. 연결을 포기합니다.");
175
181
  emitter.emit("state", "closed");
176
182
  }
177
183
 
@@ -180,32 +186,32 @@ export function createSocketProvider(
180
186
  lastHeartbeatTime = Date.now();
181
187
 
182
188
  heartbeatTimer = setInterval(() => {
183
- // Timeout check
189
+ // 타임아웃 확인
184
190
  if (Date.now() - lastHeartbeatTime > HEARTBEAT_TIMEOUT) {
185
- logger.warn("Heartbeat Timeout. Connection lost.");
191
+ logger.warn("하트비트 타임아웃. 연결이 끊어졌습니다.");
186
192
 
187
- // Stop the timer immediately on timeout to prevent repeated execution.
193
+ // 반복 실행 방지를 위해 타임아웃 즉시 타이머 중지
188
194
  stopHeartbeat();
189
195
 
190
- // Don't wait for socket close (onclose may not fire); force cleanup and reconnect.
196
+ // 소켓 종료를 기다리지 않음 (onclose 발생하지 않을 수 있음); 강제 정리 재연결
191
197
  if (ws != null) {
192
198
  const tempWs = ws;
193
- ws = undefined; // Consider connection as disconnected
199
+ ws = undefined; // 연결 끊김으로 간주
194
200
 
195
- // Remove event handlers from old socket
196
- // Prevent duplicate reconnect from late onclose events
201
+ // 이전 소켓에서 이벤트 핸들러 제거
202
+ // 늦게 발생하는 onclose 이벤트로 인한 중복 재연결 방지
197
203
  tempWs.onclose = null;
198
204
  tempWs.onerror = null;
199
205
  tempWs.onmessage = null;
200
206
 
201
- // Attempt to close socket (ignore errors)
207
+ // 소켓 닫기 시도 (에러 무시)
202
208
  try {
203
209
  tempWs.close();
204
210
  } catch {
205
- // ignore
211
+ // 무시
206
212
  }
207
213
 
208
- // Force reconnect logic if not a manual close
214
+ // 수동 종료가 아닌 경우 강제 재연결 로직 실행
209
215
  if (!isManualClose) {
210
216
  void tryReconnect();
211
217
  }
@@ -213,13 +219,13 @@ export function createSocketProvider(
213
219
  return;
214
220
  }
215
221
 
216
- // Send ping
222
+ // ping 전송
217
223
  const currentWs = ws;
218
224
  if (isConnected() && currentWs != null) {
219
225
  try {
220
226
  currentWs.send(PING_PACKET);
221
227
  } catch (err) {
222
- logger.warn("Ping send failed", err);
228
+ logger.warn("ping 전송 실패", err);
223
229
  }
224
230
  }
225
231
  }, HEARTBEAT_INTERVAL);