@ecopages/core 0.2.0-beta.17 → 0.2.0-beta.19
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/package.json +10 -2
- package/src/adapters/bun/server-adapter.js +20 -3
- package/src/adapters/node/server-adapter-dependencies.js +2 -0
- package/src/adapters/shared/hmr-entrypoint-registrar.d.ts +0 -2
- package/src/adapters/shared/hmr-entrypoint-registrar.js +3 -26
- package/src/adapters/shared/runtime-server-lifecycle.js +4 -0
- package/src/adapters/shared/server-adapter.js +1 -2
- package/src/adapters/shared/shared-hmr-manager.d.ts +5 -3
- package/src/adapters/shared/shared-hmr-manager.js +15 -8
- package/src/dev/hmr-manager-registry.d.ts +8 -0
- package/src/dev/hmr-manager-registry.js +15 -0
- package/src/hmr/hmr-entrypoint-output.d.ts +28 -0
- package/src/hmr/hmr-entrypoint-output.js +58 -0
- package/src/hmr/strategies/js-hmr-strategy.d.ts +3 -0
- package/src/hmr/strategies/js-hmr-strategy.js +68 -18
- package/src/router/client/link-navigation-policy.d.ts +6 -0
- package/src/router/client/link-navigation-policy.js +15 -0
- package/src/services/assets/asset-processing-service/processors/script/file-script.processor.js +5 -1
- package/src/services/invalidation/development-invalidation.service.d.ts +10 -1
- package/src/services/invalidation/development-invalidation.service.js +25 -0
- package/src/services/module-loading/page-module-import.service.d.ts +1 -0
- package/src/services/module-loading/page-module-import.service.js +38 -6
- package/src/services/module-loading/route-module-dependency-hasher.d.ts +2 -2
- package/src/services/module-loading/route-module-dependency-hasher.js +2 -2
- package/src/types/internal-types.d.ts +4 -0
- package/src/watchers/project-watcher.d.ts +12 -1
- package/src/watchers/project-watcher.js +86 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/core",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.19",
|
|
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-beta.
|
|
20
|
+
"@ecopages/file-system": "0.2.0-beta.19",
|
|
21
21
|
"@ecopages/logger": "^0.2.3",
|
|
22
22
|
"@ecopages/scripts-injector": "^0.1.5",
|
|
23
23
|
"@oxc-project/runtime": "0.134.0",
|
|
@@ -133,6 +133,10 @@
|
|
|
133
133
|
"types": "./src/dev/client-bridge-registry.d.ts",
|
|
134
134
|
"default": "./src/dev/client-bridge-registry.js"
|
|
135
135
|
},
|
|
136
|
+
"./dev/hmr-manager-registry": {
|
|
137
|
+
"types": "./src/dev/hmr-manager-registry.d.ts",
|
|
138
|
+
"default": "./src/dev/hmr-manager-registry.js"
|
|
139
|
+
},
|
|
136
140
|
"./dev/dev-client-ownership": {
|
|
137
141
|
"types": "./src/dev/dev-client-ownership.d.ts",
|
|
138
142
|
"default": "./src/dev/dev-client-ownership.js"
|
|
@@ -338,6 +342,10 @@
|
|
|
338
342
|
"types": "./src/dev/client-bridge-registry.d.ts",
|
|
339
343
|
"default": "./src/dev/client-bridge-registry.js"
|
|
340
344
|
},
|
|
345
|
+
"./dev/hmr-manager-registry.ts": {
|
|
346
|
+
"types": "./src/dev/hmr-manager-registry.d.ts",
|
|
347
|
+
"default": "./src/dev/hmr-manager-registry.js"
|
|
348
|
+
},
|
|
341
349
|
"./dev/dev-client-ownership.ts": {
|
|
342
350
|
"types": "./src/dev/dev-client-ownership.d.ts",
|
|
343
351
|
"default": "./src/dev/dev-client-ownership.js"
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../shared/runtime-server-lifecycle.js";
|
|
25
25
|
import { ClientBridge } from "./client-bridge.js";
|
|
26
26
|
import { setAppDevClientBridge } from "../../dev/client-bridge-registry.js";
|
|
27
|
+
import { setAppHmrManager } from "../../dev/hmr-manager-registry.js";
|
|
27
28
|
import { HmrManager } from "./hmr-manager.js";
|
|
28
29
|
import { BunStaticPreviewHost } from "./static-preview-host.js";
|
|
29
30
|
class BunServerAdapter extends SharedServerAdapter {
|
|
@@ -312,6 +313,7 @@ class BunServerAdapter extends SharedServerAdapter {
|
|
|
312
313
|
wrapFetchWithWebSocketUpgrades(originalFetch, { serveHmrEndpoints }) {
|
|
313
314
|
const matchRoute = (pathname) => findWebSocketRoute(this.websocketHandlers, pathname);
|
|
314
315
|
const hmrManager = this.hmrManager;
|
|
316
|
+
const waitForInit = this.waitForInitialization.bind(this);
|
|
315
317
|
return async function(request, server) {
|
|
316
318
|
const url = new URL(request.url);
|
|
317
319
|
if (serveHmrEndpoints) {
|
|
@@ -320,9 +322,23 @@ class BunServerAdapter extends SharedServerAdapter {
|
|
|
320
322
|
return success ? void 0 : new Response("WebSocket upgrade failed", { status: 400 });
|
|
321
323
|
}
|
|
322
324
|
if (url.pathname === "/_hmr_runtime.js") {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
325
|
+
await waitForInit();
|
|
326
|
+
const runtimePath = hmrManager.getRuntimePath();
|
|
327
|
+
if (!fileSystem.exists(runtimePath)) {
|
|
328
|
+
appLogger.warn(
|
|
329
|
+
`[HMR] Runtime script missing at ${runtimePath}; attempting to rebuild before serving.`
|
|
330
|
+
);
|
|
331
|
+
await hmrManager.buildRuntime();
|
|
332
|
+
}
|
|
333
|
+
if (fileSystem.exists(runtimePath)) {
|
|
334
|
+
return new Response(fileSystem.readFileAsBuffer(runtimePath), {
|
|
335
|
+
headers: { "Content-Type": "application/javascript" }
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
appLogger.warn(
|
|
339
|
+
`[HMR] Runtime script not found at ${runtimePath}; the HMR runtime build likely failed during startup.`
|
|
340
|
+
);
|
|
341
|
+
return new Response("Not Found", { status: 404 });
|
|
326
342
|
}
|
|
327
343
|
}
|
|
328
344
|
const wsMatch = matchRoute(url.pathname);
|
|
@@ -600,6 +616,7 @@ async function createBunServerAdapter(params) {
|
|
|
600
616
|
const bridge = params.bridge ?? new ClientBridge();
|
|
601
617
|
const hmrManager = params.hmrManager ?? new HmrManager({ appConfig: params.appConfig, bridge });
|
|
602
618
|
setAppDevClientBridge(params.appConfig, bridge);
|
|
619
|
+
setAppHmrManager(params.appConfig, hmrManager);
|
|
603
620
|
const previewHost = params.previewHost ?? new BunStaticPreviewHost();
|
|
604
621
|
const adapter = new BunServerAdapter({
|
|
605
622
|
...params,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WebSocketServer } from "ws";
|
|
2
2
|
import { setAppDevClientBridge } from "../../dev/client-bridge-registry.js";
|
|
3
|
+
import { setAppHmrManager } from "../../dev/hmr-manager-registry.js";
|
|
3
4
|
import { NodeClientBridge } from "./node-client-bridge.js";
|
|
4
5
|
import { NodeHmrManager } from "./node-hmr-manager.js";
|
|
5
6
|
class DefaultNodeServerDevRuntimeFactory {
|
|
@@ -8,6 +9,7 @@ class DefaultNodeServerDevRuntimeFactory {
|
|
|
8
9
|
const bridge = new NodeClientBridge();
|
|
9
10
|
const hmrManager = new NodeHmrManager({ appConfig: options.appConfig, bridge });
|
|
10
11
|
setAppDevClientBridge(options.appConfig, bridge);
|
|
12
|
+
setAppHmrManager(options.appConfig, hmrManager);
|
|
11
13
|
return {
|
|
12
14
|
websocketServer,
|
|
13
15
|
bridge,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileSystem } from "@ecopages/file-system";
|
|
3
|
-
import {
|
|
4
|
-
import { RESOLVED_ASSETS_DIR } from "../../config/constants.js";
|
|
3
|
+
import { removeStaleHmrEntrypointOutput, resolveHmrEntrypointOutputPaths } from "../../hmr/hmr-entrypoint-output.js";
|
|
5
4
|
class HmrEntrypointRegistrar {
|
|
6
5
|
options;
|
|
7
6
|
constructor(options) {
|
|
@@ -36,7 +35,7 @@ class HmrEntrypointRegistrar {
|
|
|
36
35
|
}
|
|
37
36
|
const { outputPath, outputUrl } = this.getEntrypointOutput(entrypointPath);
|
|
38
37
|
this.options.watchedFiles.set(entrypointPath, outputUrl);
|
|
39
|
-
|
|
38
|
+
removeStaleHmrEntrypointOutput(outputPath, "HMR");
|
|
40
39
|
await registrationOptions.emit(entrypointPath, outputPath);
|
|
41
40
|
if (!fileSystem.exists(outputPath)) {
|
|
42
41
|
throw registrationOptions.getMissingOutputError(entrypointPath, outputPath);
|
|
@@ -57,29 +56,7 @@ class HmrEntrypointRegistrar {
|
|
|
57
56
|
]);
|
|
58
57
|
}
|
|
59
58
|
getEntrypointOutput(entrypointPath) {
|
|
60
|
-
|
|
61
|
-
const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx?)$/, ".js");
|
|
62
|
-
const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
|
|
63
|
-
const urlPath = encodedPathJs.split(path.sep).join("/");
|
|
64
|
-
return {
|
|
65
|
-
outputUrl: `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", urlPath)}`,
|
|
66
|
-
outputPath: path.join(this.options.distDir, urlPath)
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
removeStaleEntrypointOutput(outputPath) {
|
|
70
|
-
if (!fileSystem.exists(outputPath)) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
fileSystem.remove(outputPath);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
appLogger.warn(
|
|
77
|
-
`[HMR] Failed to remove stale entrypoint output ${outputPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
encodeDynamicSegments(filepath) {
|
|
82
|
-
return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
|
|
59
|
+
return resolveHmrEntrypointOutputPaths(this.options.srcDir, this.options.distDir, entrypointPath);
|
|
83
60
|
}
|
|
84
61
|
}
|
|
85
62
|
export {
|
|
@@ -4,6 +4,8 @@ import { RESOLVED_ASSETS_DIR } from "../../config/constants.js";
|
|
|
4
4
|
import { disposeAppBuildRuntime } from "../../build/build-runtime.js";
|
|
5
5
|
import { getAppBrowserBuildPlugins } from "../../build/build-adapter.js";
|
|
6
6
|
import { copyRuntimePublicDirIfChanged } from "./copy-runtime-public-dir.js";
|
|
7
|
+
import { clearAppDevClientBridge } from "../../dev/client-bridge-registry.js";
|
|
8
|
+
import { clearAppHmrManager } from "../../dev/hmr-manager-registry.js";
|
|
7
9
|
import { injectHmrRuntimeIntoHtmlResponse, isHtmlResponse, shouldInjectHmrHtmlResponse } from "./hmr-html-response.js";
|
|
8
10
|
function prepareRuntimePublicDir(appConfig) {
|
|
9
11
|
const srcPublicDir = path.join(appConfig.rootDir, appConfig.srcDir, appConfig.publicDir);
|
|
@@ -29,6 +31,8 @@ async function disposeDevResources(options) {
|
|
|
29
31
|
await disposeAppBuildRuntime(options.appConfig);
|
|
30
32
|
options.hmrManager?.stop();
|
|
31
33
|
options.bridge?.destroy();
|
|
34
|
+
clearAppDevClientBridge(options.appConfig);
|
|
35
|
+
clearAppHmrManager(options.appConfig);
|
|
32
36
|
await options.previewHost.stop();
|
|
33
37
|
}
|
|
34
38
|
export {
|
|
@@ -166,8 +166,7 @@ class SharedServerAdapter extends AbstractServerAdapter {
|
|
|
166
166
|
importServerModule: async (filePath) => await serverModuleTranspiler.importModule({
|
|
167
167
|
filePath,
|
|
168
168
|
outdir: path.join(resolveInternalExecutionDir(this.appConfig), ".server-modules"),
|
|
169
|
-
externalPackages: true
|
|
170
|
-
bypassCache: this.options?.watch === true
|
|
169
|
+
externalPackages: true
|
|
171
170
|
})
|
|
172
171
|
});
|
|
173
172
|
}
|
|
@@ -47,10 +47,12 @@ export declare abstract class SharedHmrManager implements IHmrManager {
|
|
|
47
47
|
handleFileChange(filePath: string, options?: HandleFileChangeOptions): Promise<void>;
|
|
48
48
|
getOutputUrl(entrypointPath: string): string | undefined;
|
|
49
49
|
/**
|
|
50
|
-
* Returns the emitted HMR script output when the
|
|
50
|
+
* Returns the emitted HMR script output when the entrypoint is already registered
|
|
51
|
+
* and its browser bundle exists on disk.
|
|
51
52
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
53
|
+
* @remarks
|
|
54
|
+
* Disk artifacts alone are not enough: a fresh dev session must still register
|
|
55
|
+
* the entrypoint so file watchers can rebuild it on change.
|
|
54
56
|
*/
|
|
55
57
|
getResolvedScriptOutput(entrypointPath: string): {
|
|
56
58
|
outputUrl: string;
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { RESOLVED_ASSETS_DIR } from "../../config/constants.js";
|
|
5
|
+
import { resolveHmrEntrypointOutputPaths } from "../../hmr/hmr-entrypoint-output.js";
|
|
5
6
|
import { requireBuildRuntime } from "../../build/build-runtime.js";
|
|
6
7
|
import { fileSystem } from "@ecopages/file-system";
|
|
7
8
|
import { HmrStrategyType } from "../../hmr/hmr-strategy.js";
|
|
@@ -184,21 +185,27 @@ class SharedHmrManager {
|
|
|
184
185
|
return this.watchedFiles.get(entrypointPath);
|
|
185
186
|
}
|
|
186
187
|
/**
|
|
187
|
-
* Returns the emitted HMR script output when the
|
|
188
|
+
* Returns the emitted HMR script output when the entrypoint is already registered
|
|
189
|
+
* and its browser bundle exists on disk.
|
|
188
190
|
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
+
* @remarks
|
|
192
|
+
* Disk artifacts alone are not enough: a fresh dev session must still register
|
|
193
|
+
* the entrypoint so file watchers can rebuild it on change.
|
|
191
194
|
*/
|
|
192
195
|
getResolvedScriptOutput(entrypointPath) {
|
|
193
196
|
const normalizedEntrypoint = path.resolve(entrypointPath);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const outputPath =
|
|
197
|
+
if (!this.watchedFiles.has(normalizedEntrypoint)) {
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
const { outputPath, outputUrl: derivedOutputUrl } = resolveHmrEntrypointOutputPaths(
|
|
201
|
+
this.appConfig.absolutePaths.srcDir,
|
|
202
|
+
this.distDir,
|
|
203
|
+
normalizedEntrypoint
|
|
204
|
+
);
|
|
198
205
|
if (!fileSystem.exists(outputPath)) {
|
|
199
206
|
return void 0;
|
|
200
207
|
}
|
|
201
|
-
const outputUrl = this.watchedFiles.get(normalizedEntrypoint) ??
|
|
208
|
+
const outputUrl = this.watchedFiles.get(normalizedEntrypoint) ?? derivedOutputUrl;
|
|
202
209
|
return { outputUrl, outputPath };
|
|
203
210
|
}
|
|
204
211
|
getWatchedFiles() {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-app registry for the shared HMR manager used by host-owned dev clients.
|
|
3
|
+
*/
|
|
4
|
+
import type { EcoPagesAppConfig } from '../types/public-types.js';
|
|
5
|
+
import type { IHmrManager } from '../types/public-types.js';
|
|
6
|
+
export declare function setAppHmrManager(appConfig: EcoPagesAppConfig, hmrManager: IHmrManager): void;
|
|
7
|
+
export declare function getAppHmrManager(appConfig: EcoPagesAppConfig): IHmrManager | undefined;
|
|
8
|
+
export declare function clearAppHmrManager(appConfig: EcoPagesAppConfig): void;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const appHmrManagers = /* @__PURE__ */ new WeakMap();
|
|
2
|
+
function setAppHmrManager(appConfig, hmrManager) {
|
|
3
|
+
appHmrManagers.set(appConfig, hmrManager);
|
|
4
|
+
}
|
|
5
|
+
function getAppHmrManager(appConfig) {
|
|
6
|
+
return appHmrManagers.get(appConfig);
|
|
7
|
+
}
|
|
8
|
+
function clearAppHmrManager(appConfig) {
|
|
9
|
+
appHmrManagers.delete(appConfig);
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
clearAppHmrManager,
|
|
13
|
+
getAppHmrManager,
|
|
14
|
+
setAppHmrManager
|
|
15
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns whether a source path is a registered client script HMR entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Registration comes from `dependencies.scripts` via `FileScriptProcessor`,
|
|
6
|
+
* not from filename conventions such as `.script.tsx`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isRegisteredScriptEntrypoint(watchedFiles: ReadonlyMap<string, string>, filePath: string): boolean;
|
|
9
|
+
export declare function encodeHmrDynamicSegments(filepath: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the on-disk and browser URL targets for one HMR script entrypoint.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveHmrEntrypointOutputPaths(srcDir: string, distDir: string, entrypointPath: string): {
|
|
14
|
+
outputPath: string;
|
|
15
|
+
outputUrl: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* @remarks
|
|
19
|
+
* Rebuilds must delete the previous artifact so browser-hmr does not reuse a
|
|
20
|
+
* stale bundle when the source file changed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function removeStaleHmrEntrypointOutput(outputPath: string, scope: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* @remarks
|
|
25
|
+
* Guards against broadcasting reload/update before the bundler has flushed a
|
|
26
|
+
* fresher artifact than the edited source file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isHmrOutputFresh(outputPath: string, sourcePath: string): boolean;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileSystem } from "@ecopages/file-system";
|
|
4
|
+
import { RESOLVED_ASSETS_DIR } from "../config/constants.js";
|
|
5
|
+
import { appLogger } from "../global/app-logger.js";
|
|
6
|
+
function isRegisteredScriptEntrypoint(watchedFiles, filePath) {
|
|
7
|
+
const resolvedPath = path.resolve(filePath);
|
|
8
|
+
if (watchedFiles.has(resolvedPath)) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
for (const entrypointPath of watchedFiles.keys()) {
|
|
12
|
+
if (path.resolve(entrypointPath) === resolvedPath) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function encodeHmrDynamicSegments(filepath) {
|
|
19
|
+
return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
|
|
20
|
+
}
|
|
21
|
+
function resolveHmrEntrypointOutputPaths(srcDir, distDir, entrypointPath) {
|
|
22
|
+
const normalizedEntrypoint = path.resolve(entrypointPath);
|
|
23
|
+
const relativePath = path.relative(srcDir, normalizedEntrypoint);
|
|
24
|
+
const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx?)$/, ".js");
|
|
25
|
+
const encodedPathJs = encodeHmrDynamicSegments(relativePathJs);
|
|
26
|
+
const urlPath = encodedPathJs.split(path.sep).join("/");
|
|
27
|
+
return {
|
|
28
|
+
outputUrl: `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", urlPath).split(path.sep).join("/")}`,
|
|
29
|
+
outputPath: path.join(distDir, urlPath)
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function removeStaleHmrEntrypointOutput(outputPath, scope) {
|
|
33
|
+
if (!fileSystem.exists(outputPath)) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
fileSystem.remove(outputPath);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
appLogger.warn(
|
|
40
|
+
`[${scope}] Failed to remove stale entrypoint output ${outputPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function isHmrOutputFresh(outputPath, sourcePath) {
|
|
45
|
+
if (!fileSystem.exists(outputPath) || !fileSystem.exists(sourcePath)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const outputMtimeMs = fs.statSync(outputPath).mtimeMs;
|
|
49
|
+
const sourceMtimeMs = fs.statSync(sourcePath).mtimeMs;
|
|
50
|
+
return outputMtimeMs >= sourceMtimeMs;
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
encodeHmrDynamicSegments,
|
|
54
|
+
isHmrOutputFresh,
|
|
55
|
+
isRegisteredScriptEntrypoint,
|
|
56
|
+
removeStaleHmrEntrypointOutput,
|
|
57
|
+
resolveHmrEntrypointOutputPaths
|
|
58
|
+
};
|
|
@@ -98,6 +98,7 @@ export declare class JsHmrStrategy extends HmrStrategy {
|
|
|
98
98
|
* Matches if:
|
|
99
99
|
* 1. There are registered entrypoints to rebuild
|
|
100
100
|
* 2. The changed file is a JS/TS file in the src directory
|
|
101
|
+
* 3. Registered entrypoints always match, even when they share an integration template extension
|
|
101
102
|
*
|
|
102
103
|
* @param filePath - Absolute path to the changed file
|
|
103
104
|
* @returns True if this file should trigger entrypoint rebuilds
|
|
@@ -116,6 +117,8 @@ export declare class JsHmrStrategy extends HmrStrategy {
|
|
|
116
117
|
* @returns Action to broadcast update events
|
|
117
118
|
*/
|
|
118
119
|
process(filePath: string): Promise<HmrAction>;
|
|
120
|
+
private resolveEntrypointOutputPath;
|
|
121
|
+
private removeStaleEntrypointOutput;
|
|
119
122
|
/**
|
|
120
123
|
* Bundles one or more entrypoints in a single build invocation.
|
|
121
124
|
* Uses the source directory as the output base so that the directory structure
|
|
@@ -2,6 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import { fileSystem } from "@ecopages/file-system";
|
|
3
3
|
import { HmrStrategy, HmrStrategyType } from "../hmr-strategy.js";
|
|
4
4
|
import { appLogger } from "../../global/app-logger.js";
|
|
5
|
+
import {
|
|
6
|
+
removeStaleHmrEntrypointOutput,
|
|
7
|
+
resolveHmrEntrypointOutputPaths,
|
|
8
|
+
isRegisteredScriptEntrypoint,
|
|
9
|
+
isHmrOutputFresh
|
|
10
|
+
} from "../hmr-entrypoint-output.js";
|
|
5
11
|
class JsHmrStrategy extends HmrStrategy {
|
|
6
12
|
type = HmrStrategyType.SCRIPT;
|
|
7
13
|
context;
|
|
@@ -15,27 +21,30 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
15
21
|
* Matches if:
|
|
16
22
|
* 1. There are registered entrypoints to rebuild
|
|
17
23
|
* 2. The changed file is a JS/TS file in the src directory
|
|
24
|
+
* 3. Registered entrypoints always match, even when they share an integration template extension
|
|
18
25
|
*
|
|
19
26
|
* @param filePath - Absolute path to the changed file
|
|
20
27
|
* @returns True if this file should trigger entrypoint rebuilds
|
|
21
28
|
*/
|
|
22
29
|
matches(filePath) {
|
|
23
30
|
const watchedFiles = this.context.getWatchedFiles();
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
31
|
+
const resolvedPath = path.resolve(filePath);
|
|
32
|
+
const isJsTs = /\.(ts|tsx|js|jsx)$/.test(resolvedPath);
|
|
33
|
+
const srcDir = path.resolve(this.context.getSrcDir());
|
|
34
|
+
const isInSrc = resolvedPath.startsWith(`${srcDir}${path.sep}`) || resolvedPath === srcDir;
|
|
35
|
+
const isIntegrationTemplate = this.context.getTemplateExtensions().some((extension) => resolvedPath.endsWith(extension));
|
|
27
36
|
if (watchedFiles.size === 0) {
|
|
28
37
|
return false;
|
|
29
38
|
}
|
|
30
39
|
if (!isJsTs || !isInSrc) {
|
|
31
40
|
return false;
|
|
32
41
|
}
|
|
42
|
+
if (watchedFiles.has(resolvedPath)) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
33
45
|
if (isIntegrationTemplate) {
|
|
34
46
|
return false;
|
|
35
47
|
}
|
|
36
|
-
if (watchedFiles.has(filePath)) {
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
48
|
return true;
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
@@ -53,22 +62,27 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
53
62
|
async process(filePath) {
|
|
54
63
|
appLogger.debug(`[JsHmrStrategy] Processing ${filePath}`);
|
|
55
64
|
const watchedFiles = this.context.getWatchedFiles();
|
|
65
|
+
const resolvedChanged = path.resolve(filePath);
|
|
66
|
+
const isRegisteredEntrypointEdit = isRegisteredScriptEntrypoint(watchedFiles, resolvedChanged);
|
|
56
67
|
if (watchedFiles.size === 0) {
|
|
57
68
|
appLogger.debug(`[JsHmrStrategy] No watched files to rebuild`);
|
|
58
69
|
return { type: "none" };
|
|
59
70
|
}
|
|
60
71
|
const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(filePath);
|
|
61
72
|
const hasDependencyHit = dependencyHits.size > 0;
|
|
62
|
-
const impactedEntrypoints = hasDependencyHit ? Array.from(dependencyHits).filter((entrypoint) => watchedFiles.has(entrypoint)) : Array.from(watchedFiles.keys());
|
|
73
|
+
const impactedEntrypoints = isRegisteredEntrypointEdit ? [resolvedChanged] : hasDependencyHit ? Array.from(dependencyHits).filter((entrypoint) => watchedFiles.has(path.resolve(entrypoint))) : Array.from(watchedFiles.keys());
|
|
63
74
|
const buildableEntrypoints = impactedEntrypoints.filter(
|
|
64
75
|
(entrypoint) => this.context.shouldProcessEntrypoint?.(entrypoint) ?? true
|
|
65
76
|
);
|
|
66
|
-
if (!hasDependencyHit) {
|
|
77
|
+
if (!hasDependencyHit && !isRegisteredEntrypointEdit) {
|
|
67
78
|
appLogger.debug("[JsHmrStrategy] Dependency graph miss, rebuilding all watched entrypoints");
|
|
68
79
|
}
|
|
69
80
|
if (buildableEntrypoints.length === 0) {
|
|
70
81
|
return { type: "none" };
|
|
71
82
|
}
|
|
83
|
+
for (const entrypoint of buildableEntrypoints) {
|
|
84
|
+
this.removeStaleEntrypointOutput(this.resolveEntrypointOutputPath(entrypoint));
|
|
85
|
+
}
|
|
72
86
|
const buildResult = await this.bundleEntrypoints(buildableEntrypoints);
|
|
73
87
|
if (!buildResult.success) {
|
|
74
88
|
return { type: "none" };
|
|
@@ -76,17 +90,19 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
76
90
|
const updates = [];
|
|
77
91
|
let reloadRequired = false;
|
|
78
92
|
for (const entrypoint of buildableEntrypoints) {
|
|
79
|
-
const
|
|
80
|
-
|
|
93
|
+
const resolvedEntrypoint = path.resolve(entrypoint);
|
|
94
|
+
const derivedOutput = resolveHmrEntrypointOutputPaths(
|
|
95
|
+
this.context.getSrcDir(),
|
|
96
|
+
this.context.getDistDir(),
|
|
97
|
+
resolvedEntrypoint
|
|
98
|
+
);
|
|
99
|
+
const outputUrl = watchedFiles.get(resolvedEntrypoint) ?? derivedOutput.outputUrl;
|
|
100
|
+
const outputPath = this.resolveEntrypointOutputPath(resolvedEntrypoint);
|
|
81
101
|
if (buildResult.dependencies) {
|
|
82
|
-
const entrypointDeps = buildResult.dependencies.get(
|
|
83
|
-
this.context.getEntrypointDependencyGraph().setEntrypointDependencies(
|
|
102
|
+
const entrypointDeps = buildResult.dependencies.get(resolvedEntrypoint) ?? [];
|
|
103
|
+
this.context.getEntrypointDependencyGraph().setEntrypointDependencies(resolvedEntrypoint, entrypointDeps);
|
|
84
104
|
}
|
|
85
|
-
const
|
|
86
|
-
const relativePath = path.relative(srcDir, entrypoint);
|
|
87
|
-
const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx?)$/, ".js");
|
|
88
|
-
const outputPath = path.join(this.context.getDistDir(), relativePathJs);
|
|
89
|
-
const result = await this.processOutput(outputPath, outputUrl);
|
|
105
|
+
const result = await this.processOutput(outputPath, outputUrl, resolvedEntrypoint);
|
|
90
106
|
if (result.success) {
|
|
91
107
|
updates.push(outputUrl);
|
|
92
108
|
if (result.requiresReload) {
|
|
@@ -113,6 +129,12 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
113
129
|
}
|
|
114
130
|
return { type: "none" };
|
|
115
131
|
}
|
|
132
|
+
resolveEntrypointOutputPath(entrypointPath) {
|
|
133
|
+
return resolveHmrEntrypointOutputPaths(this.context.getSrcDir(), this.context.getDistDir(), entrypointPath).outputPath;
|
|
134
|
+
}
|
|
135
|
+
removeStaleEntrypointOutput(outputPath) {
|
|
136
|
+
removeStaleHmrEntrypointOutput(outputPath, "JsHmrStrategy");
|
|
137
|
+
}
|
|
116
138
|
/**
|
|
117
139
|
* Bundles one or more entrypoints in a single build invocation.
|
|
118
140
|
* Uses the source directory as the output base so that the directory structure
|
|
@@ -120,6 +142,30 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
120
142
|
*/
|
|
121
143
|
async bundleEntrypoints(entrypoints) {
|
|
122
144
|
try {
|
|
145
|
+
if (entrypoints.length === 1) {
|
|
146
|
+
const entrypoint = entrypoints[0];
|
|
147
|
+
const outputPath = this.resolveEntrypointOutputPath(entrypoint);
|
|
148
|
+
const naming = path.relative(this.context.getDistDir(), outputPath).split(path.sep).join("/");
|
|
149
|
+
const result2 = await this.context.getBrowserBundleService().bundle({
|
|
150
|
+
profile: "hmr-entrypoint",
|
|
151
|
+
entrypoints: [entrypoint],
|
|
152
|
+
outdir: this.context.getDistDir(),
|
|
153
|
+
naming,
|
|
154
|
+
plugins: this.context.getPlugins(),
|
|
155
|
+
minify: false
|
|
156
|
+
});
|
|
157
|
+
if (!result2.success) {
|
|
158
|
+
appLogger.error("[JsHmrStrategy] Entrypoint build failed:", result2.logs);
|
|
159
|
+
return { success: false };
|
|
160
|
+
}
|
|
161
|
+
const dependencies2 = /* @__PURE__ */ new Map();
|
|
162
|
+
if (result2.dependencyGraph?.entrypoints) {
|
|
163
|
+
for (const [resolvedEntrypoint, deps] of Object.entries(result2.dependencyGraph.entrypoints)) {
|
|
164
|
+
dependencies2.set(path.resolve(resolvedEntrypoint), deps);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { success: true, dependencies: dependencies2 };
|
|
168
|
+
}
|
|
123
169
|
const result = await this.context.getBrowserBundleService().bundle({
|
|
124
170
|
profile: "hmr-entrypoint",
|
|
125
171
|
entrypoints,
|
|
@@ -153,8 +199,12 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
153
199
|
* @param url - URL path for the bundled file
|
|
154
200
|
* @returns True if processing was successful and update should be broadcast
|
|
155
201
|
*/
|
|
156
|
-
async processOutput(filepath, url) {
|
|
202
|
+
async processOutput(filepath, url, sourcePath) {
|
|
157
203
|
try {
|
|
204
|
+
if (sourcePath && !isHmrOutputFresh(filepath, sourcePath)) {
|
|
205
|
+
appLogger.warn(`[JsHmrStrategy] Skipping broadcast for stale HMR output ${url}`);
|
|
206
|
+
return { success: false, requiresReload: false };
|
|
207
|
+
}
|
|
158
208
|
const code = await fileSystem.readFile(filepath);
|
|
159
209
|
if (code.includes("/* [ecopages] hmr */")) {
|
|
160
210
|
return { success: true, requiresReload: !code.includes("import.meta.hot.accept") };
|
|
@@ -37,3 +37,9 @@ export declare function shouldPrefetchLink(link: HTMLAnchorElement, options: Lin
|
|
|
37
37
|
* `application/xhtml+xml` responses are accepted; missing or asset types are rejected.
|
|
38
38
|
*/
|
|
39
39
|
export declare function isHtmlPageResponse(response: Response): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* @remarks
|
|
42
|
+
* Servers should send `text/html`. When the type is missing or the runtime
|
|
43
|
+
* defaults to `text/plain` (common in tests), a document-shaped body is accepted.
|
|
44
|
+
*/
|
|
45
|
+
export declare function assertHtmlPageResponse(response: Response): Promise<void>;
|
|
@@ -77,7 +77,22 @@ function isHtmlPageResponse(response) {
|
|
|
77
77
|
const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
78
78
|
return normalized === "text/html" || normalized === "application/xhtml+xml";
|
|
79
79
|
}
|
|
80
|
+
async function assertHtmlPageResponse(response) {
|
|
81
|
+
if (isHtmlPageResponse(response)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const contentType = response.headers.get("Content-Type");
|
|
85
|
+
const normalized = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
86
|
+
if (normalized === "text/plain" || normalized === "") {
|
|
87
|
+
const sample = (await response.clone().text()).trimStart();
|
|
88
|
+
if (sample.startsWith("<")) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Expected HTML page response, received ${contentType ?? "unknown content type"}`);
|
|
93
|
+
}
|
|
80
94
|
export {
|
|
95
|
+
assertHtmlPageResponse,
|
|
81
96
|
getLinkNavigationDecision,
|
|
82
97
|
getNavigableHrefFromClick,
|
|
83
98
|
isHtmlPageResponse,
|
package/src/services/assets/asset-processing-service/processors/script/file-script.processor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { RESOLVED_ASSETS_DIR } from "../../../../../config/constants.js";
|
|
3
3
|
import { fileSystem } from "@ecopages/file-system";
|
|
4
|
+
import { appLogger } from "../../../../../global/app-logger.js";
|
|
4
5
|
import { BaseScriptProcessor } from "../base/base-script-processor.js";
|
|
5
6
|
class FileScriptProcessor extends BaseScriptProcessor {
|
|
6
7
|
hmrManager;
|
|
@@ -50,7 +51,10 @@ class FileScriptProcessor extends BaseScriptProcessor {
|
|
|
50
51
|
packageRole: dep.packageRole,
|
|
51
52
|
bundledSourceFilepaths: dep.bundledSourceFilepaths
|
|
52
53
|
};
|
|
53
|
-
} catch {
|
|
54
|
+
} catch (error) {
|
|
55
|
+
appLogger.warn(
|
|
56
|
+
`[FileScriptProcessor] HMR script registration failed for ${dep.filepath}, falling back to non-HMR asset: ${error instanceof Error ? error.message : String(error)}`
|
|
57
|
+
);
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
const content = fileSystem.readFileSync(dep.filepath);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { EcoPagesAppConfig } from '../../types/internal-types.js';
|
|
1
|
+
import type { EcoPagesAppConfig, RegisteredScriptEntrypointChangeHandler } from '../../types/internal-types.js';
|
|
2
|
+
export type { RegisteredScriptEntrypointChangeHandler };
|
|
2
3
|
export type DevelopmentInvalidationCategory = 'public-asset' | 'additional-watch' | 'include-source' | 'explicit-server-view' | 'route-source' | 'processor-owned-asset' | 'server-source' | 'other';
|
|
3
4
|
/**
|
|
4
5
|
* Framework-owned invalidation plan for one changed file.
|
|
@@ -38,6 +39,14 @@ export declare class DevelopmentInvalidationService {
|
|
|
38
39
|
* Invalidates the app-owned server-module graph.
|
|
39
40
|
*/
|
|
40
41
|
invalidateServerModules(changedFiles?: string[]): void;
|
|
42
|
+
/**
|
|
43
|
+
* Registers an integration-owned handler for registered script entrypoint edits.
|
|
44
|
+
*/
|
|
45
|
+
registerRegisteredScriptEntrypointChangeHandler(handler: RegisteredScriptEntrypointChangeHandler): void;
|
|
46
|
+
/**
|
|
47
|
+
* Notifies integration handlers that a registered script entrypoint changed.
|
|
48
|
+
*/
|
|
49
|
+
notifyRegisteredScriptEntrypointChange(filePath: string): Promise<void>;
|
|
41
50
|
/**
|
|
42
51
|
* Resets runtime-owned graph state and invalidates server modules.
|
|
43
52
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { getAppServerInvalidationState } from "../runtime-state/server-invalidation-state.service.js";
|
|
3
|
+
import { appLogger } from "../../global/app-logger.js";
|
|
3
4
|
class DevelopmentInvalidationService {
|
|
4
5
|
appConfig;
|
|
5
6
|
constructor(appConfig) {
|
|
@@ -18,6 +19,30 @@ class DevelopmentInvalidationService {
|
|
|
18
19
|
getAppServerInvalidationState(this.appConfig).invalidateServerModules(changedFiles);
|
|
19
20
|
this.appConfig.runtime?.appModuleLoader?.invalidateDevelopmentGraph();
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Registers an integration-owned handler for registered script entrypoint edits.
|
|
24
|
+
*/
|
|
25
|
+
registerRegisteredScriptEntrypointChangeHandler(handler) {
|
|
26
|
+
const runtime = this.appConfig.runtime ?? {};
|
|
27
|
+
this.appConfig.runtime = runtime;
|
|
28
|
+
runtime.registeredScriptEntrypointChangeHandlers ??= [];
|
|
29
|
+
runtime.registeredScriptEntrypointChangeHandlers.push(handler);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Notifies integration handlers that a registered script entrypoint changed.
|
|
33
|
+
*/
|
|
34
|
+
async notifyRegisteredScriptEntrypointChange(filePath) {
|
|
35
|
+
const handlers = this.appConfig.runtime?.registeredScriptEntrypointChangeHandlers ?? [];
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
try {
|
|
38
|
+
await handler(filePath);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
appLogger.error(
|
|
41
|
+
`Failed to handle registered script entrypoint change for ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
21
46
|
/**
|
|
22
47
|
* Resets runtime-owned graph state and invalidates server modules.
|
|
23
48
|
*/
|
|
@@ -62,6 +62,7 @@ export interface PageModuleImportDependencies {
|
|
|
62
62
|
export declare class PageModuleImportService {
|
|
63
63
|
private readonly appConfig?;
|
|
64
64
|
private readonly dependencies;
|
|
65
|
+
private readonly dependencyHasher;
|
|
65
66
|
private readonly importCache;
|
|
66
67
|
private developmentInvalidationVersion;
|
|
67
68
|
constructor(appConfig?: EcoPagesAppConfig, dependencies?: Partial<PageModuleImportDependencies>);
|
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
createJsxCacheKey
|
|
17
17
|
} from "./route-module-build-cache.js";
|
|
18
18
|
import { getSharedRouteModuleBuildCache } from "./route-module-build-cache-registry.js";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
RouteModuleDependencyHasher,
|
|
21
|
+
resolveRouteModuleDependencyPaths
|
|
22
|
+
} from "./route-module-dependency-hasher.js";
|
|
20
23
|
import { supportsSourceModuleLoading } from "./source-module-support.js";
|
|
21
24
|
class PageModuleImportService {
|
|
22
25
|
appConfig;
|
|
23
26
|
dependencies;
|
|
27
|
+
dependencyHasher;
|
|
24
28
|
importCache = /* @__PURE__ */ new Map();
|
|
25
29
|
developmentInvalidationVersion = 0;
|
|
26
30
|
constructor(appConfig, dependencies) {
|
|
@@ -31,6 +35,10 @@ class PageModuleImportService {
|
|
|
31
35
|
canLoadSourceModuleFromHost: dependencies?.canLoadSourceModuleFromHost ?? supportsSourceModuleLoading,
|
|
32
36
|
getHostModuleLoader: dependencies?.getHostModuleLoader ?? (() => void 0)
|
|
33
37
|
};
|
|
38
|
+
this.dependencyHasher = new RouteModuleDependencyHasher({
|
|
39
|
+
hashFile: (filePath) => this.dependencies.hashFile(filePath),
|
|
40
|
+
exists: (filePath) => fileSystem.exists(filePath)
|
|
41
|
+
});
|
|
34
42
|
}
|
|
35
43
|
/**
|
|
36
44
|
* Clears the shared import cache used by framework-owned page-module loads.
|
|
@@ -49,6 +57,7 @@ class PageModuleImportService {
|
|
|
49
57
|
*/
|
|
50
58
|
invalidateDevelopmentGraph() {
|
|
51
59
|
this.clearImportCache();
|
|
60
|
+
this.dependencyHasher.clearMemo();
|
|
52
61
|
this.developmentInvalidationVersion += 1;
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
@@ -97,13 +106,21 @@ class PageModuleImportService {
|
|
|
97
106
|
].join("::");
|
|
98
107
|
const cachedModule = this.importCache.get(cacheKey);
|
|
99
108
|
if (cachedModule) {
|
|
100
|
-
|
|
109
|
+
if (!cachedModule.dependencyHashes) {
|
|
110
|
+
return await cachedModule.promise;
|
|
111
|
+
}
|
|
112
|
+
this.dependencyHasher.clearMemo();
|
|
113
|
+
if (this.dependencyHasher.matchesStoredHashes(cachedModule.dependencyHashes, filePath, fileHash)) {
|
|
114
|
+
return await cachedModule.promise;
|
|
115
|
+
}
|
|
116
|
+
this.importCache.delete(cacheKey);
|
|
101
117
|
}
|
|
102
118
|
const importPromise = this.loadModule({
|
|
103
119
|
...options,
|
|
104
|
-
fileHash
|
|
120
|
+
fileHash,
|
|
121
|
+
importCacheKey: cacheKey
|
|
105
122
|
});
|
|
106
|
-
this.importCache.set(cacheKey, importPromise);
|
|
123
|
+
this.importCache.set(cacheKey, { promise: importPromise });
|
|
107
124
|
try {
|
|
108
125
|
return await importPromise;
|
|
109
126
|
} catch (error) {
|
|
@@ -112,7 +129,13 @@ class PageModuleImportService {
|
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
async loadModule(options) {
|
|
115
|
-
const {
|
|
132
|
+
const {
|
|
133
|
+
filePath,
|
|
134
|
+
invalidationVersion = this.developmentInvalidationVersion,
|
|
135
|
+
cacheScope,
|
|
136
|
+
fileHash,
|
|
137
|
+
importCacheKey
|
|
138
|
+
} = options;
|
|
116
139
|
const {
|
|
117
140
|
rootDir,
|
|
118
141
|
outdir,
|
|
@@ -170,11 +193,20 @@ class PageModuleImportService {
|
|
|
170
193
|
throw new Error(noOutputMessage(filePath));
|
|
171
194
|
}
|
|
172
195
|
normalizeNodeRuntimeBuildOutputFile(compiledOutput, rootDir);
|
|
196
|
+
const dependencyModulePaths = resolveRouteModuleDependencyPaths(buildResult, filePath, rootDir);
|
|
197
|
+
const dependencyHashes = this.dependencyHasher.createDependencyHashes(dependencyModulePaths);
|
|
198
|
+
dependencyHashes[path.normalize(filePath)] = fileHash;
|
|
199
|
+
if (importCacheKey) {
|
|
200
|
+
const cacheEntry = this.importCache.get(importCacheKey);
|
|
201
|
+
if (cacheEntry) {
|
|
202
|
+
cacheEntry.dependencyHashes = dependencyHashes;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
173
205
|
routeModuleBuildCache.recordBuild({
|
|
174
206
|
...options,
|
|
175
207
|
fileHash,
|
|
176
208
|
outputPath: compiledOutput,
|
|
177
|
-
dependencyModulePaths
|
|
209
|
+
dependencyModulePaths
|
|
178
210
|
});
|
|
179
211
|
const compiledOutputUrl = pathToFileURL(compiledOutput);
|
|
180
212
|
if (shouldAddRuntimeUpdateQuery(invalidationVersion, cacheScope)) {
|
|
@@ -50,8 +50,8 @@ export declare class RouteModuleDependencyHasher {
|
|
|
50
50
|
* @returns `true` when all stored hashes are present and still valid.
|
|
51
51
|
*/
|
|
52
52
|
matchesStoredHashes(storedHashes: RouteModuleDependencyHashes | undefined, entrypointPath?: string, entrypointSourceHash?: string): boolean;
|
|
53
|
-
/** Clears the per-build memo
|
|
54
|
-
|
|
53
|
+
/** Clears the per-build memo so subsequent reads reflect current file contents. */
|
|
54
|
+
clearMemo(): void;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* Returns whether a bundler module id should participate in route-module cache invalidation.
|
|
@@ -73,8 +73,8 @@ class RouteModuleDependencyHasher {
|
|
|
73
73
|
}
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
|
-
/** Clears the per-build memo
|
|
77
|
-
|
|
76
|
+
/** Clears the per-build memo so subsequent reads reflect current file contents. */
|
|
77
|
+
clearMemo() {
|
|
78
78
|
this.memo.clear();
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -15,6 +15,8 @@ import type { EntrypointDependencyGraph } from '../services/runtime-state/entryp
|
|
|
15
15
|
import type { ServerInvalidationState } from '../services/runtime-state/server-invalidation-state.service.js';
|
|
16
16
|
import type { ServerModuleTranspiler } from '../services/module-loading/server-module-transpiler.service.js';
|
|
17
17
|
import type { RouteModuleBuildCache } from '../services/module-loading/route-module-build-cache.store.js';
|
|
18
|
+
/** Integration hook for registered `dependencies.scripts` entrypoint changes in dev. */
|
|
19
|
+
export type RegisteredScriptEntrypointChangeHandler = (filePath: string) => void | Promise<void>;
|
|
18
20
|
export interface RobotsPreference {
|
|
19
21
|
/**
|
|
20
22
|
* The user agent
|
|
@@ -164,6 +166,8 @@ export type EcoPagesAppConfig = {
|
|
|
164
166
|
runtimeAssetsPrepared?: boolean;
|
|
165
167
|
/** When `'host'`, the embedded dev server owns browser dev-client bootstrap. */
|
|
166
168
|
devClientOwner?: 'core' | 'host';
|
|
169
|
+
/** Integration hooks run when a registered `dependencies.scripts` entrypoint changes. */
|
|
170
|
+
registeredScriptEntrypointChangeHandlers?: RegisteredScriptEntrypointChangeHandler[];
|
|
167
171
|
/** @deprecated Prefer {@link devClientOwner}: `'host'`. */
|
|
168
172
|
delegateBrowserReloadToHost?: boolean;
|
|
169
173
|
};
|
|
@@ -15,6 +15,8 @@ export interface ProjectWatcherConfig {
|
|
|
15
15
|
bridge: IClientBridge;
|
|
16
16
|
/** When true, the host dev server owns browser dev-client bootstrap. */
|
|
17
17
|
hostOwnsDevClient?: boolean;
|
|
18
|
+
/** Delay before a change event is processed; 0 disables debouncing. */
|
|
19
|
+
changeDebounceMs?: number;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* ProjectWatcher handles file system changes for hot module replacement (HMR).
|
|
@@ -46,10 +48,12 @@ export declare class ProjectWatcher {
|
|
|
46
48
|
private bridge;
|
|
47
49
|
private readonly hostOwnsDevClient;
|
|
48
50
|
private readonly invalidationService;
|
|
51
|
+
private readonly changeDebounceMs;
|
|
49
52
|
private watcher;
|
|
50
53
|
private lastHandledChange;
|
|
54
|
+
private pendingChangeTimers;
|
|
51
55
|
private changeQueue;
|
|
52
|
-
constructor({ config, refreshRouterRoutesCallback, hmrManager, bridge, hostOwnsDevClient }: ProjectWatcherConfig);
|
|
56
|
+
constructor({ config, refreshRouterRoutesCallback, hmrManager, bridge, hostOwnsDevClient, changeDebounceMs, }: ProjectWatcherConfig);
|
|
53
57
|
/**
|
|
54
58
|
* Uncaches modules in the source directory to ensure fresh imports.
|
|
55
59
|
* This is necessary for hot module replacement to work correctly.
|
|
@@ -88,6 +92,13 @@ export declare class ProjectWatcher {
|
|
|
88
92
|
* @param event - The type of file system event
|
|
89
93
|
*/
|
|
90
94
|
private handleFileChange;
|
|
95
|
+
private processFileChange;
|
|
96
|
+
/**
|
|
97
|
+
* Re-imports server modules before HMR broadcast so reload/refetch does not
|
|
98
|
+
* race stale in-memory imports or custom-element registry state.
|
|
99
|
+
*/
|
|
100
|
+
private prewarmBeforeHmr;
|
|
101
|
+
private prewarmServerModuleImports;
|
|
91
102
|
/**
|
|
92
103
|
* Notifies all processors whose watch config matches the given file extension.
|
|
93
104
|
* This is called before checking processor ownership so that dependency-only
|
|
@@ -2,7 +2,11 @@ import path from "node:path";
|
|
|
2
2
|
import chokidar, {} from "chokidar";
|
|
3
3
|
import { fileSystem } from "@ecopages/file-system";
|
|
4
4
|
import { appLogger } from "../global/app-logger.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DevelopmentInvalidationService
|
|
7
|
+
} from "../services/invalidation/development-invalidation.service.js";
|
|
8
|
+
import { isRegisteredScriptEntrypoint } from "../hmr/hmr-entrypoint-output.js";
|
|
9
|
+
import { resolveInternalExecutionDir } from "../utils/resolve-work-dir.js";
|
|
6
10
|
import { createProjectWatcherIgnorePredicate } from "./project-watcher-ignore.js";
|
|
7
11
|
class ProjectWatcher {
|
|
8
12
|
/**
|
|
@@ -19,19 +23,31 @@ class ProjectWatcher {
|
|
|
19
23
|
bridge;
|
|
20
24
|
hostOwnsDevClient;
|
|
21
25
|
invalidationService;
|
|
26
|
+
changeDebounceMs;
|
|
22
27
|
watcher = null;
|
|
23
28
|
lastHandledChange = /* @__PURE__ */ new Map();
|
|
29
|
+
pendingChangeTimers = /* @__PURE__ */ new Map();
|
|
24
30
|
changeQueue = Promise.resolve();
|
|
25
|
-
constructor({
|
|
31
|
+
constructor({
|
|
32
|
+
config,
|
|
33
|
+
refreshRouterRoutesCallback,
|
|
34
|
+
hmrManager,
|
|
35
|
+
bridge,
|
|
36
|
+
hostOwnsDevClient,
|
|
37
|
+
changeDebounceMs
|
|
38
|
+
}) {
|
|
26
39
|
this.appConfig = config;
|
|
27
40
|
this.refreshRouterRoutesCallback = refreshRouterRoutesCallback;
|
|
28
41
|
this.hmrManager = hmrManager;
|
|
29
42
|
this.bridge = bridge;
|
|
30
43
|
this.hostOwnsDevClient = hostOwnsDevClient === true;
|
|
44
|
+
const envDebounceMs = process.env.ECOPAGES_WATCH_CHANGE_DEBOUNCE_MS;
|
|
45
|
+
this.changeDebounceMs = changeDebounceMs ?? (envDebounceMs !== void 0 && envDebounceMs !== "" ? Number(envDebounceMs) : void 0) ?? ProjectWatcher.duplicateChangeWindowMs;
|
|
31
46
|
this.invalidationService = new DevelopmentInvalidationService(config);
|
|
32
47
|
this.triggerRouterRefresh = this.triggerRouterRefresh.bind(this);
|
|
33
48
|
this.handleError = this.handleError.bind(this);
|
|
34
49
|
this.handleFileChange = this.handleFileChange.bind(this);
|
|
50
|
+
this.processFileChange = this.processFileChange.bind(this);
|
|
35
51
|
}
|
|
36
52
|
/**
|
|
37
53
|
* Uncaches modules in the source directory to ensure fresh imports.
|
|
@@ -86,6 +102,7 @@ class ProjectWatcher {
|
|
|
86
102
|
enqueueChange(task) {
|
|
87
103
|
const queuedTask = this.changeQueue.then(task, task);
|
|
88
104
|
this.changeQueue = queuedTask.catch(() => void 0);
|
|
105
|
+
return queuedTask;
|
|
89
106
|
}
|
|
90
107
|
/**
|
|
91
108
|
* Handles file changes by uncaching modules, refreshing routes, and delegating appropriately.
|
|
@@ -105,8 +122,23 @@ class ProjectWatcher {
|
|
|
105
122
|
* @param rawPath - Path of the changed file
|
|
106
123
|
* @param event - The type of file system event
|
|
107
124
|
*/
|
|
108
|
-
|
|
125
|
+
handleFileChange(rawPath, event = "change") {
|
|
109
126
|
const filePath = path.resolve(rawPath);
|
|
127
|
+
if (this.changeDebounceMs === 0) {
|
|
128
|
+
return this.enqueueChange(() => this.processFileChange(filePath, event));
|
|
129
|
+
}
|
|
130
|
+
const existing = this.pendingChangeTimers.get(filePath);
|
|
131
|
+
if (existing) {
|
|
132
|
+
clearTimeout(existing);
|
|
133
|
+
}
|
|
134
|
+
const timer = setTimeout(() => {
|
|
135
|
+
this.pendingChangeTimers.delete(filePath);
|
|
136
|
+
this.enqueueChange(() => this.processFileChange(filePath, event));
|
|
137
|
+
}, this.changeDebounceMs);
|
|
138
|
+
this.pendingChangeTimers.set(filePath, timer);
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
}
|
|
141
|
+
async processFileChange(filePath, event) {
|
|
110
142
|
const now = Date.now();
|
|
111
143
|
const lastHandledAt = this.lastHandledChange.get(filePath);
|
|
112
144
|
if (lastHandledAt !== void 0 && now - lastHandledAt < ProjectWatcher.duplicateChangeWindowMs) {
|
|
@@ -120,8 +152,16 @@ class ProjectWatcher {
|
|
|
120
152
|
return;
|
|
121
153
|
}
|
|
122
154
|
this.uncacheModules();
|
|
155
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
156
|
+
const isRegisteredScriptEdit = isRegisteredScriptEntrypoint(
|
|
157
|
+
this.hmrManager.getWatchedFiles(),
|
|
158
|
+
resolvedFilePath
|
|
159
|
+
);
|
|
123
160
|
if (plan.invalidateServerModules) {
|
|
124
161
|
this.invalidationService.invalidateServerModules([filePath]);
|
|
162
|
+
if (isRegisteredScriptEdit) {
|
|
163
|
+
await this.invalidationService.notifyRegisteredScriptEntrypointChange(resolvedFilePath);
|
|
164
|
+
}
|
|
125
165
|
}
|
|
126
166
|
if (plan.refreshRoutes) {
|
|
127
167
|
await this.refreshRouterRoutesCallback();
|
|
@@ -130,8 +170,9 @@ class ProjectWatcher {
|
|
|
130
170
|
this.requestBrowserReload();
|
|
131
171
|
return;
|
|
132
172
|
}
|
|
133
|
-
const deferProcessorNotifications = plan.category === "include-source" || plan.category === "explicit-server-view";
|
|
173
|
+
const deferProcessorNotifications = plan.category === "include-source" || plan.category === "explicit-server-view" || isRegisteredScriptEdit;
|
|
134
174
|
if (deferProcessorNotifications && plan.delegateToHmr) {
|
|
175
|
+
await this.prewarmBeforeHmr(resolvedFilePath, plan, isRegisteredScriptEdit);
|
|
135
176
|
await this.hmrManager.handleFileChange(filePath);
|
|
136
177
|
void this.notifyProcessors(filePath, event);
|
|
137
178
|
return;
|
|
@@ -150,6 +191,46 @@ class ProjectWatcher {
|
|
|
150
191
|
}
|
|
151
192
|
}
|
|
152
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Re-imports server modules before HMR broadcast so reload/refetch does not
|
|
196
|
+
* race stale in-memory imports or custom-element registry state.
|
|
197
|
+
*/
|
|
198
|
+
async prewarmBeforeHmr(filePath, plan, isRegisteredScriptEdit) {
|
|
199
|
+
if (isRegisteredScriptEdit) {
|
|
200
|
+
await this.prewarmServerModuleImports([filePath], { bypassCache: true, scope: "registered script" });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (plan.category !== "include-source" && plan.category !== "explicit-server-view") {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const modulePaths = plan.category === "include-source" ? [this.appConfig.absolutePaths.htmlTemplatePath] : [path.resolve(filePath)];
|
|
207
|
+
await this.prewarmServerModuleImports(modulePaths, { scope: "server template" });
|
|
208
|
+
}
|
|
209
|
+
async prewarmServerModuleImports(modulePaths, options) {
|
|
210
|
+
const appModuleLoader = this.appConfig.runtime?.appModuleLoader;
|
|
211
|
+
if (!appModuleLoader) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const outdir = path.join(resolveInternalExecutionDir(this.appConfig), ".server-modules");
|
|
215
|
+
for (const modulePath of modulePaths) {
|
|
216
|
+
if (!modulePath) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await appModuleLoader.importModule({
|
|
221
|
+
filePath: modulePath,
|
|
222
|
+
rootDir: this.appConfig.rootDir,
|
|
223
|
+
outdir,
|
|
224
|
+
externalPackages: true,
|
|
225
|
+
bypassCache: options.bypassCache
|
|
226
|
+
});
|
|
227
|
+
} catch (error) {
|
|
228
|
+
appLogger.error(
|
|
229
|
+
`Failed to prewarm ${options.scope} ${modulePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
153
234
|
/**
|
|
154
235
|
* Notifies all processors whose watch config matches the given file extension.
|
|
155
236
|
* This is called before checking processor ownership so that dependency-only
|
|
@@ -272,7 +353,7 @@ class ProjectWatcher {
|
|
|
272
353
|
pollInterval: 50
|
|
273
354
|
}
|
|
274
355
|
});
|
|
275
|
-
this.watcher.on("change", (p) => this.
|
|
356
|
+
this.watcher.on("change", (p) => this.handleFileChange(p, "change")).on("add", (p) => this.handleFileChange(p, "add")).on("addDir", (p) => this.enqueueChange(() => this.triggerRouterRefresh(p))).on("unlink", (p) => this.handleFileChange(p, "unlink")).on("unlinkDir", (p) => this.enqueueChange(() => this.triggerRouterRefresh(p))).on("error", (error) => this.handleError(error));
|
|
276
357
|
for (const processor of this.appConfig.processors.values()) {
|
|
277
358
|
const watchConfig = processor.getWatchConfig();
|
|
278
359
|
if (watchConfig?.onError) {
|