@ecopages/core 0.2.0-beta.18 → 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 +2 -2
- package/src/adapters/bun/server-adapter.js +18 -3
- package/src/adapters/shared/server-adapter.js +1 -2
- package/src/hmr/hmr-entrypoint-output.d.ts +14 -0
- package/src/hmr/hmr-entrypoint-output.js +23 -0
- package/src/hmr/strategies/js-hmr-strategy.js +28 -11
- 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",
|
|
@@ -313,6 +313,7 @@ class BunServerAdapter extends SharedServerAdapter {
|
|
|
313
313
|
wrapFetchWithWebSocketUpgrades(originalFetch, { serveHmrEndpoints }) {
|
|
314
314
|
const matchRoute = (pathname) => findWebSocketRoute(this.websocketHandlers, pathname);
|
|
315
315
|
const hmrManager = this.hmrManager;
|
|
316
|
+
const waitForInit = this.waitForInitialization.bind(this);
|
|
316
317
|
return async function(request, server) {
|
|
317
318
|
const url = new URL(request.url);
|
|
318
319
|
if (serveHmrEndpoints) {
|
|
@@ -321,9 +322,23 @@ class BunServerAdapter extends SharedServerAdapter {
|
|
|
321
322
|
return success ? void 0 : new Response("WebSocket upgrade failed", { status: 400 });
|
|
322
323
|
}
|
|
323
324
|
if (url.pathname === "/_hmr_runtime.js") {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 });
|
|
327
342
|
}
|
|
328
343
|
}
|
|
329
344
|
const wsMatch = matchRoute(url.pathname);
|
|
@@ -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
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
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;
|
|
1
9
|
export declare function encodeHmrDynamicSegments(filepath: string): string;
|
|
2
10
|
/**
|
|
3
11
|
* Resolves the on-disk and browser URL targets for one HMR script entrypoint.
|
|
@@ -12,3 +20,9 @@ export declare function resolveHmrEntrypointOutputPaths(srcDir: string, distDir:
|
|
|
12
20
|
* stale bundle when the source file changed.
|
|
13
21
|
*/
|
|
14
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;
|
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { fileSystem } from "@ecopages/file-system";
|
|
3
4
|
import { RESOLVED_ASSETS_DIR } from "../config/constants.js";
|
|
4
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
|
+
}
|
|
5
18
|
function encodeHmrDynamicSegments(filepath) {
|
|
6
19
|
return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
|
|
7
20
|
}
|
|
@@ -28,8 +41,18 @@ function removeStaleHmrEntrypointOutput(outputPath, scope) {
|
|
|
28
41
|
);
|
|
29
42
|
}
|
|
30
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
|
+
}
|
|
31
52
|
export {
|
|
32
53
|
encodeHmrDynamicSegments,
|
|
54
|
+
isHmrOutputFresh,
|
|
55
|
+
isRegisteredScriptEntrypoint,
|
|
33
56
|
removeStaleHmrEntrypointOutput,
|
|
34
57
|
resolveHmrEntrypointOutputPaths
|
|
35
58
|
};
|
|
@@ -2,7 +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 {
|
|
5
|
+
import {
|
|
6
|
+
removeStaleHmrEntrypointOutput,
|
|
7
|
+
resolveHmrEntrypointOutputPaths,
|
|
8
|
+
isRegisteredScriptEntrypoint,
|
|
9
|
+
isHmrOutputFresh
|
|
10
|
+
} from "../hmr-entrypoint-output.js";
|
|
6
11
|
class JsHmrStrategy extends HmrStrategy {
|
|
7
12
|
type = HmrStrategyType.SCRIPT;
|
|
8
13
|
context;
|
|
@@ -25,7 +30,8 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
25
30
|
const watchedFiles = this.context.getWatchedFiles();
|
|
26
31
|
const resolvedPath = path.resolve(filePath);
|
|
27
32
|
const isJsTs = /\.(ts|tsx|js|jsx)$/.test(resolvedPath);
|
|
28
|
-
const
|
|
33
|
+
const srcDir = path.resolve(this.context.getSrcDir());
|
|
34
|
+
const isInSrc = resolvedPath.startsWith(`${srcDir}${path.sep}`) || resolvedPath === srcDir;
|
|
29
35
|
const isIntegrationTemplate = this.context.getTemplateExtensions().some((extension) => resolvedPath.endsWith(extension));
|
|
30
36
|
if (watchedFiles.size === 0) {
|
|
31
37
|
return false;
|
|
@@ -56,17 +62,19 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
56
62
|
async process(filePath) {
|
|
57
63
|
appLogger.debug(`[JsHmrStrategy] Processing ${filePath}`);
|
|
58
64
|
const watchedFiles = this.context.getWatchedFiles();
|
|
65
|
+
const resolvedChanged = path.resolve(filePath);
|
|
66
|
+
const isRegisteredEntrypointEdit = isRegisteredScriptEntrypoint(watchedFiles, resolvedChanged);
|
|
59
67
|
if (watchedFiles.size === 0) {
|
|
60
68
|
appLogger.debug(`[JsHmrStrategy] No watched files to rebuild`);
|
|
61
69
|
return { type: "none" };
|
|
62
70
|
}
|
|
63
71
|
const dependencyHits = this.context.getEntrypointDependencyGraph().getDependencyEntrypoints(filePath);
|
|
64
72
|
const hasDependencyHit = dependencyHits.size > 0;
|
|
65
|
-
const impactedEntrypoints = hasDependencyHit ? Array.from(dependencyHits).filter((entrypoint) => watchedFiles.has(path.resolve(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());
|
|
66
74
|
const buildableEntrypoints = impactedEntrypoints.filter(
|
|
67
75
|
(entrypoint) => this.context.shouldProcessEntrypoint?.(entrypoint) ?? true
|
|
68
76
|
);
|
|
69
|
-
if (!hasDependencyHit) {
|
|
77
|
+
if (!hasDependencyHit && !isRegisteredEntrypointEdit) {
|
|
70
78
|
appLogger.debug("[JsHmrStrategy] Dependency graph miss, rebuilding all watched entrypoints");
|
|
71
79
|
}
|
|
72
80
|
if (buildableEntrypoints.length === 0) {
|
|
@@ -82,14 +90,19 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
82
90
|
const updates = [];
|
|
83
91
|
let reloadRequired = false;
|
|
84
92
|
for (const entrypoint of buildableEntrypoints) {
|
|
85
|
-
const
|
|
86
|
-
|
|
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);
|
|
87
101
|
if (buildResult.dependencies) {
|
|
88
|
-
const entrypointDeps = buildResult.dependencies.get(
|
|
89
|
-
this.context.getEntrypointDependencyGraph().setEntrypointDependencies(
|
|
102
|
+
const entrypointDeps = buildResult.dependencies.get(resolvedEntrypoint) ?? [];
|
|
103
|
+
this.context.getEntrypointDependencyGraph().setEntrypointDependencies(resolvedEntrypoint, entrypointDeps);
|
|
90
104
|
}
|
|
91
|
-
const
|
|
92
|
-
const result = await this.processOutput(outputPath, outputUrl);
|
|
105
|
+
const result = await this.processOutput(outputPath, outputUrl, resolvedEntrypoint);
|
|
93
106
|
if (result.success) {
|
|
94
107
|
updates.push(outputUrl);
|
|
95
108
|
if (result.requiresReload) {
|
|
@@ -186,8 +199,12 @@ class JsHmrStrategy extends HmrStrategy {
|
|
|
186
199
|
* @param url - URL path for the bundled file
|
|
187
200
|
* @returns True if processing was successful and update should be broadcast
|
|
188
201
|
*/
|
|
189
|
-
async processOutput(filepath, url) {
|
|
202
|
+
async processOutput(filepath, url, sourcePath) {
|
|
190
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
|
+
}
|
|
191
208
|
const code = await fileSystem.readFile(filepath);
|
|
192
209
|
if (code.includes("/* [ecopages] hmr */")) {
|
|
193
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) {
|