@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-beta.18",
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.18",
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
- return new Response(fileSystem.readFileAsBuffer(hmrManager.getRuntimePath()), {
325
- headers: { "Content-Type": "application/javascript" }
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 { removeStaleHmrEntrypointOutput, resolveHmrEntrypointOutputPaths } from "../hmr-entrypoint-output.js";
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 isInSrc = resolvedPath.startsWith(this.context.getSrcDir());
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 outputUrl = watchedFiles.get(entrypoint);
86
- 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);
87
101
  if (buildResult.dependencies) {
88
- const entrypointDeps = buildResult.dependencies.get(path.resolve(entrypoint)) ?? [];
89
- this.context.getEntrypointDependencyGraph().setEntrypointDependencies(entrypoint, entrypointDeps);
102
+ const entrypointDeps = buildResult.dependencies.get(resolvedEntrypoint) ?? [];
103
+ this.context.getEntrypointDependencyGraph().setEntrypointDependencies(resolvedEntrypoint, entrypointDeps);
90
104
  }
91
- const outputPath = this.resolveEntrypointOutputPath(entrypoint);
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,
@@ -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) {