@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.
- package/README.md +403 -89
- package/dist/features/event-client.d.ts.map +1 -1
- package/dist/features/event-client.js +75 -67
- package/dist/features/event-client.js.map +1 -6
- package/dist/features/file-client.d.ts +3 -2
- package/dist/features/file-client.d.ts.map +1 -1
- package/dist/features/file-client.js +41 -39
- package/dist/features/file-client.js.map +1 -6
- package/dist/features/orm/orm-client-connector.js +37 -38
- package/dist/features/orm/orm-client-connector.js.map +1 -6
- package/dist/features/orm/orm-client-db-context-executor.d.ts.map +1 -1
- package/dist/features/orm/orm-client-db-context-executor.js +60 -60
- package/dist/features/orm/orm-client-db-context-executor.js.map +1 -6
- package/dist/features/orm/orm-connect-options.js +2 -1
- package/dist/features/orm/orm-connect-options.js.map +1 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -6
- package/dist/protocol/client-protocol-wrapper.d.ts +1 -0
- package/dist/protocol/client-protocol-wrapper.d.ts.map +1 -1
- package/dist/protocol/client-protocol-wrapper.js +93 -70
- package/dist/protocol/client-protocol-wrapper.js.map +1 -6
- package/dist/service-client.d.ts +5 -3
- package/dist/service-client.d.ts.map +1 -1
- package/dist/service-client.js +110 -111
- package/dist/service-client.js.map +1 -6
- package/dist/transport/service-transport.d.ts +0 -1
- package/dist/transport/service-transport.d.ts.map +1 -1
- package/dist/transport/service-transport.js +115 -104
- package/dist/transport/service-transport.js.map +1 -6
- package/dist/transport/socket-provider.d.ts.map +1 -1
- package/dist/transport/socket-provider.js +185 -155
- package/dist/transport/socket-provider.js.map +1 -6
- package/dist/types/browser-compat.d.ts +33 -0
- package/dist/types/browser-compat.d.ts.map +1 -0
- package/dist/types/browser-compat.js +17 -0
- package/dist/types/browser-compat.js.map +1 -0
- package/dist/types/connection-options.d.ts +1 -1
- package/dist/types/connection-options.d.ts.map +1 -1
- package/dist/types/connection-options.js +2 -1
- package/dist/types/connection-options.js.map +1 -6
- package/dist/types/progress.types.d.ts +1 -0
- package/dist/types/progress.types.d.ts.map +1 -1
- package/dist/types/progress.types.js +2 -1
- package/dist/types/progress.types.js.map +1 -6
- package/dist/workers/client-protocol.worker.js +38 -24
- package/dist/workers/client-protocol.worker.js.map +1 -6
- package/package.json +16 -9
- package/src/features/event-client.ts +19 -17
- package/src/features/file-client.ts +11 -10
- package/src/features/orm/orm-client-connector.ts +2 -2
- package/src/features/orm/orm-client-db-context-executor.ts +8 -7
- package/src/index.ts +6 -5
- package/src/protocol/client-protocol-wrapper.ts +37 -28
- package/src/service-client.ts +24 -20
- package/src/transport/service-transport.ts +19 -25
- package/src/transport/socket-provider.ts +44 -38
- package/src/types/browser-compat.ts +47 -0
- package/src/types/connection-options.ts +1 -1
- package/src/types/progress.types.ts +1 -0
- package/src/workers/client-protocol.worker.ts +10 -10
- package/docs/features.md +0 -143
- package/docs/protocol.md +0 -29
- package/docs/service-client.md +0 -93
- package/docs/transport.md +0 -96
- 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
|
-
//
|
|
15
|
-
let worker:
|
|
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, //
|
|
21
|
-
expireTime: 60 * 1000, //
|
|
23
|
+
gcInterval: 5 * 1000, // 5초마다 만료된 항목 확인
|
|
24
|
+
expireTime: 60 * 1000, // 60초 후 만료 (타임아웃)
|
|
22
25
|
onExpire: (key, item) => {
|
|
23
|
-
//
|
|
24
|
-
item.reject(new Error(`Worker
|
|
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 =
|
|
35
|
+
workerAvailable = isWorkerSupported();
|
|
33
36
|
}
|
|
34
37
|
return workerAvailable;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
function getWorker():
|
|
40
|
+
function getWorker(): WorkerLike | undefined {
|
|
38
41
|
if (!isWorkerAvailable()) {
|
|
39
42
|
return undefined;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
if (!worker) {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
worker =
|
|
46
|
-
|
|
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 ?? "
|
|
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
|
-
*
|
|
75
|
-
*
|
|
79
|
+
* Worker에 작업을 위임하고 결과를 대기
|
|
80
|
+
* 참고: workerAvailable이 true일 때만 호출할 것
|
|
76
81
|
*/
|
|
77
82
|
async function runWorker(
|
|
78
83
|
type: "encode" | "decode",
|
|
79
84
|
data: unknown,
|
|
80
|
-
transferables:
|
|
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
|
-
//
|
|
87
|
-
getWorker()!.postMessage({ id, type, data },
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
118
|
+
// Worker가 없거나 데이터가 작으면 메인 스레드에서 처리
|
|
114
119
|
if (!isWorkerAvailable() || !shouldUseWorkerForEncode(message)) {
|
|
115
120
|
return protocol.encode(uuid, message);
|
|
116
121
|
}
|
|
117
122
|
|
|
118
123
|
// [Worker]
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
}
|
package/src/service-client.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
52
|
+
// 모듈 초기화
|
|
51
53
|
this._socket = createSocketProvider(wsUrl, this.name, this.options.maxReconnectCount ?? 10);
|
|
52
54
|
const protocol = createServiceProtocol();
|
|
53
|
-
|
|
54
|
-
this._transport = createServiceTransport(this._socket,
|
|
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
|
-
//
|
|
60
|
+
// 이벤트 바인딩
|
|
59
61
|
this._socket.on("state", async (state) => {
|
|
60
62
|
this.emit("state", state);
|
|
61
63
|
|
|
62
|
-
//
|
|
64
|
+
// 재연결 시 이벤트 리스너 자동 복구
|
|
63
65
|
if (state === "connected") {
|
|
64
66
|
try {
|
|
65
67
|
if (this._authToken != null) {
|
|
66
|
-
await this.auth(this._authToken); //
|
|
68
|
+
await this.auth(this._authToken); // 재인증
|
|
67
69
|
}
|
|
68
70
|
await this._eventClient.resubscribeAll();
|
|
69
71
|
} catch (err) {
|
|
70
|
-
logger.error("
|
|
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
|
-
//
|
|
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("
|
|
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[] |
|
|
154
|
+
async uploadFile(files: File[] | FileCollection | { name: string; data: BlobInput }[]) {
|
|
151
155
|
if (this._authToken == null) {
|
|
152
156
|
throw new Error(
|
|
153
|
-
"
|
|
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
|
-
//
|
|
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; //
|
|
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
|
-
//
|
|
43
|
+
// 응답 progress totalSize 저장 (완료 시 100% 전송용)
|
|
45
44
|
const responseProgressTotalSize = new Map<string, number>();
|
|
46
45
|
|
|
47
46
|
socket.on("message", onMessage);
|
|
48
47
|
|
|
49
|
-
//
|
|
48
|
+
// 소켓 연결 끊김 시 메모리 해제를 위해 모든 대기 중인 요청을 reject
|
|
50
49
|
socket.on("state", (state) => {
|
|
51
50
|
if (state === "closed" || state === "reconnecting") {
|
|
52
|
-
cancelAllRequests("
|
|
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
|
-
//
|
|
58
|
+
// 응답 대기 시작 (안전을 위해 요청 전송 전에 리스너 등록)
|
|
60
59
|
const responsePromise = new Promise((resolve, reject) => {
|
|
61
60
|
pendingRequests.set(uuid, { resolve, reject, progress });
|
|
62
61
|
});
|
|
63
62
|
|
|
64
|
-
//
|
|
63
|
+
// Promise가 고아가 되었을 때 unhandled rejection 방지 (예: 소켓 연결 끊김 시)
|
|
65
64
|
responsePromise.catch(() => {});
|
|
66
65
|
|
|
67
|
-
//
|
|
66
|
+
// 요청 전송
|
|
68
67
|
try {
|
|
69
68
|
const { chunks, totalSize } = await protocol.encode(uuid, message);
|
|
70
69
|
|
|
71
|
-
//
|
|
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
|
-
//
|
|
79
|
+
// 전송
|
|
81
80
|
for (const chunk of chunks) {
|
|
82
81
|
await socket.send(chunk);
|
|
83
82
|
}
|
|
84
83
|
} catch (err) {
|
|
85
|
-
//
|
|
84
|
+
// 전송 실패 시 즉시 정리
|
|
86
85
|
pendingRequests.get(uuid)?.reject(err as Error);
|
|
87
86
|
pendingRequests.delete(uuid);
|
|
88
87
|
throw err;
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
//
|
|
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
|
-
//
|
|
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?.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
134
|
+
// progress totalSize 정리
|
|
136
135
|
responseProgressTotalSize.delete(decoded.uuid);
|
|
137
136
|
|
|
138
|
-
//
|
|
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("
|
|
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
|
-
//
|
|
153
|
+
// 모든 대기 중인 요청 취소
|
|
160
154
|
function cancelAllRequests(reason: string): void {
|
|
161
155
|
for (const listenerInfo of pendingRequests.values()) {
|
|
162
|
-
listenerInfo.reject(new Error(
|
|
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
|
-
//
|
|
34
|
-
const HEARTBEAT_TIMEOUT = 30000; //
|
|
35
|
-
const HEARTBEAT_INTERVAL = 5000; //
|
|
36
|
-
const RECONNECT_DELAY = 3000; //
|
|
39
|
+
// 설정 상수
|
|
40
|
+
const HEARTBEAT_TIMEOUT = 30000; // 30초 동안 메시지가 없으면 연결 끊김으로 간주
|
|
41
|
+
const HEARTBEAT_INTERVAL = 5000; // 5초마다 ping 전송
|
|
42
|
+
const RECONNECT_DELAY = 3000; // 3초마다 재연결 시도
|
|
37
43
|
|
|
38
|
-
//
|
|
44
|
+
// 1바이트 버퍼 사전 할당 (메모리 절약)
|
|
39
45
|
const PING_PACKET = new Uint8Array([0x01]);
|
|
40
46
|
|
|
41
|
-
//
|
|
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; //
|
|
67
|
+
reconnectCount = 0; // 연결 성공 시 카운트 초기화
|
|
62
68
|
emitter.emit("state", "connected");
|
|
63
69
|
} catch (err) {
|
|
64
|
-
//
|
|
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
|
-
//
|
|
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("
|
|
91
|
+
throw new Error("서버에 연결되지 않았습니다. 인터넷 연결을 확인해 주세요.");
|
|
86
92
|
}
|
|
87
93
|
const currentWs = ws;
|
|
88
94
|
if (currentWs == null) {
|
|
89
|
-
throw new Error("WebSocket
|
|
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
|
-
//
|
|
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
|
-
//
|
|
127
|
+
// 이 시점에서 ws는 항상 할당됨 (ws.onopen에서 할당됨)
|
|
122
128
|
const currentWs = ws;
|
|
123
129
|
if (currentWs == null) {
|
|
124
|
-
throw new Error("WebSocket
|
|
130
|
+
throw new Error("WebSocket 초기화 실패");
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
currentWs.onmessage = (event) => {
|
|
128
|
-
lastHeartbeatTime = Date.now(); //
|
|
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
|
|
134
|
-
//
|
|
135
|
-
// (
|
|
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
|
-
//
|
|
156
|
+
// 루프 기반 재연결 (스택 안전성을 위해 재귀 대신 사용)
|
|
151
157
|
while (reconnectCount < maxReconnectCount) {
|
|
152
158
|
reconnectCount++;
|
|
153
159
|
emitter.emit("state", "reconnecting");
|
|
154
|
-
logger.warn("WebSocket
|
|
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"); //
|
|
166
|
-
logger.info("WebSocket
|
|
167
|
-
return; //
|
|
171
|
+
emitter.emit("state", "connected"); // 재연결 성공 알림
|
|
172
|
+
logger.info("WebSocket 재연결 성공");
|
|
173
|
+
return; // 재연결 성공 시 루프 종료
|
|
168
174
|
} catch {
|
|
169
|
-
//
|
|
175
|
+
// 실패 시 루프 계속
|
|
170
176
|
}
|
|
171
177
|
}
|
|
172
178
|
|
|
173
|
-
//
|
|
174
|
-
logger.error("
|
|
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
|
-
//
|
|
189
|
+
// 타임아웃 확인
|
|
184
190
|
if (Date.now() - lastHeartbeatTime > HEARTBEAT_TIMEOUT) {
|
|
185
|
-
logger.warn("
|
|
191
|
+
logger.warn("하트비트 타임아웃. 연결이 끊어졌습니다.");
|
|
186
192
|
|
|
187
|
-
//
|
|
193
|
+
// 반복 실행 방지를 위해 타임아웃 시 즉시 타이머 중지
|
|
188
194
|
stopHeartbeat();
|
|
189
195
|
|
|
190
|
-
//
|
|
196
|
+
// 소켓 종료를 기다리지 않음 (onclose가 발생하지 않을 수 있음); 강제 정리 후 재연결
|
|
191
197
|
if (ws != null) {
|
|
192
198
|
const tempWs = ws;
|
|
193
|
-
ws = undefined; //
|
|
199
|
+
ws = undefined; // 연결 끊김으로 간주
|
|
194
200
|
|
|
195
|
-
//
|
|
196
|
-
//
|
|
201
|
+
// 이전 소켓에서 이벤트 핸들러 제거
|
|
202
|
+
// 늦게 발생하는 onclose 이벤트로 인한 중복 재연결 방지
|
|
197
203
|
tempWs.onclose = null;
|
|
198
204
|
tempWs.onerror = null;
|
|
199
205
|
tempWs.onmessage = null;
|
|
200
206
|
|
|
201
|
-
//
|
|
207
|
+
// 소켓 닫기 시도 (에러 무시)
|
|
202
208
|
try {
|
|
203
209
|
tempWs.close();
|
|
204
210
|
} catch {
|
|
205
|
-
//
|
|
211
|
+
// 무시
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
//
|
|
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
|
-
//
|
|
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("
|
|
228
|
+
logger.warn("ping 전송 실패", err);
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
}, HEARTBEAT_INTERVAL);
|