@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.
Files changed (27) hide show
  1. package/package.json +10 -2
  2. package/src/adapters/bun/server-adapter.js +20 -3
  3. package/src/adapters/node/server-adapter-dependencies.js +2 -0
  4. package/src/adapters/shared/hmr-entrypoint-registrar.d.ts +0 -2
  5. package/src/adapters/shared/hmr-entrypoint-registrar.js +3 -26
  6. package/src/adapters/shared/runtime-server-lifecycle.js +4 -0
  7. package/src/adapters/shared/server-adapter.js +1 -2
  8. package/src/adapters/shared/shared-hmr-manager.d.ts +5 -3
  9. package/src/adapters/shared/shared-hmr-manager.js +15 -8
  10. package/src/dev/hmr-manager-registry.d.ts +8 -0
  11. package/src/dev/hmr-manager-registry.js +15 -0
  12. package/src/hmr/hmr-entrypoint-output.d.ts +28 -0
  13. package/src/hmr/hmr-entrypoint-output.js +58 -0
  14. package/src/hmr/strategies/js-hmr-strategy.d.ts +3 -0
  15. package/src/hmr/strategies/js-hmr-strategy.js +68 -18
  16. package/src/router/client/link-navigation-policy.d.ts +6 -0
  17. package/src/router/client/link-navigation-policy.js +15 -0
  18. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.js +5 -1
  19. package/src/services/invalidation/development-invalidation.service.d.ts +10 -1
  20. package/src/services/invalidation/development-invalidation.service.js +25 -0
  21. package/src/services/module-loading/page-module-import.service.d.ts +1 -0
  22. package/src/services/module-loading/page-module-import.service.js +38 -6
  23. package/src/services/module-loading/route-module-dependency-hasher.d.ts +2 -2
  24. package/src/services/module-loading/route-module-dependency-hasher.js +2 -2
  25. package/src/types/internal-types.d.ts +4 -0
  26. package/src/watchers/project-watcher.d.ts +12 -1
  27. 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.17",
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.17",
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
- return new Response(fileSystem.readFileAsBuffer(hmrManager.getRuntimePath()), {
324
- headers: { "Content-Type": "application/javascript" }
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,
@@ -50,6 +50,4 @@ export declare class HmrEntrypointRegistrar {
50
50
  private registerEntrypointInternal;
51
51
  private awaitEntrypointRegistration;
52
52
  private getEntrypointOutput;
53
- private removeStaleEntrypointOutput;
54
- private encodeDynamicSegments;
55
53
  }
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { fileSystem } from "@ecopages/file-system";
3
- import { appLogger } from "../../global/app-logger.js";
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
- this.removeStaleEntrypointOutput(outputPath);
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
- const relativePath = path.relative(this.options.srcDir, entrypointPath);
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 artifact already exists on disk.
50
+ * Returns the emitted HMR script output when the entrypoint is already registered
51
+ * and its browser bundle exists on disk.
51
52
  *
52
- * SSR must not block on entrypoint registration when a previous build already
53
- * produced the browser bundle.
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 artifact already exists on disk.
188
+ * Returns the emitted HMR script output when the entrypoint is already registered
189
+ * and its browser bundle exists on disk.
188
190
  *
189
- * SSR must not block on entrypoint registration when a previous build already
190
- * produced the browser bundle.
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
- const relativePath = path.relative(this.appConfig.absolutePaths.srcDir, normalizedEntrypoint);
195
- const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx?)$/, ".js").replace(/\[([^\]]+)\]/g, "_$1_");
196
- const urlPath = relativePathJs.split(path.sep).join("/");
197
- const outputPath = path.join(this.distDir, urlPath);
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) ?? `/${path.join(RESOLVED_ASSETS_DIR, "_hmr", urlPath).split(path.sep).join("/")}`;
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 isJsTs = /\.(ts|tsx|js|jsx)$/.test(filePath);
25
- const isInSrc = filePath.startsWith(this.context.getSrcDir());
26
- const isIntegrationTemplate = this.context.getTemplateExtensions().some((extension) => filePath.endsWith(extension));
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 outputUrl = watchedFiles.get(entrypoint);
80
- if (!outputUrl) continue;
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(path.resolve(entrypoint)) ?? [];
83
- this.context.getEntrypointDependencyGraph().setEntrypointDependencies(entrypoint, entrypointDeps);
102
+ const entrypointDeps = buildResult.dependencies.get(resolvedEntrypoint) ?? [];
103
+ this.context.getEntrypointDependencyGraph().setEntrypointDependencies(resolvedEntrypoint, entrypointDeps);
84
104
  }
85
- const srcDir = this.context.getSrcDir();
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,
@@ -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 { resolveRouteModuleDependencyPaths } from "./route-module-dependency-hasher.js";
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
- return await cachedModule;
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 { filePath, invalidationVersion = this.developmentInvalidationVersion, cacheScope, fileHash } = options;
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: resolveRouteModuleDependencyPaths(buildResult, filePath, rootDir)
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. Intended for unit tests only. */
54
- clearMemoForTests(): void;
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. Intended for unit tests only. */
77
- clearMemoForTests() {
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 { DevelopmentInvalidationService } from "../services/invalidation/development-invalidation.service.js";
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({ config, refreshRouterRoutesCallback, hmrManager, bridge, hostOwnsDevClient }) {
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
- async handleFileChange(rawPath, event = "change") {
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.enqueueChange(() => this.handleFileChange(p, "change"))).on("add", (p) => this.enqueueChange(() => this.handleFileChange(p, "add"))).on("addDir", (p) => this.enqueueChange(() => this.triggerRouterRefresh(p))).on("unlink", (p) => this.enqueueChange(() => this.handleFileChange(p, "unlink"))).on("unlinkDir", (p) => this.enqueueChange(() => this.triggerRouterRefresh(p))).on("error", (error) => this.handleError(error));
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) {