@ecopages/core 0.2.0-beta.20 → 0.2.0-beta.22

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 (31) hide show
  1. package/package.json +2 -2
  2. package/src/adapters/bun/hmr-manager.d.ts +1 -2
  3. package/src/adapters/bun/hmr-manager.js +2 -2
  4. package/src/adapters/bun/server-adapter.js +5 -4
  5. package/src/adapters/node/node-hmr-manager.d.ts +1 -2
  6. package/src/adapters/node/node-hmr-manager.js +2 -2
  7. package/src/adapters/node/server-adapter.js +5 -6
  8. package/src/adapters/shared/hmr-entrypoint-registrar.d.ts +21 -28
  9. package/src/adapters/shared/hmr-entrypoint-registrar.js +49 -31
  10. package/src/adapters/shared/runtime-server-lifecycle.d.ts +9 -1
  11. package/src/adapters/shared/runtime-server-lifecycle.js +10 -3
  12. package/src/adapters/shared/server-static-builder.js +4 -1
  13. package/src/adapters/shared/shared-hmr-manager.d.ts +29 -23
  14. package/src/adapters/shared/shared-hmr-manager.js +144 -108
  15. package/src/build/app-build-manifest-runtime.d.ts +1 -2
  16. package/src/build/app-build-manifest-runtime.js +0 -3
  17. package/src/hmr/hmr-entrypoint-output.d.ts +24 -3
  18. package/src/hmr/hmr-entrypoint-output.js +13 -11
  19. package/src/hmr/hmr-strategy.d.ts +15 -0
  20. package/src/hmr/hmr-strategy.js +19 -0
  21. package/src/hmr/strategies/js-hmr-strategy.d.ts +0 -5
  22. package/src/hmr/strategies/js-hmr-strategy.js +7 -4
  23. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.d.ts +0 -1
  24. package/src/services/assets/asset-processing-service/processors/script/file-script.processor.js +13 -32
  25. package/src/services/assets/browser-bundle.service.js +1 -1
  26. package/src/services/module-loading/page-module-import.service.js +0 -1
  27. package/src/services/module-loading/route-module-dependency-hasher.js +1 -0
  28. package/src/types/public-types.d.ts +10 -18
  29. package/src/watchers/project-watcher.d.ts +2 -2
  30. package/src/watchers/project-watcher.js +23 -28
  31. package/src/watchers/project-watcher.test-helpers.js +5 -5
@@ -2,7 +2,11 @@ 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
+ import {
6
+ removeStaleHmrEntrypointOutput,
7
+ isRegisteredScriptEntrypoint,
8
+ isBrowserOnlyRegisteredScriptEntrypoint
9
+ } from "../../hmr/hmr-entrypoint-output.js";
6
10
  import { requireBuildRuntime } from "../../build/build-runtime.js";
7
11
  import { fileSystem } from "@ecopages/file-system";
8
12
  import { HmrStrategyType } from "../../hmr/hmr-strategy.js";
@@ -19,52 +23,31 @@ import {
19
23
  setAppEntrypointDependencyGraph
20
24
  } from "../../services/runtime-state/entrypoint-dependency-graph.service.js";
21
25
  import { resolveInternalExecutionDir, resolveInternalWorkDir } from "../../utils/resolve-work-dir.js";
