@dimina-kit/devkit 0.1.2-dev.20260703051110 → 0.1.2-dev.20260706064107

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 (32) hide show
  1. package/README.md +16 -0
  2. package/dist/compile-worker-adopt.test.d.ts +2 -0
  3. package/dist/compile-worker-adopt.test.d.ts.map +1 -0
  4. package/dist/compile-worker-adopt.test.js +187 -0
  5. package/dist/compile-worker-entry-warmpool.test.d.ts +2 -0
  6. package/dist/compile-worker-entry-warmpool.test.d.ts.map +1 -0
  7. package/dist/compile-worker-entry-warmpool.test.js +110 -0
  8. package/dist/compile-worker-entry.d.ts +35 -5
  9. package/dist/compile-worker-entry.d.ts.map +1 -1
  10. package/dist/compile-worker-entry.js +45 -1
  11. package/dist/compile-worker-standby-hardening.test.d.ts +2 -0
  12. package/dist/compile-worker-standby-hardening.test.d.ts.map +1 -0
  13. package/dist/compile-worker-standby-hardening.test.js +212 -0
  14. package/dist/compile-worker-standby-integration.test.d.ts +2 -0
  15. package/dist/compile-worker-standby-integration.test.d.ts.map +1 -0
  16. package/dist/compile-worker-standby-integration.test.js +243 -0
  17. package/dist/compile-worker-standby.d.ts +60 -0
  18. package/dist/compile-worker-standby.d.ts.map +1 -0
  19. package/dist/compile-worker-standby.js +283 -0
  20. package/dist/compile-worker-standby.test.d.ts +2 -0
  21. package/dist/compile-worker-standby.test.d.ts.map +1 -0
  22. package/dist/compile-worker-standby.test.js +356 -0
  23. package/dist/compile-worker.d.ts +53 -0
  24. package/dist/compile-worker.d.ts.map +1 -1
  25. package/dist/compile-worker.js +30 -11
  26. package/dist/index.d.ts +21 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +74 -11
  29. package/dist/open-project-watcher-error-callback.test.d.ts +2 -0
  30. package/dist/open-project-watcher-error-callback.test.d.ts.map +1 -0
  31. package/dist/open-project-watcher-error-callback.test.js +191 -0
  32. package/package.json +3 -3
@@ -1,3 +1,23 @@
1
+ /**
2
+ * Parent-side orchestration of the forked compile worker.
3
+ *
4
+ * One long-lived worker per `openProject` session: the first compile and
5
+ * every watcher rebuild are `{ cmd: 'build' }` IPC messages to the SAME
6
+ * child, so the compiler's module/worker caches stay warm. The parent never
7
+ * calls `process.chdir` — the worker chdirs in its own process (the root
8
+ * motive of this architecture).
9
+ *
10
+ * Crash handling: an unexpected worker death ('exit', 'close' or a lone
11
+ * 'error' event — Node does NOT guarantee an 'exit' after 'error') rejects
12
+ * the in-flight build (so the rebuild scheduler settles instead of hanging)
13
+ * and the NEXT build lazily re-forks a fresh worker. The death handler is
14
+ * idempotent and generation-guarded: a dead child's late 'exit' can never
15
+ * settle a build that belongs to a fresh worker. `close()` kills, rejects
16
+ * the in-flight build itself, and resolves on the child's first death event
17
+ * ('exit'/'close', or a lone 'error') — it never re-forks
18
+ * (refill-on-graceful-close would wedge process teardown).
19
+ */
20
+ import { type ChildProcess } from 'node:child_process';
1
21
  import type { WorkerAppInfo, WorkerBuildOptions } from './compile-worker-entry.js';
