@simplysm/service-client 14.0.47 → 14.0.49

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.
@@ -5,7 +5,18 @@ import type {
5
5
  ServiceMessage,
6
6
  ServiceProtocol,
7
7
  } from "@simplysm/service-common";
8
- import { isWorkerSupported } from "../types/browser-compat";
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, // 5초마다 만료된 항목 확인
23
- expireTime: 60 * 1000, // 60초 후 만료 (타임아웃)
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 getWorker(): Worker | undefined {
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 (!worker) {
45
- // 모던 번들러 (Vite/Esbuild/Webpack) 구문을 사용하여 Worker를 별도 파일로 분리/로드함
46
- // 참고: import.meta.resolve 대신 상대 경로 사용 (Vite 호환성)
47
- worker = new Worker(
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
- return worker;
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
- // workerAvailable 확인 호출되므로 worker는 항상 존재
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
- // [Worker]
136
- // 인코딩은 객체 전송이 필요하므로 Structured Clone이 발생함.
137
- // 하지만 메인 스레드에서 JSON.stringify 비용을 오프로드하는 이점이 더 큼.
138
- return (await runWorker("encode", { uuid, message })) as {
139
- chunks: Bytes[];
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
- // Worker의 plain object 결과에서 클래스 인스턴스 복원 (DateTime 등)
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 API 지원 여부 확인 */
22
- export function isWorkerSupported(): boolean {
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
- self.onmessage = (event: MessageEvent) => {
9
- const { id, type, data } = event.data as {
10
- id: string;
11
- type: "encode" | "decode";
12
- data: unknown;
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
- // [Main -> Worker] 인코딩 요청 (data: { uuid, message })
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
- // [Main -> Worker] 디코딩 요청 (data: Uint8Array)
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
- // [Worker -> Main] 성공 응답
45
- self.postMessage({ id, type: "success", result }, transferList);
38
+ return { response: { id: msg.id, type: "success", result }, transferList };
46
39
  } catch (err) {
47
- // [Worker -> Main] 에러 응답
48
- self.postMessage({
49
- id,
50
- type: "error",
51
- error:
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
+ }