22
- const DEFAULT_HMR_REGISTRATION_TIMEOUT_MS = 4e3;
23
- const DEVELOPMENT_HMR_REGISTRATION_TIMEOUT_MS = 15e3;
24
- function resolveHmrRegistrationTimeoutMs(explicitTimeoutMs) {
25
- if (explicitTimeoutMs !== void 0) {
26
- return explicitTimeoutMs;
27
- }
28
- const envTimeoutMs = process.env.ECOPAGES_HMR_REGISTRATION_TIMEOUT_MS;
29
- if (envTimeoutMs !== void 0 && envTimeoutMs !== "") {
30
- const parsedTimeoutMs = Number(envTimeoutMs);
31
- if (Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0) {
32
- return parsedTimeoutMs;
33
- }
34
- }
35
- if (process.env.NODE_ENV === "development") {
36
- return DEVELOPMENT_HMR_REGISTRATION_TIMEOUT_MS;
37
- }
38
- return DEFAULT_HMR_REGISTRATION_TIMEOUT_MS;
39
- }
40
26
  class SharedHmrManager {
41
27
  appConfig;
42
28
  bridge;
43
29
  watchers = /* @__PURE__ */ new Map();
44
- watchedFiles = /* @__PURE__ */ new Map();
45
- entrypointRegistrations = /* @__PURE__ */ new Map();
46
30
  distDir;
47
- plugins = [];
48
31
  enabled = false;
49
32
  strategies = [];
50
33
  entrypointRegistrar;
51
34
  browserBundleService;
35
+ invalidationService;
52
36
  entrypointDependencyGraph;
53
37
  serverModuleTranspiler;
54
38
  runtimeBuildPromise = null;
55
- constructor({ appConfig, bridge, registrationTimeoutMs }) {
39
+ runtimeReady = false;
40
+ constructor({ appConfig, bridge }) {
56
41
  this.appConfig = appConfig;
57
42
  this.bridge = bridge;
58
43
  this.distDir = path.join(resolveInternalWorkDir(this.appConfig), RESOLVED_ASSETS_DIR, "_hmr");
59
44
  this.entrypointRegistrar = new HmrEntrypointRegistrar({
60
45
  srcDir: this.appConfig.absolutePaths.srcDir,
61
46
  distDir: this.distDir,
62
- entrypointRegistrations: this.entrypointRegistrations,
63
- watchedFiles: this.watchedFiles,
64
- clearFailedRegistration: (entrypointPath) => this.clearFailedEntrypointRegistration(entrypointPath),
65
- registrationTimeoutMs: resolveHmrRegistrationTimeoutMs(registrationTimeoutMs)
47
+ clearFailedRegistration: (entrypointPath) => this.clearFailedEntrypointRegistration(entrypointPath)
66
48
  });
67
49
  this.browserBundleService = new BrowserBundleService(appConfig);
50
+ this.invalidationService = new DevelopmentInvalidationService(appConfig);
68
51
  this.entrypointDependencyGraph = this.createEntrypointDependencyGraph(
69
52
  getAppEntrypointDependencyGraph(appConfig)
70
53
  );
@@ -97,9 +80,8 @@ class SharedHmrManager {
97
80
  }
98
81
  initializeStrategies() {
99
82
  const jsContext = {
100
- getWatchedFiles: () => this.watchedFiles,
83
+ getWatchedFiles: () => this.entrypointRegistrar.getWatchedFiles(),
101
84
  getDistDir: () => this.distDir,
102
- getPlugins: () => this.plugins,
103
85
  getSrcDir: () => this.appConfig.absolutePaths.srcDir,
104
86
  getPagesDir: () => this.appConfig.absolutePaths.pagesDir,
105
87
  getLayoutsDir: () => this.appConfig.absolutePaths.layoutsDir,
@@ -118,9 +100,6 @@ class SharedHmrManager {
118
100
  registerStrategy(strategy) {
119
101
  this.strategies.push(strategy);
120
102
  }
121
- setPlugins(plugins) {
122
- this.plugins = [...plugins];
123
- }
124
103
  setEnabled(enabled) {
125
104
  this.enabled = enabled;
126
105
  }
@@ -128,13 +107,13 @@ class SharedHmrManager {
128
107
  return this.enabled;
129
108
  }
130
109
  isRuntimeReady() {
131
- return fileSystem.exists(this.getRuntimePath());
110
+ return this.runtimeReady && fileSystem.exists(this.getRuntimePath());
132
111
  }
133
112
  /**
134
- * Builds the browser HMR runtime once and reuses the in-flight build for concurrent callers.
113
+ * Builds the browser HMR runtime once per manager session and reuses the in-flight build for concurrent callers.
135
114
  */
136
115
  async ensureRuntimeReady() {
137
- if (this.isRuntimeReady()) {
116
+ if (this.runtimeReady) {
138
117
  return true;
139
118
  }
140
119
  if (this.runtimeBuildPromise) {
@@ -142,7 +121,9 @@ class SharedHmrManager {
142
121
  }
143
122
  this.runtimeBuildPromise = this.buildRuntimeInternal();
144
123
  try {
145
- return await this.runtimeBuildPromise;
124
+ const ready = await this.runtimeBuildPromise;
125
+ this.runtimeReady = ready;
126
+ return ready;
146
127
  } finally {
147
128
  this.runtimeBuildPromise = null;
148
129
  }
@@ -155,44 +136,35 @@ class SharedHmrManager {
155
136
  }
156
137
  async buildRuntimeInternal() {
157
138
  const runtimeSource = fileURLToPath(import.meta.resolve("@ecopages/core/hmr/client/hmr-runtime"));
139
+ const runtimePath = this.getRuntimePath();
140
+ removeStaleHmrEntrypointOutput(runtimePath, "HMR");
158
141
  try {
159
142
  const result = await this.browserBundleService.bundle({
160
143
  profile: "hmr-runtime",
161
144
  entrypoints: [runtimeSource],
162
145
  outdir: this.distDir,
163
146
  naming: "_hmr_runtime.js",
164
- minify: false,
165
- plugins: this.plugins
147
+ minify: false
166
148
  });
167
149
  if (!result.success) {
168
150
  this.onRuntimeBundleFailure(result.logs);
169
151
  return false;
170
152
  }
171
- this.syncRuntimeOutput(result);
172
- return this.isRuntimeReady();
153
+ const emittedRuntime = result.outputs.find(
154
+ (output) => path.resolve(output.path) === path.resolve(runtimePath)
155
+ );
156
+ if (!emittedRuntime || !fileSystem.exists(runtimePath)) {
157
+ this.onRuntimeBundleFailure(
158
+ new Error(`[HMR] Runtime bundle missing expected output at ${runtimePath}`)
159
+ );
160
+ return false;
161
+ }
162
+ return true;
173
163
  } catch (error) {
174
164
  this.onRuntimeBundleFailure(error);
175
165
  return false;
176
166
  }
177
167
  }
178
- /**
179
- * @remarks
180
- * Rolldown may emit the runtime bundle under a derived filename when the
181
- * entrypoint lives outside the app root. The dev server always serves
182
- * `/_hmr_runtime.js` from a stable path under `.eco/assets/_hmr/`.
183
- */
184
- syncRuntimeOutput(result) {
185
- const runtimePath = this.getRuntimePath();
186
- if (fileSystem.exists(runtimePath)) {
187
- return;
188
- }
189
- const emittedRuntime = result.outputs.find((output) => output.path.endsWith(".js"));
190
- if (!emittedRuntime || emittedRuntime.path === runtimePath) {
191
- return;
192
- }
193
- fileSystem.ensureDir(path.dirname(runtimePath));
194
- fileSystem.copyFile(emittedRuntime.path, runtimePath);
195
- }
196
168
  broadcast(event) {
197
169
  appLogger.debug(
198
170
  `[HMR] Broadcasting ${event.type} event, path=${event.path || "all"}, subscribers=${this.bridge.subscriberCount}`
@@ -200,20 +172,16 @@ class SharedHmrManager {
200
172
  this.bridge.broadcast(event);
201
173
  }
202
174
  async handleFileChange(filePath, options = {}) {
175
+ const resolvedFilePath = path.resolve(filePath);
203
176
  if (this.shouldSkipMissingFileChange(filePath) && !fileSystem.exists(filePath)) {
204
177
  appLogger.debug(`[${this.constructor.name}] Skipping missing file change: ${filePath}`);
205
178
  this.clearFailedEntrypointRegistration(filePath);
206
179
  return;
207
180
  }
208
- const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
209
- const strategy = sorted.find((candidate) => {
210
- try {
211
- return candidate.matches(filePath);
212
- } catch (error) {
213
- appLogger.error(error);
214
- return false;
215
- }
216
- });
181
+ if (isRegisteredScriptEntrypoint(this.entrypointRegistrar.getRegistered(), resolvedFilePath)) {
182
+ await this.prepareRegisteredScriptChange(resolvedFilePath);
183
+ }
184
+ const strategy = this.selectChangeStrategy(filePath);
217
185
  if (!strategy) {
218
186
  appLogger.warn(`[HMR] No strategy found for ${filePath}`);
219
187
  return;
@@ -227,8 +195,92 @@ class SharedHmrManager {
227
195
  }
228
196
  }
229
197
  }
198
+ getStrategiesByPriority() {
199
+ return [...this.strategies].sort((left, right) => right.priority - left.priority);
200
+ }
201
+ selectChangeStrategy(filePath) {
202
+ return this.getStrategiesByPriority().find((candidate) => {
203
+ try {
204
+ return candidate.matches(filePath);
205
+ } catch (error) {
206
+ appLogger.error(error);
207
+ return false;
208
+ }
209
+ });
210
+ }
211
+ selectEntrypointEmitter(entrypointPath) {
212
+ return this.getStrategiesByPriority().find((candidate) => {
213
+ if (candidate.type !== HmrStrategyType.INTEGRATION) {
214
+ return false;
215
+ }
216
+ try {
217
+ return candidate.canEmitEntrypoint(entrypointPath);
218
+ } catch (error) {
219
+ appLogger.error(error);
220
+ return false;
221
+ }
222
+ });
223
+ }
224
+ createUnownedEntrypointError(entrypointPath) {
225
+ return new Error(
226
+ `[HMR] No integration owns entrypoint ${entrypointPath}. Page entrypoints must be emitted by their owning integration.`
227
+ );
228
+ }
229
+ /**
230
+ * Materializes one integration-owned page entrypoint during cold registration.
231
+ *
232
+ * @remarks
233
+ * Registration uses {@link HmrStrategy.canEmitEntrypoint} / {@link HmrStrategy.emitEntrypoint},
234
+ * not {@link HmrStrategy.matches} / {@link HmrStrategy.process}. File-change handling stays
235
+ * on `handleFileChange()`.
236
+ */
237
+ async emitIntegrationEntrypoint(entrypointPath, outputPath) {
238
+ const emitter = this.selectEntrypointEmitter(entrypointPath);
239
+ if (!emitter) {
240
+ throw this.createUnownedEntrypointError(entrypointPath);
241
+ }
242
+ appLogger.debug(`[${this.constructor.name}] Selected entrypoint emitter: ${emitter.constructor.name}`);
243
+ await emitter.emitEntrypoint(entrypointPath, outputPath);
244
+ }
245
+ /**
246
+ * Runs server invalidation and integration hooks before rebuilding a registered script entrypoint.
247
+ *
248
+ * @remarks
249
+ * Browser-only `*.script.ts` entrypoints skip server module invalidation and bypass-cache
250
+ * import; only integration change handlers and the downstream strategy rebuild run.
251
+ */
252
+ async prepareRegisteredScriptChange(filePath) {
253
+ const isBrowserOnlyRegisteredScript = isBrowserOnlyRegisteredScriptEntrypoint(filePath);
254
+ if (!isBrowserOnlyRegisteredScript) {
255
+ this.invalidationService.invalidateServerModules([filePath]);
256
+ }
257
+ const handlers = this.appConfig.runtime?.registeredScriptEntrypointChangeHandlers ?? [];
258
+ for (const handler of handlers) {
259
+ try {
260
+ await handler(filePath);
261
+ } catch (error) {
262
+ const message = error instanceof Error ? error.message : `Failed to handle registered script entrypoint change for ${filePath}: ${String(error)}`;
263
+ this.broadcast({ type: "error", message });
264
+ throw error;
265
+ }
266
+ }
267
+ if (isBrowserOnlyRegisteredScript) {
268
+ return;
269
+ }
270
+ const appModuleLoader = this.appConfig.runtime?.appModuleLoader;
271
+ if (!appModuleLoader) {
272
+ return;
273
+ }
274
+ await appModuleLoader.importModule({
275
+ filePath,
276
+ rootDir: this.appConfig.rootDir,
277
+ outdir: path.join(resolveInternalExecutionDir(this.appConfig), ".server-modules"),
278
+ externalPackages: true,
279
+ bypassCache: true
280
+ });
281
+ }
230
282
  getOutputUrl(entrypointPath) {
231
- return this.watchedFiles.get(entrypointPath);
283
+ return this.entrypointRegistrar.getRegistered().get(path.resolve(entrypointPath))?.outputUrl;
232
284
  }
233
285
  /**
234
286
  * Returns the emitted HMR script output when the entrypoint is already registered
@@ -240,34 +292,22 @@ class SharedHmrManager {
240
292
  */
241
293
  getResolvedScriptOutput(entrypointPath) {
242
294
  const normalizedEntrypoint = path.resolve(entrypointPath);
243
- if (!this.watchedFiles.has(normalizedEntrypoint)) {
244
- return void 0;
245
- }
246
- const { outputPath, outputUrl: derivedOutputUrl } = resolveHmrEntrypointOutputPaths(
247
- this.appConfig.absolutePaths.srcDir,
248
- this.distDir,
249
- normalizedEntrypoint
250
- );
251
- if (!fileSystem.exists(outputPath)) {
295
+ const registered = this.entrypointRegistrar.getRegistered().get(normalizedEntrypoint);
296
+ if (!registered || !fileSystem.exists(registered.outputPath)) {
252
297
  return void 0;
253
298
  }
254
- const outputUrl = this.watchedFiles.get(normalizedEntrypoint) ?? derivedOutputUrl;
255
- return { outputUrl, outputPath };
299
+ return registered;
256
300
  }
257
301
  getWatchedFiles() {
258
- return this.watchedFiles;
302
+ return this.entrypointRegistrar.getWatchedFiles();
259
303
  }
260
304
  getDistDir() {
261
305
  return this.distDir;
262
306
  }
263
- getPlugins() {
264
- return this.plugins;
265
- }
266
307
  getDefaultContext() {
267
308
  return {
268
- getWatchedFiles: () => this.watchedFiles,
309
+ getWatchedFiles: () => this.entrypointRegistrar.getWatchedFiles(),
269
310
  getDistDir: () => this.distDir,
270
- getPlugins: () => this.plugins,
271
311
  getSrcDir: () => this.appConfig.absolutePaths.srcDir,
272
312
  getLayoutsDir: () => this.appConfig.absolutePaths.layoutsDir,
273
313
  getPagesDir: () => this.appConfig.absolutePaths.pagesDir,
@@ -281,27 +321,34 @@ class SharedHmrManager {
281
321
  })
282
322
  };
283
323
  }
324
+ stop() {
325
+ this.runtimeReady = false;
326
+ this.entrypointRegistrar.clearAll();
327
+ for (const watcher of this.watchers.values()) {
328
+ watcher.close();
329
+ }
330
+ this.watchers.clear();
331
+ this.entrypointDependencyGraph.reset();
332
+ }
284
333
  clearFailedEntrypointRegistration(entrypointPath) {
285
- this.watchedFiles.delete(entrypointPath);
334
+ this.entrypointRegistrar.clearRegistration(entrypointPath);
286
335
  this.entrypointDependencyGraph.clearEntrypointDependencies(entrypointPath);
287
336
  }
288
337
  async registerEntrypoint(entrypointPath) {
289
- return await this.entrypointRegistrar.registerEntrypoint(entrypointPath, {
290
- emit: async (normalizedEntrypoint) => await this.emitStrictEntrypoint(normalizedEntrypoint),
338
+ const resolved = await this.entrypointRegistrar.registerEntrypoint(entrypointPath, {
339
+ emit: async (normalizedEntrypoint, outputPath) => await this.emitIntegrationEntrypoint(normalizedEntrypoint, outputPath),
291
340
  getMissingOutputError: (normalizedEntrypoint, outputPath) => new Error(
292
341
  `[HMR] Integration failed to emit entrypoint ${normalizedEntrypoint} to ${outputPath}. Page entrypoints must be produced by their owning integration.`
293
342
  )
294
343
  });
344
+ return resolved.outputUrl;
295
345
  }
296
346
  async registerScriptEntrypoint(entrypointPath) {
297
347
  return await this.entrypointRegistrar.registerEntrypoint(entrypointPath, {
298
348
  emit: async (normalizedEntrypoint, outputPath) => await this.emitScriptEntrypoint(normalizedEntrypoint, outputPath),
299
- getMissingOutputError: (normalizedEntrypoint) => new Error(`[HMR] Failed to register script entrypoint: ${normalizedEntrypoint}`)
349
+ getMissingOutputError: (normalizedEntrypoint, outputPath) => new Error(`[HMR] Failed to register script entrypoint: ${normalizedEntrypoint} to ${outputPath}`)
300
350
  });
301
351
  }
302
- async emitStrictEntrypoint(entrypointPath) {
303
- await this.handleFileChange(entrypointPath, { broadcast: false });
304
- }
305
352
  async emitScriptEntrypoint(entrypointPath, outputPath) {
306
353
  const naming = path.relative(this.distDir, outputPath).split(path.sep).join("/");
307
354
  const buildResult = await this.browserBundleService.bundle({
@@ -309,12 +356,12 @@ class SharedHmrManager {
309
356
  entrypoints: [entrypointPath],
310
357
  outdir: this.distDir,
311
358
  naming,
312
- minify: false,
313
- plugins: this.plugins
359
+ minify: false
314
360
  });
315
361
  if (!buildResult.success) {
316
- appLogger.error(`[HMR] Generic script entrypoint build failed for ${entrypointPath}:`, buildResult.logs);
317
- return;
362
+ throw new Error(
363
+ `[HMR] Generic script entrypoint build failed for ${entrypointPath}: ${JSON.stringify(buildResult.logs)}`
364
+ );
318
365
  }
319
366
  if (!fileSystem.exists(outputPath) && buildResult.outputs.length > 0) {
320
367
  const resolvedOutputPath = path.resolve(outputPath);
@@ -331,21 +378,10 @@ class SharedHmrManager {
331
378
  this.entrypointDependencyGraph.setEntrypointDependencies(entrypointPath, entrypointDependencies);
332
379
  }
333
380
  }
334
- stop() {
335
- this.entrypointRegistrations.clear();
336
- for (const watcher of this.watchers.values()) {
337
- watcher.close();
338
- }
339
- this.watchers.clear();
340
- this.watchedFiles.clear();
341
- this.entrypointDependencyGraph.reset();
342
- this.plugins = [];
343
- }
344
381
  [Symbol.dispose]() {
345
382
  this.stop();
346
383
  }
347
384
  }
348
385
  export {
349
- SharedHmrManager,
350
- resolveHmrRegistrationTimeoutMs
386
+ SharedHmrManager
351
387
  };
@@ -1,10 +1,9 @@
1
1
  import type { EcoBuildPlugin } from './build-types.js';
2
2
  import type { AppBuildManifest } from './build-manifest.js';
3
- import type { EcoPagesAppConfig, IHmrManager } from '../types/internal-types.js';
3
+ import type { EcoPagesAppConfig } from '../types/internal-types.js';
4
4
  export declare function collectConfiguredAppBuildManifestContributions(appConfig: EcoPagesAppConfig): Promise<Pick<AppBuildManifest, 'runtimePlugins' | 'browserBundlePlugins' | 'browserRuntimeManifest'>>;
5
5
  export declare function setupAppRuntimePlugins(options: {
6
6
  appConfig: EcoPagesAppConfig;
7
7
  runtimeOrigin: string;
8
- hmrManager?: IHmrManager;
9
8
  onRuntimePlugin?: (plugin: EcoBuildPlugin) => void;
10
9
  }): Promise<void>;
@@ -71,9 +71,6 @@ async function setupAppRuntimePlugins(options) {
71
71
  for (const integration of options.appConfig.integrations) {
72
72
  integration.setConfig(options.appConfig);
73
73
  integration.setRuntimeOrigin(options.runtimeOrigin);
74
- if (options.hmrManager) {
75
- integration.setHmrManager(options.hmrManager);
76
- }
77
74
  await integration.setup();
78
75
  for (const plugin of integration.plugins) {
79
76
  options.onRuntimePlugin?.(plugin);
@@ -1,11 +1,26 @@
1
+ /**
2
+ * Verified HMR entrypoint artifact produced by registration.
3
+ */
4
+ export interface ResolvedHmrEntrypoint {
5
+ sourcePath: string;
6
+ outputPath: string;
7
+ outputUrl: string;
8
+ }
1
9
  /**
2
10
  * Returns whether a source path is a registered client script HMR entrypoint.
11
+ */
12
+ export declare function isRegisteredScriptEntrypoint(registered: ReadonlyMap<string, unknown>, filePath: string): boolean;
13
+ /**
14
+ * Returns whether a registered script entrypoint is browser-only and must not be
15
+ * server-imported during HMR invalidation.
3
16
  *
4
17
  * @remarks
5
- * Registration comes from `dependencies.scripts` via `FileScriptProcessor`,
6
- * not from filename conventions such as `.script.tsx`.
18
+ * Declared `*.script.ts` modules run only in the browser bundle. Re-importing them
19
+ * on the server during `prepareRegisteredScriptChange()` executes DOM globals and
20
+ * aborts the watcher before the client artifact rebuilds. Radiant `*.script.tsx`
21
+ * entrypoints are server-rendered and remain on the invalidation path.
7
22
  */
8
- export declare function isRegisteredScriptEntrypoint(watchedFiles: ReadonlyMap<string, string>, filePath: string): boolean;
23
+ export declare function isBrowserOnlyRegisteredScriptEntrypoint(filePath: string): boolean;
9
24
  export declare function encodeHmrDynamicSegments(filepath: string): string;
10
25
  /**
11
26
  * Resolves the on-disk and browser URL targets for one HMR script entrypoint.
@@ -26,3 +41,9 @@ export declare function removeStaleHmrEntrypointOutput(outputPath: string, scope
26
41
  * fresher artifact than the edited source file.
27
42
  */
28
43
  export declare function isHmrOutputFresh(outputPath: string, sourcePath: string): boolean;
44
+ /**
45
+ * @remarks
46
+ * Missing output is treated as a benign rebuild race. This helper isolates the
47
+ * post-build case where a bundle exists but is still older than its source.
48
+ */
49
+ export declare function isHmrOutputOlderThanSource(outputPath: string, sourcePath: string): boolean;
@@ -3,17 +3,11 @@ import path from "node:path";
3
3
  import { fileSystem } from "@ecopages/file-system";
4
4
  import { RESOLVED_ASSETS_DIR } from "../config/constants.js";
5
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;
6
+ function isRegisteredScriptEntrypoint(registered, filePath) {
7
+ return registered.has(path.resolve(filePath));
8
+ }
9
+ function isBrowserOnlyRegisteredScriptEntrypoint(filePath) {
10
+ return /\.script\.ts$/u.test(path.resolve(filePath));
17
11
  }
18
12
  function encodeHmrDynamicSegments(filepath) {
19
13
  return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
@@ -49,9 +43,17 @@ function isHmrOutputFresh(outputPath, sourcePath) {
49
43
  const sourceMtimeMs = fs.statSync(sourcePath).mtimeMs;
50
44
  return outputMtimeMs >= sourceMtimeMs;
51
45
  }
46
+ function isHmrOutputOlderThanSource(outputPath, sourcePath) {
47
+ if (!fileSystem.exists(outputPath) || !fileSystem.exists(sourcePath)) {
48
+ return false;
49
+ }
50
+ return fs.statSync(outputPath).mtimeMs < fs.statSync(sourcePath).mtimeMs;
51
+ }
52
52
  export {
53
53
  encodeHmrDynamicSegments,
54
+ isBrowserOnlyRegisteredScriptEntrypoint,
54
55
  isHmrOutputFresh,
56
+ isHmrOutputOlderThanSource,
55
57
  isRegisteredScriptEntrypoint,
56
58
  removeStaleHmrEntrypointOutput,
57
59
  resolveHmrEntrypointOutputPaths
@@ -133,6 +133,21 @@ export declare abstract class HmrStrategy {
133
133
  * ```
134
134
  */
135
135
  abstract matches(filePath: string): boolean;
136
+ /**
137
+ * Returns whether this strategy owns cold registration for the entrypoint.
138
+ *
139
+ * @remarks
140
+ * Registration dispatch uses only integration strategies that claim the
141
+ * entrypoint. File-change dispatch continues to use {@link matches}.
142
+ */
143
+ canEmitEntrypoint(_entrypointPath: string): boolean;
144
+ /**
145
+ * Materializes one integration-owned HMR entrypoint during registration.
146
+ *
147
+ * @remarks
148
+ * Must write the canonical output path and must not broadcast client events.
149
+ */
150
+ emitEntrypoint(_entrypointPath: string, _outputPath: string): Promise<void>;
136
151
  /**
137
152
  * Processes a file change and returns the action to take.
138
153
  *
@@ -37,6 +37,25 @@ class HmrStrategy {
37
37
  get priority() {
38
38
  return this.type + this.priorityOffset;
39
39
  }
40
+ /**
41
+ * Returns whether this strategy owns cold registration for the entrypoint.
42
+ *
43
+ * @remarks
44
+ * Registration dispatch uses only integration strategies that claim the
45
+ * entrypoint. File-change dispatch continues to use {@link matches}.
46
+ */
47
+ canEmitEntrypoint(_entrypointPath) {
48
+ return false;
49
+ }
50
+ /**
51
+ * Materializes one integration-owned HMR entrypoint during registration.
52
+ *
53
+ * @remarks
54
+ * Must write the canonical output path and must not broadcast client events.
55
+ */
56
+ async emitEntrypoint(_entrypointPath, _outputPath) {
57
+ return;
58
+ }
40
59
  }
41
60
  export {
42
61
  HmrStrategy,
@@ -8,7 +8,6 @@
8
8
  * @module
9
9
  */
10
10
  import { HmrStrategy, type HmrAction } from '../hmr-strategy.js';
11
- import type { EcoBuildPlugin } from '../../build/build-types.js';
12
11
  import type { BrowserBundleExecutor } from '../../services/assets/browser-bundle.service.js';
13
12
  import type { EntrypointDependencyGraph } from '../../services/runtime-state/entrypoint-dependency-graph.service.js';
14
13
  /**
@@ -25,10 +24,6 @@ export interface JsHmrContext {
25
24
  * Directory where HMR bundles are written.
26
25
  */
27
26
  getDistDir(): string;
28
- /**
29
- * Build plugins to use during bundling.
30
- */
31
- getPlugins(): EcoBuildPlugin[];
32
27
  /**
33
28
  * Absolute path to the source directory.
34
29
  */
@@ -6,7 +6,8 @@ import {
6
6
  removeStaleHmrEntrypointOutput,
7
7
  resolveHmrEntrypointOutputPaths,
8
8
  isRegisteredScriptEntrypoint,
9
- isHmrOutputFresh
9
+ isHmrOutputFresh,
10
+ isHmrOutputOlderThanSource
10
11
  } from "../hmr-entrypoint-output.js";
11
12
  class JsHmrStrategy extends HmrStrategy {
12
13
  type = HmrStrategyType.SCRIPT;
@@ -151,7 +152,6 @@ class JsHmrStrategy extends HmrStrategy {
151
152
  entrypoints: [entrypoint],
152
153
  outdir: this.context.getDistDir(),
153
154
  naming,
154
- plugins: this.context.getPlugins(),
155
155
  minify: false
156
156
  });
157
157
  if (!result2.success) {
@@ -172,7 +172,6 @@ class JsHmrStrategy extends HmrStrategy {
172
172
  outdir: this.context.getDistDir(),
173
173
  outbase: this.context.getSrcDir(),
174
174
  naming: "[dir]/[name]",
175
- plugins: this.context.getPlugins(),
176
175
  minify: false
177
176
  });
178
177
  if (!result.success) {
@@ -202,7 +201,11 @@ class JsHmrStrategy extends HmrStrategy {
202
201
  async processOutput(filepath, url, sourcePath) {
203
202
  try {
204
203
  if (sourcePath && !isHmrOutputFresh(filepath, sourcePath)) {
205
- appLogger.warn(`[JsHmrStrategy] Skipping broadcast for stale HMR output ${url}`);
204
+ if (isHmrOutputOlderThanSource(filepath, sourcePath)) {
205
+ appLogger.warn(
206
+ `[JsHmrStrategy] HMR output is older than source after rebuild; skipping broadcast for ${url}`
207
+ );
208
+ }
206
209
  return { success: false, requiresReload: false };
207
210
  }
208
211
  const code = await fileSystem.readFile(filepath);
@@ -3,7 +3,6 @@ import type { FileScriptAsset, ProcessedAsset } from '../../assets.types.js';
3
3
  import { BaseScriptProcessor } from '../base/base-script-processor.js';
4
4
  export declare class FileScriptProcessor extends BaseScriptProcessor<FileScriptAsset> {
5
5
  private hmrManager?;
6
- private resolveHmrOutputFilepath;
7
6
  setHmrManager(hmrManager: IHmrManager): void;
8
7
  process(dep: FileScriptAsset): Promise<ProcessedAsset>;
9
8
  }