@defold-typescript/cli 0.1.0

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.
@@ -0,0 +1,224 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import * as path from "node:path";
11
+ import {
12
+ loadApiTargetsRegistry,
13
+ type RegistryTarget,
14
+ resolveTypesPackageRoot,
15
+ } from "./api-registry";
16
+ import type { SelectedApiSurface } from "./api-surface";
17
+ import { excludedModulesForKind, type ScriptKind } from "./script-kind";
18
+
19
+ const MATERIALIZED_ROOT = ".defold-types";
20
+
21
+ export interface MaterializeApiSurfaceOptions {
22
+ readonly cwd: string;
23
+ readonly surface: SelectedApiSurface;
24
+ readonly sourceGeneratedDir: string | null;
25
+ readonly scriptKind?: ScriptKind | null;
26
+ }
27
+
28
+ export interface MaterializeApiSurfaceResult {
29
+ readonly materializedDir: string | null;
30
+ readonly active: string | null;
31
+ }
32
+
33
+ function writeJson(filePath: string, value: unknown): void {
34
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
35
+ }
36
+
37
+ function listDts(dir: string): string[] {
38
+ return readdirSync(dir)
39
+ .filter((file) => file.endsWith(".d.ts"))
40
+ .sort();
41
+ }
42
+
43
+ export function materializeApiSurface(
44
+ opts: MaterializeApiSurfaceOptions,
45
+ ): MaterializeApiSurfaceResult {
46
+ const { cwd, surface, sourceGeneratedDir } = opts;
47
+ if (!surface.available || surface.surfaceId === null || sourceGeneratedDir === null) {
48
+ return { materializedDir: null, active: null };
49
+ }
50
+
51
+ const { surfaceId } = surface;
52
+ const relDir = path.posix.join(MATERIALIZED_ROOT, surfaceId);
53
+ const absDir = path.join(cwd, MATERIALIZED_ROOT, surfaceId);
54
+ mkdirSync(absDir, { recursive: true });
55
+
56
+ const excluded = excludedModulesForKind(opts.scriptKind ?? null);
57
+ const sources = listDts(sourceGeneratedDir)
58
+ .filter((file) => file !== "index.d.ts")
59
+ .filter((file) => !excluded.has(file.replace(/\.d\.ts$/, "")));
60
+
61
+ // The `*-overloads` augmentations and the `core-types` they import live in the
62
+ // types package `src/` (sibling of `generated/`), not among the generated
63
+ // module surfaces. Mirror the per-kind entrypoints, which import them so that
64
+ // `msg.post`, `go.get`/`set`, etc. resolve; without this the materialized
65
+ // surface silently drops those globals. Skipped when the source has no
66
+ // sibling `src/` (e.g. synthetic test fixtures).
67
+ const srcDir = path.resolve(sourceGeneratedDir, "..", "src");
68
+ const overloads = ["msg-overloads.d.ts", "go-overloads.d.ts"].filter((file) =>
69
+ existsSync(path.join(srcDir, file)),
70
+ );
71
+ const coreTypesSrc = path.join(srcDir, "core-types.ts");
72
+ const includeCoreTypes = overloads.length > 0 && existsSync(coreTypesSrc);
73
+
74
+ const wanted = new Set(sources);
75
+ for (const file of overloads) {
76
+ wanted.add(file);
77
+ }
78
+ if (includeCoreTypes) {
79
+ wanted.add("core-types.d.ts");
80
+ }
81
+
82
+ for (const existing of readdirSync(absDir)) {
83
+ if (existing.endsWith(".d.ts") && existing !== "index.d.ts" && !wanted.has(existing)) {
84
+ rmSync(path.join(absDir, existing));
85
+ }
86
+ }
87
+
88
+ for (const file of sources) {
89
+ writeFileSync(
90
+ path.join(absDir, file),
91
+ readFileSync(path.join(sourceGeneratedDir, file), "utf8"),
92
+ );
93
+ }
94
+ if (includeCoreTypes) {
95
+ writeFileSync(path.join(absDir, "core-types.d.ts"), readFileSync(coreTypesSrc, "utf8"));
96
+ }
97
+ for (const file of overloads) {
98
+ writeFileSync(path.join(absDir, file), readFileSync(path.join(srcDir, file), "utf8"));
99
+ }
100
+
101
+ const modules = [...sources, ...overloads].map((file) => file.replace(/\.d\.ts$/, ""));
102
+ const imports = modules.map((mod) => `import "./${mod}";`).join("\n");
103
+ writeFileSync(path.join(absDir, "index.d.ts"), `${imports}\n\nexport {};\n`);
104
+
105
+ writeJson(path.join(absDir, "package.json"), {
106
+ name: `@defold-typescript/materialized-${surfaceId}`,
107
+ types: "index.d.ts",
108
+ });
109
+
110
+ return { materializedDir: relDir, active: surfaceId };
111
+ }
112
+
113
+ function ensureGitignoreLine(cwd: string, line: string): void {
114
+ const gitignorePath = path.join(cwd, ".gitignore");
115
+ if (!existsSync(gitignorePath)) {
116
+ writeFileSync(gitignorePath, `${line}\n`);
117
+ return;
118
+ }
119
+ const existing = readFileSync(gitignorePath, "utf8");
120
+ const present = new Set(existing.split("\n").map((entry) => entry.trim()));
121
+ if (present.has(line)) {
122
+ return;
123
+ }
124
+ const prefix = existing.endsWith("\n") || existing === "" ? "" : "\n";
125
+ writeFileSync(gitignorePath, `${existing}${prefix}${line}\n`);
126
+ }
127
+
128
+ export function ensureMaterializedReference(cwd: string, materializedDir: string | null): void {
129
+ if (materializedDir === null) {
130
+ return;
131
+ }
132
+ const surfaceId = path.posix.basename(materializedDir);
133
+
134
+ const tsconfigPath = path.join(cwd, "tsconfig.json");
135
+ if (existsSync(tsconfigPath)) {
136
+ const tsconfig = JSON.parse(readFileSync(tsconfigPath, "utf8")) as {
137
+ compilerOptions?: Record<string, unknown>;
138
+ [key: string]: unknown;
139
+ };
140
+ tsconfig.compilerOptions = {
141
+ ...(tsconfig.compilerOptions ?? {}),
142
+ typeRoots: [MATERIALIZED_ROOT],
143
+ types: [surfaceId],
144
+ };
145
+ writeJson(tsconfigPath, tsconfig);
146
+ }
147
+
148
+ ensureGitignoreLine(cwd, `${MATERIALIZED_ROOT}/`);
149
+ }
150
+
151
+ export function resolveCurrentSurfaceGeneratedDir(): string | null {
152
+ const root = resolveTypesPackageRoot();
153
+ return root === null ? null : path.join(root, "generated");
154
+ }
155
+
156
+ export interface RefDocResolveOptions {
157
+ readonly cacheDir?: string;
158
+ readonly download?: (version: string) => Promise<Uint8Array>;
159
+ readonly readZip?: (zipPath: string) => unknown;
160
+ }
161
+
162
+ interface MaterializeVersionedSurfaceModule {
163
+ readonly materializeVersionedSurface: (
164
+ target: unknown,
165
+ opts: {
166
+ destDir: string;
167
+ resolveOpts?: RefDocResolveOptions;
168
+ excludeModules?: readonly string[];
169
+ },
170
+ ) => Promise<void>;
171
+ }
172
+
173
+ export interface MaterializeRefDocSurfaceOptions {
174
+ readonly cwd: string;
175
+ readonly surfaceId: string;
176
+ readonly resolveOpts?: RefDocResolveOptions;
177
+ readonly scriptKind?: ScriptKind | null;
178
+ // Registry override (defaults to the installed types package's
179
+ // api-targets.json). Injected only by tests that need a multi-module ref-doc
180
+ // target to observe kind narrowing.
181
+ readonly registry?: readonly RegistryTarget[];
182
+ }
183
+
184
+ // Generate a pinned, ref-doc-sourced surface on the fly into the project's
185
+ // `.defold-types/<id>/`. The generator (`materializeVersionedSurface`) ships in
186
+ // the `@defold-typescript/types` tarball; it is imported by resolved path so the
187
+ // `current` build path never pulls in its fixture-reading module side effects.
188
+ // The faux package is made self-contained by emitting core-type imports as a
189
+ // sibling `./core-types` and copying `core-types.d.ts` in, so the surface
190
+ // resolves from a real `.defold-types/<id>/` regardless of dest depth.
191
+ export async function materializeRefDocSurface(
192
+ opts: MaterializeRefDocSurfaceOptions,
193
+ ): Promise<MaterializeApiSurfaceResult> {
194
+ const { cwd, surfaceId, resolveOpts } = opts;
195
+ const root = resolveTypesPackageRoot();
196
+ if (root === null) {
197
+ return { materializedDir: null, active: null };
198
+ }
199
+ const registry = opts.registry ?? loadApiTargetsRegistry();
200
+ const target = registry.find((t) => t.id === surfaceId);
201
+ if (!target || target.source?.kind !== "ref-doc") {
202
+ return { materializedDir: null, active: null };
203
+ }
204
+ const excludeModules = [...excludedModulesForKind(opts.scriptKind ?? null)];
205
+
206
+ const relDir = path.posix.join(MATERIALIZED_ROOT, surfaceId);
207
+ const absDir = path.join(cwd, MATERIALIZED_ROOT, surfaceId);
208
+ try {
209
+ const mod = (await import(
210
+ path.join(root, "scripts", "materialize-version.ts")
211
+ )) as MaterializeVersionedSurfaceModule;
212
+ const selfContained = { ...target, coreTypesImport: "./core-types" };
213
+ await mod.materializeVersionedSurface(selfContained, {
214
+ destDir: absDir,
215
+ ...(resolveOpts ? { resolveOpts } : {}),
216
+ ...(excludeModules.length > 0 ? { excludeModules } : {}),
217
+ });
218
+ copyFileSync(path.join(root, "src", "core-types.ts"), path.join(absDir, "core-types.d.ts"));
219
+ } catch {
220
+ rmSync(absDir, { recursive: true, force: true });
221
+ return { materializedDir: null, active: null };
222
+ }
223
+ return { materializedDir: relDir, active: surfaceId };
224
+ }
@@ -0,0 +1,92 @@
1
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { RegistryTarget } from "./api-registry";
5
+
6
+ const LABEL_FIXTURE = path.resolve(import.meta.dir, "../../types/fixtures/label_doc.json");
7
+ const TYPES_FIXTURES = path.resolve(import.meta.dir, "../../types/fixtures");
8
+
9
+ interface FakeZip {
10
+ has: (entry: string) => boolean;
11
+ read: (entry: string) => string;
12
+ }
13
+
14
+ // namespace -> ref-doc.zip entry for the multi-kind fixture. Mirrors the
15
+ // types-package SYNC_MANIFEST so the fake zip serves docs under the same keys
16
+ // `resolveTargetModules` reads.
17
+ const MULTI_KIND_ZIP_ENTRIES: Record<string, string> = {
18
+ gui: "doc/gui_script.cpp_doc.json",
19
+ render: "doc/render-render_script.cpp_doc.json",
20
+ sprite: "doc/scripts-script_sprite.cpp_doc.json",
21
+ };
22
+
23
+ export const noDownload = async (): Promise<Uint8Array> => {
24
+ throw new Error("download should not be called");
25
+ };
26
+
27
+ // Offline ref-doc resolve options for the `defold-1.9.8` (label-only) target: a
28
+ // seeded cache pointing at an in-memory zip that serves the committed label
29
+ // fixture. Only `label` is resolved, so the fake zip ignores the entry key.
30
+ export function labelRefDocResolveOpts(): {
31
+ cacheDir: string;
32
+ readZip: () => FakeZip;
33
+ download: typeof noDownload;
34
+ } {
35
+ const version = "1.9.8";
36
+ const json = readFileSync(LABEL_FIXTURE, "utf8");
37
+ const fakeZip: FakeZip = {
38
+ has: () => true,
39
+ read: () => json,
40
+ };
41
+ const cacheDir = mkdtempSync(path.join(os.tmpdir(), "defold-typescript-ref-doc-"));
42
+ mkdirSync(path.join(cacheDir, version), { recursive: true });
43
+ writeFileSync(path.join(cacheDir, version, "ref-doc.zip"), "seeded");
44
+ return { cacheDir, readZip: () => fakeZip, download: noDownload };
45
+ }
46
+
47
+ // Offline ref-doc resolve options for a multi-namespace target serving a
48
+ // restricted-pair (`gui`, `render`) plus a universal module (`sprite`), so a
49
+ // kind-narrowed materialization can be observed (the forbidden namespace's
50
+ // `.d.ts` drops out while the universal one stays).
51
+ export function multiKindRefDocResolveOpts(): {
52
+ cacheDir: string;
53
+ readZip: () => FakeZip;
54
+ download: typeof noDownload;
55
+ } {
56
+ const version = "1.9.8";
57
+ const docs: Record<string, string> = {};
58
+ for (const [namespace, zipEntry] of Object.entries(MULTI_KIND_ZIP_ENTRIES)) {
59
+ docs[zipEntry] = readFileSync(path.join(TYPES_FIXTURES, `${namespace}_doc.json`), "utf8");
60
+ }
61
+ const fakeZip: FakeZip = {
62
+ has: (entry) => entry in docs,
63
+ read: (entry) => {
64
+ const doc = docs[entry];
65
+ if (doc === undefined) throw new Error(`unexpected zip entry ${entry}`);
66
+ return doc;
67
+ },
68
+ };
69
+ const cacheDir = mkdtempSync(path.join(os.tmpdir(), "defold-typescript-ref-doc-"));
70
+ mkdirSync(path.join(cacheDir, version), { recursive: true });
71
+ writeFileSync(path.join(cacheDir, version, "ref-doc.zip"), "seeded");
72
+ return { cacheDir, readZip: () => fakeZip, download: noDownload };
73
+ }
74
+
75
+ // A registry-shaped `defold-1.9.8` ref-doc target carrying the multi-kind
76
+ // module trio, for injecting into `materializeRefDocSurface`/`dispatch` so the
77
+ // real (label-only) registry entry need not be mutated to prove narrowing.
78
+ export function multiKindRefDocTarget(): RegistryTarget {
79
+ return {
80
+ id: "defold-1.9.8",
81
+ default: false,
82
+ fixturesDir: "fixtures",
83
+ generatedDir: "generated",
84
+ coreTypesImport: "../src/core-types",
85
+ source: { kind: "ref-doc", version: "1.9.8" },
86
+ modules: [
87
+ { namespace: "gui", fixture: "gui_doc.json", outFile: "gui.d.ts" },
88
+ { namespace: "render", fixture: "render_doc.json", outFile: "render.d.ts" },
89
+ { namespace: "sprite", fixture: "sprite_doc.json", outFile: "sprite.d.ts" },
90
+ ],
91
+ };
92
+ }
package/src/scan.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { globSync, statSync } from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ // `Bun.Glob` is undefined when the published bin runs under plain node, so the
5
+ // scaffold/build path must use the cross-runtime `node:fs` glob instead. Bun's
6
+ // `globSync` does not support `withFileTypes`, so directories are filtered out
7
+ // with a stat to preserve the original `onlyFiles` contract.
8
+ export function scanFilesSync(cwd: string, pattern: string): string[] {
9
+ return globSync(pattern, { cwd }).filter((rel) => statSync(path.join(cwd, rel)).isFile());
10
+ }
@@ -0,0 +1,68 @@
1
+ import { scanFilesSync } from "./scan";
2
+
3
+ export type ScriptKind = "script" | "gui-script" | "render-script";
4
+
5
+ export const DEFAULT_TYPES_ENTRYPOINT = "@defold-typescript/types";
6
+
7
+ const KIND_BY_EXT: Record<string, ScriptKind> = {
8
+ ".script": "script",
9
+ ".gui_script": "gui-script",
10
+ ".render_script": "render-script",
11
+ };
12
+
13
+ const SKIP_SEGMENTS = new Set(["node_modules", ".defold-types", "build"]);
14
+
15
+ export function isSkipped(relPath: string): boolean {
16
+ return relPath.split(/[/\\]/).some((segment) => SKIP_SEGMENTS.has(segment));
17
+ }
18
+
19
+ export function isComponentPath(relPath: string): boolean {
20
+ return Object.keys(KIND_BY_EXT).some((ext) => relPath.endsWith(ext));
21
+ }
22
+
23
+ export function detectScriptKinds(cwd: string): Set<ScriptKind> {
24
+ const kinds = new Set<ScriptKind>();
25
+ for (const [ext, kind] of Object.entries(KIND_BY_EXT)) {
26
+ for (const match of scanFilesSync(cwd, `**/*${ext}`)) {
27
+ if (!isSkipped(match)) {
28
+ kinds.add(kind);
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ return kinds;
34
+ }
35
+
36
+ export function selectScriptKind(kinds: Set<ScriptKind>): ScriptKind | null {
37
+ if (kinds.size !== 1) {
38
+ return null;
39
+ }
40
+ for (const kind of kinds) {
41
+ return kind;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export function selectScriptKindEntrypoint(kinds: Set<ScriptKind>): string {
47
+ const kind = selectScriptKind(kinds);
48
+ return kind === null ? DEFAULT_TYPES_ENTRYPOINT : `${DEFAULT_TYPES_ENTRYPOINT}/${kind}`;
49
+ }
50
+
51
+ // The one restricted namespace each kind allows; mirrors `regen.ts`'s
52
+ // RESTRICTED_NAMESPACES (gui -> gui_script, render -> render_script). `script`
53
+ // allows neither.
54
+ const RESTRICTED_MODULES: Record<ScriptKind, string> = {
55
+ script: "",
56
+ "gui-script": "gui",
57
+ "render-script": "render",
58
+ };
59
+
60
+ const ALL_RESTRICTED_MODULES: readonly string[] = ["gui", "render"];
61
+
62
+ export function excludedModulesForKind(kind: ScriptKind | null): Set<string> {
63
+ if (kind === null) {
64
+ return new Set();
65
+ }
66
+ const allowed = RESTRICTED_MODULES[kind];
67
+ return new Set(ALL_RESTRICTED_MODULES.filter((mod) => mod !== allowed));
68
+ }
package/src/watch.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { existsSync, watch as fsWatch } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { toPosix } from "./build-output";
4
+ import { type BuildSession, createBuildSession } from "./build-session";
5
+ import { isComponentPath, isSkipped } from "./script-kind";
6
+
7
+ export interface WatchEvent {
8
+ readonly kind: "change" | "rename";
9
+ readonly path: string;
10
+ }
11
+
12
+ export interface Watcher {
13
+ close(): void;
14
+ }
15
+
16
+ export type WatcherFactory = (srcDir: string, onEvent: (e: WatchEvent) => void) => Watcher;
17
+
18
+ export interface RunWatchOptions {
19
+ readonly cwd: string;
20
+ readonly stdout: NodeJS.WritableStream;
21
+ readonly stderr: NodeJS.WritableStream;
22
+ readonly debounceMs?: number;
23
+ readonly watcherFactory?: WatcherFactory;
24
+ readonly syncSurface?: () => void;
25
+ readonly componentWatcherFactory?: WatcherFactory;
26
+ }
27
+
28
+ export interface RunWatchHandle {
29
+ readonly stop: () => void;
30
+ readonly done: Promise<number>;
31
+ readonly waitForIdle: () => Promise<void>;
32
+ }
33
+
34
+ const DEFAULT_DEBOUNCE_MS = 50;
35
+
36
+ export const recursiveWatcherFactory: WatcherFactory = (srcDir, onEvent) => {
37
+ const w = fsWatch(srcDir, { recursive: true }, (eventType, filename) => {
38
+ onEvent({
39
+ kind: eventType === "rename" ? "rename" : "change",
40
+ path: filename ?? "",
41
+ });
42
+ });
43
+ return { close: () => w.close() };
44
+ };
45
+
46
+ function formatBuildLine(written: readonly string[]): string {
47
+ return `defold-typescript build: wrote ${written.length} files: ${written.join(", ")}\n`;
48
+ }
49
+
50
+ function rewrapInitError(err: unknown): Error {
51
+ const message = err instanceof Error ? err.message : String(err);
52
+ return new Error(message.replace(/^defold-typescript build:/, "defold-typescript watch:"));
53
+ }
54
+
55
+ export function runWatch(opts: RunWatchOptions): RunWatchHandle {
56
+ const { cwd, stdout, stderr } = opts;
57
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
58
+ const factory = opts.watcherFactory ?? recursiveWatcherFactory;
59
+
60
+ let resolveDone!: (code: number) => void;
61
+ let rejectDone!: (err: Error) => void;
62
+ const done = new Promise<number>((res, rej) => {
63
+ resolveDone = res;
64
+ rejectDone = rej;
65
+ });
66
+
67
+ let session: BuildSession;
68
+ try {
69
+ opts.syncSurface?.();
70
+ session = createBuildSession({ cwd });
71
+ const { written } = session.buildAll();
72
+ stdout.write(formatBuildLine(written));
73
+ } catch (err) {
74
+ rejectDone(rewrapInitError(err));
75
+ return {
76
+ stop: () => {},
77
+ done,
78
+ waitForIdle: () => Promise.resolve(),
79
+ };
80
+ }
81
+
82
+ const srcDir = path.join(cwd, "src");
83
+
84
+ let scheduled: ReturnType<typeof setTimeout> | null = null;
85
+ let syncScheduled: ReturnType<typeof setTimeout> | null = null;
86
+ let rebuildBusy = false;
87
+ let syncBusy = false;
88
+ let stopped = false;
89
+ let idleResolvers: Array<() => void> = [];
90
+ const pending = new Set<string>();
91
+
92
+ function notifyIdle(): void {
93
+ if (rebuildBusy || syncBusy) return;
94
+ const resolvers = idleResolvers;
95
+ idleResolvers = [];
96
+ for (const resolve of resolvers) resolve();
97
+ }
98
+
99
+ function rebuild(): void {
100
+ scheduled = null;
101
+ const drained = [...pending];
102
+ pending.clear();
103
+ const changed: string[] = [];
104
+ const removed: string[] = [];
105
+ for (const rel of drained) {
106
+ const key = `src/${toPosix(rel)}`;
107
+ if (existsSync(path.join(srcDir, rel))) {
108
+ changed.push(key);
109
+ } else {
110
+ removed.push(key);
111
+ }
112
+ }
113
+ try {
114
+ const { written } = session.applyEvents(changed, removed);
115
+ stdout.write(formatBuildLine(written));
116
+ } catch (err) {
117
+ const message = err instanceof Error ? err.message : String(err);
118
+ stderr.write(`${message}\n`);
119
+ }
120
+ rebuildBusy = false;
121
+ notifyIdle();
122
+ }
123
+
124
+ function onEvent(e: WatchEvent): void {
125
+ if (stopped) return;
126
+ rebuildBusy = true;
127
+ if (e.path) pending.add(e.path);
128
+ if (scheduled) clearTimeout(scheduled);
129
+ scheduled = setTimeout(rebuild, debounceMs);
130
+ }
131
+
132
+ const watcher = factory(srcDir, onEvent);
133
+
134
+ function runSync(): void {
135
+ syncScheduled = null;
136
+ try {
137
+ opts.syncSurface?.();
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ stderr.write(`${message}\n`);
141
+ }
142
+ syncBusy = false;
143
+ notifyIdle();
144
+ }
145
+
146
+ function onComponentEvent(e: WatchEvent): void {
147
+ if (stopped) return;
148
+ if (!e.path || isSkipped(e.path) || !isComponentPath(e.path)) return;
149
+ syncBusy = true;
150
+ if (syncScheduled) clearTimeout(syncScheduled);
151
+ syncScheduled = setTimeout(runSync, debounceMs);
152
+ }
153
+
154
+ const componentWatcher = opts.componentWatcherFactory
155
+ ? opts.componentWatcherFactory(cwd, onComponentEvent)
156
+ : null;
157
+
158
+ function stop(): void {
159
+ if (stopped) return;
160
+ stopped = true;
161
+ if (scheduled) {
162
+ clearTimeout(scheduled);
163
+ scheduled = null;
164
+ }
165
+ if (syncScheduled) {
166
+ clearTimeout(syncScheduled);
167
+ syncScheduled = null;
168
+ }
169
+ watcher.close();
170
+ componentWatcher?.close();
171
+ rebuildBusy = false;
172
+ syncBusy = false;
173
+ notifyIdle();
174
+ resolveDone(0);
175
+ }
176
+
177
+ function waitForIdle(): Promise<void> {
178
+ if (!rebuildBusy && !syncBusy) return Promise.resolve();
179
+ return new Promise<void>((resolve) => {
180
+ idleResolvers.push(resolve);
181
+ });
182
+ }
183
+
184
+ return { stop, done, waitForIdle };
185
+ }