@dimina-kit/devkit 0.1.2-dev.20260612025610 → 0.1.2-dev.20260615070430

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 (41) hide show
  1. package/README.md +5 -3
  2. package/dist/compile-log.d.ts +16 -0
  3. package/dist/compile-log.d.ts.map +1 -0
  4. package/dist/compile-log.js +42 -0
  5. package/dist/compile-log.test.d.ts +2 -0
  6. package/dist/compile-log.test.d.ts.map +1 -0
  7. package/dist/compile-log.test.js +134 -0
  8. package/dist/compile-worker-entry.d.ts +47 -0
  9. package/dist/compile-worker-entry.d.ts.map +1 -0
  10. package/dist/compile-worker-entry.js +117 -0
  11. package/dist/compile-worker-entry.test.d.ts +2 -0
  12. package/dist/compile-worker-entry.test.d.ts.map +1 -0
  13. package/dist/compile-worker-entry.test.js +247 -0
  14. package/dist/compile-worker-leak.test.d.ts +2 -0
  15. package/dist/compile-worker-leak.test.d.ts.map +1 -0
  16. package/dist/compile-worker-leak.test.js +284 -0
  17. package/dist/compile-worker.d.ts +33 -0
  18. package/dist/compile-worker.d.ts.map +1 -0
  19. package/dist/compile-worker.js +213 -0
  20. package/dist/compile-worker.test.d.ts +2 -0
  21. package/dist/compile-worker.test.d.ts.map +1 -0
  22. package/dist/compile-worker.test.js +791 -0
  23. package/dist/index.d.ts +15 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +100 -31
  26. package/dist/open-project-cleanup.test.d.ts +2 -0
  27. package/dist/open-project-cleanup.test.d.ts.map +1 -0
  28. package/dist/open-project-cleanup.test.js +176 -0
  29. package/dist/open-project-compile-log.test.d.ts +2 -0
  30. package/dist/open-project-compile-log.test.d.ts.map +1 -0
  31. package/dist/open-project-compile-log.test.js +174 -0
  32. package/dist/rebuild-scheduler.d.ts +24 -0
  33. package/dist/rebuild-scheduler.d.ts.map +1 -0
  34. package/dist/rebuild-scheduler.js +58 -0
  35. package/dist/rebuild-scheduler.test.d.ts +2 -0
  36. package/dist/rebuild-scheduler.test.d.ts.map +1 -0
  37. package/dist/rebuild-scheduler.test.js +201 -0
  38. package/dist/watch-rebuild.testutil.d.ts +21 -0
  39. package/dist/watch-rebuild.testutil.d.ts.map +1 -0
  40. package/dist/watch-rebuild.testutil.js +81 -0
  41. package/package.json +3 -3
package/README.md CHANGED
@@ -49,6 +49,7 @@ await session.close()
49
49
  | `watch` | `boolean` | `true` | 为 `false` 时跳过 chokidar 文件监听 / 自动重编译循环 |
50
50
  | `onRebuild` | `() => void` | -- | 文件变更触发重新编译后的回调 |
51
51
  | `onBuildError` | `(err: unknown) => void` | -- | 编译出错时的回调 |
52
+ | `onLog` | `(entry) => void` | -- | 逐行编译日志回调;`entry = { stream: 'stdout' \| 'stderr', text }`,已经过内置噪音过滤(`filterDmccLogLine`) |
52
53
 
53
54
  #### 返回值 ProjectSession
54
55
 
@@ -62,9 +63,10 @@ await session.close()
62
63
 
63
64
  ## 工作流程
64
65
 
65
- 1. 调用 `@dimina/compiler` 编译小程序项目
66
- 2. 启动基于 Express H5 容器预览服务器(含 CORS、SPA fallback、可选 live-reload)
67
- 3. 通过 chokidar 监听项目目录变更,自动触发增量编译和页面刷新
66
+ 1. fork 出的长驻编译子进程中调用 `@dimina/compiler` 编译小程序项目(cwd 隔离:`chdir` 只发生在子进程内,宿主进程 cwd 不被污染;编译器崩溃不会拖垮宿主,下次重编译自动重新 fork 恢复)
67
+ 2. 子进程的 stdout/stderr 按行管道回父进程,经 `filterDmccLogLine` 过滤噪音后通过 `onLog` 回调送出
68
+ 3. 启动基于 Express 的 H5 容器预览服务器(含 CORS、SPA fallback、可选 live-reload)
69
+ 4. 通过 chokidar 监听项目目录变更,自动触发增量编译和页面刷新;编译进行中的变更不会丢失,会在当前编译结束后合并为恰好一次尾随重编译(trailing rebuild)
68
70
 
69
71
  ---
