@ecopages/core 0.2.0-alpha.3 → 0.2.0-alpha.5

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 (25) hide show
  1. package/CHANGELOG.md +5 -2
  2. package/package.json +2 -2
  3. package/src/adapters/bun/hmr-manager.d.ts +0 -6
  4. package/src/adapters/bun/hmr-manager.js +1 -19
  5. package/src/adapters/bun/hmr-manager.ts +1 -21
  6. package/src/adapters/node/node-hmr-manager.d.ts +0 -1
  7. package/src/adapters/node/node-hmr-manager.js +1 -17
  8. package/src/adapters/node/node-hmr-manager.ts +1 -19
  9. package/src/build/build-adapter.d.ts +1 -0
  10. package/src/build/build-adapter.ts +1 -0
  11. package/src/build/esbuild-build-adapter.js +1 -0
  12. package/src/build/esbuild-build-adapter.ts +1 -0
  13. package/src/hmr/strategies/js-hmr-strategy.d.ts +6 -6
  14. package/src/hmr/strategies/js-hmr-strategy.js +41 -37
  15. package/src/hmr/strategies/js-hmr-strategy.ts +49 -42
  16. package/src/public-types.d.ts +0 -4
  17. package/src/public-types.ts +0 -5
  18. package/src/watchers/project-watcher.d.ts +31 -24
  19. package/src/watchers/project-watcher.js +70 -54
  20. package/src/watchers/project-watcher.test-helpers.js +0 -1
  21. package/src/watchers/project-watcher.test-helpers.ts +0 -1
  22. package/src/watchers/project-watcher.ts +87 -67
  23. package/src/hmr/client/__screenshots__/hmr-runtime.test.browser.ts/HMR-Runtime-HMR-Server-Integration-should-have-HMR-script-injected-in-page-1.png +0 -0
  24. package/src/hmr/client/__screenshots__/hmr-runtime.test.browser.ts/HMR-Runtime-HMR-Server-Integration-should-load-fixture-app-page-1.png +0 -0
  25. package/src/hmr/client/__screenshots__/hmr-runtime.test.browser.ts/HMR-Runtime-WebSocket-Connection-should-connect-to-correct-HMR-endpoint-1.png +0 -0
package/CHANGELOG.md CHANGED
@@ -65,8 +65,11 @@ All notable changes to `@ecopages/core` are documented here.
65
65
  - Fixed invariant checks for route paths with improved error messaging in `AbstractApplicationAdapter` (`9c2a6242`).
66
66
  - Fixed dependency import name extraction in `extractEcopagesVirtualImports` (`39bbc472`).
67
67
  - Removed an invalid npm export entry that pointed to a non-existent `utils/ecopages-url-resolver` declaration target.
68
- - Kept source module HMR active when stylesheet processors also watch TSX and JSX files for Tailwind-driven CSS rebuilds.
69
- - Triggered HMR current-page refreshes instead of fallback reload suppression for processor-watched TSX and JSX changes that are not client entrypoints.
68
+ - Fixed processor lifecycle hijacking where PostCSS watching TSX/HTML for Tailwind class scanning incorrectly prevented those files from reaching the HMR strategy pipeline.
69
+ - Unified the file watcher event pipeline: processors are now notified inside `handleFileChange` instead of binding separate chokidar listeners, eliminating double-execution and race conditions.
70
+ - Added async task queue to `ProjectWatcher` to serialize concurrent file change handling and prevent overlapping builds.
71
+ - Batched `JsHmrStrategy` entrypoint builds into a single esbuild invocation for improved AST sharing and rebuild speed.
72
+ - Added `outbase` support to `BuildOptions` for correct output directory structure with multi-entrypoint builds.
70
73
 
71
74
  ### Tests
72
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-alpha.3",
3
+ "version": "0.2.0-alpha.5",
4
4
  "description": "Core package for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/core"
18
18
  },
