@simplysm/service-client 14.0.46 → 14.0.48
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 +200 -0
- package/dist/protocol/client-protocol-wrapper.d.ts.map +1 -1
- package/dist/protocol/client-protocol-wrapper.js +94 -51
- package/dist/protocol/client-protocol-wrapper.js.map +1 -1
- package/dist/types/browser-compat.d.ts +12 -1
- package/dist/types/browser-compat.d.ts.map +1 -1
- package/dist/types/browser-compat.js +11 -2
- package/dist/types/browser-compat.js.map +1 -1
- package/dist/workers/client-protocol.worker.js +33 -22
- package/dist/workers/client-protocol.worker.js.map +1 -1
- package/docs/features.md +217 -0
- package/docs/main.md +148 -0
- package/docs/protocol.md +56 -0
- package/docs/transport.md +131 -0
- package/docs/types.md +93 -0
- package/package.json +6 -5
- package/src/protocol/client-protocol-wrapper.ts +125 -68
- package/src/types/browser-compat.ts +24 -2
- package/src/types/node-worker-compat.d.ts +23 -0
- package/src/workers/client-protocol.worker.ts +39 -27
|
@@ -5,7 +5,18 @@ import type {
|
|
|
5
5
|
ServiceMessage,
|
|
6
6
|
ServiceProtocol,
|
|
7
7
|
} from "@simplysm/service-common";
|
|
8
|
-
import {
|
|
8
|
+
import type { BrowserWorker } from "../types/browser-compat";
|
|
9
|
+
import {
|
|
10
|
+
isBrowserWorkerSupported,
|
|
11
|
+
isNodeWorkerSupported,
|
|
12
|
+
isWorkerSupported,
|
|
13
|
+
} from "../types/browser-compat";
|
|
14
|
+
|
|
15
|
+
// node env typecheck에서 DOM Worker 생성자가 없으므로 모듈 스코프 선언으로 보완
|
|
16
|
+
// browser env에서는 global Worker를 shadow (구조적 호환). 컴파일 시 제거됨.
|
|
17
|
+
declare const Worker: {
|
|
18
|
+
new (scriptURL: string | URL, options?: { type?: string }): BrowserWorker;
|
|
19
|
+
};
|
|
9
20
|
|
|
10
21
|
export interface ClientProtocolWrapper {
|
|
11
22
|
encode(uuid: string, message: ServiceMessage): Promise<{ chunks: Bytes[]; totalSize: number }>;
|
|
@@ -13,16 +24,13 @@ export interface ClientProtocolWrapper {
|
|
|
13
24
|
dispose(): void;
|
|
14
25
|
}
|
|
15
26
|
|
|
16
|
-
// 공유 worker 상태 (싱글턴 패턴)
|
|
17
|
-
let worker: Worker | undefined;
|
|
18
27
|
const workerResolvers = new LazyGcMap<
|
|
19
28
|
string,
|
|
20
29
|
{ resolve: (res: unknown) => void; reject: (err: Error) => void }
|
|
21
30
|
>({
|
|
22
|
-
gcInterval: 5 * 1000,
|
|
23
|
-
expireTime: 60 * 1000,
|
|
31
|
+
gcInterval: 5 * 1000,
|
|
32
|
+
expireTime: 60 * 1000,
|
|
24
33
|
onExpire: (key, item) => {
|
|
25
|
-
// 만료 시 reject (메모리 누수 방지에 필수)
|
|
26
34
|
item.reject(new Error(`Worker 작업 시간 초과 (uuid: ${key})`));
|
|
27
35
|
},
|
|
28
36
|
});
|
|
@@ -36,72 +44,124 @@ function isWorkerAvailable(): boolean {
|
|
|
36
44
|
return workerAvailable;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
function
|
|
47
|
+
function setupWorkerHandlers(w: BrowserWorker): void {
|
|
48
|
+
w.onmessage = (event: MessageEvent) => {
|
|
49
|
+
const { id, type, result, error } = event.data as {
|
|
50
|
+
id: string;
|
|
51
|
+
type: "success" | "error";
|
|
52
|
+
result?: unknown;
|
|
53
|
+
error?: { message: string; stack?: string };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const resolver = workerResolvers.get(id);
|
|
57
|
+
if (resolver != null) {
|
|
58
|
+
if (type === "success") {
|
|
59
|
+
resolver.resolve(result);
|
|
60
|
+
} else {
|
|
61
|
+
const err = new Error(error?.message ?? "알 수 없는 worker 에러");
|
|
62
|
+
err.stack = error?.stack;
|
|
63
|
+
resolver.reject(err);
|
|
64
|
+
}
|
|
65
|
+
workerResolvers.delete(id);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
w.onerror = () => {
|
|
70
|
+
const workerErr = new Error("Worker 초기화 실패");
|
|
71
|
+
for (const resolver of workerResolvers.values()) {
|
|
72
|
+
resolver.reject(workerErr);
|
|
73
|
+
}
|
|
74
|
+
workerResolvers.clear();
|
|
75
|
+
|
|
76
|
+
workerInitPromise = undefined;
|
|
77
|
+
workerAvailable = false;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createNodeWorkerAdapter(nodeWorker: import("worker_threads").Worker): BrowserWorker {
|
|
82
|
+
const adapter: BrowserWorker = {
|
|
83
|
+
onmessage: null,
|
|
84
|
+
onerror: null,
|
|
85
|
+
postMessage(message: unknown, transferItems?: unknown[]) {
|
|
86
|
+
nodeWorker.postMessage(message, transferItems as import("worker_threads").TransferListItem[]);
|
|
87
|
+
},
|
|
88
|
+
terminate() {
|
|
89
|
+
void nodeWorker.terminate();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
nodeWorker.on("message", (data: unknown) => {
|
|
94
|
+
adapter.onmessage?.({ data } as MessageEvent);
|
|
95
|
+
});
|
|
96
|
+
nodeWorker.on("error", (err: Error) => {
|
|
97
|
+
adapter.onerror?.(err as unknown as Event);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return adapter;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let workerInitPromise: Promise<BrowserWorker | undefined> | undefined;
|
|
104
|
+
|
|
105
|
+
async function initWorker(): Promise<BrowserWorker | undefined> {
|
|
106
|
+
try {
|
|
107
|
+
if (isBrowserWorkerSupported()) {
|
|
108
|
+
// esbuild Worker 번들링 플러그인(sd-worker-bundle)이 이 패턴을 AST에서 인식
|
|
109
|
+
const w: BrowserWorker = new Worker(
|
|
110
|
+
new URL("../workers/client-protocol.worker.js", import.meta.url),
|
|
111
|
+
{ type: "module" },
|
|
112
|
+
);
|
|
113
|
+
setupWorkerHandlers(w);
|
|
114
|
+
return w;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isNodeWorkerSupported()) {
|
|
118
|
+
// esbuild Worker 번들링 플러그인이 import.meta.resolve 패턴을 인식
|
|
119
|
+
const workerUrl = import.meta.resolve("../workers/client-protocol.worker.js");
|
|
120
|
+
const workerThreadsId = "worker_threads";
|
|
121
|
+
const { Worker: NodeWorker } = await import(/* @vite-ignore */ workerThreadsId);
|
|
122
|
+
const nodeWorker = new NodeWorker(new URL(workerUrl)) as import("worker_threads").Worker;
|
|
123
|
+
const adapter = createNodeWorkerAdapter(nodeWorker);
|
|
124
|
+
setupWorkerHandlers(adapter);
|
|
125
|
+
return adapter;
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
workerAvailable = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function getWorker(): Promise<BrowserWorker | undefined> {
|
|
40
135
|
if (!isWorkerAvailable()) {
|
|
41
136
|
return undefined;
|
|
42
137
|
}
|
|
43
138
|
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
new URL("../workers/client-protocol.worker.js", import.meta.url),
|
|
49
|
-
{ type: "module" },
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
worker.onmessage = (event: MessageEvent) => {
|
|
53
|
-
const { id, type, result, error } = event.data as {
|
|
54
|
-
id: string;
|
|
55
|
-
type: "success" | "error";
|
|
56
|
-
result?: unknown;
|
|
57
|
-
error?: { message: string; stack?: string };
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const resolver = workerResolvers.get(id);
|
|
61
|
-
if (resolver != null) {
|
|
62
|
-
if (type === "success") {
|
|
63
|
-
resolver.resolve(result);
|
|
64
|
-
} else {
|
|
65
|
-
const err = new Error(error?.message ?? "알 수 없는 worker 에러");
|
|
66
|
-
err.stack = error?.stack;
|
|
67
|
-
resolver.reject(err);
|
|
68
|
-
}
|
|
69
|
-
workerResolvers.delete(id);
|
|
139
|
+
if (workerInitPromise == null) {
|
|
140
|
+
workerInitPromise = initWorker().then((w) => {
|
|
141
|
+
if (w == null) {
|
|
142
|
+
workerAvailable = false;
|
|
70
143
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
worker.onerror = () => {
|
|
74
|
-
// Worker 로드 실패 또는 초기화 에러 시
|
|
75
|
-
// 대기 중인 모든 요청 즉시 reject
|
|
76
|
-
const workerErr = new Error("Worker 초기화 실패");
|
|
77
|
-
for (const resolver of workerResolvers.values()) {
|
|
78
|
-
resolver.reject(workerErr);
|
|
79
|
-
}
|
|
80
|
-
workerResolvers.clear();
|
|
81
|
-
|
|
82
|
-
// 이후 메인 스레드로 fallback
|
|
83
|
-
worker = undefined;
|
|
84
|
-
workerAvailable = false;
|
|
85
|
-
};
|
|
144
|
+
return w;
|
|
145
|
+
});
|
|
86
146
|
}
|
|
87
|
-
|
|
147
|
+
|
|
148
|
+
return workerInitPromise;
|
|
88
149
|
}
|
|
89
150
|
|
|
90
|
-
/**
|
|
91
|
-
* Worker에 작업을 위임하고 결과를 대기
|
|
92
|
-
* 참고: workerAvailable이 true일 때만 호출할 것
|
|
93
|
-
*/
|
|
94
151
|
async function runWorker(
|
|
95
152
|
type: "encode" | "decode",
|
|
96
153
|
data: unknown,
|
|
97
154
|
transferables: ArrayBuffer[] = [],
|
|
98
|
-
): Promise<unknown> {
|
|
155
|
+
): Promise<unknown | undefined> {
|
|
156
|
+
const w = await getWorker();
|
|
157
|
+
if (w == null) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
99
161
|
return new Promise((resolve, reject) => {
|
|
100
162
|
const id = Uuid.generate().toString();
|
|
101
|
-
|
|
102
163
|
workerResolvers.set(id, { resolve, reject });
|
|
103
|
-
|
|
104
|
-
getWorker()!.postMessage({ id, type, data }, transferables);
|
|
164
|
+
w.postMessage({ id, type, data }, transferables);
|
|
105
165
|
});
|
|
106
166
|
}
|
|
107
167
|
|
|
@@ -132,13 +192,11 @@ export function createClientProtocolWrapper(protocol: ServiceProtocol): ClientPr
|
|
|
132
192
|
return protocol.encode(uuid, message);
|
|
133
193
|
}
|
|
134
194
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
totalSize: number;
|
|
141
|
-
};
|
|
195
|
+
const workerResult = await runWorker("encode", { uuid, message });
|
|
196
|
+
if (workerResult == null) {
|
|
197
|
+
return protocol.encode(uuid, message);
|
|
198
|
+
}
|
|
199
|
+
return workerResult as { chunks: Bytes[]; totalSize: number };
|
|
142
200
|
}
|
|
143
201
|
|
|
144
202
|
async function decode(bytes: Bytes): Promise<ServiceMessageDecodeResult<ServiceMessage>> {
|
|
@@ -149,11 +207,10 @@ export function createClientProtocolWrapper(protocol: ServiceProtocol): ClientPr
|
|
|
149
207
|
return protocol.decode(bytes);
|
|
150
208
|
}
|
|
151
209
|
|
|
152
|
-
// [Worker]
|
|
153
|
-
// Zero-copy 전송 (버퍼 소유권이 Worker로 이동)
|
|
154
210
|
const rawResult = await runWorker("decode", bytes, [bytes.buffer as ArrayBuffer]);
|
|
155
|
-
|
|
156
|
-
|
|
211
|
+
if (rawResult == null) {
|
|
212
|
+
return protocol.decode(bytes);
|
|
213
|
+
}
|
|
157
214
|
return transfer.decode(rawResult) as ServiceMessageDecodeResult<ServiceMessage>;
|
|
158
215
|
}
|
|
159
216
|
|
|
@@ -18,7 +18,29 @@ export interface FileCollection {
|
|
|
18
18
|
[Symbol.iterator](): IterableIterator<File>;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Web Worker
|
|
22
|
-
export
|
|
21
|
+
/** Web Worker 최소 인터페이스 (DOM lib 없이도 타입체크 통과용) */
|
|
22
|
+
export interface BrowserWorker {
|
|
23
|
+
onmessage: ((event: MessageEvent) => void) | null;
|
|
24
|
+
onerror: ((event: Event) => void) | null;
|
|
25
|
+
postMessage(message: unknown, transfer?: unknown[]): void;
|
|
26
|
+
terminate(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** DOM Worker API 지원 여부 확인 (browser 환경) */
|
|
30
|
+
export function isBrowserWorkerSupported(): boolean {
|
|
23
31
|
return "Worker" in globalThis;
|
|
24
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Node.js worker_threads 지원 여부 확인 */
|
|
35
|
+
export function isNodeWorkerSupported(): boolean {
|
|
36
|
+
const proc = (globalThis as Record<string, unknown>)["process"] as
|
|
37
|
+
| { versions?: { node?: string } }
|
|
38
|
+
| undefined;
|
|
39
|
+
return proc?.versions?.node != null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Worker 오프로딩 지원 여부 (browser DOM Worker 또는 Node.js worker_threads) */
|
|
43
|
+
export function isWorkerSupported(): boolean {
|
|
44
|
+
return isBrowserWorkerSupported() || isNodeWorkerSupported();
|
|
45
|
+
}
|
|
46
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// browser env typecheck에서 @types/node가 제외될 때 worker_threads 최소 타입 제공
|
|
2
|
+
// node env에서는 @types/node의 선언과 병합됨
|
|
3
|
+
declare module "worker_threads" {
|
|
4
|
+
class Worker {
|
|
5
|
+
constructor(filename: string | URL);
|
|
6
|
+
on(event: "message", listener: (value: unknown) => void): this;
|
|
7
|
+
on(event: "error", listener: (err: Error) => void): this;
|
|
8
|
+
postMessage(value: unknown, transferList?: unknown[]): void;
|
|
9
|
+
terminate(): Promise<number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const parentPort: {
|
|
13
|
+
on(event: "message", listener: (value: unknown) => void): void;
|
|
14
|
+
on(event: "error", listener: (err: Error) => void): void;
|
|
15
|
+
postMessage(value: unknown, transferList?: unknown[]): void;
|
|
16
|
+
} | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// import.meta.resolve — Node.js 20+ 표준 API
|
|
20
|
+
// browser env에서 @types/node 없이도 typecheck 통과용
|
|
21
|
+
interface ImportMeta {
|
|
22
|
+
resolve(specifier: string): string;
|
|
23
|
+
}
|
|
@@ -5,53 +5,65 @@ import { transfer } from "@simplysm/core-common";
|
|
|
5
5
|
|
|
6
6
|
const protocol = createServiceProtocol();
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
};
|
|
8
|
+
interface WorkerRequest {
|
|
9
|
+
id: string;
|
|
10
|
+
type: "encode" | "decode";
|
|
11
|
+
data: unknown;
|
|
12
|
+
}
|
|
14
13
|
|
|
14
|
+
function handleRequest(msg: WorkerRequest): {
|
|
15
|
+
response: { id: string; type: "success" | "error"; result?: unknown; error?: { message: string; stack?: string } };
|
|
16
|
+
transferList: Transferable[];
|
|
17
|
+
} {
|
|
15
18
|
try {
|
|
16
19
|
let result: unknown;
|
|
17
20
|
let transferList: Transferable[] = [];
|
|
18
21
|
|
|
19
|
-
if (type === "encode") {
|
|
20
|
-
|
|
21
|
-
// message는 이미 Plain Object임 (Structured Clone을 통해 전달)
|
|
22
|
-
const { uuid, message } = data as {
|
|
22
|
+
if (msg.type === "encode") {
|
|
23
|
+
const { uuid, message } = msg.data as {
|
|
23
24
|
uuid: string;
|
|
24
25
|
message: Parameters<typeof protocol.encode>[1];
|
|
25
26
|
};
|
|
26
27
|
const { chunks } = protocol.encode(uuid, message);
|
|
27
|
-
|
|
28
|
-
// Buffer[]는 전송 가능하므로 결과로 반환
|
|
29
28
|
result = chunks;
|
|
30
|
-
// 결과 chunk의 내부 ArrayBuffer를 전송 목록에 추가
|
|
31
29
|
transferList = chunks.map((chunk) => chunk.buffer as ArrayBuffer);
|
|
32
30
|
} else {
|
|
33
|
-
|
|
34
|
-
// data는 Uint8Array로 전달됨
|
|
35
|
-
const bytes = new Uint8Array(data as ArrayBuffer);
|
|
31
|
+
const bytes = new Uint8Array(msg.data as ArrayBuffer);
|
|
36
32
|
const decodeResult = protocol.decode(bytes);
|
|
37
|
-
|
|
38
|
-
// 결과 객체를 전송 가능한 형태로 변환 (zero-copy 준비)
|
|
39
33
|
const encoded = transfer.encode(decodeResult);
|
|
40
34
|
result = encoded.result;
|
|
41
35
|
transferList = encoded.transferList;
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
|
|
45
|
-
self.postMessage({ id, type: "success", result }, transferList);
|
|
38
|
+
return { response: { id: msg.id, type: "success", result }, transferList };
|
|
46
39
|
} catch (err) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
err instanceof Error
|
|
40
|
+
return {
|
|
41
|
+
response: {
|
|
42
|
+
id: msg.id,
|
|
43
|
+
type: "error",
|
|
44
|
+
error: err instanceof Error
|
|
53
45
|
? { message: err.message, stack: err.stack }
|
|
54
46
|
: { message: String(err) },
|
|
47
|
+
},
|
|
48
|
+
transferList: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof self !== "undefined" && typeof self.postMessage === "function") {
|
|
54
|
+
// Browser Worker
|
|
55
|
+
self.onmessage = (event: MessageEvent) => {
|
|
56
|
+
const { response, transferList } = handleRequest(event.data as WorkerRequest);
|
|
57
|
+
self.postMessage(response, transferList);
|
|
58
|
+
};
|
|
59
|
+
} else {
|
|
60
|
+
// Node.js worker_threads
|
|
61
|
+
const workerThreadsId = "worker_threads";
|
|
62
|
+
const { parentPort } = await import(workerThreadsId);
|
|
63
|
+
if (parentPort != null) {
|
|
64
|
+
parentPort.on("message", (data: unknown) => {
|
|
65
|
+
const { response, transferList } = handleRequest(data as WorkerRequest);
|
|
66
|
+
parentPort.postMessage(response, transferList as ArrayBuffer[]);
|
|
55
67
|
});
|
|
56
68
|
}
|
|
57
|
-
}
|
|
69
|
+
}
|