70
72
 
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pure line filter for dmcc (`@dimina/compiler`) terminal output.
3
+ *
4
+ * The compiler + its listr2 renderer write directly to stdout/stderr. In the
5
+ * fork architecture the parent reads the worker's piped streams and runs every
6
+ * line through this filter before delivering it to `onLog`. DROP rules are
7
+ * derived from real captured output (`.repro/dmcc-log-spike/`); everything
8
+ * else is KEPT (default-keep), so unanticipated high-value lines — e.g.
9
+ * esbuild's `<stdin>:L:C: ERROR: …` detail — always survive.
10
+ */
11
+ /**
12
+ * Strip ANSI escapes, then decide keep/drop. Returns the cleaned line, or
13
+ * `null` when the line is known noise (or empty once stripped).
14
+ */
15
+ export declare function filterDmccLogLine(line: string): string | null;
16
+ //# sourceMappingURL=compile-log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-log.d.ts","sourceRoot":"","sources":["../src/compile-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAsBH;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO7D"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure line filter for dmcc (`@dimina/compiler`) terminal output.
3
+ *
4
+ * The compiler + its listr2 renderer write directly to stdout/stderr. In the
5
+ * fork architecture the parent reads the worker's piped streams and runs every
6
+ * line through this filter before delivering it to `onLog`. DROP rules are
7
+ * derived from real captured output (`.repro/dmcc-log-spike/`); everything
8
+ * else is KEPT (default-keep), so unanticipated high-value lines — e.g.
9
+ * esbuild's `<stdin>:L:C: ERROR: …` detail — always survive.
10
+ */
11
+ // CSI escape sequences (colors, erase-line, cursor moves) as emitted by
12
+ // chalk / listr2 TTY renderers.
13
+ // eslint-disable-next-line no-control-regex
14
+ const ANSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
15
+ const DROP_RULES = [
16
+ // dmcc logo banner (art.js): box-drawing characters only.
17
+ /^[█╔╗╚╝═║\s]+$/,
18
+ // `❯ ` listr task-start lines — transient noise; ✔/✖ cover the outcome.
19
+ /^❯ /,
20
+ // `› [██░░] %` per-worker progress-bar lines.
21
+ /^› \[/,
22
+ // devkit fe-server banner — the port is already surfaced via session.port.
23
+ /^Server is running on port /,
24
+ /^Press Ctrl\+C to stop/,
25
+ // Stack-trace frames (esbuild / Node internals) — the message line above
26
+ // them is kept; frames are noise for a compile panel.
27
+ /^\s+at /,
28
+ ];
29
+ /**
30
+ * Strip ANSI escapes, then decide keep/drop. Returns the cleaned line, or
31
+ * `null` when the line is known noise (or empty once stripped).
32
+ */
33
+ export function filterDmccLogLine(line) {
34
+ const cleaned = line.replace(ANSI_RE, '');
35
+ if (cleaned.trim() === '')
36
+ return null;
37
+ for (const rule of DROP_RULES) {
38
+ if (rule.test(cleaned))
39
+ return null;
40
+ }
41
+ return cleaned;
42
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-log.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-log.test.d.ts","sourceRoot":"","sources":["../src/compile-log.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as devkit from './index.js';
3
+ function getFilter() {
4
+ const fn = devkit.filterDmccLogLine;
5
+ expect(typeof fn, 'devkit must export filterDmccLogLine(line): string | null — the pure dmcc noise filter (re-exported from the new compile-log module via src/index.ts)').toBe('function');
6
+ return fn;
7
+ }
8
+ describe('filterDmccLogLine — DROP rules (real dmcc noise from the spike archives)', () => {
9
+ // success.stdout.txt lines 4-9: the dmcc logo banner (art.js). Printed
10
+ // only on the first build of a process, but must always be stripped.
11
+ const LOGO_LINES = [
12
+ '██████╗ ██╗███╗ ███╗██╗███╗ ██╗ █████╗',
13
+ '██╔══██╗██║████╗ ████║██║████╗ ██║██╔══██╗',
14
+ '██║ ██║██║██╔████╔██║██║██╔██╗ ██║███████║',
15
+ '██║ ██║██║██║╚██╔╝██║██║██║╚██╗██║██╔══██║',
16
+ '██████╔╝██║██║ ╚═╝ ██║██║██║ ╚████║██║ ██║',
17
+ '╚═════╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝',
18
+ ];
19
+ it('drops every logo / box-drawing banner line', () => {
20
+ const filter = getFilter();
21
+ for (const line of LOGO_LINES) {
22
+ expect(filter(line), `logo line must be dropped: ${JSON.stringify(line)}`).toBeNull();
23
+ }
24
+ });
25
+ it('drops blank and whitespace-only lines (logo surroundings)', () => {
26
+ const filter = getFilter();
27
+ expect(filter('')).toBeNull();
28
+ expect(filter(' ')).toBeNull();
29
+ expect(filter('\t')).toBeNull();
30
+ });
31
+ it('drops `❯ ` listr task-start lines (transient — ✔/✖ already cover them)', () => {
32
+ const filter = getFilter();
33
+ // success.stdout.txt lines 11 / 21
34
+ expect(filter('❯ 准备项目编译环境')).toBeNull();
35
+ expect(filter('❯ 开始编译:demo-app')).toBeNull();
36
+ expect(filter('❯ 编译页面逻辑')).toBeNull();
37
+ });
38
+ it('drops `› [██░░] %` progress-bar lines', () => {
39
+ const filter = getFilter();
40
+ // success.stdout.txt lines 25 / 32 / 43
41
+ expect(filter('› [████░░░░░░░░░░░░░░░░░░░░░░░░░░] 12.50%')).toBeNull();
42
+ expect(filter('› [██████████████████████████████] 100.00%')).toBeNull();
43
+ expect(filter('› [███████░░░░░░░░░░░░░░░░░░░░░░░] 22.22%')).toBeNull();
44
+ });
45
+ it('drops the devkit fe-server lines (port is already surfaced via session.port)', () => {
46
+ const filter = getFilter();
47
+ // success.stdout.txt lines 56-57
48
+ expect(filter('Server is running on port 52197')).toBeNull();
49
+ expect(filter('Press Ctrl+C to stop')).toBeNull();
50
+ });
51
+ it('drops stack-trace frames (esbuild/Node internals)', () => {
52
+ const filter = getFilter();
53
+ // error.stderr.txt lines 20-29
54
+ expect(filter(' at failureErrorWithLog (/Volumes/jdisk/code/dimina-kit/node_modules/.pnpm/esbuild@0.28.0/node_modules/esbuild/lib/main.js:1748:15)')).toBeNull();
55
+ expect(filter(' at /Volumes/jdisk/code/dimina-kit/node_modules/.pnpm/esbuild@0.28.0/node_modules/esbuild/lib/main.js:1017:50')).toBeNull();
56
+ expect(filter(' at Socket.emit (node:events:507:28)')).toBeNull();
57
+ expect(filter(' at Pipe.onStreamRead (node:internal/stream_base_commons:189:23)')).toBeNull();
58
+ });
59
+ });
60
+ describe('filterDmccLogLine — KEEP rules (real dmcc signal from the spike archives)', () => {
61
+ it('keeps the `[logic]` esbuild transform-failure line (the highest-value error line)', () => {
62
+ const filter = getFilter();
63
+ // error.stderr.txt line 1
64
+ const line = '[logic] esbuild 转换失败 /Volumes/jdisk/code/dimina-kit/packages/demo-app/pages/index/index.js: Transform failed with 1 error:';
65
+ expect(filter(line), 'esbuild 转换失败 must be kept — it carries the failing file path').toBe(line);
66
+ });
67
+ it('keeps the esbuild `<stdin>:L:C: ERROR:` detail line (default-keep for unrecognized content)', () => {
68
+ const filter = getFilter();
69
+ // error.stderr.txt line 2 — the actual syntax-error location. It matches
70
+ // no DROP rule and MUST survive: a whitelist-only filter would lose the
71
+ // only line that tells the developer what is wrong.
72
+ const line = '<stdin>:96:16: ERROR: Expected identifier but found "{"';
73
+ expect(filter(line)).toBe(line);
74
+ });
75
+ it('keeps `[logic]/[view]/[style]/[compat]` prefixed warnings', () => {
76
+ const filter = getFilter();
77
+ // success.stderr.txt lines 1 / 2 / 10 / 11
78
+ const lines = [
79
+ '[logic] 检测到循环依赖: pages/component-test/component-test -> /components/nested-item/nested-item -> /components/nested-item/nested-item',
80
+ '[compat] Unsupported wx API: wx.createInnerAudioContext (/pages/audio-test/audio-test.js:33)',
81
+ '[view] 检测到循环依赖,跳过处理: /components/nested-item/nested-item',
82
+ '[style] 检测到循环依赖: pages/component-test/component-test -> /components/nested-item/nested-item -> /components/nested-item/nested-item',
83
+ ];
84
+ for (const line of lines) {
85
+ expect(filter(line), `prefixed warning must be kept: ${JSON.stringify(line)}`).toBe(line);
86
+ }
87
+ });
88
+ it('keeps `✖ ` listr failure lines (with the FAILED reason)', () => {
89
+ const filter = getFilter();
90
+ // error.stderr.txt lines 12 / 14
91
+ const lines = [
92
+ '✖ 编译页面逻辑 [FAILED: Transform failed with 1 error:',
93
+ '✖ 开始编译:demo-app [FAILED: Transform failed with 1 error:',
94
+ ];
95
+ for (const line of lines) {
96
+ expect(filter(line)).toBe(line);
97
+ }
98
+ });
99
+ it('keeps the `编译出错: ` summary line', () => {
100
+ const filter = getFilter();
101
+ // error.stderr.txt line 16
102
+ const line = '/Volumes/jdisk/code/dimina-kit/packages/demo-app 编译出错: Transform failed with 1 error:';
103
+ expect(filter(line)).toBe(line);
104
+ });
105
+ it('keeps `✔ ` stage-completion lines', () => {
106
+ const filter = getFilter();
107
+ // success.stdout.txt lines 13 / 33 / 53 / 55
108
+ const lines = [
109
+ '✔ 收集配置信息',
110
+ '✔ 编译页面逻辑',
111
+ '✔ 开始编译:demo-app',
112
+ '✔ 输出编译产物',
113
+ ];
114
+ for (const line of lines) {
115
+ expect(filter(line)).toBe(line);
116
+ }
117
+ });
118
+ });
119
+ describe('filterDmccLogLine — ANSI stripping happens BEFORE rule matching', () => {
120
+ it('strips ANSI from a kept line and returns the cleaned text', () => {
121
+ const filter = getFilter();
122
+ expect(filter('\u001B[32m✔\u001B[39m 收集配置信息')).toBe('✔ 收集配置信息');
123
+ });
124
+ it('drops an ANSI-wrapped progress line (rules apply to the stripped text)', () => {
125
+ const filter = getFilter();
126
+ // What a TTY listr renderer chunk looks like: erase-line + column-1
127
+ // escapes around the same progress payload.
128
+ expect(filter('\u001B[2K\u001B[1G› [██████████████████████████████] 100.00%')).toBeNull();
129
+ });
130
+ it('drops a line that is empty once ANSI is stripped', () => {
131
+ const filter = getFilter();
132
+ expect(filter('\u001B[2K\u001B[1G')).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,47 @@
1
+ export interface WorkerAppInfo {
2
+ appId: string;
3
+ name: string;
4
+ path: string;
5
+ }
6
+ export interface WorkerBuildOptions {
7
+ sourcemap?: boolean;
8
+ }
9
+ export type WorkerBuildFn = (outputDir: string, projectPath: string, useAppIdDir: boolean, options: WorkerBuildOptions) => Promise<WorkerAppInfo | null | undefined>;
10
+ export interface WorkerResultMessage {
11
+ type: 'result';
12
+ appInfo: WorkerAppInfo | null;
13
+ error?: {
14
+ message: string;
15
+ };
16
+ }
17
+ export interface CompileWorkerHandlerDeps {
18
+ /** Lazy `require('@dimina/compiler')` — deferred to the first build. */
19
+ loadCompiler: () => WorkerBuildFn;
20
+ /** `process.chdir` — mutates the CHILD process cwd only. */
21
+ chdir: (dir: string) => void;
22
+ /** `process.send` — replies to the parent. */
23
+ send: (msg: WorkerResultMessage) => void;
24
+ }
25
+ /**
26
+ * Build the fork-side IPC message handler. Non-build messages are ignored
27
+ * (no compiler load, no reply). A build command chdirs into the project,
28
+ * runs the compiler exactly as the old in-process call sites did, and ALWAYS
29
+ * replies — `@dimina/compiler` swallows compile errors internally (resolves
30
+ * undefined), which is normalized to `appInfo: null`; a genuine throw still
31
+ * replies with `error.message` so the parent never hangs on a lost build.
32
+ */
33
+ export declare function createCompileWorkerHandler(deps: CompileWorkerHandlerDeps): (msg: unknown) => Promise<void>;
34
+ /**
35
+ * Wire a worker process to its message handler. Extracted so the fork-mode
36
+ * self-wiring below and unit tests exercise the SAME wiring code (an inline
37
+ * copy next to an exported one would drift apart silently).
38
+ *
39
+ * Both subscriptions are registered synchronously at call time — an async gap
40
+ * between fork and the 'disconnect' subscription is a window where a dying
41
+ * parent leaks the child as an orphan.
42
+ */
43
+ export declare function wireCompileWorkerProcess(proc: {
44
+ on: (event: 'message' | 'disconnect', listener: (...args: unknown[]) => void) => unknown;
45
+ exit: (code?: number) => void;
46
+ }, handler: (msg: unknown) => Promise<void>): void;
47
+ //# sourceMappingURL=compile-worker-entry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker-entry.d.ts","sourceRoot":"","sources":["../src/compile-worker-entry.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,aAAa;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,kBAAkB;IAClC,SAAS,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,MAAM,aAAa,GAAG,CAC3B,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,OAAO,EACpB,OAAO,EAAE,kBAAkB,KACvB,OAAO,CAAC,aAAa,GAAG,IAAI,GAAG,SAAS,CAAC,CAAA;AAE9C,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,QAAQ,CAAA;IACd,OAAO,EAAE,aAAa,GAAG,IAAI,CAAA;IAC7B,KAAK,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAC3B;AAED,MAAM,WAAW,wBAAwB;IACxC,wEAAwE;IACxE,YAAY,EAAE,MAAM,aAAa,CAAA;IACjC,4DAA4D;IAC5D,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC5B,8CAA8C;IAC9C,IAAI,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,IAAI,CAAA;CACxC;AAiBD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACzC,IAAI,EAAE,wBAAwB,GAC5B,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAwBjC;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACvC,IAAI,EAAE;IACL,EAAE,EAAE,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,KAAK,OAAO,CAAA;IACxF,IAAI,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAC7B,EACD,OAAO,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GACtC,IAAI,CASN"}
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `child_process.fork()` target for the long-lived compile worker.
3
+ *
4
+ * Compilation runs OUT of the host (Electron main) process so that:
5
+ * - `process.chdir(projectPath)` mutates the WORKER's cwd, never the host's,
6
+ * - dmcc/listr2 output lands on the worker's piped stdout/stderr (read
7
+ * line-by-line by the parent — no global write-hooks, no isTTY hacks),
8
+ * - a compiler crash kills the worker, not the host.
9
+ *
10
+ * Zero-dependency thin shell: the compiler is required lazily on the first
11
+ * build command and cached for the worker's lifetime. The IPC handling lives
12
+ * in `createCompileWorkerHandler` (dependency-injected) so it is unit-testable
13
+ * without forking.
14
+ */
15
+ import { createRequire } from 'node:module';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ function isBuildMessage(msg) {
19
+ return (typeof msg === 'object'
20
+ && msg !== null
21
+ && msg.cmd === 'build');
22
+ }
23
+ /**
24
+ * Build the fork-side IPC message handler. Non-build messages are ignored
25
+ * (no compiler load, no reply). A build command chdirs into the project,
26
+ * runs the compiler exactly as the old in-process call sites did, and ALWAYS
27
+ * replies — `@dimina/compiler` swallows compile errors internally (resolves
28
+ * undefined), which is normalized to `appInfo: null`; a genuine throw still
29
+ * replies with `error.message` so the parent never hangs on a lost build.
30
+ */
31
+ export function createCompileWorkerHandler(deps) {
32
+ let cachedBuild = null;
33
+ return async (msg) => {
34
+ if (!isBuildMessage(msg))
35
+ return;
36
+ try {
37
+ if (!cachedBuild)
38
+ cachedBuild = deps.loadCompiler();
39
+ deps.chdir(msg.projectPath);
40
+ const appInfo = await cachedBuild(msg.outputDir, msg.projectPath, true, msg.options);
41
+ deps.send({ type: 'result', appInfo: appInfo ?? null });
42
+ }
43
+ catch (err) {
44
+ deps.send({
45
+ type: 'result',
46
+ appInfo: null,
47
+ error: { message: err instanceof Error ? err.message : String(err) },
48
+ });
49
+ }
50
+ };
51
+ }
52
+ /**
53
+ * Wire a worker process to its message handler. Extracted so the fork-mode
54
+ * self-wiring below and unit tests exercise the SAME wiring code (an inline
55
+ * copy next to an exported one would drift apart silently).
56
+ *
57
+ * Both subscriptions are registered synchronously at call time — an async gap
58
+ * between fork and the 'disconnect' subscription is a window where a dying
59
+ * parent leaks the child as an orphan.
60
+ */
61
+ export function wireCompileWorkerProcess(proc, handler) {
62
+ proc.on('message', (msg) => {
63
+ void handler(msg);
64
+ });
65
+ // The parent kills the worker on session close; if the parent dies first
66
+ // (IPC channel gone), exit instead of lingering as an orphan.
67
+ proc.on('disconnect', () => {
68
+ proc.exit(0);
69
+ });
70
+ }
71
+ // ── Fork-mode self-wiring ───────────────────────────────────────────────────
72
+ //
73
+ // Only engage when this module IS the forked entry: argv[1] must be EXACTLY
74
+ // this module's own file (normalized-path comparison against import.meta.url),
75
+ // not merely a path containing the substring 'compile-worker-entry' — a
76
+ // substring gate would let any unrelated IPC-child script whose path happens
77
+ // to contain that fragment import this module and get its process hijacked
78
+ // (a disconnect→exit(0) handler + a message handler stealing IPC traffic).
79
+ // A plain `process.send` check is not enough either: vitest's default "forks"
80
+ // pool also runs test workers as IPC children, and importing this module from
81
+ // a unit test must stay side-effect free there.
82
+ //
83
+ // `stripModuleQuery` drops a `?query` / `#hash` suffix before comparing —
84
+ // dev-time loaders (vite-node/vitest) append cache-busting queries like
85
+ // `?v=…` to module URLs, which fileURLToPath would choke on and which never
86
+ // appear in the real argv[1].
87
+ function stripModuleQuery(spec) {
88
+ return spec.replace(/[?#].*$/, '');
89
+ }
90
+ const selfModulePath = (() => {
91
+ try {
92
+ return path.resolve(fileURLToPath(stripModuleQuery(import.meta.url)));
93
+ }
94
+ catch {
95
+ // Non-file module URL (bundler virtual module etc.) — never the fork
96
+ // target, so the gate simply stays disengaged.
97
+ return null;
98
+ }
99
+ })();
100
+ const isForkedWorkerEntry = typeof process.argv[1] === 'string'
101
+ && selfModulePath !== null
102
+ && path.resolve(stripModuleQuery(process.argv[1])) === selfModulePath
103
+ && typeof process.send === 'function';
104
+ if (isForkedWorkerEntry) {
105
+ const require = createRequire(import.meta.url);
106
+ const handler = createCompileWorkerHandler({
107
+ loadCompiler: () => require('@dimina/compiler'),
108
+ chdir: dir => process.chdir(dir),
109
+ send: (msg) => {
110
+ process.send?.(msg);
111
+ },
112
+ });
113
+ wireCompileWorkerProcess({
114
+ on: (event, listener) => process.on(event, listener),
115
+ exit: code => process.exit(code),
116
+ }, handler);
117
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-worker-entry.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile-worker-entry.test.d.ts","sourceRoot":"","sources":["../src/compile-worker-entry.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,247 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ async function getCreateHandler() {
4
+ const mod = await import('./compile-worker-entry.js').catch(() => null);
5
+ expect(mod, 'src/compile-worker-entry must exist — it is the child_process.fork() target of the compile worker').not.toBeNull();
6
+ const fn = mod.createCompileWorkerHandler;
7
+ expect(typeof fn, 'compile-worker-entry must export createCompileWorkerHandler(deps) so the IPC message handling is unit-testable without forking').toBe('function');
8
+ return fn;
9
+ }
10
+ const FIXTURE_APP = {
11
+ appId: 'fixture_app_001',
12
+ name: 'fixture-app',
13
+ path: '/tmp/fixture-project',
14
+ };
15
+ const BUILD_MSG = {
16
+ cmd: 'build',
17
+ projectPath: '/tmp/fixture-project',
18
+ outputDir: '/tmp/fixture-out',
19
+ options: { sourcemap: true },
20
+ };
21
+ function makeDeps(build) {
22
+ const callOrder = [];
23
+ const buildSpy = vi.fn((...args) => {
24
+ callOrder.push('build');
25
+ return build(...args);
26
+ });
27
+ const deps = {
28
+ loadCompiler: vi.fn(() => buildSpy),
29
+ chdir: vi.fn((_dir) => {
30
+ callOrder.push('chdir');
31
+ }),
32
+ send: vi.fn((_msg) => { }),
33
+ };
34
+ return { deps, buildSpy, callOrder };
35
+ }
36
+ describe('compile-worker-entry — createCompileWorkerHandler (fork-side IPC handler)', () => {
37
+ it('does NOT load the compiler at handler creation (the fork must stay fast)', async () => {
38
+ const createHandler = await getCreateHandler();
39
+ const { deps } = makeDeps(async () => FIXTURE_APP);
40
+ createHandler(deps);
41
+ expect(deps.loadCompiler, 'loadCompiler must be deferred to the first build command — requiring @dimina/compiler at fork time defeats the fast long-lived worker').not.toHaveBeenCalled();
42
+ });
43
+ it('ignores non-build messages: no compiler load, no reply, no throw', async () => {
44
+ const createHandler = await getCreateHandler();
45
+ const { deps } = makeDeps(async () => FIXTURE_APP);
46
+ const handler = createHandler(deps);
47
+ await handler(null);
48
+ await handler('hello');
49
+ await handler({ cmd: 'shutdown' });
50
+ await handler({});
51
+ expect(deps.loadCompiler).not.toHaveBeenCalled();
52
+ expect(deps.send, 'unknown messages must not produce a reply').not.toHaveBeenCalled();
53
+ });
54
+ it('on a build command: chdir(projectPath) in the child BEFORE build, build(outputDir, projectPath, true, options), then send the result', async () => {
55
+ const createHandler = await getCreateHandler();
56
+ const { deps, buildSpy, callOrder } = makeDeps(async () => FIXTURE_APP);
57
+ const handler = createHandler(deps);
58
+ await handler(BUILD_MSG);
59
+ expect(deps.chdir).toHaveBeenCalledWith(BUILD_MSG.projectPath);
60
+ expect(buildSpy).toHaveBeenCalledWith(BUILD_MSG.outputDir, BUILD_MSG.projectPath, true, BUILD_MSG.options);
61
+ expect(callOrder, 'chdir must happen before build — the compiler resolves project-relative paths off cwd (the whole reason the OLD architecture chdir-ed the host)').toEqual(['chdir', 'build']);
62
+ expect(deps.send).toHaveBeenCalledTimes(1);
63
+ expect(deps.send).toHaveBeenCalledWith({ type: 'result', appInfo: FIXTURE_APP });
64
+ });
65
+ it('loads the compiler lazily on the FIRST build command and caches it across builds', async () => {
66
+ const createHandler = await getCreateHandler();
67
+ const { deps, buildSpy } = makeDeps(async () => FIXTURE_APP);
68
+ const handler = createHandler(deps);
69
+ expect(deps.loadCompiler).not.toHaveBeenCalled();
70
+ await handler(BUILD_MSG);
71
+ expect(deps.loadCompiler).toHaveBeenCalledTimes(1);
72
+ await handler(BUILD_MSG);
73
+ expect(deps.loadCompiler, 'the compiler module must be loaded ONCE per worker lifetime and reused for every subsequent build').toHaveBeenCalledTimes(1);
74
+ expect(buildSpy).toHaveBeenCalledTimes(2);
75
+ expect(deps.send).toHaveBeenCalledTimes(2);
76
+ });
77
+ it('normalizes a swallowed-error build (undefined return) to { type: "result", appInfo: null }', async () => {
78
+ const createHandler = await getCreateHandler();
79
+ // @dimina/compiler build() catches compile errors internally and
80
+ // resolves undefined (RESULTS.md §① — never rethrows). The protocol
81
+ // must surface that as an explicit null so the parent keeps its
82
+ // existing project.config.json fallback path.
83
+ const { deps } = makeDeps(async () => undefined);
84
+ const handler = createHandler(deps);
85
+ await handler(BUILD_MSG);
86
+ expect(deps.send).toHaveBeenCalledTimes(1);
87
+ expect(deps.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'result', appInfo: null }));
88
+ });
89
+ it('a throwing build still replies ({ appInfo: null, error.message }) and never rejects the handler', async () => {
90
+ const createHandler = await getCreateHandler();
91
+ const { deps } = makeDeps(async () => {
92
+ throw new Error('boom — compiler exploded');
93
+ });
94
+ const handler = createHandler(deps);
95
+ // Must resolve — an unreplied build command would hang the parent's
96
+ // in-flight rebuild and wedge the rebuild scheduler forever.
97
+ await expect(handler(BUILD_MSG)).resolves.toBeUndefined();
98
+ expect(deps.send).toHaveBeenCalledTimes(1);
99
+ expect(deps.send).toHaveBeenCalledWith(expect.objectContaining({
100
+ type: 'result',
101
+ appInfo: null,
102
+ error: expect.objectContaining({
103
+ message: expect.stringContaining('boom'),
104
+ }),
105
+ }));
106
+ });
107
+ });
108
+ class FakeWorkerProcess {
109
+ listeners = new Map();
110
+ exit = vi.fn((_code) => { });
111
+ on = vi.fn((event, listener) => {
112
+ const bucket = this.listeners.get(event) ?? [];
113
+ bucket.push(listener);
114
+ this.listeners.set(event, bucket);
115
+ return this;
116
+ });
117
+ emit(event, ...args) {
118
+ for (const listener of this.listeners.get(event) ?? [])
119
+ listener(...args);
120
+ }
121
+ }
122
+ async function getWireWorkerProcess() {
123
+ const mod = await import('./compile-worker-entry.js').catch(() => null);
124
+ expect(mod, 'src/compile-worker-entry must exist').not.toBeNull();
125
+ const fn = mod.wireCompileWorkerProcess;
126
+ expect(typeof fn, 'compile-worker-entry must export wireCompileWorkerProcess(proc, handler) — the process-lifecycle '
127
+ + 'wiring (message routing + disconnect self-exit) must be a unit-testable seam, exactly like '
128
+ + 'createCompileWorkerHandler already is for the message handling').toBe('function');
129
+ return fn;
130
+ }
131
+ describe('compile-worker-entry — wireCompileWorkerProcess (orphan-safety-net seam)', () => {
132
+ it('subscribes the disconnect self-exit SYNCHRONOUSLY at wire time, and never exits eagerly', async () => {
133
+ const wire = await getWireWorkerProcess();
134
+ const proc = new FakeWorkerProcess();
135
+ wire(proc, async () => { });
136
+ expect(proc.listeners.get('disconnect')?.length ?? 0, 'the disconnect listener must be registered synchronously inside wireCompileWorkerProcess — '
137
+ + 'any async gap is a window where a dying parent orphans the worker').toBeGreaterThanOrEqual(1);
138
+ expect(proc.exit, 'wiring alone must never exit the process').not.toHaveBeenCalled();
139
+ });
140
+ it('on IPC disconnect the worker exits itself with code 0 — the orphan safety net', async () => {
141
+ const wire = await getWireWorkerProcess();
142
+ const proc = new FakeWorkerProcess();
143
+ wire(proc, async () => { });
144
+ proc.emit('disconnect');
145
+ expect(proc.exit, 'channel gone ⇒ the worker must kill ITSELF — this covers every parent death that never runs close() '
146
+ + '(host crash, SIGKILL, teardown race)').toHaveBeenCalledWith(0);
147
+ });
148
+ it('routes every inbound IPC message to the provided handler (the forked path and the unit-tested path stay one code path)', async () => {
149
+ const wire = await getWireWorkerProcess();
150
+ const proc = new FakeWorkerProcess();
151
+ const handled = [];
152
+ wire(proc, async (msg) => {
153
+ handled.push(msg);
154
+ });
155
+ proc.emit('message', BUILD_MSG);
156
+ proc.emit('message', { cmd: 'noop' });
157
+ expect(handled).toEqual([BUILD_MSG, { cmd: 'noop' }]);
158
+ expect(proc.exit, 'message traffic must never exit the worker').not.toHaveBeenCalled();
159
+ });
160
+ });
161
+ /**
162
+ * CODEX-REVIEW REGRESSION (m9) — the fork-mode self-wiring gate currently
163
+ * uses `process.argv[1].includes('compile-worker-entry')`, a SUBSTRING match.
164
+ * Any unrelated script whose path merely contains that fragment (run with an
165
+ * IPC channel, e.g. itself a fork) would import this module and silently
166
+ * self-wire: a disconnect→exit(0) handler and a message handler hijacking the
167
+ * host's IPC traffic. The gate must engage ONLY when argv[1] is THIS module
168
+ * (same path as import.meta.url) — pinned behaviorally below via a fresh
169
+ * module evaluation under a manipulated argv[1].
170
+ *
171
+ * Observation seam: self-wiring is `process.on('message'|'disconnect', …)` on
172
+ * the REAL process — the tests diff the process listener sets around the
173
+ * import and remove any added listeners again in cleanup (so a failing run
174
+ * can never leak a disconnect→exit(0) handler into the vitest worker).
175
+ */
176
+ describe('compile-worker-entry — fork-mode self-wiring gate (codex m9)', () => {
177
+ function snapshotListeners() {
178
+ return {
179
+ message: process.listeners('message'),
180
+ disconnect: process.listeners('disconnect'),
181
+ };
182
+ }
183
+ function addedSince(before) {
184
+ const current = snapshotListeners();
185
+ return {
186
+ message: current.message.filter(listener => !before.message.includes(listener)),
187
+ disconnect: current.disconnect.filter(listener => !before.disconnect.includes(listener)),
188
+ };
189
+ }
190
+ function removeListeners(added) {
191
+ for (const listener of added.message)
192
+ process.removeListener('message', listener);
193
+ for (const listener of added.disconnect)
194
+ process.removeListener('disconnect', listener);
195
+ }
196
+ async function importEntryFreshWithArgv1(argv1) {
197
+ process.argv[1] = argv1;
198
+ vi.resetModules();
199
+ await import('./compile-worker-entry.js');
200
+ }
201
+ it('m9: an argv[1] LOOKALIKE path (substring match) must NOT self-wire the worker IPC handlers', async () => {
202
+ const originalArgv1 = process.argv[1] ?? '';
203
+ const originalSend = process.send;
204
+ const before = snapshotListeners();
205
+ try {
206
+ // An IPC channel is present (vitest forks pool) or stubbed — the
207
+ // substring gate is the only thing standing between this unrelated
208
+ // script path and a hijacked process.
209
+ if (typeof process.send !== 'function') {
210
+ process.send = (() => true);
211
+ }
212
+ await importEntryFreshWithArgv1('/tmp/my-compile-worker-entry-lookalike.js');
213
+ const added = addedSince(before);
214
+ expect(added.disconnect, 'argv[1] = /tmp/my-compile-worker-entry-lookalike.js is NOT this module — a substring gate self-wires '
215
+ + 'a disconnect→exit(0) handler into the unsuspecting host process').toHaveLength(0);
216
+ expect(added.message, 'the lookalike path must not get a message handler either — it would intercept the host\'s own IPC traffic').toHaveLength(0);
217
+ }
218
+ finally {
219
+ removeListeners(addedSince(before));
220
+ process.argv[1] = originalArgv1;
221
+ process.send = originalSend;
222
+ vi.resetModules();
223
+ }
224
+ });
225
+ it('m9 positive control: when argv[1] IS this entry module, the fork-mode self-wiring engages', async () => {
226
+ const originalArgv1 = process.argv[1] ?? '';
227
+ const originalSend = process.send;
228
+ const before = snapshotListeners();
229
+ try {
230
+ if (typeof process.send !== 'function') {
231
+ process.send = (() => true);
232
+ }
233
+ const entryPath = fileURLToPath(new URL('./compile-worker-entry.ts', import.meta.url));
234
+ await importEntryFreshWithArgv1(entryPath);
235
+ const added = addedSince(before);
236
+ expect(added.disconnect.length, 'with argv[1] pointing at THIS module the self-wiring must engage (disconnect orphan safety net) — '
237
+ + 'a fix that simply disables the gate would orphan every real fork').toBeGreaterThanOrEqual(1);
238
+ expect(added.message.length, 'the real fork path must wire the message handler').toBeGreaterThanOrEqual(1);
239
+ }
240
+ finally {
241
+ removeListeners(addedSince(before));
242
+ process.argv[1] = originalArgv1;
243
+ process.send = originalSend;
244
+ vi.resetModules();
245
+ }
246
+ });
247
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compile-worker-leak.test.d.ts.map