2
22
  export interface CompileLogEntry {
3
23
  stream: 'stdout' | 'stderr';
@@ -6,6 +26,16 @@ export interface CompileLogEntry {
6
26
  export interface CompileWorkerOptions {
7
27
  /** Filtered dmcc log lines (already through `filterDmccLogLine`). */
8
28
  onLog?: (entry: CompileLogEntry) => void;
29
+ /**
30
+ * An already-forked compile-worker-entry process to reuse for the first
31
+ * build (a warm-standby hand-off) instead of paying a fresh fork + compiler
32
+ * import. Must be an IPC fork with piped stdio — the same shape spawnWorker
33
+ * produces. Pure accelerator: an adoptee that is already dead (or dies
34
+ * before the first build) is simply skipped and the next build forks fresh.
35
+ * Once adopted, the process is owned by this worker exactly like a
36
+ * self-forked one — close() kills it, its death rejects in-flight builds.
37
+ */
38
+ adopt?: ChildProcess;
9
39
  }
10
40
  export interface BuildRequest {
11
41
  projectPath: string;
@@ -29,5 +59,28 @@ export interface CompileWorker {
29
59
  */
30
60
  close: () => Promise<void>;
31
61
  }
62
+ /**
63
+ * Resolve the fork target. In the published build this is the compiled
64
+ * `dist/compile-worker-entry.js` sibling; under vitest/dev the module runs
65
+ * from `src/`, where only the `.ts` source exists — the entry is a
66
+ * zero-dependency, erasable-syntax-only shell by design, so Node can run it
67
+ * with type stripping (default since 22.18; behind `--experimental-strip-types`
68
+ * on 22.6–22.17, which `workerExecArgv` passes for a `.ts` entry).
69
+ *
70
+ * Exported (with `workerExecArgv`) as the single authority on the fork shape:
71
+ * the warm-standby manager forks the SAME entry with the SAME execArgv, so a
72
+ * spare is indistinguishable from a worker this module forks itself.
73
+ */
74
+ export declare function resolveWorkerEntry(): string;
75
+ /**
76
+ * execArgv for the fork. A compiled `.js` entry (the published build, engines
77
+ * `node >=20`) needs nothing. A `.ts` entry (vitest/dev only) needs Node type
78
+ * stripping: `--experimental-strip-types` exists since 22.6 — on older Node
79
+ * the child would die at startup with an opaque ERR_UNKNOWN_FILE_EXTENSION /
80
+ * bad-option crash, so fail HERE with an actionable message instead. The
81
+ * ExperimentalWarning is silenced because the child's stderr is the dmcc log
82
+ * transport. On ≥22.18 (stripping on by default) the flag is still accepted.
83
+ */
84
+ export declare function workerExecArgv(entry: string): string[];
32
85
  export declare function createCompileWorker(opts?: CompileWorkerOptions): CompileWorker;
33
86
  //# sourceMappingURL=compile-worker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"compile-worker.d.ts","sourceRoot":"","sources":["../src/compile-worker.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAuB,MAAM,2BAA2B,CAAA;AAIvG,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,oBAAoB;IACpC,qEAAqE;IACrE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;CACxC;AAED,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,kBAAkB,CAAA;CAC3B;AAED,MAAM,WAAW,aAAa;IAC7B;;;;;OAKG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC3D;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAsCD,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CA6KlF"}
1
+ {"version":3,"file":"compile-worker.d.ts","sourceRoot":"","sources":["../src/compile-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAQ,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAA;AAM5D,OAAO,KAAK,EAAE,aAAa,EAAE,kBAAkB,EAAuB,MAAM,2BAA2B,CAAA;AAIvG,MAAM,WAAW,eAAe;IAC/B,MAAM,EAAE,QAAQ,GAAG,QAAQ,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,oBAAoB;IACpC,qEAAqE;IACrE,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACxC;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,YAAY,CAAA;CACpB;AAED,MAAM,WAAW,YAAY;IAC5B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,kBAAkB,CAAA;CAC3B;AAED,MAAM,WAAW,aAAa;IAC7B;;;;;OAKG;IACH,KAAK,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC3D;;;;;;OAMG;IACH,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAI3C;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAWtD;AAED,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,oBAAyB,GAAG,aAAa,CA8LlF"}
@@ -30,8 +30,12 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
30
  * zero-dependency, erasable-syntax-only shell by design, so Node can run it
31
31
  * with type stripping (default since 22.18; behind `--experimental-strip-types`
32
32
  * on 22.6–22.17, which `workerExecArgv` passes for a `.ts` entry).
33
+ *
34
+ * Exported (with `workerExecArgv`) as the single authority on the fork shape:
35
+ * the warm-standby manager forks the SAME entry with the SAME execArgv, so a
36
+ * spare is indistinguishable from a worker this module forks itself.
33
37
  */
34
- function resolveWorkerEntry() {
38
+ export function resolveWorkerEntry() {
35
39
  const js = path.join(__dirname, 'compile-worker-entry.js');
36
40
  if (fs.existsSync(js))
37
41
  return js;
@@ -46,7 +50,7 @@ function resolveWorkerEntry() {
46
50
  * ExperimentalWarning is silenced because the child's stderr is the dmcc log
47
51
  * transport. On ≥22.18 (stripping on by default) the flag is still accepted.
48
52
  */
49
- function workerExecArgv(entry) {
53
+ export function workerExecArgv(entry) {
50
54
  if (!entry.endsWith('.ts'))
51
55
  return [];
52
56
  const [major = 0, minor = 0] = process.versions.node.split('.').map(Number);
@@ -112,15 +116,10 @@ export function createCompileWorker(opts = {}) {
112
116
  stream.on('end', flush);
113
117
  stream.on('close', flush);
114
118
  }
115
- function spawnWorker() {
116
- const entry = resolveWorkerEntry();
117
- const worker = fork(entry, [], {
118
- // Pipe stdout/stderr back to the parent — the dmcc log transport.
119
- silent: true,
120
- // Explicit execArgv, NOT the parent's (vitest/electron loaders would
121
- // leak into a plain Node child).
122
- execArgv: workerExecArgv(entry),
123
- });
119
+ // Attach the log pipes, result routing and death handling to a worker
120
+ // process the SAME wiring whether the process was self-forked or adopted
121
+ // from a warm standby (an inline copy for one of the two would drift).
122
+ function wireWorker(worker) {
124
123
  attachLineReader(worker.stdout, 'stdout');
125
124
  attachLineReader(worker.stderr, 'stderr');
126
125
  // Shared, idempotent death handler for 'exit' / 'close' / 'error': the
@@ -177,6 +176,26 @@ export function createCompileWorker(opts = {}) {
177
176
  worker.on('error', (err) => settleDeath(`compile worker errored: ${err.message}`));
178
177
  return worker;
179
178
  }
179
+ function spawnWorker() {
180
+ const entry = resolveWorkerEntry();
181
+ return wireWorker(fork(entry, [], {
182
+ // Pipe stdout/stderr back to the parent — the dmcc log transport.
183
+ silent: true,
184
+ // Explicit execArgv, NOT the parent's (vitest/electron loaders would
185
+ // leak into a plain Node child).
186
+ execArgv: workerExecArgv(entry),
187
+ }));
188
+ }
189
+ // Adopt the warm-standby hand-off, if any, at creation time — wiring
190
+ // immediately (not at the first build) so a spare that dies in the gap is
191
+ // handled by the normal death path (child cleared → next build re-forks)
192
+ // instead of being discovered as a broken pipe mid-build. An adoptee that
193
+ // is ALREADY dead is skipped outright: its death events fired before any
194
+ // listener could attach, so wiring it would leave a permanently wedged
195
+ // child slot.
196
+ if (opts.adopt && opts.adopt.exitCode === null && opts.adopt.signalCode === null && opts.adopt.connected) {
197
+ child = wireWorker(opts.adopt);
198
+ }
180
199
  function runBuild(req) {
181
200
  if (closed) {
182
201
  return Promise.reject(new Error('compile worker is closed'));
package/dist/index.d.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  import type { CompileLogEntry } from './compile-worker.js';
2
+ import type { CompileWorkerStandby, CompileWorkerStandbyOptions } from './compile-worker-standby.js';
2
3
  export { createRebuildScheduler } from './rebuild-scheduler.js';
3
4
  export type { RebuildScheduler } from './rebuild-scheduler.js';
4
5
  export { filterDmccLogLine } from './compile-log.js';
5
6
  export { createCompileWorker } from './compile-worker.js';
6
7
  export type { CompileLogEntry, CompileWorker } from './compile-worker.js';
8
+ export { createCompileWorkerStandby } from './compile-worker-standby.js';
9
+ export type { CompileWorkerStandby, CompileWorkerStandbyOptions, StandbyEvent, StandbyState, } from './compile-worker-standby.js';
10
+ /**
11
+ * Turn on the warm-standby accelerator and start warming the first spare
12
+ * immediately. Idempotent while the returned manager is live — repeat calls
13
+ * return the SAME manager (their opts are ignored). After `dispose()` (host
14
+ * shutdown, or a test tearing down) a new call builds a fresh manager. The
15
+ * caller owns disposal; a disposed manager makes every openProject fall back
16
+ * to cold forks and never spawns again.
17
+ */
18
+ export declare function enableCompileWorkerStandby(opts?: CompileWorkerStandbyOptions): CompileWorkerStandby;
7
19
  export interface AppInfo {
8
20
  appId: string;
9
21
  name: string;
@@ -41,6 +53,14 @@ export interface OpenProjectOptions {
41
53
  * worker's piped stdout/stderr, tagged with their source stream.
42
54
  */
43
55
  onLog?: (entry: CompileLogEntry) => void;
56
+ /**
57
+ * Fired when the file watcher emits `'error'` (EMFILE, permission loss, …)
58
+ * AFTER its initial scan already resolved `ready` — i.e. the session is
59
+ * live and auto-rebuild-on-save has silently stopped working. A pre-ready
60
+ * error instead rejects `openProject` itself (see `createProjectWatcher`),
61
+ * so this only ever fires once per session, at most.
62
+ */
63
+ onWatcherError?: (err: unknown) => void;
44
64
  }
45
65
  export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSession>;
46
66
  /**
@@ -49,7 +69,7 @@ export declare function openProject(opts: OpenProjectOptions): Promise<ProjectSe
49
69
  * watcher and whose `ready` resolves once the initial scan is complete (only
50
70
  * changes made after that point are guaranteed to surface).
51
71
  */
52
- export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void>): {
72
+ export declare function createProjectWatcher(projectPath: string, onChange: () => void | Promise<void>, onWatcherError?: (err: unknown) => void): {
53
73
  close: () => Promise<void>;
54
74
  ready: Promise<void>;
55
75
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAgB,eAAe,EAAiB,MAAM,qBAAqB,CAAA;AAEvF,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAyCzE,MAAM,WAAW,OAAO;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;CACxC;AAuFD,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,cAAc,CAAC,CAqInF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAClC;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CA+CtD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAgB,eAAe,EAAiB,MAAM,qBAAqB,CAAA;AAEvF,OAAO,KAAK,EAAE,oBAAoB,EAAE,2BAA2B,EAAE,MAAM,6BAA6B,CAAA;AAEpG,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAC/D,YAAY,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAC9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AACzD,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACzE,OAAO,EAAE,0BAA0B,EAAE,MAAM,6BAA6B,CAAA;AACxE,YAAY,EACX,oBAAoB,EACpB,2BAA2B,EAC3B,YAAY,EACZ,YAAY,GACZ,MAAM,6BAA6B,CAAA;AAYpC;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACzC,IAAI,CAAC,EAAE,2BAA2B,GAChC,oBAAoB,CAKtB;AAqDD,MAAM,WAAW,OAAO;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,cAAc;IAC9B,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC1B;AAED,MAAM,WAAW,kBAAkB;IAClC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oFAAoF;IACpF,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;IACtB,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC;;;;OAIG;IACH,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IACxC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAA;CACvC;AAuFD,wBAAsB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,cAAc,CAAC,CAoJnF;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CACnC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACpC,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,GACrC;IAAE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CA2DtD"}
package/dist/index.js CHANGED
@@ -8,9 +8,46 @@ import { createRequire } from 'node:module';
8
8
  import chokidar from 'chokidar';
9
9
  import { createRebuildScheduler } from './rebuild-scheduler.js';
10
10
  import { createCompileWorker } from './compile-worker.js';
11
+ import { createCompileWorkerStandby } from './compile-worker-standby.js';
11
12
  export { createRebuildScheduler } from './rebuild-scheduler.js';
12
13
  export { filterDmccLogLine } from './compile-log.js';
13
14
  export { createCompileWorker } from './compile-worker.js';
15
+ export { createCompileWorkerStandby } from './compile-worker-standby.js';
16
+ // ── warm standby (opt-in) ──────────────────────────────────────────────────
17
+ //
18
+ // One module-level spare compile worker shared by every `openProject` in this
19
+ // process. While no project is open the spare sits forked + prewarmed
20
+ // (project-agnostic — it never chdirs, see compile-worker-entry); openProject
21
+ // adopts it for the first compile and each session close refills it. Purely
22
+ // additive: hosts that never call `enableCompileWorkerStandby` get exactly the
23
+ // cold-fork behavior they had before.
24
+ let standbyManager = null;
25
+ /**
26
+ * Turn on the warm-standby accelerator and start warming the first spare
27
+ * immediately. Idempotent while the returned manager is live — repeat calls
28
+ * return the SAME manager (their opts are ignored). After `dispose()` (host
29
+ * shutdown, or a test tearing down) a new call builds a fresh manager. The
30
+ * caller owns disposal; a disposed manager makes every openProject fall back
31
+ * to cold forks and never spawns again.
32
+ */
33
+ export function enableCompileWorkerStandby(opts) {
34
+ if (standbyManager && standbyManager.state !== 'disposed')
35
+ return standbyManager;
36
+ standbyManager = createCompileWorkerStandby(opts);
37
+ standbyManager.ensure();
38
+ return standbyManager;
39
+ }
40
+ /**
41
+ * Refill the spare after a compile worker has been consumed and fully closed.
42
+ * Fire-and-forget BY DESIGN: the fork happens asynchronously inside the
43
+ * manager, so a session close never waits on (and can never be wedged by)
44
+ * standby work — the refill-on-graceful-close deadlock class stays
45
+ * structurally impossible. No-ops when the standby is off, already warming,
46
+ * or disposed.
47
+ */
48
+ function refillStandby() {
49
+ standbyManager?.ensure();
50
+ }
14
51
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
52
  const require = createRequire(import.meta.url);
16
53
  // esbuild's native binary is shipped inside node_modules, but when packaged
@@ -114,7 +151,7 @@ async function cleanupFailedOpen(watcher, compileWorker, server) {
114
151
  }
115
152
  }
116
153
  export async function openProject(opts) {
117
- const { projectPath: rawProjectPath, port = 0, sourcemap = false, fileTypes, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, onLog, } = opts;
154
+ const { projectPath: rawProjectPath, port = 0, sourcemap = false, fileTypes, simulatorDir, containerDir: overrideContainerDir, outputDir, watch = true, onRebuild, onBuildError, onLog, onWatcherError, } = opts;
118
155
  const projectPath = path.resolve(rawProjectPath);
119
156
  const buildOptions = { sourcemap, fileTypes };
120
157
  const resolvedPort = port === 0 ? await getRandomPort() : port;
@@ -125,8 +162,12 @@ export async function openProject(opts) {
125
162
  // Compilation runs in a long-lived forked worker — the worker chdirs in
126
163
  // its OWN process, so this (host) process never mutates its cwd, and dmcc
127
164
  // terminal output arrives on the worker's piped streams (delivered to
128
- // `onLog` line-by-line after `filterDmccLogLine`).
129
- const compileWorker = createCompileWorker({ onLog });
165
+ // `onLog` line-by-line after `filterDmccLogLine`). When the warm standby is
166
+ // enabled and holds a healthy spare, adopt it: the first compile then runs
167
+ // on an already-forked, toolchain-warm process. adopt() degrades to null on
168
+ // every failure path, which lands in the normal cold-fork behavior.
169
+ const adopted = standbyManager ? await standbyManager.adopt() : null;
170
+ const compileWorker = createCompileWorker({ onLog, ...(adopted ? { adopt: adopted } : {}) });
130
171
  const buildRequest = {
131
172
  projectPath,
132
173
  outputDir: resolvedOutputDir,
@@ -138,6 +179,9 @@ export async function openProject(opts) {
138
179
  }
139
180
  catch (err) {
140
181
  await compileWorker.close();
182
+ // A failed open consumed the spare too — refill (fire-and-forget) so the
183
+ // NEXT open attempt still starts warm.
184
+ refillStandby();
141
185
  throw err;
142
186
  }
143
187
  // When the compiler is racing (e.g. opening a project that was just
@@ -194,7 +238,9 @@ export async function openProject(opts) {
194
238
  // a build is in flight is never dropped: it coalesces into exactly one
195
239
  // trailing rebuild once the current run settles.
196
240
  const rebuildScheduler = createRebuildScheduler(() => runRebuild(compileWorker, buildRequest, sessionApps, () => reload, onRebuild, onBuildError));
197
- watcher = watch ? createProjectWatcher(projectPath, () => rebuildScheduler.schedule()) : null;
241
+ watcher = watch
242
+ ? createProjectWatcher(projectPath, () => rebuildScheduler.schedule(), onWatcherError)
243
+ : null;
198
244
  // Don't resolve until the watcher's initial scan is done: a save landing
199
245
  // in the gap between `openProject` resolving and chokidar going live
200
246
  // (fsevents stream not yet active on macOS) would otherwise be silently
@@ -205,6 +251,7 @@ export async function openProject(opts) {
205
251
  }
206
252
  catch (err) {
207
253
  await cleanupFailedOpen(watcher, compileWorker, server);
254
+ refillStandby();
208
255
  throw err;
209
256
  }
210
257
  // `server` is always assigned when the try block completes without throwing
@@ -218,9 +265,13 @@ export async function openProject(opts) {
218
265
  close: async () => {
219
266
  await watcher?.close();
220
267
  // Kill the long-lived compile worker and WAIT for the child to be
221
- // actually dead — only kill, never re-fork on a graceful close (a
222
- // refill here would wedge process teardown).
268
+ // actually dead — THIS worker is never re-forked on a graceful close
269
+ // (an awaited refill here would wedge process teardown).
223
270
  await compileWorker.close();
271
+ // The standby refill is different: fire-and-forget into the manager
272
+ // (nothing here waits on it), sequenced after the old worker's death
273
+ // so spare + dying worker never hold memory simultaneously.
274
+ refillStandby();
224
275
  liveServer.closeAllConnections?.();
225
276
  await new Promise(resolve => liveServer.close(() => resolve()));
226
277
  },
@@ -232,7 +283,7 @@ export async function openProject(opts) {
232
283
  * watcher and whose `ready` resolves once the initial scan is complete (only
233
284
  * changes made after that point are guaranteed to surface).
234
285
  */
235
- export function createProjectWatcher(projectPath, onChange) {
286
+ export function createProjectWatcher(projectPath, onChange, onWatcherError) {
236
287
  // Ignore dotfiles/dot-directories that live *inside* the project (.git,
237
288
  // .DS_Store, editor scratch, etc.) without nuking the whole watch when the
238
289
  // project itself sits under a dotted ancestor path. A regex like
@@ -267,12 +318,24 @@ export function createProjectWatcher(projectPath, onChange) {
267
318
  // A watcher 'error' before the initial scan completes (EMFILE, permission
268
319
  // loss, …) must REJECT `ready`: resolving only on 'ready' would leave
269
320
  // `await watcher.ready` — and the whole openProject — hung forever. The
270
- // listener also doubles as the EventEmitter unhandled-'error' guard. A
271
- // post-ready 'error' makes the reject a settled-promise no-op.
321
+ // listener also doubles as the EventEmitter unhandled-'error' guard. Once
322
+ // `ready` has settled, the reject above is a no-op — that's the live-session
323
+ // case (watcher died mid-session, not at open time) and is instead surfaced
324
+ // through `onWatcherError`, the only remaining signal that auto-rebuild has
325
+ // silently stopped.
326
+ let readySettled = false;
272
327
  const ready = new Promise((resolve, reject) => {
273
- watcher.once('ready', () => resolve());
328
+ watcher.once('ready', () => {
329
+ readySettled = true;
330
+ resolve();
331
+ });
274
332
  watcher.on('error', (err) => {
275
- reject(err instanceof Error ? err : new Error(String(err)));
333
+ if (!readySettled) {
334
+ readySettled = true;
335
+ reject(err instanceof Error ? err : new Error(String(err)));
336
+ return;
337
+ }
338
+ onWatcherError?.(err);
276
339
  });
277
340
  });
278
341
  return {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=open-project-watcher-error-callback.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-project-watcher-error-callback.test.d.ts","sourceRoot":"","sources":["../src/open-project-watcher-error-callback.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,191 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { PassThrough } from 'node:stream';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import * as devkit from './index.js';
8
+ /**
9
+ * Contract: `openProject` gains an `onWatcherError?: (err: unknown) => void`
10
+ * option. A watcher 'error' BEFORE the initial scan completes must keep
11
+ * rejecting `openProject` exactly as today (see open-project-cleanup.test.ts)
12
+ * — `onWatcherError` must NOT be called for that pre-ready path, so callers
13
+ * don't get a double signal for the same failure.
14
+ *
15
+ * A watcher 'error' AFTER 'ready' (EMFILE, permission loss mid-session, …) is
16
+ * TODAY silently dropped: `createProjectWatcher`'s `watcher.on('error', ...)`
17
+ * only rejects the (long-since-settled) `ready` promise, a no-op once ready
18
+ * has already resolved. The project keeps "running" with a dead watcher and
19
+ * nothing downstream ever finds out — a save after that point compiles
20
+ * nothing, forever, with no diagnostic. The fix: `openProject` must forward a
21
+ * post-ready watcher 'error' to `onWatcherError` so a caller (devtools'
22
+ * workspace-service) can surface "auto-rebuild stopped working" instead of
23
+ * silently going stale.
24
+ *
25
+ * Harness: verbatim copy of open-project-cleanup.test.ts's fake-fork +
26
+ * fake-watcher setup (fork/fe/chokidar all mocked so the failure point is
27
+ * injectable), plus a FakeWatcher that can fire 'error' AFTER 'ready'.
28
+ */
29
+ const mocks = vi.hoisted(() => ({
30
+ fork: vi.fn(),
31
+ feStart: vi.fn(),
32
+ watch: vi.fn(),
33
+ }));
34
+ vi.mock('node:child_process', async (importOriginal) => {
35
+ const actual = await importOriginal();
36
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
37
+ });
38
+ vi.mock('child_process', async (importOriginal) => {
39
+ const actual = await importOriginal();
40
+ return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } };
41
+ });
42
+ vi.mock('../fe/index.js', () => ({ start: mocks.feStart }));
43
+ vi.mock('chokidar', () => ({
44
+ default: { watch: mocks.watch },
45
+ watch: mocks.watch,
46
+ }));
47
+ class FakeChild extends EventEmitter {
48
+ stdout = new PassThrough();
49
+ stderr = new PassThrough();
50
+ connected = true;
51
+ pid = 4343;
52
+ send = vi.fn((msg) => {
53
+ const m = msg;
54
+ if (m && m.cmd === 'build') {
55
+ queueMicrotask(() => {
56
+ if (!this.connected)
57
+ return;
58
+ this.emit('message', {
59
+ type: 'result',
60
+ appInfo: { appId: 'watcher_err_app_001', name: 'watcher-err-app', path: String(m.projectPath ?? '') },
61
+ });
62
+ });
63
+ }
64
+ return true;
65
+ });
66
+ kill = vi.fn(() => {
67
+ this.connected = false;
68
+ queueMicrotask(() => this.emit('exit', null, 'SIGTERM'));
69
+ return true;
70
+ });
71
+ }
72
+ class FakeWatcher extends EventEmitter {
73
+ close = vi.fn(async () => { });
74
+ }
75
+ function makeFakeFe() {
76
+ const server = {
77
+ close: vi.fn((cb) => {
78
+ cb?.();
79
+ return server;
80
+ }),
81
+ closeAllConnections: vi.fn(),
82
+ };
83
+ return { server, reload: vi.fn() };
84
+ }
85
+ const children = [];
86
+ const watchers = [];
87
+ const feInstances = [];
88
+ const cleanupRoots = [];
89
+ beforeEach(() => {
90
+ children.length = 0;
91
+ watchers.length = 0;
92
+ feInstances.length = 0;
93
+ mocks.fork.mockReset();
94
+ mocks.fork.mockImplementation(() => {
95
+ const child = new FakeChild();
96
+ children.push(child);
97
+ return child;
98
+ });
99
+ mocks.feStart.mockReset();
100
+ mocks.feStart.mockImplementation(async () => {
101
+ const fe = makeFakeFe();
102
+ feInstances.push(fe);
103
+ return fe;
104
+ });
105
+ mocks.watch.mockReset();
106
+ });
107
+ afterEach(() => {
108
+ for (const root of cleanupRoots.splice(0)) {
109
+ fs.rmSync(root, { recursive: true, force: true });
110
+ }
111
+ });
112
+ function makeFixture() {
113
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-watcher-err-'));
114
+ cleanupRoots.push(root);
115
+ fs.writeFileSync(path.join(root, 'project.config.json'), JSON.stringify({ appid: 'watcher_err_app_001', projectname: 'watcher-err-app' }));
116
+ return root;
117
+ }
118
+ function sleep(ms) {
119
+ return new Promise(resolve => setTimeout(resolve, ms));
120
+ }
121
+ describe('openProject onWatcherError — post-ready watcher errors are surfaced, pre-ready path is unchanged', () => {
122
+ it('does NOT call onWatcherError for a watcher error BEFORE ready (openProject still rejects, same as today)', async () => {
123
+ const root = makeFixture();
124
+ mocks.watch.mockImplementation(() => {
125
+ const watcher = new FakeWatcher();
126
+ watchers.push(watcher);
127
+ setTimeout(() => {
128
+ try {
129
+ watcher.emit('error', new Error('EMFILE: too many open files, watch'));
130
+ }
131
+ catch {
132
+ // no 'error' listener path — matches open-project-cleanup.test.ts
133
+ }
134
+ }, 10);
135
+ return watcher;
136
+ });
137
+ const onWatcherError = vi.fn();
138
+ await expect(devkit.openProject({
139
+ projectPath: root,
140
+ watch: true,
141
+ outputDir: path.join(root, '.out'),
142
+ onWatcherError,
143
+ })).rejects.toThrow();
144
+ expect(onWatcherError, 'a pre-ready watcher error must keep rejecting openProject WITHOUT also invoking onWatcherError — '
145
+ + 'the caller already gets the failure via the rejected promise').not.toHaveBeenCalled();
146
+ }, 15_000);
147
+ it('calls onWatcherError with the error when the watcher fails AFTER ready — openProject has already resolved by then', async () => {
148
+ const root = makeFixture();
149
+ let capturedWatcher = null;
150
+ mocks.watch.mockImplementation(() => {
151
+ const watcher = new FakeWatcher();
152
+ watchers.push(watcher);
153
+ capturedWatcher = watcher;
154
+ queueMicrotask(() => watcher.emit('ready'));
155
+ return watcher;
156
+ });
157
+ const onWatcherError = vi.fn();
158
+ const session = await devkit.openProject({
159
+ projectPath: root,
160
+ watch: true,
161
+ outputDir: path.join(root, '.out'),
162
+ onWatcherError,
163
+ });
164
+ const postReadyError = new Error('EMFILE: too many open files, watch (post-ready)');
165
+ capturedWatcher.emit('error', postReadyError);
166
+ await sleep(20);
167
+ expect(onWatcherError, 'today nothing listens for a post-ready watcher error beyond the already-settled ready promise — '
168
+ + 'the project silently stops auto-rebuilding forever with no signal').toHaveBeenCalledWith(postReadyError);
169
+ await session.close();
170
+ }, 15_000);
171
+ it('a post-ready watcher error does not crash the process (no unhandled "error" event) even without onWatcherError supplied', async () => {
172
+ const root = makeFixture();
173
+ let capturedWatcher = null;
174
+ mocks.watch.mockImplementation(() => {
175
+ const watcher = new FakeWatcher();
176
+ watchers.push(watcher);
177
+ capturedWatcher = watcher;
178
+ queueMicrotask(() => watcher.emit('ready'));
179
+ return watcher;
180
+ });
181
+ const session = await devkit.openProject({
182
+ projectPath: root,
183
+ watch: true,
184
+ outputDir: path.join(root, '.out'),
185
+ });
186
+ expect(() => {
187
+ capturedWatcher.emit('error', new Error('EMFILE (no onWatcherError supplied)'));
188
+ }).not.toThrow();
189
+ await session.close();
190
+ }, 15_000);
191
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/devkit",
3
- "version": "0.1.2-dev.20260703051110",
3
+ "version": "0.1.2-dev.20260706064107",
4
4
  "description": "Development toolkit for Dimina mini-apps with H5 container preview",
5
5
  "keywords": [
6
6
  "dimina",
@@ -62,7 +62,7 @@
62
62
  "cors": "^2.8.6",
63
63
  "express": "^5.2.1",
64
64
  "open": "^11.0.0",
65
- "@dimina-kit/compiler": "0.0.1-dev.20260703051110"
65
+ "@dimina-kit/compiler": "0.0.1-dev.20260706064107"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"
@@ -71,7 +71,7 @@
71
71
  "build": "tsc",
72
72
  "check-types": "tsc --noEmit",
73
73
  "lint": "eslint . --max-warnings 0",
74
- "test": "vitest run",
74
+ "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
75
75
  "test:dev": "vitest",
76
76
  "test:coverage": "vitest run --coverage"
77
77
  }