@ecopages/core 0.2.0-alpha.4 → 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.
- package/CHANGELOG.md +5 -2
- package/package.json +2 -2
- package/src/adapters/bun/hmr-manager.d.ts +0 -6
- package/src/adapters/bun/hmr-manager.js +1 -19
- package/src/adapters/bun/hmr-manager.ts +1 -21
- package/src/adapters/node/node-hmr-manager.d.ts +0 -1
- package/src/adapters/node/node-hmr-manager.js +1 -17
- package/src/adapters/node/node-hmr-manager.ts +1 -19
- package/src/build/build-adapter.d.ts +1 -0
- package/src/build/build-adapter.ts +1 -0
- package/src/build/esbuild-build-adapter.js +1 -0
- package/src/build/esbuild-build-adapter.ts +1 -0
- package/src/hmr/strategies/js-hmr-strategy.d.ts +6 -6
- package/src/hmr/strategies/js-hmr-strategy.js +41 -37
- package/src/hmr/strategies/js-hmr-strategy.ts +49 -42
- package/src/public-types.d.ts +0 -4
- package/src/public-types.ts +0 -5
- package/src/watchers/project-watcher.d.ts +31 -24
- package/src/watchers/project-watcher.js +70 -54
- package/src/watchers/project-watcher.test-helpers.js +0 -1
- package/src/watchers/project-watcher.test-helpers.ts +0 -1
- package/src/watchers/project-watcher.ts +87 -67
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
|
-
-
|
|
69
|
-
-
|
|
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
|
+
"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.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
130
|
+
appLogger.error(err);
|
|
149
131
|
return false;
|
|
150
132
|
}
|
|
151
133
|
});
|
|
@@ -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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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((
|
|
103
|
+
events: updates.map((p) => ({
|
|
96
104
|
type: "update",
|
|
97
|
-
path:
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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
|
|
120
|
+
entrypoints,
|
|
119
121
|
outdir: this.context.getDistDir(),
|
|
120
|
-
|
|
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(
|
|
128
|
-
return { success: false
|
|
130
|
+
appLogger.error("[JsHmrStrategy] Batched build failed:", result.logs);
|
|
131
|
+
return { success: false };
|
|
129
132
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
138
|
-
return { success: false
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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((
|
|
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
|
|
217
|
-
*
|
|
218
|
-
*
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
238
|
+
entrypoints,
|
|
234
239
|
outdir: this.context.getDistDir(),
|
|
235
|
-
|
|
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(
|
|
244
|
-
return { success: false
|
|
249
|
+
appLogger.error('[JsHmrStrategy] Batched build failed:', result.logs);
|
|
250
|
+
return { success: false };
|
|
245
251
|
}
|
|
246
252
|
|
|
247
|
-
const
|
|
248
|
-
|
|
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(
|
|
256
|
-
return { success: false
|
|
262
|
+
appLogger.error('[JsHmrStrategy] Error in batched build:', error as Error);
|
|
263
|
+
return { success: false };
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
package/src/public-types.d.ts
CHANGED
|
@@ -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
|
*/
|
package/src/public-types.ts
CHANGED
|
@@ -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?
|
|
63
|
-
* 1. additionalWatchPaths match?
|
|
64
|
-
* 2. Processor
|
|
65
|
-
* 3. Otherwise
|
|
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
|
|
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.
|
|
117
|
-
* 2.
|
|
118
|
-
* 3.
|
|
119
|
-
*
|
|
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?
|
|
67
|
-
* 1. additionalWatchPaths match?
|
|
68
|
-
* 2. Processor
|
|
69
|
-
* 3. Otherwise
|
|
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
|
|
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.
|
|
213
|
-
* 2.
|
|
214
|
-
* 3.
|
|
215
|
-
*
|
|
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 (
|
|
249
|
-
|
|
250
|
-
|
|
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?
|
|
108
|
-
* 1. additionalWatchPaths match?
|
|
109
|
-
* 2. Processor
|
|
110
|
-
* 3. Otherwise
|
|
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
|
-
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
158
|
+
await this.notifyProcessors(filePath, event);
|
|
146
159
|
|
|
147
|
-
if (this.
|
|
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
|
|
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.
|
|
280
|
-
* 2.
|
|
281
|
-
* 3.
|
|
282
|
-
*
|
|
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', (
|
|
333
|
-
.on('add', (
|
|
334
|
-
this.handleFileChange(
|
|
335
|
-
this.triggerRouterRefresh(
|
|
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('
|
|
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
|
}
|