19
19
  "dependencies": {
20
- "@ecopages/file-system": "0.2.0-alpha.3",
20
+ "@ecopages/file-system": "0.2.0-alpha.5",
21
21
  "@ecopages/logger": "latest",
22
22
  "@ecopages/scripts-injector": "^0.1.3",
23
23
  "@worker-tools/html-rewriter": "0.1.0-pre.19",
@@ -58,12 +58,6 @@ export declare class HmrManager implements IHmrManager {
58
58
  buildRuntime(): Promise<void>;
59
59
  getRuntimePath(): string;
60
60
  broadcast(event: ClientBridgeEvent): void;
61
- canHandleFileChange(filePath: string): boolean;
62
- /**
63
- * Handles file changes using registered HMR strategies.
64
- * Strategies are evaluated in priority order until one matches.
65
- * @param filePath - Absolute path to the changed file
66
- */
67
61
  handleFileChange(filePath: string): Promise<void>;
68
62
  /**
69
63
  * Registers a client entrypoint to be built and watched by Bun.
@@ -3,7 +3,6 @@ import path from "node:path";
3
3
  import { RESOLVED_ASSETS_DIR } from "../../constants";
4
4
  import { defaultBuildAdapter } from "../../build/build-adapter.js";
5
5
  import { fileSystem } from "@ecopages/file-system";
6
- import { HmrStrategyType } from "../../hmr/hmr-strategy";
7
6
  import { DefaultHmrStrategy } from "../../hmr/strategies/default-hmr-strategy";
8
7
  import { JsHmrStrategy } from "../../hmr/strategies/js-hmr-strategy";
9
8
  import { appLogger } from "../../global/app-logger";
@@ -122,30 +121,13 @@ class HmrManager {
122
121
  );
123
122
  this.bridge.broadcast(event);
124
123
  }
125
- canHandleFileChange(filePath) {
126
- const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
127
- const strategy = sorted.find((candidate) => {
128
- try {
129
- return candidate.matches(filePath);
130
- } catch (err) {
131
- appLogger.error(`[HmrManager] Error checking match for ${candidate.constructor.name}:`, err);
132
- return false;
133
- }
134
- });
135
- return strategy !== void 0 && strategy.type !== HmrStrategyType.FALLBACK;
136
- }
137
- /**
138
- * Handles file changes using registered HMR strategies.
139
- * Strategies are evaluated in priority order until one matches.
140
- * @param filePath - Absolute path to the changed file
141
- */
142
124
  async handleFileChange(filePath) {
143
125
  const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
144
126
  const strategy = sorted.find((s) => {
145
127
  try {
146
128
  return s.matches(filePath);
147
129
  } catch (err) {
148
- appLogger.error(`[HmrManager] Error checking match for ${s.constructor.name}:`, err);
130
+ appLogger.error(err);
149
131
  return false;
150
132
  }
151
133
  });
@@ -7,7 +7,6 @@ import type { DefaultHmrContext, EcoPagesAppConfig, IHmrManager } from '../../in
7
7
  import type { EcoBuildPlugin } from '../../build/build-types.ts';
8
8
  import { fileSystem } from '@ecopages/file-system';
9
9
  import type { HmrStrategy } from '../../hmr/hmr-strategy';
10
- import { HmrStrategyType } from '../../hmr/hmr-strategy';
11
10
  import { DefaultHmrStrategy } from '../../hmr/strategies/default-hmr-strategy';
12
11
  import { JsHmrStrategy } from '../../hmr/strategies/js-hmr-strategy';
13
12
  import { appLogger } from '../../global/app-logger';
@@ -159,32 +158,13 @@ export class HmrManager implements IHmrManager {
159
158
  this.bridge.broadcast(event);
160
159
  }
161
160
 
162
- public canHandleFileChange(filePath: string): boolean {
163
- const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
164
- const strategy = sorted.find((candidate) => {
165
- try {
166
- return candidate.matches(filePath);
167
- } catch (err) {
168
- appLogger.error(`[HmrManager] Error checking match for ${candidate.constructor.name}:`, err as Error);
169
- return false;
170
- }
171
- });
172
-
173
- return strategy !== undefined && strategy.type !== HmrStrategyType.FALLBACK;
174
- }
175
-
176
- /**
177
- * Handles file changes using registered HMR strategies.
178
- * Strategies are evaluated in priority order until one matches.
179
- * @param filePath - Absolute path to the changed file
180
- */
181
161
  public async handleFileChange(filePath: string): Promise<void> {
182
162
  const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
183
163
  const strategy = sorted.find((s) => {
184
164
  try {
185
165
  return s.matches(filePath);
186
166
  } catch (err) {
187
- appLogger.error(`[HmrManager] Error checking match for ${s.constructor.name}:`, err as Error);
167
+ appLogger.error(err);
188
168
  return false;
189
169
  }
190
170
  });
@@ -41,7 +41,6 @@ export declare class NodeHmrManager implements IHmrManager {
41
41
  buildRuntime(): Promise<void>;
42
42
  getRuntimePath(): string;
43
43
  broadcast(event: ClientBridgeEvent): void;
44
- canHandleFileChange(filePath: string): boolean;
45
44
  handleFileChange(filePath: string): Promise<void>;
46
45
  getOutputUrl(entrypointPath: string): string | undefined;
47
46
  getWatchedFiles(): Map<string, string>;
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
4
4
  import { RESOLVED_ASSETS_DIR } from "../../constants.js";
5
5
  import { defaultBuildAdapter } from "../../build/build-adapter.js";
6
6
  import { fileSystem } from "@ecopages/file-system";
7
- import { HmrStrategyType } from "../../hmr/hmr-strategy.js";
8
7
  import { DefaultHmrStrategy } from "../../hmr/strategies/default-hmr-strategy.js";
9
8
  import { JsHmrStrategy } from "../../hmr/strategies/js-hmr-strategy.js";
10
9
  import { appLogger } from "../../global/app-logger.js";
@@ -95,28 +94,13 @@ class NodeHmrManager {
95
94
  );
96
95
  this.bridge.broadcast(event);
97
96
  }
98
- canHandleFileChange(filePath) {
99
- const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
100
- const strategy = sorted.find((candidate) => {
101
- try {
102
- return candidate.matches(filePath);
103
- } catch (err) {
104
- appLogger.error(
105
- `[NodeHmrManager] Error checking match for ${candidate.constructor.name}:`,
106
- err
107
- );
108
- return false;
109
- }
110
- });
111
- return strategy !== void 0 && strategy.type !== HmrStrategyType.FALLBACK;
112
- }
113
97
  async handleFileChange(filePath) {
114
98
  const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
115
99
  const strategy = sorted.find((s) => {
116
100
  try {
117
101
  return s.matches(filePath);
118
102
  } catch (err) {
119
- appLogger.error(`[NodeHmrManager] Error checking match for ${s.constructor.name}:`, err);
103
+ appLogger.error(err);
120
104
  return false;
121
105
  }
122
106
  });
@@ -7,7 +7,6 @@ import type { DefaultHmrContext, EcoPagesAppConfig, IHmrManager, IClientBridge }
7
7
  import type { EcoBuildPlugin } from '../../build/build-types.ts';
8
8
  import { fileSystem } from '@ecopages/file-system';
9
9
  import type { HmrStrategy } from '../../hmr/hmr-strategy.ts';
10
- import { HmrStrategyType } from '../../hmr/hmr-strategy.ts';
11
10
  import { DefaultHmrStrategy } from '../../hmr/strategies/default-hmr-strategy.ts';
12
11
  import { JsHmrStrategy } from '../../hmr/strategies/js-hmr-strategy.ts';
13
12
  import { appLogger } from '../../global/app-logger.ts';
@@ -122,30 +121,13 @@ export class NodeHmrManager implements IHmrManager {
122
121
  this.bridge.broadcast(event);
123
122
  }
124
123
 
125
- public canHandleFileChange(filePath: string): boolean {
126
- const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
127
- const strategy = sorted.find((candidate) => {
128
- try {
129
- return candidate.matches(filePath);
130
- } catch (err) {
131
- appLogger.error(
132
- `[NodeHmrManager] Error checking match for ${candidate.constructor.name}:`,
133
- err as Error,
134
- );
135
- return false;
136
- }
137
- });
138
-
139
- return strategy !== undefined && strategy.type !== HmrStrategyType.FALLBACK;
140
- }
141
-
142
124
  public async handleFileChange(filePath: string): Promise<void> {
143
125
  const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
144
126
  const strategy = sorted.find((s) => {
145
127
  try {
146
128
  return s.matches(filePath);
147
129
  } catch (err) {
148
- appLogger.error(`[NodeHmrManager] Error checking match for ${s.constructor.name}:`, err as Error);
130
+ appLogger.error(err);
149
131
  return false;
150
132
  }
151
133
  });
@@ -36,6 +36,7 @@ export interface BuildResult {
36
36
  export interface BuildOptions {
37
37
  entrypoints: string[];
38
38
  outdir?: string;
39
+ outbase?: string;
39
40
  naming?: string;
40
41
  minify?: boolean;
41
42
  treeshaking?: boolean;
@@ -45,6 +45,7 @@ export interface BuildResult {
45
45
  export interface BuildOptions {
46
46
  entrypoints: string[];
47
47
  outdir?: string;
48
+ outbase?: string;
48
49
  naming?: string;
49
50
  minify?: boolean;
50
51
  treeshaking?: boolean;
@@ -318,6 +318,7 @@ class EsbuildBuildAdapter {
318
318
  }
319
319
  const outputOptions = outfile ? { outfile } : {
320
320
  outdir,
321
+ ...options.outbase ? { outbase: path.resolve(options.outbase) } : {},
321
322
  entryNames: usesTemplatedNaming ? this.toEntryNamePattern(options.naming) : "[name]",
322
323
  chunkNames: "[name]-[hash]",
323
324
  assetNames: "[name]-[hash]"
@@ -433,6 +433,7 @@ export class EsbuildBuildAdapter implements BuildAdapter {
433
433
  ? { outfile }
434
434
  : {
435
435
  outdir,
436
+ ...(options.outbase ? { outbase: path.resolve(options.outbase) } : {}),
436
437
  entryNames: usesTemplatedNaming ? this.toEntryNamePattern(options.naming) : '[name]',
437
438
  chunkNames: '[name]-[hash]',
438
439
  assetNames: '[name]-[hash]',
@@ -105,17 +105,17 @@ export declare class JsHmrStrategy extends HmrStrategy {
105
105
  * @remarks
106
106
  * If runtime-specific dependency graph hooks are unavailable, this strategy
107
107
  * falls back to rebuilding all watched entrypoints.
108
+ * When multiple entrypoints are impacted they are bundled in a single esbuild
109
+ * invocation to share AST parsing and chunk deduplication.
108
110
  * @returns Action to broadcast update events
109
111
  */
110
112
  process(filePath: string): Promise<HmrAction>;
111
113
  /**
112
- * Bundles a single entrypoint and processes the output.
113
- *
114
- * @param entrypointPath - Absolute path to the source file
115
- * @param outputUrl - URL path for the bundled file
116
- * @returns True if bundling was successful
114
+ * Bundles one or more entrypoints in a single esbuild invocation.
115
+ * Uses the source directory as the output base so that the directory structure
116
+ * is preserved under the HMR dist folder.
117
117
  */
118
- private bundleEntrypoint;
118
+ private bundleEntrypoints;
119
119
  /**
120
120
  * Processes bundled output by replacing specifiers and injecting HMR code.
121
121
  *
@@ -45,6 +45,8 @@ class JsHmrStrategy extends HmrStrategy {
45
45
  * @remarks
46
46
  * If runtime-specific dependency graph hooks are unavailable, this strategy
47
47
  * falls back to rebuilding all watched entrypoints.
48
+ * When multiple entrypoints are impacted they are bundled in a single esbuild
49
+ * invocation to share AST parsing and chunk deduplication.
48
50
  * @returns Action to broadcast update events
49
51
  */
50
52
  async process(filePath) {
@@ -54,24 +56,34 @@ class JsHmrStrategy extends HmrStrategy {
54
56
  appLogger.debug(`[JsHmrStrategy] No watched files to rebuild`);
55
57
  return { type: "none" };
56
58
  }
57
- const updates = [];
58
- let reloadRequired = false;
59
59
  const dependencyHits = this.context.getDependencyEntrypoints?.(filePath) ?? /* @__PURE__ */ new Set();
60
60
  const hasDependencyHit = dependencyHits.size > 0;
61
61
  const impactedEntrypoints = hasDependencyHit ? Array.from(dependencyHits).filter((entrypoint) => watchedFiles.has(entrypoint)) : Array.from(watchedFiles.keys());
62
62
  if (!hasDependencyHit) {
63
63
  appLogger.debug("[JsHmrStrategy] Dependency graph miss, rebuilding all watched entrypoints");
64
64
  }
65
+ if (impactedEntrypoints.length === 0) {
66
+ return { type: "none" };
67
+ }
68
+ const buildResult = await this.bundleEntrypoints(impactedEntrypoints);
69
+ if (!buildResult.success) {
70
+ return { type: "none" };
71
+ }
72
+ const updates = [];
73
+ let reloadRequired = false;
65
74
  for (const entrypoint of impactedEntrypoints) {
66
75
  const outputUrl = watchedFiles.get(entrypoint);
67
- if (!outputUrl) {
68
- continue;
76
+ if (!outputUrl) continue;
77
+ if (buildResult.dependencies && this.context.setEntrypointDependencies) {
78
+ const entrypointDeps = buildResult.dependencies.get(path.resolve(entrypoint)) ?? [];
79
+ this.context.setEntrypointDependencies(entrypoint, entrypointDeps);
69
80
  }
70
- const result = await this.bundleEntrypoint(entrypoint, outputUrl);
81
+ const srcDir = this.context.getSrcDir();
82
+ const relativePath = path.relative(srcDir, entrypoint);
83
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?)$/, ".js");
84
+ const outputPath = path.join(this.context.getDistDir(), relativePathJs);
85
+ const result = await this.processOutput(outputPath, outputUrl);
71
86
  if (result.success) {
72
- if (result.dependencies && this.context.setEntrypointDependencies) {
73
- this.context.setEntrypointDependencies(entrypoint, result.dependencies);
74
- }
75
87
  updates.push(outputUrl);
76
88
  if (result.requiresReload) {
77
89
  reloadRequired = true;
@@ -83,18 +95,14 @@ class JsHmrStrategy extends HmrStrategy {
83
95
  appLogger.debug(`[JsHmrStrategy] Full reload required (no HMR accept found)`);
84
96
  return {
85
97
  type: "broadcast",
86
- events: [
87
- {
88
- type: "reload"
89
- }
90
- ]
98
+ events: [{ type: "reload" }]
91
99
  };
92
100
  }
93
101
  return {
94
102
  type: "broadcast",
95
- events: updates.map((path2) => ({
103
+ events: updates.map((p) => ({
96
104
  type: "update",
97
- path: path2,
105
+ path: p,
98
106
  timestamp: Date.now()
99
107
  }))
100
108
  };
@@ -102,40 +110,36 @@ class JsHmrStrategy extends HmrStrategy {
102
110
  return { type: "none" };
103
111
  }
104
112
  /**
105
- * Bundles a single entrypoint and processes the output.
106
- *
107
- * @param entrypointPath - Absolute path to the source file
108
- * @param outputUrl - URL path for the bundled file
109
- * @returns True if bundling was successful
113
+ * Bundles one or more entrypoints in a single esbuild invocation.
114
+ * Uses the source directory as the output base so that the directory structure
115
+ * is preserved under the HMR dist folder.
110
116
  */
111
- async bundleEntrypoint(entrypointPath, outputUrl) {
117
+ async bundleEntrypoints(entrypoints) {
112
118
  try {
113
- const srcDir = this.context.getSrcDir();
114
- const relativePath = path.relative(srcDir, entrypointPath);
115
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?)$/, ".js");
116
- const outputPath = path.join(this.context.getDistDir(), relativePathJs);
117
119
  const result = await defaultBuildAdapter.build({
118
- entrypoints: [entrypointPath],
120
+ entrypoints,
119
121
  outdir: this.context.getDistDir(),
120
- naming: relativePathJs,
122
+ outbase: this.context.getSrcDir(),
123
+ naming: "[dir]/[name]",
121
124
  ...defaultBuildAdapter.getTranspileOptions("hmr-entrypoint"),
122
125
  plugins: this.context.getPlugins(),
123
126
  minify: false,
124
127
  external: ["react", "react-dom"]
125
128
  });
126
129
  if (!result.success) {
127
- appLogger.error(`[JsHmrStrategy] Failed to build ${entrypointPath}:`, result.logs);
128
- return { success: false, requiresReload: false, dependencies: void 0 };
130
+ appLogger.error("[JsHmrStrategy] Batched build failed:", result.logs);
131
+ return { success: false };
129
132
  }
130
- const dependencyGraph = result.dependencyGraph?.entrypoints?.[path.resolve(entrypointPath)] ?? [];
131
- const output = await this.processOutput(outputPath, outputUrl);
132
- return {
133
- ...output,
134
- dependencies: dependencyGraph
135
- };
133
+ const dependencies = /* @__PURE__ */ new Map();
134
+ if (result.dependencyGraph?.entrypoints) {
135
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
136
+ dependencies.set(path.resolve(entrypoint), deps);
137
+ }
138
+ }
139
+ return { success: true, dependencies };
136
140
  } catch (error) {
137
- appLogger.error(`[JsHmrStrategy] Error bundling ${entrypointPath}:`, error);
138
- return { success: false, requiresReload: false, dependencies: void 0 };
141
+ appLogger.error("[JsHmrStrategy] Error in batched build:", error);
142
+ return { success: false };
139
143
  }
140
144
  }
141
145
  /**
@@ -144,6 +144,8 @@ export class JsHmrStrategy extends HmrStrategy {
144
144
  * @remarks
145
145
  * If runtime-specific dependency graph hooks are unavailable, this strategy
146
146
  * falls back to rebuilding all watched entrypoints.
147
+ * When multiple entrypoints are impacted they are bundled in a single esbuild
148
+ * invocation to share AST parsing and chunk deduplication.
147
149
  * @returns Action to broadcast update events
148
150
  */
149
151
  async process(filePath: string): Promise<HmrAction> {
@@ -155,8 +157,6 @@ export class JsHmrStrategy extends HmrStrategy {
155
157
  return { type: 'none' };
156
158
  }
157
159
 
158
- const updates: string[] = [];
159
- let reloadRequired = false;
160
160
  const dependencyHits = this.context.getDependencyEntrypoints?.(filePath) ?? new Set<string>();
161
161
  const hasDependencyHit = dependencyHits.size > 0;
162
162
  const impactedEntrypoints = hasDependencyHit
@@ -167,18 +167,35 @@ export class JsHmrStrategy extends HmrStrategy {
167
167
  appLogger.debug('[JsHmrStrategy] Dependency graph miss, rebuilding all watched entrypoints');
168
168
  }
169
169
 
170
+ if (impactedEntrypoints.length === 0) {
171
+ return { type: 'none' };
172
+ }
173
+
174
+ const buildResult = await this.bundleEntrypoints(impactedEntrypoints);
175
+
176
+ if (!buildResult.success) {
177
+ return { type: 'none' };
178
+ }
179
+
180
+ const updates: string[] = [];
181
+ let reloadRequired = false;
182
+
170
183
  for (const entrypoint of impactedEntrypoints) {
171
184
  const outputUrl = watchedFiles.get(entrypoint);
172
- if (!outputUrl) {
173
- continue;
185
+ if (!outputUrl) continue;
186
+
187
+ if (buildResult.dependencies && this.context.setEntrypointDependencies) {
188
+ const entrypointDeps = buildResult.dependencies.get(path.resolve(entrypoint)) ?? [];
189
+ this.context.setEntrypointDependencies(entrypoint, entrypointDeps);
174
190
  }
175
191
 
176
- const result = await this.bundleEntrypoint(entrypoint, outputUrl);
177
- if (result.success) {
178
- if (result.dependencies && this.context.setEntrypointDependencies) {
179
- this.context.setEntrypointDependencies(entrypoint, result.dependencies);
180
- }
192
+ const srcDir = this.context.getSrcDir();
193
+ const relativePath = path.relative(srcDir, entrypoint);
194
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?)$/, '.js');
195
+ const outputPath = path.join(this.context.getDistDir(), relativePathJs);
181
196
 
197
+ const result = await this.processOutput(outputPath, outputUrl);
198
+ if (result.success) {
182
199
  updates.push(outputUrl);
183
200
  if (result.requiresReload) {
184
201
  reloadRequired = true;
@@ -191,19 +208,15 @@ export class JsHmrStrategy extends HmrStrategy {
191
208
  appLogger.debug(`[JsHmrStrategy] Full reload required (no HMR accept found)`);
192
209
  return {
193
210
  type: 'broadcast',
194
- events: [
195
- {
196
- type: 'reload',
197
- },
198
- ],
211
+ events: [{ type: 'reload' }],
199
212
  };
200
213
  }
201
214
 
202
215
  return {
203
216
  type: 'broadcast',
204
- events: updates.map((path) => ({
217
+ events: updates.map((p) => ({
205
218
  type: 'update',
206
- path,
219
+ path: p,
207
220
  timestamp: Date.now(),
208
221
  })),
209
222
  };
@@ -213,26 +226,19 @@ export class JsHmrStrategy extends HmrStrategy {
213
226
  }
214
227
 
215
228
  /**
216
- * Bundles a single entrypoint and processes the output.
217
- *
218
- * @param entrypointPath - Absolute path to the source file
219
- * @param outputUrl - URL path for the bundled file
220
- * @returns True if bundling was successful
229
+ * Bundles one or more entrypoints in a single esbuild invocation.
230
+ * Uses the source directory as the output base so that the directory structure
231
+ * is preserved under the HMR dist folder.
221
232
  */
222
- private async bundleEntrypoint(
223
- entrypointPath: string,
224
- outputUrl: string,
225
- ): Promise<{ success: boolean; requiresReload: boolean; dependencies?: string[] }> {
233
+ private async bundleEntrypoints(
234
+ entrypoints: string[],
235
+ ): Promise<{ success: boolean; dependencies?: Map<string, string[]> }> {
226
236
  try {
227
- const srcDir = this.context.getSrcDir();
228
- const relativePath = path.relative(srcDir, entrypointPath);
229
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?)$/, '.js');
230
- const outputPath = path.join(this.context.getDistDir(), relativePathJs);
231
-
232
237
  const result = await defaultBuildAdapter.build({
233
- entrypoints: [entrypointPath],
238
+ entrypoints,
234
239
  outdir: this.context.getDistDir(),
235
- naming: relativePathJs,
240
+ outbase: this.context.getSrcDir(),
241
+ naming: '[dir]/[name]',
236
242
  ...defaultBuildAdapter.getTranspileOptions('hmr-entrypoint'),
237
243
  plugins: this.context.getPlugins(),
238
244
  minify: false,
@@ -240,20 +246,21 @@ export class JsHmrStrategy extends HmrStrategy {
240
246
  });
241
247
 
242
248
  if (!result.success) {
243
- appLogger.error(`[JsHmrStrategy] Failed to build ${entrypointPath}:`, result.logs);
244
- return { success: false, requiresReload: false, dependencies: undefined };
249
+ appLogger.error('[JsHmrStrategy] Batched build failed:', result.logs);
250
+ return { success: false };
245
251
  }
246
252
 
247
- const dependencyGraph = result.dependencyGraph?.entrypoints?.[path.resolve(entrypointPath)] ?? [];
248
- const output = await this.processOutput(outputPath, outputUrl);
253
+ const dependencies = new Map<string, string[]>();
254
+ if (result.dependencyGraph?.entrypoints) {
255
+ for (const [entrypoint, deps] of Object.entries(result.dependencyGraph.entrypoints)) {
256
+ dependencies.set(path.resolve(entrypoint), deps);
257
+ }
258
+ }
249
259
 
250
- return {
251
- ...output,
252
- dependencies: dependencyGraph,
253
- };
260
+ return { success: true, dependencies };
254
261
  } catch (error) {
255
- appLogger.error(`[JsHmrStrategy] Error bundling ${entrypointPath}:`, error as Error);
256
- return { success: false, requiresReload: false, dependencies: undefined };
262
+ appLogger.error('[JsHmrStrategy] Error in batched build:', error as Error);
263
+ return { success: false };
257
264
  }
258
265
  }
259
266
 
@@ -178,10 +178,6 @@ export interface IHmrManager {
178
178
  * Returns whether HMR is enabled.
179
179
  */
180
180
  isEnabled(): boolean;
181
- /**
182
- * Returns true when a changed file matches a non-fallback HMR strategy.
183
- */
184
- canHandleFileChange(path: string): boolean;
185
181
  /**
186
182
  * Broadcasts an HMR event to connected clients.
187
183
  */
@@ -209,11 +209,6 @@ export interface IHmrManager {
209
209
  */
210
210
  isEnabled(): boolean;
211
211
 
212
- /**
213
- * Returns true when a changed file matches a non-fallback HMR strategy.
214
- */
215
- canHandleFileChange(path: string): boolean;
216
-
217
212
  /**
218
213
  * Broadcasts an HMR event to connected clients.
219
214
  */
@@ -44,6 +44,7 @@ export declare class ProjectWatcher {
44
44
  private bridge;
45
45
  private watcher;
46
46
  private lastHandledChange;
47
+ private changeQueue;
47
48
  constructor({ config, refreshRouterRoutesCallback, hmrManager, bridge }: ProjectWatcherConfig);
48
49
  /**
49
50
  * Uncaches modules in the source directory to ensure fresh imports.
@@ -56,19 +57,37 @@ export declare class ProjectWatcher {
56
57
  * @param filePath - Absolute path of the changed file
57
58
  */
58
59
  private handlePublicDirFileChange;
60
+ /**
61
+ * Serializes file change handling so that concurrent chokidar events are
62
+ * processed one at a time, preventing overlapping builds and race conditions.
63
+ */
64
+ private enqueueChange;
59
65
  /**
60
66
  * Handles file changes by uncaching modules, refreshing routes, and delegating appropriately.
61
67
  * Follows 4-rule priority:
62
- * 0. Public directory match? copy file and reload
63
- * 1. additionalWatchPaths match? reload
64
- * 2. Processor extension match? processor handles (skip HMR)
65
- * 3. Otherwise HMR strategies
68
+ * 0. Public directory match? -> copy file and reload
69
+ * 1. additionalWatchPaths match? -> reload
70
+ * 2. Processor-owned asset? -> processor already handled it via notification, skip HMR
71
+ * 3. Otherwise -> HMR strategies
72
+ *
73
+ * Processors that watch a file extension as a dependency (e.g. PostCSS watching
74
+ * .tsx for Tailwind class scanning) are always notified first, but do not
75
+ * prevent the file from flowing through the normal HMR strategy pipeline.
66
76
  *
67
77
  * Duplicate identical watcher events for the same file are coalesced within a
68
78
  * short window before any of the priority rules run.
69
79
  * @param rawPath - Path of the changed file
80
+ * @param event - The type of file system event
70
81
  */
71
82
  private handleFileChange;
83
+ /**
84
+ * Notifies all processors whose watch config matches the given file extension.
85
+ * This is called before checking processor ownership so that dependency-only
86
+ * processors (e.g. PostCSS watching .tsx for class scanning) receive their
87
+ * notifications regardless of whether they own the file.
88
+ */
89
+ private notifyProcessors;
90
+ private getProcessorHandler;
72
91
  /**
73
92
  * Checks if a file is in the public directory.
74
93
  */
@@ -77,14 +96,10 @@ export declare class ProjectWatcher {
77
96
  * Checks if file path matches any additionalWatchPaths patterns.
78
97
  */
79
98
  private matchesAdditionalWatchPaths;
80
- /**
81
- * Checks whether a file is watched by any processor, even if that processor
82
- * does not own the file as a primary asset.
83
- */
84
- private isWatchedByProcessor;
85
99
  /**
86
100
  * Checks if a file is handled by a processor.
87
- * Processors that declare extensions own those file types.
101
+ * Processors that declare asset capabilities own those file types.
102
+ * Processors without capabilities fall back to checking watch extensions.
88
103
  */
89
104
  private isHandledByProcessor;
90
105
  /**
@@ -100,23 +115,15 @@ export declare class ProjectWatcher {
100
115
  * @param {unknown} error - The error to handle
101
116
  */
102
117
  handleError(error: unknown): void;
103
- /**
104
- * Processes file changes for specific file extensions.
105
- * Used by processors to handle their specific file types.
106
- *
107
- * @private
108
- * @param {string} path - Path of the changed file
109
- * @param {string[]} extensions - File extensions to process
110
- * @param {(ctx: ProcessorWatchContext) => void} handler - Handler function for the file change
111
- */
112
- private shouldProcess;
113
118
  /**
114
119
  * Creates and configures the file system watcher.
115
120
  * This sets up:
116
- * 1. Processor-specific file watching
117
- * 2. Page file watching
118
- * 3. Directory watching
119
- * 4. Error handling
121
+ * 1. Page file watching
122
+ * 2. Directory watching
123
+ * 3. Error handling
124
+ *
125
+ * Processor notifications are dispatched inside handleFileChange, ensuring
126
+ * a single unified event pipeline with no parallel chokidar bindings.
120
127
  *
121
128
  * Uses chokidar's built-in debouncing through `awaitWriteFinish` to handle
122
129
  * rapid file changes efficiently.
@@ -17,6 +17,7 @@ class ProjectWatcher {
17
17
  bridge;
18
18
  watcher = null;
19
19
  lastHandledChange = /* @__PURE__ */ new Map();
20
+ changeQueue = Promise.resolve();
20
21
  constructor({ config, refreshRouterRoutesCallback, hmrManager, bridge }) {
21
22
  this.appConfig = config;
22
23
  this.refreshRouterRoutesCallback = refreshRouterRoutesCallback;
@@ -60,19 +61,32 @@ class ProjectWatcher {
60
61
  this.bridge.reload();
61
62
  }
62
63
  }
64
+ /**
65
+ * Serializes file change handling so that concurrent chokidar events are
66
+ * processed one at a time, preventing overlapping builds and race conditions.
67
+ */
68
+ enqueueChange(task) {
69
+ const queuedTask = this.changeQueue.then(task, task);
70
+ this.changeQueue = queuedTask.catch(() => void 0);
71
+ }
63
72
  /**
64
73
  * Handles file changes by uncaching modules, refreshing routes, and delegating appropriately.
65
74
  * Follows 4-rule priority:
66
- * 0. Public directory match? copy file and reload
67
- * 1. additionalWatchPaths match? reload
68
- * 2. Processor extension match? processor handles (skip HMR)
69
- * 3. Otherwise HMR strategies
75
+ * 0. Public directory match? -> copy file and reload
76
+ * 1. additionalWatchPaths match? -> reload
77
+ * 2. Processor-owned asset? -> processor already handled it via notification, skip HMR
78
+ * 3. Otherwise -> HMR strategies
79
+ *
80
+ * Processors that watch a file extension as a dependency (e.g. PostCSS watching
81
+ * .tsx for Tailwind class scanning) are always notified first, but do not
82
+ * prevent the file from flowing through the normal HMR strategy pipeline.
70
83
  *
71
84
  * Duplicate identical watcher events for the same file are coalesced within a
72
85
  * short window before any of the priority rules run.
73
86
  * @param rawPath - Path of the changed file
87
+ * @param event - The type of file system event
74
88
  */
75
- async handleFileChange(rawPath) {
89
+ async handleFileChange(rawPath, event = "change") {
76
90
  const filePath = path.resolve(rawPath);
77
91
  const now = Date.now();
78
92
  const lastHandledAt = this.lastHandledChange.get(filePath);
@@ -94,13 +108,10 @@ class ProjectWatcher {
94
108
  this.bridge.reload();
95
109
  return;
96
110
  }
111
+ await this.notifyProcessors(filePath, event);
97
112
  if (this.isHandledByProcessor(filePath)) {
98
113
  return;
99
114
  }
100
- if (this.isWatchedByProcessor(filePath) && !this.hmrManager.canHandleFileChange(filePath)) {
101
- this.hmrManager.broadcast({ type: "layout-update" });
102
- return;
103
- }
104
115
  await this.hmrManager.handleFileChange(filePath);
105
116
  } catch (error) {
106
117
  if (error instanceof Error) {
@@ -109,6 +120,37 @@ class ProjectWatcher {
109
120
  }
110
121
  }
111
122
  }
123
+ /**
124
+ * Notifies all processors whose watch config matches the given file extension.
125
+ * This is called before checking processor ownership so that dependency-only
126
+ * processors (e.g. PostCSS watching .tsx for class scanning) receive their
127
+ * notifications regardless of whether they own the file.
128
+ */
129
+ async notifyProcessors(filePath, event) {
130
+ const ctx = { path: filePath, bridge: this.bridge };
131
+ for (const processor of this.appConfig.processors.values()) {
132
+ const watchConfig = processor.getWatchConfig();
133
+ if (!watchConfig) continue;
134
+ const { extensions = [] } = watchConfig;
135
+ if (extensions.length && !extensions.some((ext) => filePath.endsWith(ext))) {
136
+ continue;
137
+ }
138
+ const handler = this.getProcessorHandler(watchConfig, event);
139
+ if (handler) {
140
+ await handler(ctx);
141
+ }
142
+ }
143
+ }
144
+ getProcessorHandler(watchConfig, event) {
145
+ switch (event) {
146
+ case "change":
147
+ return watchConfig.onChange;
148
+ case "add":
149
+ return watchConfig.onCreate;
150
+ case "unlink":
151
+ return watchConfig.onDelete;
152
+ }
153
+ }
112
154
  /**
113
155
  * Checks if a file is in the public directory.
114
156
  */
@@ -131,24 +173,10 @@ class ProjectWatcher {
131
173
  }
132
174
  return false;
133
175
  }
134
- /**
135
- * Checks whether a file is watched by any processor, even if that processor
136
- * does not own the file as a primary asset.
137
- */
138
- isWatchedByProcessor(filePath) {
139
- for (const processor of this.appConfig.processors.values()) {
140
- const watchConfig = processor.getWatchConfig();
141
- if (!watchConfig) continue;
142
- const { extensions = [] } = watchConfig;
143
- if (extensions.length && extensions.some((ext) => filePath.endsWith(ext))) {
144
- return true;
145
- }
146
- }
147
- return false;
148
- }
149
176
  /**
150
177
  * Checks if a file is handled by a processor.
151
- * Processors that declare extensions own those file types.
178
+ * Processors that declare asset capabilities own those file types.
179
+ * Processors without capabilities fall back to checking watch extensions.
152
180
  */
153
181
  isHandledByProcessor(filePath) {
154
182
  for (const processor of this.appConfig.processors.values()) {
@@ -192,27 +220,15 @@ class ProjectWatcher {
192
220
  }
193
221
  appLogger.error(`Watcher error: ${error}`);
194
222
  }
195
- /**
196
- * Processes file changes for specific file extensions.
197
- * Used by processors to handle their specific file types.
198
- *
199
- * @private
200
- * @param {string} path - Path of the changed file
201
- * @param {string[]} extensions - File extensions to process
202
- * @param {(ctx: ProcessorWatchContext) => void} handler - Handler function for the file change
203
- */
204
- shouldProcess(path2, extensions, handler) {
205
- if (!extensions.length || extensions.some((ext) => path2.endsWith(ext))) {
206
- handler({ path: path2, bridge: this.bridge });
207
- }
208
- }
209
223
  /**
210
224
  * Creates and configures the file system watcher.
211
225
  * This sets up:
212
- * 1. Processor-specific file watching
213
- * 2. Page file watching
214
- * 3. Directory watching
215
- * 4. Error handling
226
+ * 1. Page file watching
227
+ * 2. Directory watching
228
+ * 3. Error handling
229
+ *
230
+ * Processor notifications are dispatched inside handleFileChange, ensuring
231
+ * a single unified event pipeline with no parallel chokidar bindings.
216
232
  *
217
233
  * Uses chokidar's built-in debouncing through `awaitWriteFinish` to handle
218
234
  * rapid file changes efficiently.
@@ -243,20 +259,20 @@ class ProjectWatcher {
243
259
  }
244
260
  });
245
261
  }
262
+ this.watcher.add(this.appConfig.absolutePaths.srcDir);
263
+ this.watcher.on("change", (p) => this.enqueueChange(() => this.handleFileChange(p, "change"))).on("add", (p) => {
264
+ this.enqueueChange(() => this.handleFileChange(p, "add"));
265
+ this.triggerRouterRefresh(p);
266
+ }).on("addDir", (p) => this.triggerRouterRefresh(p)).on("unlink", (p) => {
267
+ this.enqueueChange(() => this.handleFileChange(p, "unlink"));
268
+ this.triggerRouterRefresh(p);
269
+ }).on("unlinkDir", (p) => this.triggerRouterRefresh(p)).on("error", (error) => this.handleError(error));
246
270
  for (const processor of this.appConfig.processors.values()) {
247
271
  const watchConfig = processor.getWatchConfig();
248
- if (!watchConfig) continue;
249
- const { extensions = [], onCreate, onChange, onDelete, onError } = watchConfig;
250
- if (onCreate) this.watcher.on("add", (path2) => this.shouldProcess(path2, extensions, onCreate));
251
- if (onChange) this.watcher.on("change", (path2) => this.shouldProcess(path2, extensions, onChange));
252
- if (onDelete) this.watcher.on("unlink", (path2) => this.shouldProcess(path2, extensions, onDelete));
253
- if (onError) this.watcher.on("error", onError);
272
+ if (watchConfig?.onError) {
273
+ this.watcher.on("error", watchConfig.onError);
274
+ }
254
275
  }
255
- this.watcher.add(this.appConfig.absolutePaths.srcDir);
256
- this.watcher.on("change", (path2) => this.handleFileChange(path2)).on("add", (path2) => {
257
- this.handleFileChange(path2);
258
- this.triggerRouterRefresh(path2);
259
- }).on("addDir", (path2) => this.triggerRouterRefresh(path2)).on("unlink", (path2) => this.triggerRouterRefresh(path2)).on("unlinkDir", (path2) => this.triggerRouterRefresh(path2)).on("error", (error) => this.handleError(error));
260
276
  return this.watcher;
261
277
  }
262
278
  }
@@ -14,7 +14,6 @@ const createMockHmrManager = () => ({
14
14
  registerStrategy: vi.fn(() => {
15
15
  }),
16
16
  isEnabled: vi.fn(() => true),
17
- canHandleFileChange: vi.fn(() => true),
18
17
  getOutputUrl: vi.fn(() => void 0),
19
18
  getWatchedFiles: vi.fn(() => /* @__PURE__ */ new Map()),
20
19
  getSpecifierMap: vi.fn(() => /* @__PURE__ */ new Map()),
@@ -12,7 +12,6 @@ export const createMockHmrManager = (): IHmrManager =>
12
12
  registerSpecifierMap: vi.fn(() => {}),
13
13
  registerStrategy: vi.fn(() => {}),
14
14
  isEnabled: vi.fn(() => true),
15
- canHandleFileChange: vi.fn(() => true),
16
15
  getOutputUrl: vi.fn(() => undefined),
17
16
  getWatchedFiles: vi.fn(() => new Map()),
18
17
  getSpecifierMap: vi.fn(() => new Map()),
@@ -3,7 +3,7 @@ import chokidar, { type FSWatcher } from 'chokidar';
3
3
  import { fileSystem } from '@ecopages/file-system';
4
4
  import { appLogger } from '../global/app-logger.ts';
5
5
  import type { EcoPagesAppConfig, IHmrManager, IClientBridge } from '../internal-types.ts';
6
- import type { ProcessorWatchContext } from '../plugins/processor.ts';
6
+ import type { ProcessorWatchConfig, ProcessorWatchContext } from '../plugins/processor.ts';
7
7
 
8
8
  /**
9
9
  * Configuration options for the ProjectWatcher
@@ -50,6 +50,7 @@ export class ProjectWatcher {
50
50
  private bridge: IClientBridge;
51
51
  private watcher: FSWatcher | null = null;
52
52
  private lastHandledChange = new Map<string, number>();
53
+ private changeQueue: Promise<void> = Promise.resolve();
53
54
 
54
55
  constructor({ config, refreshRouterRoutesCallback, hmrManager, bridge }: ProjectWatcherConfig) {
55
56
  this.appConfig = config;
@@ -101,19 +102,33 @@ export class ProjectWatcher {
101
102
  }
102
103
  }
103
104
 
105
+ /**
106
+ * Serializes file change handling so that concurrent chokidar events are
107
+ * processed one at a time, preventing overlapping builds and race conditions.
108
+ */
109
+ private enqueueChange(task: () => Promise<void>): void {
110
+ const queuedTask = this.changeQueue.then(task, task);
111
+ this.changeQueue = queuedTask.catch(() => undefined);
112
+ }
113
+
104
114
  /**
105
115
  * Handles file changes by uncaching modules, refreshing routes, and delegating appropriately.
106
116
  * Follows 4-rule priority:
107
- * 0. Public directory match? copy file and reload
108
- * 1. additionalWatchPaths match? reload
109
- * 2. Processor extension match? processor handles (skip HMR)
110
- * 3. Otherwise HMR strategies
117
+ * 0. Public directory match? -> copy file and reload
118
+ * 1. additionalWatchPaths match? -> reload
119
+ * 2. Processor-owned asset? -> processor already handled it via notification, skip HMR
120
+ * 3. Otherwise -> HMR strategies
121
+ *
122
+ * Processors that watch a file extension as a dependency (e.g. PostCSS watching
123
+ * .tsx for Tailwind class scanning) are always notified first, but do not
124
+ * prevent the file from flowing through the normal HMR strategy pipeline.
111
125
  *
112
126
  * Duplicate identical watcher events for the same file are coalesced within a
113
127
  * short window before any of the priority rules run.
114
128
  * @param rawPath - Path of the changed file
129
+ * @param event - The type of file system event
115
130
  */
116
- private async handleFileChange(rawPath: string): Promise<void> {
131
+ private async handleFileChange(rawPath: string, event: 'change' | 'add' | 'unlink' = 'change'): Promise<void> {
117
132
  const filePath = path.resolve(rawPath);
118
133
  const now = Date.now();
119
134
  const lastHandledAt = this.lastHandledChange.get(filePath);
@@ -140,12 +155,9 @@ export class ProjectWatcher {
140
155
  return;
141
156
  }
142
157
 
143
- if (this.isHandledByProcessor(filePath)) {
144
- return;
145
- }
158
+ await this.notifyProcessors(filePath, event);
146
159
 
147
- if (this.isWatchedByProcessor(filePath) && !this.hmrManager.canHandleFileChange(filePath)) {
148
- this.hmrManager.broadcast({ type: 'layout-update' });
160
+ if (this.isHandledByProcessor(filePath)) {
149
161
  return;
150
162
  }
151
163
 
@@ -158,6 +170,45 @@ export class ProjectWatcher {
158
170
  }
159
171
  }
160
172
 
173
+ /**
174
+ * Notifies all processors whose watch config matches the given file extension.
175
+ * This is called before checking processor ownership so that dependency-only
176
+ * processors (e.g. PostCSS watching .tsx for class scanning) receive their
177
+ * notifications regardless of whether they own the file.
178
+ */
179
+ private async notifyProcessors(filePath: string, event: 'change' | 'add' | 'unlink'): Promise<void> {
180
+ const ctx: ProcessorWatchContext = { path: filePath, bridge: this.bridge };
181
+
182
+ for (const processor of this.appConfig.processors.values()) {
183
+ const watchConfig = processor.getWatchConfig();
184
+ if (!watchConfig) continue;
185
+
186
+ const { extensions = [] } = watchConfig;
187
+ if (extensions.length && !extensions.some((ext) => filePath.endsWith(ext))) {
188
+ continue;
189
+ }
190
+
191
+ const handler = this.getProcessorHandler(watchConfig, event);
192
+ if (handler) {
193
+ await handler(ctx);
194
+ }
195
+ }
196
+ }
197
+
198
+ private getProcessorHandler(
199
+ watchConfig: ProcessorWatchConfig,
200
+ event: 'change' | 'add' | 'unlink',
201
+ ): ((ctx: ProcessorWatchContext) => Promise<void>) | undefined {
202
+ switch (event) {
203
+ case 'change':
204
+ return watchConfig.onChange;
205
+ case 'add':
206
+ return watchConfig.onCreate;
207
+ case 'unlink':
208
+ return watchConfig.onDelete;
209
+ }
210
+ }
211
+
161
212
  /**
162
213
  * Checks if a file is in the public directory.
163
214
  */
@@ -183,27 +234,10 @@ export class ProjectWatcher {
183
234
  return false;
184
235
  }
185
236
 
186
- /**
187
- * Checks whether a file is watched by any processor, even if that processor
188
- * does not own the file as a primary asset.
189
- */
190
- private isWatchedByProcessor(filePath: string): boolean {
191
- for (const processor of this.appConfig.processors.values()) {
192
- const watchConfig = processor.getWatchConfig();
193
- if (!watchConfig) continue;
194
-
195
- const { extensions = [] } = watchConfig;
196
- if (extensions.length && extensions.some((ext) => filePath.endsWith(ext))) {
197
- return true;
198
- }
199
- }
200
-
201
- return false;
202
- }
203
-
204
237
  /**
205
238
  * Checks if a file is handled by a processor.
206
- * Processors that declare extensions own those file types.
239
+ * Processors that declare asset capabilities own those file types.
240
+ * Processors without capabilities fall back to checking watch extensions.
207
241
  */
208
242
  private isHandledByProcessor(filePath: string): boolean {
209
243
  for (const processor of this.appConfig.processors.values()) {
@@ -258,28 +292,15 @@ export class ProjectWatcher {
258
292
  appLogger.error(`Watcher error: ${error}`);
259
293
  }
260
294
 
261
- /**
262
- * Processes file changes for specific file extensions.
263
- * Used by processors to handle their specific file types.
264
- *
265
- * @private
266
- * @param {string} path - Path of the changed file
267
- * @param {string[]} extensions - File extensions to process
268
- * @param {(ctx: ProcessorWatchContext) => void} handler - Handler function for the file change
269
- */
270
- private shouldProcess(path: string, extensions: string[], handler: (ctx: ProcessorWatchContext) => void) {
271
- if (!extensions.length || extensions.some((ext) => path.endsWith(ext))) {
272
- handler({ path, bridge: this.bridge });
273
- }
274
- }
275
-
276
295
  /**
277
296
  * Creates and configures the file system watcher.
278
297
  * This sets up:
279
- * 1. Processor-specific file watching
280
- * 2. Page file watching
281
- * 3. Directory watching
282
- * 4. Error handling
298
+ * 1. Page file watching
299
+ * 2. Directory watching
300
+ * 3. Error handling
301
+ *
302
+ * Processor notifications are dispatched inside handleFileChange, ensuring
303
+ * a single unified event pipeline with no parallel chokidar bindings.
283
304
  *
284
305
  * Uses chokidar's built-in debouncing through `awaitWriteFinish` to handle
285
306
  * rapid file changes efficiently.
@@ -315,30 +336,29 @@ export class ProjectWatcher {
315
336
  });
316
337
  }
317
338
 
318
- for (const processor of this.appConfig.processors.values()) {
319
- const watchConfig = processor.getWatchConfig();
320
- if (!watchConfig) continue;
321
- const { extensions = [], onCreate, onChange, onDelete, onError } = watchConfig;
322
-
323
- if (onCreate) this.watcher.on('add', (path) => this.shouldProcess(path, extensions, onCreate));
324
- if (onChange) this.watcher.on('change', (path) => this.shouldProcess(path, extensions, onChange));
325
- if (onDelete) this.watcher.on('unlink', (path) => this.shouldProcess(path, extensions, onDelete));
326
- if (onError) this.watcher.on('error', onError as (error: unknown) => void);
327
- }
328
-
329
339
  this.watcher.add(this.appConfig.absolutePaths.srcDir);
330
340
 
331
341
  this.watcher
332
- .on('change', (path) => this.handleFileChange(path))
333
- .on('add', (path) => {
334
- this.handleFileChange(path);
335
- this.triggerRouterRefresh(path);
342
+ .on('change', (p) => this.enqueueChange(() => this.handleFileChange(p, 'change')))
343
+ .on('add', (p) => {
344
+ this.enqueueChange(() => this.handleFileChange(p, 'add'));
345
+ this.triggerRouterRefresh(p);
346
+ })
347
+ .on('addDir', (p) => this.triggerRouterRefresh(p))
348
+ .on('unlink', (p) => {
349
+ this.enqueueChange(() => this.handleFileChange(p, 'unlink'));
350
+ this.triggerRouterRefresh(p);
336
351
  })
337
- .on('addDir', (path) => this.triggerRouterRefresh(path))
338
- .on('unlink', (path) => this.triggerRouterRefresh(path))
339
- .on('unlinkDir', (path) => this.triggerRouterRefresh(path))
352
+ .on('unlinkDir', (p) => this.triggerRouterRefresh(p))
340
353
  .on('error', (error) => this.handleError(error));
341
354
 
355
+ for (const processor of this.appConfig.processors.values()) {
356
+ const watchConfig = processor.getWatchConfig();
357
+ if (watchConfig?.onError) {
358
+ this.watcher.on('error', watchConfig.onError as (error: unknown) => void);
359
+ }
360
+ }
361
+
342
362
  return this.watcher;
343
363
  }
344
364
  }