@simplysm/core-node 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.
@@ -1,11 +1,11 @@
1
1
  //#region Types
2
2
 
3
3
  /**
4
- * `createWorker()`가 반환하는 워커 모듈의 타입 구조.
5
- * `Worker.create<typeof import("./worker")>()`에서 타입 추론에 사용된다.
4
+ * Type structure of the worker module returned by `createWorker()`.
5
+ * Used for type inference in `Worker.create<typeof import("./worker")>()`.
6
6
  *
7
- * @see createWorker - 워커 모듈 생성
8
- * @see Worker.create - 워커 프록시 생성
7
+ * @see createWorker - Create worker module
8
+ * @see Worker.create - Create worker proxy
9
9
  */
10
10
  export interface WorkerModule {
11
11
  default: {
@@ -15,9 +15,9 @@ export interface WorkerModule {
15
15
  }
16
16
 
17
17
  /**
18
- * 메서드 타입의 반환값을 Promise로 래핑하는 매핑 타입.
19
- * 워커 메서드는 postMessage 기반으로 동작하여 항상 비동기이므로,
20
- * 동기 메서드 타입도 `Promise<Awaited<R>>`로 변환한다.
18
+ * Mapping type that wraps method return values in Promise.
19
+ * Worker methods operate based on postMessage and are always asynchronous,
20
+ * so synchronous method types are also converted to `Promise<Awaited<R>>`.
21
21
  */
22
22
  export type PromisifyMethods<TMethods> = {
23
23
  [K in keyof TMethods]: TMethods[K] extends (...args: infer P) => infer R
@@ -26,14 +26,14 @@ export type PromisifyMethods<TMethods> = {
26
26
  };
27
27
 
28
28
  /**
29
- * SdWorker.create()가 반환하는 Proxy 타입.
30
- * Promisified 메서드들 + on() + terminate() 제공.
29
+ * Proxy type returned by Worker.create().
30
+ * Provides promisified methods + on() + terminate().
31
31
  */
32
32
  export type WorkerProxy<TModule extends WorkerModule> = PromisifyMethods<
33
33
  TModule["default"]["__methods"]
34
34
  > & {
35
35
  /**
36
- * 워커 이벤트 리스너 등록.
36
+ * Registers a worker event listener.
37
37
  */
38
38
  on<K extends keyof TModule["default"]["__events"] & string>(
39
39
  event: K,
@@ -41,7 +41,7 @@ export type WorkerProxy<TModule extends WorkerModule> = PromisifyMethods<
41
41
  ): void;
42
42
 
43
43
  /**
44
- * 워커 이벤트 리스너 제거.
44
+ * Unregisters a worker event listener.
45
45
  */
46
46
  off<K extends keyof TModule["default"]["__events"] & string>(
47
47
  event: K,
@@ -49,13 +49,13 @@ export type WorkerProxy<TModule extends WorkerModule> = PromisifyMethods<
49
49
  ): void;
50
50
 
51
51
  /**
52
- * 워커 종료.
52
+ * Terminates the worker.
53
53
  */
54
54
  terminate(): Promise<void>;
55
55
  };
56
56
 
57
57
  /**
58
- * Worker 내부 요청 메시지.
58
+ * Internal worker request message.
59
59
  */
60
60
  export interface WorkerRequest {
61
61
  id: string;
@@ -64,7 +64,7 @@ export interface WorkerRequest {
64
64
  }
65
65
 
66
66
  /**
67
- * Worker 내부 응답 메시지.
67
+ * Internal worker response message.
68
68
  */
69
69
  export type WorkerResponse =
70
70
  | {
@@ -11,11 +11,11 @@ const logger = consola.withTag("sd-worker");
11
11
  //#region WorkerInternal
12
12
 
13
13
  /**
14
- * Worker 내부 구현 클래스.
15
- * Proxy를 통해 외부에 노출됨.
14
+ * Internal implementation class for Worker.
15
+ * Exposed to the outside through a Proxy.
16
16
  *
17
- * 개발 환경(.ts)에서는 tsx를 통해 TypeScript 워커 파일을 실행하고,
18
- * 프로덕션 환경(.js)에서는 직접 Worker 생성한다.
17
+ * In development (.ts files), TypeScript worker files are executed via tsx.
18
+ * In production (.js files), Worker is created directly.
19
19
  */
20
20
  class WorkerInternal extends EventEmitter<Record<string, unknown>> {
21
21
  private readonly _worker: WorkerRaw;
@@ -30,13 +30,13 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
30
30
 
31
31
  const ext = path.extname(import.meta.filename);
32
32
 
33
- // 타입 가드를 통한 env 객체 추출
33
+ // Extract env object through type guard
34
34
  const envObj = opt?.env != null && typeof opt.env === "object" ? opt.env : {};
35
35
 
36
- // 개발 환경 (.ts 파일) 경우 tsx를 통해 실행
37
- // worker-dev-proxy.js: tsx로 TypeScript 워커 파일을 동적으로 로드하는 프록시
36
+ // In development (.ts files), execute via tsx
37
+ // worker-dev-proxy.js: Proxy to dynamically load TypeScript worker files via tsx
38
38
  if (ext === ".ts") {
39
- // file:// URL 경우 절대 경로로 변환 (worker-dev-proxy.js에서 다시 pathToFileURL 적용)
39
+ // If file:// URL, convert to absolute path (worker-dev-proxy.js applies pathToFileURL again)
40
40
  const workerPath = filePath.startsWith("file://") ? fileURLToPath(filePath) : filePath;
41
41
  this._worker = new WorkerRaw(
42
42
  path.resolve(import.meta.dirname, "../../lib/worker-dev-proxy.js"),
@@ -52,8 +52,8 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
52
52
  },
53
53
  );
54
54
  } else {
55
- // 프로덕션 환경 (.js 파일)
56
- // file:// URL 경우 변환, 이미 절대 경로인 경우 그대로 사용
55
+ // Production environment (.js files)
56
+ // If file:// URL, convert it; otherwise use absolute path as-is
57
57
  const workerPath = filePath.startsWith("file://") ? fileURLToPath(filePath) : filePath;
58
58
  this._worker = new WorkerRaw(workerPath, {
59
59
  stdout: true,
@@ -66,30 +66,30 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
66
66
  });
67
67
  }
68
68
 
69
- // 워커의 stdout/stderr 메인에 출력
69
+ // Pipe worker's stdout/stderr to main process
70
70
  this._worker.stdout.pipe(process.stdout);
71
71
  this._worker.stderr.pipe(process.stderr);
72
72
 
73
73
  this._worker.on("exit", (code) => {
74
74
  if (!this._isTerminated && code !== 0) {
75
- logger.error(`워커가 오류와 함께 닫힘 (code: ${code})`);
76
- // 비정상 종료 대기 중인 모든 요청 reject
77
- this._rejectAllPending(new Error(`워커가 비정상 종료됨 (code: ${code})`));
75
+ logger.error(`Worker crashed (code: ${code})`);
76
+ // Reject all pending requests on abnormal exit
77
+ this._rejectAllPending(new Error(`Worker crashed (code: ${code})`));
78
78
  }
79
79
  });
80
80
 
81
81
  this._worker.on("error", (err) => {
82
- logger.error("워커 오류:", err);
83
- // 워커 에러 대기 중인 모든 요청 reject
82
+ logger.error("Worker error:", err);
83
+ // Reject all pending requests on worker error
84
84
  this._rejectAllPending(err);
85
85
  });
86
86
 
87
87
  this._worker.on("message", (serializedResponse: unknown) => {
88
88
  const decoded = transferableDecode(serializedResponse);
89
89
 
90
- // 응답 구조 검증
90
+ // Validate response structure
91
91
  if (decoded == null || typeof decoded !== "object" || !("type" in decoded)) {
92
- logger.warn("워커에서 잘못된 형식의 응답:", decoded);
92
+ logger.warn("Invalid response format from worker:", decoded);
93
93
  return;
94
94
  }
95
95
  const response = decoded as WorkerResponse;
@@ -116,7 +116,7 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
116
116
  }
117
117
 
118
118
  /**
119
- * 대기 중인 모든 요청을 reject합니다.
119
+ * Rejects all pending requests.
120
120
  */
121
121
  private _rejectAllPending(err: Error): void {
122
122
  for (const [_id, { method, reject }] of this._pendingRequests) {
@@ -126,7 +126,7 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
126
126
  }
127
127
 
128
128
  /**
129
- * 워커 메서드 호출.
129
+ * Calls a worker method.
130
130
  */
131
131
  call(method: string, params: unknown[]): Promise<unknown> {
132
132
  return new Promise((resolve, reject) => {
@@ -144,11 +144,11 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
144
144
  }
145
145
 
146
146
  /**
147
- * 워커 종료.
147
+ * Terminates the worker.
148
148
  */
149
149
  async terminate(): Promise<void> {
150
150
  this._isTerminated = true;
151
- this._rejectAllPending(new Error("워커가 종료됨"));
151
+ this._rejectAllPending(new Error("Worker terminated"));
152
152
  await this._worker.terminate();
153
153
  }
154
154
  }
@@ -158,7 +158,7 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
158
158
  //#region Worker
159
159
 
160
160
  /**
161
- * 타입 안전한 Worker 래퍼.
161
+ * Type-safe Worker wrapper.
162
162
  *
163
163
  * @example
164
164
  * // worker.ts
@@ -173,11 +173,11 @@ class WorkerInternal extends EventEmitter<Record<string, unknown>> {
173
173
  */
174
174
  export const Worker = {
175
175
  /**
176
- * 타입 안전한 Worker Proxy 생성.
176
+ * Creates a type-safe Worker Proxy.
177
177
  *
178
- * @param filePath - 워커 파일 경로 (file:// URL 또는 절대 경로)
179
- * @param opt - Worker 옵션
180
- * @returns Proxy 객체 (메서드 직접 호출, on(), terminate() 지원)
178
+ * @param filePath - Worker file path (file:// URL or absolute path)
179
+ * @param opt - Worker options
180
+ * @returns Proxy object (supports direct method calls, on(), and terminate())
181
181
  */
182
182
  create<TModule extends WorkerModule>(
183
183
  filePath: string,
@@ -187,7 +187,7 @@ export const Worker = {
187
187
 
188
188
  return new Proxy({} as WorkerProxy<TModule>, {
189
189
  get(_target, prop: string) {
190
- // 예약된 메서드: on, off, terminate
190
+ // Reserved methods: on, off, terminate
191
191
  if (prop === "on") {
192
192
  return (event: string, listener: (data: unknown) => void) => {
193
193
  internal.on(event, listener);
@@ -202,7 +202,7 @@ export const Worker = {
202
202
  return () => internal.terminate();
203
203
  }
204
204
 
205
- // 외는 워커 메서드로 처리
205
+ // Otherwise, treat as worker method
206
206
  return (...args: unknown[]) => internal.call(prop, args);
207
207
  },
208
208
  });
@@ -0,0 +1,339 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import os from "os";
5
+ import { FsWatcher } from "../../src/features/fs-watcher";
6
+
7
+ describe("SdFsWatcher", () => {
8
+ const testDir = path.join(os.tmpdir(), "fs-watcher-test-" + Date.now());
9
+ let watcher: FsWatcher | undefined;
10
+
11
+ beforeEach(() => {
12
+ fs.mkdirSync(testDir, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ if (watcher != null) {
17
+ await watcher.close();
18
+ watcher = undefined;
19
+ }
20
+ fs.rmSync(testDir, { recursive: true, force: true });
21
+ });
22
+
23
+ //#region watch
24
+
25
+ describe("watch", () => {
26
+ it("starts watching files", async () => {
27
+ watcher = await FsWatcher.watch([path.join(testDir, "**/*")]);
28
+ expect(watcher).toBeDefined();
29
+ });
30
+
31
+ it("starts watching files with options", async () => {
32
+ watcher = await FsWatcher.watch([path.join(testDir, "**/*")], {
33
+ ignoreInitial: false,
34
+ });
35
+ expect(watcher).toBeDefined();
36
+ });
37
+
38
+ it("logs errors when error events occur", async () => {
39
+ // chokidar starts watching non-existent paths without issue
40
+ // Error events only occur on actual filesystem errors
41
+ const nonExistentPath = path.join(testDir, "non-existent-dir-" + Date.now());
42
+ watcher = await FsWatcher.watch([nonExistentPath]);
43
+
44
+ // Error handler is registered so it doesn't crash even if an error occurs
45
+ expect(watcher).toBeDefined();
46
+ });
47
+ });
48
+
49
+ //#endregion
50
+
51
+ //#region close
52
+
53
+ describe("close", () => {
54
+ it("closes the watcher", async () => {
55
+ watcher = await FsWatcher.watch([path.join(testDir, "**/*")]);
56
+
57
+ // Test passes if close() completes without error
58
+ await expect(watcher.close()).resolves.toBeUndefined();
59
+
60
+ // Release watcher reference after closing
61
+ watcher = undefined;
62
+ });
63
+ });
64
+
65
+ //#endregion
66
+
67
+ //#region chaining
68
+
69
+ describe("onChange", () => {
70
+ it("supports onChange method chaining", async () => {
71
+ watcher = await FsWatcher.watch([path.join(testDir, "**/*")]);
72
+
73
+ const fn = vi.fn();
74
+ const result = watcher.onChange({ delay: 100 }, fn);
75
+
76
+ expect(result).toBe(watcher);
77
+ });
78
+
79
+ it("can specify delay option with various values", async () => {
80
+ watcher = await FsWatcher.watch([path.join(testDir, "**/*")]);
81
+
82
+ const fn = vi.fn();
83
+ // delay option should be specifiable with various values
84
+ expect(() => watcher!.onChange({ delay: 0 }, fn)).not.toThrow();
85
+ expect(() => watcher!.onChange({ delay: 500 }, fn)).not.toThrow();
86
+ expect(() => watcher!.onChange({ delay: 1000 }, fn)).not.toThrow();
87
+ });
88
+ });
89
+
90
+ //#endregion
91
+
92
+ //#region Types
93
+
94
+ describe("Types", () => {
95
+ it("verifies FsWatcherEvent type definition", () => {
96
+ // Verify that event types are correctly defined
97
+ const validEvents = ["add", "addDir", "change", "unlink", "unlinkDir"];
98
+ expect(validEvents).toContain("add");
99
+ expect(validEvents).toContain("addDir");
100
+ expect(validEvents).toContain("change");
101
+ expect(validEvents).toContain("unlink");
102
+ expect(validEvents).toContain("unlinkDir");
103
+ });
104
+
105
+ it("verifies FsWatcherChangeInfo structure", () => {
106
+ // Type check to verify interface structure
107
+ const mockChangeInfo = {
108
+ event: "add" as const,
109
+ path: "/test/path",
110
+ };
111
+
112
+ expect(mockChangeInfo.event).toBe("add");
113
+ expect(mockChangeInfo.path).toBe("/test/path");
114
+ });
115
+ });
116
+
117
+ //#endregion
118
+
119
+ //#region Glob Pattern Filtering
120
+
121
+ describe("glob pattern filtering", () => {
122
+ const DELAY = 300;
123
+
124
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
125
+
126
+ const waitForChanges = (
127
+ watcherInstance: FsWatcher,
128
+ delay: number,
129
+ ): Promise<Array<{ event: string; path: string }>> => {
130
+ return new Promise((resolve) => {
131
+ watcherInstance.onChange({ delay }, (changeInfos) => {
132
+ resolve(changeInfos.map((c) => ({ event: c.event, path: c.path })));
133
+ });
134
+ });
135
+ };
136
+
137
+ it("receives events only for files matching glob pattern", async () => {
138
+ // Glob pattern that watches only .txt files
139
+ const globPattern = path.join(testDir, "**/*.txt");
140
+
141
+ watcher = await FsWatcher.watch([globPattern]);
142
+
143
+ const changesPromise = waitForChanges(watcher, DELAY);
144
+
145
+ // Create .txt file (matches)
146
+ fs.writeFileSync(path.join(testDir, "matched.txt"), "hello");
147
+
148
+ // Create .json file (does not match)
149
+ await wait(50);
150
+ fs.writeFileSync(path.join(testDir, "ignored.json"), "{}");
151
+
152
+ const changes = await changesPromise;
153
+
154
+ // Should only receive events for .txt file
155
+ expect(changes.length).toBe(1);
156
+ expect(changes[0].path).toContain("matched.txt");
157
+ expect(changes[0].event).toBe("add");
158
+ });
159
+ });
160
+
161
+ //#endregion
162
+
163
+ //#region Event Merging
164
+
165
+ describe("event merging", () => {
166
+ const DELAY = 300;
167
+
168
+ /**
169
+ * Helper function to wait for specified time.
170
+ */
171
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
172
+
173
+ /**
174
+ * Helper function to wait until event callback is called.
175
+ */
176
+ const waitForChanges = (
177
+ watcherInstance: FsWatcher,
178
+ delay: number,
179
+ ): Promise<Array<{ event: string; path: string }>> => {
180
+ return new Promise((resolve) => {
181
+ watcherInstance.onChange({ delay }, (changeInfos) => {
182
+ resolve(changeInfos.map((c) => ({ event: c.event, path: c.path })));
183
+ });
184
+ });
185
+ };
186
+
187
+ it("returns only add event when file is added then modified", async () => {
188
+ const testFile = path.join(testDir, "test-add-change.txt");
189
+
190
+ watcher = await FsWatcher.watch([testDir]);
191
+
192
+ const changesPromise = waitForChanges(watcher, DELAY);
193
+
194
+ // Add file
195
+ fs.writeFileSync(testFile, "initial");
196
+
197
+ // Modify file with short interval (must occur within delay)
198
+ await wait(50);
199
+ fs.writeFileSync(testFile, "modified");
200
+
201
+ // Wait for event callback
202
+ const changes = await changesPromise;
203
+
204
+ // add → change should be merged to add
205
+ expect(changes.length).toBe(1);
206
+ expect(changes[0].event).toBe("add");
207
+ });
208
+
209
+ it("produces no events or no changes when file is added then deleted", async () => {
210
+ const testFile = path.join(testDir, "test-add-unlink.txt");
211
+
212
+ watcher = await FsWatcher.watch([testDir]);
213
+
214
+ const changes: Array<{ event: string; path: string }> = [];
215
+ let resolved = false;
216
+
217
+ const changesPromise = new Promise<void>((resolve) => {
218
+ watcher!.onChange({ delay: DELAY }, (changeInfos) => {
219
+ changes.push(...changeInfos.map((c) => ({ event: c.event, path: c.path })));
220
+ if (!resolved) {
221
+ resolved = true;
222
+ resolve();
223
+ }
224
+ });
225
+ });
226
+
227
+ // Add file
228
+ fs.writeFileSync(testFile, "content");
229
+
230
+ // Delete file with short interval
231
+ await wait(50);
232
+ fs.unlinkSync(testFile);
233
+
234
+ // Wait with timeout (event may not occur)
235
+ await Promise.race([changesPromise, wait(DELAY + 200)]);
236
+
237
+ // add → unlink merged, no events
238
+ expect(changes.length).toBe(0);
239
+ });
240
+
241
+ it("produces no events or no changes when directory is added then deleted", async () => {
242
+ const testSubDir = path.join(testDir, "test-addDir-unlinkDir");
243
+
244
+ watcher = await FsWatcher.watch([testDir]);
245
+
246
+ const changes: Array<{ event: string; path: string }> = [];
247
+ let resolved = false;
248
+
249
+ const changesPromise = new Promise<void>((resolve) => {
250
+ watcher!.onChange({ delay: DELAY }, (changeInfos) => {
251
+ changes.push(...changeInfos.map((c) => ({ event: c.event, path: c.path })));
252
+ if (!resolved) {
253
+ resolved = true;
254
+ resolve();
255
+ }
256
+ });
257
+ });
258
+
259
+ // Add directory
260
+ fs.mkdirSync(testSubDir);
261
+
262
+ // Delete directory with short interval
263
+ await wait(50);
264
+ fs.rmdirSync(testSubDir);
265
+
266
+ // Wait with timeout (event may not occur)
267
+ await Promise.race([changesPromise, wait(DELAY + 200)]);
268
+
269
+ // addDir → unlinkDir merged, no events
270
+ expect(changes.length).toBe(0);
271
+ });
272
+
273
+ it("merges to add event when file is deleted then recreated", async () => {
274
+ const testFile = path.join(testDir, "test-unlink-add.txt");
275
+
276
+ // Pre-create file
277
+ fs.writeFileSync(testFile, "initial");
278
+
279
+ watcher = await FsWatcher.watch([testDir]);
280
+
281
+ const changesPromise = waitForChanges(watcher, DELAY);
282
+
283
+ // Delete file
284
+ fs.unlinkSync(testFile);
285
+
286
+ // Recreate file with short interval (must occur within delay)
287
+ await wait(50);
288
+ fs.writeFileSync(testFile, "recreated");
289
+
290
+ // Wait for event callback
291
+ const changes = await changesPromise;
292
+
293
+ // unlink → add/change should be merged to add (overwritten by later event)
294
+ // Depending on environment, chokidar may only emit change without unlink (WSL2, etc)
295
+ expect(changes.length).toBe(1);
296
+ expect(["add", "change"]).toContain(changes[0].event);
297
+ });
298
+
299
+ it("correctly merges events when multiple files are modified", async () => {
300
+ const file1 = path.join(testDir, "file1.txt");
301
+ const file2 = path.join(testDir, "file2.txt");
302
+ const file3 = path.join(testDir, "file3.txt");
303
+
304
+ // Pre-create file3 (to trigger change event)
305
+ fs.writeFileSync(file3, "existing");
306
+
307
+ watcher = await FsWatcher.watch([testDir]);
308
+
309
+ const changesPromise = waitForChanges(watcher, DELAY);
310
+
311
+ // file1: only add
312
+ fs.writeFileSync(file1, "content1");
313
+
314
+ // file2: add then delete (merged and removed)
315
+ await wait(50);
316
+ fs.writeFileSync(file2, "content2");
317
+ await wait(50);
318
+ fs.unlinkSync(file2);
319
+
320
+ // file3: modify
321
+ await wait(50);
322
+ fs.writeFileSync(file3, "modified");
323
+
324
+ // Wait for event callback
325
+ const changes = await changesPromise;
326
+
327
+ // file1: add, file2: removed by merge, file3: change
328
+ expect(changes.length).toBe(2);
329
+
330
+ const file1Change = changes.find((c) => c.path.endsWith("file1.txt"));
331
+ const file3Change = changes.find((c) => c.path.endsWith("file3.txt"));
332
+
333
+ expect(file1Change?.event).toBe("add");
334
+ expect(file3Change?.event).toBe("change");
335
+ });
336
+ });
337
+
338
+ //#endregion
339
+ });