@ecopages/core 0.2.0-beta.23 → 0.2.0-beta.24

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 (37) hide show
  1. package/package.json +10 -2
  2. package/src/adapters/abstract/application-adapter.js +3 -0
  3. package/src/adapters/bun/create-app.js +2 -0
  4. package/src/adapters/node/create-app.js +2 -0
  5. package/src/adapters/shared/server-adapter.js +17 -9
  6. package/src/build/app-build-manifest-runtime.js +12 -1
  7. package/src/build/dev-browser-script-cache.d.ts +34 -0
  8. package/src/build/dev-browser-script-cache.js +90 -0
  9. package/src/build/lit-static-render-worker-context.d.ts +2 -0
  10. package/src/build/lit-static-render-worker-context.js +6 -0
  11. package/src/diagnostics/request-build-dedupe.d.ts +18 -0
  12. package/src/diagnostics/request-build-dedupe.js +33 -0
  13. package/src/diagnostics/startup-trace.d.ts +25 -0
  14. package/src/diagnostics/startup-trace.js +121 -0
  15. package/src/env.d.ts +2 -0
  16. package/src/plugins/integration-plugin.d.ts +8 -0
  17. package/src/route-renderer/orchestration/page-browser-graph.service.js +15 -9
  18. package/src/route-renderer/page-loading/ecopages-virtual-imports.d.ts +6 -0
  19. package/src/route-renderer/page-loading/ecopages-virtual-imports.js +7 -2
  20. package/src/route-renderer/page-loading/module-declaration-aggregation.js +4 -0
  21. package/src/services/assets/asset-processing-service/asset-processing.service.d.ts +3 -0
  22. package/src/services/assets/asset-processing-service/asset-processing.service.js +41 -4
  23. package/src/services/assets/asset-processing-service/finalize-processed-asset.d.ts +3 -0
  24. package/src/services/assets/asset-processing-service/finalize-processed-asset.js +7 -0
  25. package/src/services/assets/asset-processing-service/grouped-content-bundles.d.ts +2 -1
  26. package/src/services/assets/asset-processing-service/grouped-content-bundles.js +20 -10
  27. package/src/services/assets/asset-processing-service/index.d.ts +1 -0
  28. package/src/services/assets/asset-processing-service/index.js +1 -0
  29. package/src/services/assets/asset-processing-service/materialize-content-script-asset.d.ts +11 -0
  30. package/src/services/assets/asset-processing-service/materialize-content-script-asset.js +17 -0
  31. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.d.ts +7 -0
  32. package/src/services/assets/asset-processing-service/processors/script/content-script.processor.js +105 -79
  33. package/src/services/assets/asset-processing-service/resolve-integration-plugin.d.ts +4 -0
  34. package/src/services/assets/asset-processing-service/resolve-integration-plugin.js +17 -0
  35. package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.d.ts +0 -1
  36. package/src/services/assets/asset-processing-service/ungrouped-dependency-processing.js +5 -10
  37. package/src/services/assets/browser-bundle.service.js +10 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-beta.23",
3
+ "version": "0.2.0-beta.24",
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.23",
20
+ "@ecopages/file-system": "0.2.0-beta.24",
21
21
  "@ecopages/logger": "^0.2.3",
22
22
  "@ecopages/scripts-injector": "^0.1.5",
23
23
  "@oxc-project/runtime": "0.134.0",
@@ -207,6 +207,10 @@
207
207
  "types": "./src/build/jsx-ownership-plugins.d.ts",
208
208
  "default": "./src/build/jsx-ownership-plugins.js"
209
209
  },
210
+ "./build/lit-static-render-worker-context": {
211
+ "types": "./src/build/lit-static-render-worker-context.d.ts",
212
+ "default": "./src/build/lit-static-render-worker-context.js"
213
+ },
210
214
  "./cache": {
211
215
  "types": "./src/cache/index.d.ts",
212
216
  "default": "./src/cache/index.js"
@@ -416,6 +420,10 @@
416
420
  "types": "./src/build/jsx-ownership-plugins.d.ts",
417
421
  "default": "./src/build/jsx-ownership-plugins.js"
418
422
  },
423
+ "./build/lit-static-render-worker-context.ts": {
424
+ "types": "./src/build/lit-static-render-worker-context.d.ts",
425
+ "default": "./src/build/lit-static-render-worker-context.js"
426
+ },
419
427
  "./cache.ts": {
420
428
  "types": "./src/cache/index.d.ts",
421
429
  "default": "./src/cache/index.js"
@@ -10,6 +10,7 @@ import {
10
10
  formatRuntimeServerStartedMessage
11
11
  } from "../../dev/runtime-server-started-message.js";
12
12
  import { parseCliArgs } from "../../utils/parse-cli-args.js";
13
+ import { startupTrace } from "../../diagnostics/startup-trace.js";
13
14
  class AbstractApplicationAdapter {
14
15
  appConfig;
15
16
  serverOptions;
@@ -42,6 +43,7 @@ class AbstractApplicationAdapter {
42
43
  setAppHostModuleLoader(this.appConfig, hostModuleLoader);
43
44
  }
44
45
  getAppModuleLoader(this.appConfig);
46
+ startupTrace.markConfigReady();
45
47
  if (options.clearOutput) {
46
48
  this.clearDistFolder().catch((error) => {
47
49
  appLogger.error("Error clearing dist folder", error);
@@ -221,6 +223,7 @@ class AbstractApplicationAdapter {
221
223
  }
222
224
  notifyListening(origin) {
223
225
  const normalizedOrigin = origin.replace(/\/$/, "");
226
+ startupTrace.markServerListening();
224
227
  if (!this.onAppStartCallback) {
225
228
  this.logServerStarted(normalizedOrigin);
226
229
  }
@@ -1,6 +1,7 @@
1
1
  import { appLogger } from "../../global/app-logger.js";
2
2
  import { SharedApplicationAdapter } from "../shared/application-adapter.js";
3
3
  import { resolveRuntimeBinding, resolveStaticRuntimeMode } from "../shared/runtime-app-bootstrap.js";
4
+ import { startupTrace } from "../../diagnostics/startup-trace.js";
4
5
  import { createBunServerAdapter } from "./server-adapter.js";
5
6
  import { BunRuntimeHost } from "./runtime-host.js";
6
7
  import { hostOwnsDevClient } from "../../dev/dev-client-ownership.js";
@@ -101,6 +102,7 @@ class BunEcopagesApp extends SharedApplicationAdapter {
101
102
  const enableHmr = dev || !preview && !build;
102
103
  const serverOptions = this.serverAdapter.getServerOptions({ enableHmr });
103
104
  const runtimeServerOptions = serverOptions;
105
+ startupTrace.beginServerListen();
104
106
  this.server = await this.runtimeHost.start({
105
107
  serveOptions: runtimeServerOptions,
106
108
  handleRequest: async () => new Response(null, { status: 500 }),
@@ -5,6 +5,7 @@ import { createNodeServerAdapter } from "./server-adapter.js";
5
5
  import { NodeHttpRequestBridge } from "./http-request-bridge.js";
6
6
  import { NodeRuntimeHost } from "./runtime-host.js";
7
7
  import { hostOwnsDevClient } from "../../dev/dev-client-ownership.js";
8
+ import { startupTrace } from "../../diagnostics/startup-trace.js";
8
9
  class NodeEcopagesApp extends SharedApplicationAdapter {
9
10
  serverAdapter;
10
11
  server = null;
@@ -87,6 +88,7 @@ class NodeEcopagesApp extends SharedApplicationAdapter {
87
88
  return;
88
89
  }
89
90
  const serveOptions = this.serverAdapter.getServerOptions();
91
+ startupTrace.beginServerListen();
90
92
  this.server = await this.runtimeHost.start({
91
93
  serveOptions,
92
94
  handleRequest: async (request) => await this.serverAdapter.handleRequest(request),
@@ -2,6 +2,8 @@ import path from "node:path";
2
2
  import { AbstractServerAdapter } from "../abstract/server-adapter.js";
3
3
  import { RouteRendererFactory } from "../../route-renderer/route-renderer.js";
4
4
  import { RouteRegistry } from "../../router/server/route-registry.js";
5
+ import { startupTrace } from "../../diagnostics/startup-trace.js";
6
+ import { requestBuildDedupe } from "../../diagnostics/request-build-dedupe.js";
5
7
  import { MemoryCacheStore } from "../../services/cache/memory-cache-store.js";
6
8
  import { PageCacheService } from "../../services/cache/page-cache-service.js";
7
9
  import { SchemaValidationService } from "../../services/validation/schema-validation-service.js";
@@ -71,6 +73,7 @@ class SharedServerAdapter extends AbstractServerAdapter {
71
73
  * Web Requests (`Request`) to their corresponding internal execution paths.
72
74
  */
73
75
  async initSharedRouter() {
76
+ startupTrace.markPhaseStart("route-registry");
74
77
  this.router = new RouteRegistry({
75
78
  pagesDir: path.join(this.appConfig.rootDir, this.appConfig.srcDir, this.appConfig.pagesDir),
76
79
  appConfig: this.appConfig,
@@ -80,6 +83,7 @@ class SharedServerAdapter extends AbstractServerAdapter {
80
83
  pageModuleAdapter: this.createRouteRegistryPageModuleAdapter()
81
84
  });
82
85
  await this.router.init();
86
+ startupTrace.markPhaseEnd("route-registry");
83
87
  }
84
88
  createRouteRegistryPageModuleAdapter() {
85
89
  this.ensureRouteRendererFactory();
@@ -428,15 +432,19 @@ class SharedServerAdapter extends AbstractServerAdapter {
428
432
  * native HTTP objects into Web Standard Requests.
429
433
  */
430
434
  async handleSharedRequest(request, context) {
431
- const hmrResponse = this.tryHandleSharedHmrRequest(request, context);
432
- if (hmrResponse) {
433
- return hmrResponse;
434
- }
435
- const apiResponse = await this.tryHandleSharedApiRequest(request, context);
436
- if (apiResponse) {
437
- return apiResponse;
438
- }
439
- return this.routeHandler.handleResponse(request);
435
+ return requestBuildDedupe.run(
436
+ () => startupTrace.traceFirstRequest(request, async () => {
437
+ const hmrResponse = this.tryHandleSharedHmrRequest(request, context);
438
+ if (hmrResponse) {
439
+ return hmrResponse;
440
+ }
441
+ const apiResponse = await this.tryHandleSharedApiRequest(request, context);
442
+ if (apiResponse) {
443
+ return apiResponse;
444
+ }
445
+ return this.routeHandler.handleResponse(request);
446
+ })
447
+ );
440
448
  }
441
449
  }
442
450
  export {
@@ -1,5 +1,7 @@
1
1
  import { mergeBrowserRuntimeManifests } from "./browser-runtime-manifest.js";
2
2
  import { appLogger } from "../global/app-logger.js";
3
+ import { startupTrace } from "../diagnostics/startup-trace.js";
4
+ import { isLitStaticRenderWorkerThread } from "./lit-static-render-worker-context.js";
3
5
  function patchAppRuntime(appConfig, patch) {
4
6
  appConfig.runtime = {
5
7
  ...appConfig.runtime ?? {},
@@ -50,18 +52,26 @@ async function collectConfiguredAppBuildManifestContributions(appConfig) {
50
52
  };
51
53
  }
52
54
  async function setupAppRuntimePlugins(options) {
55
+ startupTrace.markPhaseStart("setupAppRuntimePlugins");
53
56
  if (options.appConfig.runtime?.runtimeAssetsPrepared) {
54
57
  appLogger.debug("Skipped setupAppRuntimePlugins: runtime assets already prepared");
55
58
  registerRuntimePlugins(options.appConfig, options.onRuntimePlugin);
59
+ startupTrace.markPhaseEnd("setupAppRuntimePlugins");
56
60
  return;
57
61
  }
62
+ if (isLitStaticRenderWorkerThread()) {
63
+ appLogger.debug("Lit static-render worker: skipping processor setup (main thread already prepared artifacts)");
64
+ }
58
65
  appLogger.debugTime("setupAppRuntimePlugins");
59
66
  try {
60
67
  for (const loader of options.appConfig.loaders.values()) {
61
68
  options.onRuntimePlugin?.(loader);
62
69
  }
70
+ const skipProcessorSetup = isLitStaticRenderWorkerThread();
63
71
  for (const processor of options.appConfig.processors.values()) {
64
- await processor.setup();
72
+ if (!skipProcessorSetup) {
73
+ await processor.setup();
74
+ }
65
75
  if (processor.plugins) {
66
76
  for (const plugin of processor.plugins) {
67
77
  options.onRuntimePlugin?.(plugin);
@@ -79,6 +89,7 @@ async function setupAppRuntimePlugins(options) {
79
89
  patchAppRuntime(options.appConfig, { runtimeAssetsPrepared: true });
80
90
  } finally {
81
91
  appLogger.debugTimeEnd("setupAppRuntimePlugins");
92
+ startupTrace.markPhaseEnd("setupAppRuntimePlugins");
82
93
  }
83
94
  }
84
95
  export {
@@ -0,0 +1,34 @@
1
+ import type { EcoPagesAppConfig } from '../types/internal-types.js';
2
+ import type { ProcessedAsset } from '../services/assets/asset-processing-service/assets.types.js';
3
+ export declare const DEV_BROWSER_SCRIPT_CACHE_DIR = ".browser-script-bundles";
4
+ export declare const DEV_BROWSER_SCRIPT_CACHE_FILENAME = ".build-cache.json";
5
+ /** Output path restored from the dev disk cache before dependency metadata is merged. */
6
+ export type DevBrowserScriptCacheHit = {
7
+ filepath: string;
8
+ };
9
+ /**
10
+ * @remarks
11
+ * Dev asset caching spans three scopes that should not be conflated:
12
+ * - request dedupe (`request-build-dedupe.ts`) — one in-flight build per request key
13
+ * - in-memory service cache (`AssetProcessingService`) — per-process reuse within one dev server
14
+ * - this disk manifest (`.eco/.browser-script-bundles`) — cross-request reuse of content-script bundles
15
+ *
16
+ * Disk entries intentionally store only output file paths. Declarative fields such as
17
+ * `groupedBundle`, `attributes`, and `packageRole` live on the originating
18
+ * `ContentScriptAsset` and are merged via `materializeContentScriptAsset()` on lookup.
19
+ */
20
+ export interface DevBrowserScriptCacheEntry {
21
+ filepath: string;
22
+ builtAt: number;
23
+ }
24
+ export interface DevBrowserScriptCacheManifest {
25
+ invalidationVersion: string;
26
+ buildInputsFingerprint: string;
27
+ entries: Record<string, DevBrowserScriptCacheEntry>;
28
+ }
29
+ /** Returns whether dev-only cross-request content-script bundle cache is active. */
30
+ export declare function shouldUseDevBrowserScriptCache(): boolean;
31
+ /** Looks up one persisted dev content-script output path by dependency cache key. */
32
+ export declare function getDevBrowserScriptCacheEntry(appConfig: EcoPagesAppConfig, depKey: string): DevBrowserScriptCacheHit | null;
33
+ /** Persists one dev content-script bundle output for cross-request reuse. */
34
+ export declare function setDevBrowserScriptCacheEntry(appConfig: EcoPagesAppConfig, depKey: string, asset: ProcessedAsset): void;
@@ -0,0 +1,90 @@
1
+ import path from "node:path";
2
+ import { fileSystem } from "@ecopages/file-system";
3
+ import { getAppBrowserBuildPlugins } from "./build-adapter.js";
4
+ import { createBuildInputsFingerprint, hashAppConfigFile } from "./build-input-fingerprint.js";
5
+ import {
6
+ readProductionCacheManifest,
7
+ writeProductionCacheManifest,
8
+ isProductionCacheManifestCurrent,
9
+ matchesProductionCacheFingerprint
10
+ } from "./production-build-cache.js";
11
+ import { getCorePackageVersion } from "../services/module-loading/route-module-build-manifest.js";
12
+ import { resolveInternalExecutionDir } from "../utils/resolve-work-dir.js";
13
+ import { isDevelopmentRuntime } from "../utils/runtime.js";
14
+ const DEV_BROWSER_SCRIPT_CACHE_DIR = ".browser-script-bundles";
15
+ const DEV_BROWSER_SCRIPT_CACHE_FILENAME = ".build-cache.json";
16
+ function getDevBrowserScriptCacheDir(appConfig) {
17
+ return path.join(resolveInternalExecutionDir(appConfig), DEV_BROWSER_SCRIPT_CACHE_DIR);
18
+ }
19
+ function getDevBrowserScriptCacheManifestPath(appConfig) {
20
+ return path.join(getDevBrowserScriptCacheDir(appConfig), DEV_BROWSER_SCRIPT_CACHE_FILENAME);
21
+ }
22
+ function createBrowserBuildKey(appConfig) {
23
+ try {
24
+ const pluginNames = getAppBrowserBuildPlugins(appConfig).map((plugin) => plugin.name).sort().join("|");
25
+ return pluginNames || "default";
26
+ } catch {
27
+ return "default";
28
+ }
29
+ }
30
+ function createDevBrowserScriptCacheInvalidationVersion(appConfig) {
31
+ return [getCorePackageVersion(), hashAppConfigFile(appConfig), createBrowserBuildKey(appConfig)].join("::");
32
+ }
33
+ function readManifest(appConfig) {
34
+ const manifest = readProductionCacheManifest(
35
+ getDevBrowserScriptCacheManifestPath(appConfig)
36
+ );
37
+ if (!manifest) {
38
+ return void 0;
39
+ }
40
+ const invalidationVersion = createDevBrowserScriptCacheInvalidationVersion(appConfig);
41
+ const buildInputsFingerprint = createBuildInputsFingerprint(appConfig);
42
+ if (!isProductionCacheManifestCurrent(manifest, invalidationVersion) || !matchesProductionCacheFingerprint(manifest, buildInputsFingerprint)) {
43
+ return void 0;
44
+ }
45
+ return manifest;
46
+ }
47
+ function writeManifest(appConfig, manifest) {
48
+ writeProductionCacheManifest(getDevBrowserScriptCacheManifestPath(appConfig), manifest);
49
+ }
50
+ function shouldUseDevBrowserScriptCache() {
51
+ return isDevelopmentRuntime();
52
+ }
53
+ function getDevBrowserScriptCacheEntry(appConfig, depKey) {
54
+ if (!shouldUseDevBrowserScriptCache()) {
55
+ return null;
56
+ }
57
+ const manifest = readManifest(appConfig);
58
+ const entry = manifest?.entries[depKey];
59
+ if (!entry?.filepath || !fileSystem.exists(entry.filepath)) {
60
+ return null;
61
+ }
62
+ return { filepath: entry.filepath };
63
+ }
64
+ function setDevBrowserScriptCacheEntry(appConfig, depKey, asset) {
65
+ if (!shouldUseDevBrowserScriptCache() || !asset.filepath) {
66
+ return;
67
+ }
68
+ const invalidationVersion = createDevBrowserScriptCacheInvalidationVersion(appConfig);
69
+ const buildInputsFingerprint = createBuildInputsFingerprint(appConfig);
70
+ const existing = readManifest(appConfig);
71
+ const manifest = existing ?? {
72
+ invalidationVersion,
73
+ buildInputsFingerprint,
74
+ entries: {}
75
+ };
76
+ manifest.invalidationVersion = invalidationVersion;
77
+ manifest.buildInputsFingerprint = buildInputsFingerprint;
78
+ manifest.entries[depKey] = {
79
+ filepath: asset.filepath,
80
+ builtAt: Date.now()
81
+ };
82
+ writeManifest(appConfig, manifest);
83
+ }
84
+ export {
85
+ DEV_BROWSER_SCRIPT_CACHE_DIR,
86
+ DEV_BROWSER_SCRIPT_CACHE_FILENAME,
87
+ getDevBrowserScriptCacheEntry,
88
+ setDevBrowserScriptCacheEntry,
89
+ shouldUseDevBrowserScriptCache
90
+ };
@@ -0,0 +1,2 @@
1
+ /** Returns whether the current thread is the Lit static-render worker. */
2
+ export declare function isLitStaticRenderWorkerThread(): boolean;
@@ -0,0 +1,6 @@
1
+ function isLitStaticRenderWorkerThread() {
2
+ return process.env.ECOPAGES_LIT_STATIC_RENDER_WORKER === "true";
3
+ }
4
+ export {
5
+ isLitStaticRenderWorkerThread
6
+ };
@@ -0,0 +1,18 @@
1
+ import type { BuildResult } from '../build/build-adapter.js';
2
+ /**
3
+ * Per-request browser build dedupe cache.
4
+ *
5
+ * @remarks
6
+ * `DedupingBuildExecutor` only coalesces concurrent identical builds. Asset
7
+ * processing can call the same logical bundle several times in one request
8
+ * (page graph, HMR, content scripts) after the first build settles. This cache
9
+ * reuses the settled result until the request scope ends.
10
+ */
11
+ declare class RequestBuildDedupe {
12
+ private readonly storage;
13
+ run<T>(fn: () => Promise<T>): Promise<T>;
14
+ dedupeBuild(key: string, build: () => Promise<BuildResult>): Promise<BuildResult>;
15
+ getCachedCountForTests(): number;
16
+ }
17
+ export declare const requestBuildDedupe: RequestBuildDedupe;
18
+ export {};
@@ -0,0 +1,33 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ class RequestBuildDedupe {
3
+ storage = new AsyncLocalStorage();
4
+ run(fn) {
5
+ if (this.storage.getStore()) {
6
+ return fn();
7
+ }
8
+ return this.storage.run(/* @__PURE__ */ new Map(), fn);
9
+ }
10
+ dedupeBuild(key, build) {
11
+ const store = this.storage.getStore();
12
+ if (!store) {
13
+ return build();
14
+ }
15
+ const existing = store.get(key);
16
+ if (existing) {
17
+ return existing;
18
+ }
19
+ const buildPromise = build().catch((error) => {
20
+ store.delete(key);
21
+ throw error;
22
+ });
23
+ store.set(key, buildPromise);
24
+ return buildPromise;
25
+ }
26
+ getCachedCountForTests() {
27
+ return this.storage.getStore()?.size ?? 0;
28
+ }
29
+ }
30
+ const requestBuildDedupe = new RequestBuildDedupe();
31
+ export {
32
+ requestBuildDedupe
33
+ };
@@ -0,0 +1,25 @@
1
+ export type StartupTracePhase = 'config-ready' | 'setupAppRuntimePlugins' | 'route-registry' | 'server-listen' | 'first-request-ssr';
2
+ declare class StartupTrace {
3
+ private readonly phaseStarts;
4
+ private readonly phases;
5
+ private firstRequestPending;
6
+ private firstRequestPath;
7
+ private firstRequestBundleCount;
8
+ private firstRequestClientBytes;
9
+ private summaryEmitted;
10
+ isEnabled(): boolean;
11
+ markPhaseStart(phase: StartupTracePhase): void;
12
+ markPhaseEnd(phase: StartupTracePhase): void;
13
+ markConfigReady(): void;
14
+ markServerListening(): void;
15
+ beginServerListen(): void;
16
+ recordBrowserBundle(outputs: Array<{
17
+ path: string;
18
+ }>): void;
19
+ traceFirstRequest<T>(request: Request, handler: () => Promise<T>): Promise<T>;
20
+ private emitFirstRequestSummary;
21
+ /** Resets mutable trace state for unit tests. */
22
+ resetForTests(): void;
23
+ }
24
+ export declare const startupTrace: StartupTrace;
25
+ export {};
@@ -0,0 +1,121 @@
1
+ import { statSync } from "node:fs";
2
+ import { fileSystem } from "@ecopages/file-system";
3
+ const LOG_PREFIX = "[ecopages:startup-trace]";
4
+ function isStartupTraceEnabled() {
5
+ return process.env.ECOPAGES_STARTUP_TRACE === "true" || process.env.ECOPAGES_LOGGER_DEBUG === "true";
6
+ }
7
+ function wallMsSinceProcessStart() {
8
+ return Math.round(process.uptime() * 1e3);
9
+ }
10
+ function writeTraceLine(message) {
11
+ process.stderr.write(`${LOG_PREFIX} ${message}
12
+ `);
13
+ }
14
+ class StartupTrace {
15
+ phaseStarts = /* @__PURE__ */ new Map();
16
+ phases = /* @__PURE__ */ new Map();
17
+ firstRequestPending = true;
18
+ firstRequestPath;
19
+ firstRequestBundleCount = 0;
20
+ firstRequestClientBytes = 0;
21
+ summaryEmitted = false;
22
+ isEnabled() {
23
+ return isStartupTraceEnabled();
24
+ }
25
+ markPhaseStart(phase) {
26
+ if (!isStartupTraceEnabled() || this.phases.has(phase)) {
27
+ return;
28
+ }
29
+ this.phaseStarts.set(phase, performance.now());
30
+ }
31
+ markPhaseEnd(phase) {
32
+ if (!isStartupTraceEnabled() || this.phases.has(phase)) {
33
+ return;
34
+ }
35
+ const startedAt = this.phaseStarts.get(phase);
36
+ if (startedAt === void 0) {
37
+ return;
38
+ }
39
+ const durationMs = Math.round(performance.now() - startedAt);
40
+ const record = {
41
+ durationMs,
42
+ wallMs: wallMsSinceProcessStart()
43
+ };
44
+ this.phases.set(phase, record);
45
+ writeTraceLine(`phase=${phase} durationMs=${durationMs} wallMs=${record.wallMs}`);
46
+ }
47
+ markConfigReady() {
48
+ if (!isStartupTraceEnabled() || this.phases.has("config-ready")) {
49
+ return;
50
+ }
51
+ const record = {
52
+ durationMs: 0,
53
+ wallMs: wallMsSinceProcessStart()
54
+ };
55
+ this.phases.set("config-ready", record);
56
+ writeTraceLine(`phase=config-ready wallMs=${record.wallMs}`);
57
+ }
58
+ markServerListening() {
59
+ this.markPhaseEnd("server-listen");
60
+ }
61
+ beginServerListen() {
62
+ this.markPhaseStart("server-listen");
63
+ }
64
+ recordBrowserBundle(outputs) {
65
+ if (!isStartupTraceEnabled() || !this.firstRequestPending) {
66
+ return;
67
+ }
68
+ this.firstRequestBundleCount += 1;
69
+ for (const output of outputs) {
70
+ try {
71
+ if (fileSystem.exists(output.path)) {
72
+ this.firstRequestClientBytes += statSync(output.path).size;
73
+ }
74
+ } catch {
75
+ }
76
+ }
77
+ }
78
+ async traceFirstRequest(request, handler) {
79
+ if (!isStartupTraceEnabled() || !this.firstRequestPending) {
80
+ return handler();
81
+ }
82
+ this.firstRequestPath = new URL(request.url).pathname;
83
+ this.markPhaseStart("first-request-ssr");
84
+ try {
85
+ return await handler();
86
+ } finally {
87
+ this.markPhaseEnd("first-request-ssr");
88
+ this.emitFirstRequestSummary();
89
+ this.firstRequestPending = false;
90
+ }
91
+ }
92
+ emitFirstRequestSummary() {
93
+ if (!isStartupTraceEnabled() || this.summaryEmitted) {
94
+ return;
95
+ }
96
+ this.summaryEmitted = true;
97
+ writeTraceLine(
98
+ [
99
+ "summary",
100
+ `path=${this.firstRequestPath ?? "unknown"}`,
101
+ `bundleCount=${this.firstRequestBundleCount}`,
102
+ `clientBundleBytes=${this.firstRequestClientBytes}`,
103
+ `wallMs=${wallMsSinceProcessStart()}`
104
+ ].join(" ")
105
+ );
106
+ }
107
+ /** Resets mutable trace state for unit tests. */
108
+ resetForTests() {
109
+ this.phaseStarts.clear();
110
+ this.phases.clear();
111
+ this.firstRequestPending = true;
112
+ this.firstRequestPath = void 0;
113
+ this.firstRequestBundleCount = 0;
114
+ this.firstRequestClientBytes = 0;
115
+ this.summaryEmitted = false;
116
+ }
117
+ }
118
+ const startupTrace = new StartupTrace();
119
+ export {
120
+ startupTrace
121
+ };
package/src/env.d.ts CHANGED
@@ -3,6 +3,8 @@ interface EcopagesEnv {
3
3
  ECOPAGES_HOSTNAME: string;
4
4
  ECOPAGES_PORT: string;
5
5
  ECOPAGES_LOGGER_DEBUG: 'true' | 'false';
6
+ /** When `true`, emits startup phase timings to stderr. Also enabled when `ECOPAGES_LOGGER_DEBUG=true`. */
7
+ ECOPAGES_STARTUP_TRACE?: 'true' | 'false';
6
8
  }
7
9
 
8
10
  declare global {
@@ -197,6 +197,14 @@ export declare abstract class IntegrationPlugin<C = EcoPagesElement> {
197
197
  initializeRenderer(options?: {
198
198
  rendererModules?: unknown;
199
199
  }): IntegrationRenderer<C>;
200
+ /**
201
+ * Shapes one dependency batch before core asset processing runs.
202
+ *
203
+ * @remarks
204
+ * Integrations use this to assign grouped-build metadata or other batch-level
205
+ * policy without teaching core about integration-specific asset graphs.
206
+ */
207
+ prepareAssetDependencies?(dependencies: AssetDefinition[]): AssetDefinition[];
200
208
  /**
201
209
  * Prepares build-facing contributions before the app build manifest is sealed.
202
210
  *
@@ -103,6 +103,9 @@ class PageBrowserGraphService {
103
103
  );
104
104
  continue;
105
105
  }
106
+ if (this.isHmrEnabled()) {
107
+ continue;
108
+ }
106
109
  if (!contribution?.dependencies?.length) {
107
110
  continue;
108
111
  }
@@ -130,15 +133,18 @@ class PageBrowserGraphService {
130
133
  );
131
134
  const groupedAssetsByRoute = /* @__PURE__ */ new Map();
132
135
  for (const [routeFile, groupedAssetKeys] of groupedAssetKeysByRoute) {
133
- groupedAssetsByRoute.set(
134
- routeFile,
135
- processedGroupedDependencies.filter((asset) => {
136
- if (!asset.groupedBundle) {
137
- return false;
138
- }
139
- return groupedAssetKeys.has(getGroupedBundleAssetKey(asset.groupedBundle));
140
- })
141
- );
136
+ const matchedAssets = processedGroupedDependencies.filter((asset) => {
137
+ if (!asset.groupedBundle) {
138
+ return false;
139
+ }
140
+ return groupedAssetKeys.has(getGroupedBundleAssetKey(asset.groupedBundle));
141
+ });
142
+ if (groupedAssetKeys.size > 0 && matchedAssets.length === 0) {
143
+ appLogger.warn(
144
+ `Grouped page-browser assets for ${routeFile} are missing groupedBundle metadata after processing. Hydration scripts may be omitted from HTML.`
145
+ );
146
+ }
147
+ groupedAssetsByRoute.set(routeFile, matchedAssets);
142
148
  }
143
149
  return {
144
150
  assetsByRoute: groupedAssetsByRoute,
@@ -2,6 +2,12 @@ export type EcopagesVirtualImport = {
2
2
  from: string;
3
3
  imports: string[] | undefined;
4
4
  };
5
+ /**
6
+ * @remarks
7
+ * Content server modules static-import every MDX entry. They must never become
8
+ * browser module-script dependencies discovered from page or component sources.
9
+ */
10
+ export declare function isBrowserEcopagesVirtualImport(specifier: string): boolean;
5
11
  /**
6
12
  * Extracts runtime `ecopages:` virtual-module imports from a component source file.
7
13
  *
@@ -1,5 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { parseSync } from "oxc-parser";
3
+ const CONTENT_SERVER_VIRTUAL_MODULE_PATTERN = /^ecopages:content\/[a-z][a-z0-9-]+\/server$/;
4
+ function isBrowserEcopagesVirtualImport(specifier) {
5
+ return specifier.startsWith("ecopages:") && !CONTENT_SERVER_VIRTUAL_MODULE_PATTERN.test(specifier);
6
+ }
3
7
  function extractEcopagesVirtualImports(file) {
4
8
  let source;
5
9
  try {
@@ -18,7 +22,7 @@ function extractEcopagesVirtualImports(file) {
18
22
  if (node.type !== "ImportDeclaration") continue;
19
23
  if (node.importKind === "type") continue;
20
24
  const specifier = node.source?.value ?? "";
21
- if (!specifier.startsWith("ecopages:")) continue;
25
+ if (!isBrowserEcopagesVirtualImport(specifier)) continue;
22
26
  if (found.get(specifier) === null) {
23
27
  continue;
24
28
  }
@@ -53,5 +57,6 @@ function extractEcopagesVirtualImports(file) {
53
57
  }));
54
58
  }
55
59
  export {
56
- extractEcopagesVirtualImports
60
+ extractEcopagesVirtualImports,
61
+ isBrowserEcopagesVirtualImport
57
62
  };
@@ -1,4 +1,5 @@
1
1
  import { normalizeModuleDeclarations } from "../../eco/module-dependencies.js";
2
+ import { isBrowserEcopagesVirtualImport } from "./ecopages-virtual-imports.js";
2
3
  function getDeclaredModules(value) {
3
4
  if (!Array.isArray(value)) {
4
5
  return void 0;
@@ -22,6 +23,9 @@ function mergeModuleDeclaration(modulesMap, declaration) {
22
23
  }
23
24
  function collectModuleDeclarations(modulesMap, declaredModules, autoVirtualImports) {
24
25
  for (const declaration of normalizeModuleDeclarations(getDeclaredModules(declaredModules))) {
26
+ if (declaration.from.startsWith("ecopages:") && !isBrowserEcopagesVirtualImport(declaration.from)) {
27
+ continue;
28
+ }
25
29
  mergeModuleDeclaration(modulesMap, declaration);
26
30
  }
27
31
  for (const declaration of autoVirtualImports) {
@@ -42,6 +42,7 @@ export declare class AssetProcessingService {
42
42
  * entries.
43
43
  */
44
44
  processDependencies(deps: AssetDefinition[], key: string): Promise<ProcessedAsset[]>;
45
+ private prepareDependenciesForProcessing;
45
46
  /**
46
47
  * Processes deduplicated dependencies grouped by processor type.
47
48
  *
@@ -80,6 +81,8 @@ export declare class AssetProcessingService {
80
81
  * Returns the cached processed asset for a dependency key when available.
81
82
  */
82
83
  private getCachedAsset;
84
+ private getCachedContentScriptAsset;
85
+ private resolveCachedContentScriptFilepath;
83
86
  /**
84
87
  * Stores one processed asset in the dependency cache.
85
88
  */
@@ -4,12 +4,19 @@ import { appLogger } from "../../../global/app-logger.js";
4
4
  import { fileSystem } from "@ecopages/file-system";
5
5
  import { deduplicateAssetDependencies, getAssetDependencyKey } from "./asset-dependency-keys.js";
6
6
  import {
7
+ ensureGroupedContentScriptsBundle,
7
8
  partitionGroupedContentScriptDependencies,
8
9
  processGroupedDependencyBundles
9
10
  } from "./grouped-content-bundles.js";
11
+ import { resolveIntegrationPluginForProcessingKey } from "./resolve-integration-plugin.js";
10
12
  import { isHmrAware } from "./processor.interface.js";
11
13
  import { ProcessorRegistry } from "./processor.registry.js";
12
14
  import { processUngroupedDependency } from "./ungrouped-dependency-processing.js";
15
+ import { materializeContentScriptAsset } from "./materialize-content-script-asset.js";
16
+ import {
17
+ getDevBrowserScriptCacheEntry,
18
+ setDevBrowserScriptCacheEntry
19
+ } from "../../../build/dev-browser-script-cache.js";
13
20
  import {
14
21
  ContentScriptProcessor,
15
22
  ContentStylesheetProcessor,
@@ -68,10 +75,16 @@ class AssetProcessingService {
68
75
  const depsDir = path.join(this.config.absolutePaths.distDir, RESOLVED_ASSETS_DIR);
69
76
  fileSystem.ensureDir(depsDir);
70
77
  const dedupedDeps = deduplicateAssetDependencies(deps);
71
- const results = await this.processDependenciesParallel(dedupedDeps, key);
78
+ const preparedDeps = this.prepareDependenciesForProcessing(dedupedDeps, key);
79
+ ensureGroupedContentScriptsBundle(preparedDeps);
80
+ const results = await this.processDependenciesParallel(preparedDeps);
72
81
  await this.optimizeDependencies(results);
73
82
  return results;
74
83
  }
84
+ prepareDependenciesForProcessing(deps, processingKey) {
85
+ const plugin = resolveIntegrationPluginForProcessingKey(this.config, processingKey);
86
+ return plugin?.prepareAssetDependencies?.(deps) ?? deps;
87
+ }
75
88
  /**
76
89
  * Processes deduplicated dependencies grouped by processor type.
77
90
  *
@@ -80,14 +93,13 @@ class AssetProcessingService {
80
93
  * pair, while still allowing the overall dependency set to resolve in
81
94
  * parallel.
82
95
  */
83
- async processDependenciesParallel(deps, key) {
96
+ async processDependenciesParallel(deps) {
84
97
  const grouped = this.groupDependenciesByType(deps);
85
98
  const groupPromises = Object.entries(grouped).map(async ([, typeDeps]) => {
86
99
  const { groupedBundleDeps, ungroupedDeps } = partitionGroupedContentScriptDependencies(typeDeps);
87
100
  const typePromises = ungroupedDeps.map(
88
101
  (dep) => processUngroupedDependency({
89
102
  dep,
90
- key,
91
103
  depKey: getAssetDependencyKey(dep),
92
104
  getCachedAsset: (assetDep, depKey) => this.getCachedAsset(assetDep, depKey),
93
105
  getProcessor: (assetDep) => this.registry.getProcessor(assetDep.kind, assetDep.source),
@@ -109,7 +121,6 @@ class AssetProcessingService {
109
121
  );
110
122
  const groupedResults = await processGroupedDependencyBundles({
111
123
  bundles: Array.from(groupedBundleDeps.values()),
112
- key,
113
124
  getCachedAsset: (dep, depKey) => this.getCachedAsset(dep, depKey),
114
125
  getDependencyKey: getAssetDependencyKey,
115
126
  getGroupedProcessor: () => this.registry.getProcessor("script", "content"),
@@ -229,6 +240,9 @@ class AssetProcessingService {
229
240
  if (process.env.NODE_ENV !== "production" && dep.source === "file" && dep.kind === "stylesheet") {
230
241
  return null;
231
242
  }
243
+ if (dep.kind === "script" && dep.source === "content") {
244
+ return this.getCachedContentScriptAsset(dep, depKey);
245
+ }
232
246
  const cached = this.cache.get(depKey);
233
247
  if (!cached) {
234
248
  return null;
@@ -239,11 +253,34 @@ class AssetProcessingService {
239
253
  }
240
254
  return cached.asset;
241
255
  }
256
+ getCachedContentScriptAsset(dep, depKey) {
257
+ const filepath = this.resolveCachedContentScriptFilepath(depKey) ?? getDevBrowserScriptCacheEntry(this.config, depKey)?.filepath;
258
+ if (!filepath) {
259
+ return null;
260
+ }
261
+ const materialized = materializeContentScriptAsset(dep, filepath);
262
+ this.cache.set(depKey, { asset: materialized });
263
+ return materialized;
264
+ }
265
+ resolveCachedContentScriptFilepath(depKey) {
266
+ const cached = this.cache.get(depKey);
267
+ if (!cached?.asset.filepath) {
268
+ return void 0;
269
+ }
270
+ if (!fileSystem.exists(cached.asset.filepath)) {
271
+ this.cache.delete(depKey);
272
+ return void 0;
273
+ }
274
+ return cached.asset.filepath;
275
+ }
242
276
  /**
243
277
  * Stores one processed asset in the dependency cache.
244
278
  */
245
279
  setCachedAsset(dep, depKey, asset) {
246
280
  this.cache.set(depKey, { asset });
281
+ if (dep.kind === "script" && dep.source === "content") {
282
+ setDevBrowserScriptCacheEntry(this.config, depKey, asset);
283
+ }
247
284
  }
248
285
  /**
249
286
  * Clears all cached processed assets.
@@ -0,0 +1,3 @@
1
+ import type { ProcessedAsset } from './assets.types.js';
2
+ /** Applies the public source URL to one processed asset when available. */
3
+ export declare function finalizeProcessedAsset(processed: ProcessedAsset, resolveProcessedAssetSrcUrl: (processed: ProcessedAsset) => string | undefined): ProcessedAsset;
@@ -0,0 +1,7 @@
1
+ function finalizeProcessedAsset(processed, resolveProcessedAssetSrcUrl) {
2
+ const srcUrl = resolveProcessedAssetSrcUrl(processed);
3
+ return srcUrl ? { ...processed, srcUrl } : processed;
4
+ }
5
+ export {
6
+ finalizeProcessedAsset
7
+ };
@@ -1,4 +1,6 @@
1
1
  import type { AssetDefinition, ProcessedAsset } from './assets.types.js';
2
+ /** Forces grouped content scripts to run through the bundler in production builds. */
3
+ export declare function ensureGroupedContentScriptsBundle(dependencies: AssetDefinition[]): void;
2
4
  /**
3
5
  * Splits grouped content-script dependencies from ordinary dependencies so callers can
4
6
  * route them through `processGrouped` without changing ordering for the remaining assets.
@@ -12,7 +14,6 @@ type GroupedBundleProcessor = {
12
14
  };
13
15
  type ProcessGroupedDependencyBundlesOptions = {
14
16
  bundles: AssetDefinition[][];
15
- key: string;
16
17
  getCachedAsset: (dep: AssetDefinition, depKey: string) => ProcessedAsset | null;
17
18
  getDependencyKey: (dep: AssetDefinition) => string;
18
19
  getGroupedProcessor: () => GroupedBundleProcessor | undefined;
@@ -1,3 +1,18 @@
1
+ import { isDevelopmentRuntime } from "../../../utils/runtime.js";
2
+ import { finalizeProcessedAsset } from "./finalize-processed-asset.js";
3
+ function ensureGroupedContentScriptsBundle(dependencies) {
4
+ if (isDevelopmentRuntime()) {
5
+ return;
6
+ }
7
+ for (const dependency of dependencies) {
8
+ if (dependency.kind !== "script" || dependency.source !== "content" || !dependency.groupedBundle?.id) {
9
+ continue;
10
+ }
11
+ if (dependency.bundle === false) {
12
+ dependency.bundle = true;
13
+ }
14
+ }
15
+ }
1
16
  function partitionGroupedContentScriptDependencies(typeDeps) {
2
17
  const groupedBundleDeps = /* @__PURE__ */ new Map();
3
18
  const ungroupedDeps = [];
@@ -18,7 +33,6 @@ function partitionGroupedContentScriptDependencies(typeDeps) {
18
33
  async function processGroupedDependencyBundles(options) {
19
34
  const {
20
35
  bundles,
21
- key,
22
36
  getCachedAsset,
23
37
  getDependencyKey,
24
38
  getGroupedProcessor,
@@ -29,7 +43,7 @@ async function processGroupedDependencyBundles(options) {
29
43
  const groupedPromises = bundles.map(async (bundleDeps) => {
30
44
  const cachedResults = bundleDeps.map((dep) => {
31
45
  const cached = getCachedAsset(dep, getDependencyKey(dep));
32
- return cached ? { key, ...cached } : null;
46
+ return cached ? finalizeProcessedAsset(cached, resolveProcessedAssetSrcUrl) : null;
33
47
  });
34
48
  if (cachedResults.every((result) => result !== null)) {
35
49
  return cachedResults.filter((result) => result !== null);
@@ -43,14 +57,9 @@ async function processGroupedDependencyBundles(options) {
43
57
  return processedResults.map((processed, index) => {
44
58
  const dep = bundleDeps[index];
45
59
  const depKey = getDependencyKey(dep);
46
- const srcUrl = resolveProcessedAssetSrcUrl(processed);
47
- const processedWithKey = {
48
- key,
49
- ...processed,
50
- srcUrl
51
- };
52
- setCachedAsset(dep, depKey, processedWithKey);
53
- return processedWithKey;
60
+ const finalized = finalizeProcessedAsset(processed, resolveProcessedAssetSrcUrl);
61
+ setCachedAsset(dep, depKey, finalized);
62
+ return finalized;
54
63
  });
55
64
  } catch (error) {
56
65
  logError(error);
@@ -60,6 +69,7 @@ async function processGroupedDependencyBundles(options) {
60
69
  return (await Promise.all(groupedPromises)).flat();
61
70
  }
62
71
  export {
72
+ ensureGroupedContentScriptsBundle,
63
73
  partitionGroupedContentScriptDependencies,
64
74
  processGroupedDependencyBundles
65
75
  };
@@ -1,4 +1,5 @@
1
1
  export * from './asset.factory.js';
2
+ export * from './grouped-content-bundles.js';
2
3
  export * from './page-package.js';
3
4
  export * from './asset-processing.service.js';
4
5
  export * from './assets.types.js';
@@ -1,4 +1,5 @@
1
1
  export * from "./asset.factory.js";
2
+ export * from "./grouped-content-bundles.js";
2
3
  export * from "./page-package.js";
3
4
  export * from "./asset-processing.service.js";
4
5
  export * from "./assets.types.js";
@@ -0,0 +1,11 @@
1
+ import type { ContentScriptAsset, ProcessedAsset } from './assets.types.js';
2
+ /**
3
+ * Builds a processed content-script asset from one dependency declaration and a
4
+ * previously emitted output file.
5
+ *
6
+ * @remarks
7
+ * Dev disk cache entries store only output paths. Callers must materialize the
8
+ * full processed asset from the originating dependency so HTML and page-browser
9
+ * graph assembly receive grouped-bundle metadata and script attributes.
10
+ */
11
+ export declare function materializeContentScriptAsset(dep: ContentScriptAsset, filepath: string): ProcessedAsset;
@@ -0,0 +1,17 @@
1
+ function materializeContentScriptAsset(dep, filepath) {
2
+ return {
3
+ filepath,
4
+ kind: "script",
5
+ inline: dep.inline ?? false,
6
+ content: dep.inline ? dep.content : void 0,
7
+ position: dep.position,
8
+ attributes: dep.attributes,
9
+ excludeFromHtml: dep.excludeFromHtml,
10
+ packageRole: dep.packageRole,
11
+ groupedBundle: dep.groupedBundle,
12
+ bundledSourceFilepaths: dep.bundledSourceFilepaths
13
+ };
14
+ }
15
+ export {
16
+ materializeContentScriptAsset
17
+ };
@@ -1,6 +1,13 @@
1
1
  import type { ContentScriptAsset, ProcessedAsset } from '../../assets.types.js';
2
2
  import { BaseScriptProcessor } from '../base/base-script-processor.js';
3
3
  export declare class ContentScriptProcessor extends BaseScriptProcessor<ContentScriptAsset> {
4
+ private getContentScriptEntryDir;
5
+ private getContentScriptEntryPath;
6
+ private createBundleConfigHash;
7
+ private createContentScriptCacheKey;
8
+ private toProcessedAsset;
9
+ private removeContentScriptEntry;
4
10
  processGrouped(deps: ContentScriptAsset[]): Promise<ProcessedAsset[]>;
11
+ private getGroupedBundlerOptions;
5
12
  process(dep: ContentScriptAsset): Promise<ProcessedAsset>;
6
13
  }
@@ -1,7 +1,50 @@
1
1
  import path from "node:path";
2
2
  import { fileSystem } from "@ecopages/file-system";
3
+ import { shouldUseDevBrowserScriptCache } from "../../../../../build/dev-browser-script-cache.js";
3
4
  import { BaseScriptProcessor } from "../base/base-script-processor.js";
4
5
  class ContentScriptProcessor extends BaseScriptProcessor {
6
+ getContentScriptEntryDir() {
7
+ const dir = path.join(this.appConfig.absolutePaths.workDir, "content-script-entries");
8
+ fileSystem.ensureDir(dir);
9
+ return dir;
10
+ }
11
+ getContentScriptEntryPath(contentHash) {
12
+ return path.join(this.getContentScriptEntryDir(), `${contentHash}.js`);
13
+ }
14
+ createBundleConfigHash(dep, shouldBundle) {
15
+ return this.generateHash(
16
+ JSON.stringify({
17
+ bundle: shouldBundle,
18
+ minify: shouldBundle && this.isProduction,
19
+ opts: dep.bundleOptions
20
+ })
21
+ );
22
+ }
23
+ createContentScriptCacheKey(dep, shouldBundle) {
24
+ const contentHash = this.generateHash(dep.content);
25
+ const configHash = this.createBundleConfigHash(dep, shouldBundle);
26
+ return `${this.buildCacheKey(`content-script:${contentHash}`, contentHash, dep)}:${configHash}`;
27
+ }
28
+ toProcessedAsset(dep, filepath, inlineContent) {
29
+ return {
30
+ filepath,
31
+ content: dep.inline ? inlineContent : void 0,
32
+ kind: "script",
33
+ position: dep.position,
34
+ attributes: dep.attributes,
35
+ inline: dep.inline,
36
+ excludeFromHtml: dep.excludeFromHtml,
37
+ packageRole: dep.packageRole,
38
+ groupedBundle: dep.groupedBundle,
39
+ bundledSourceFilepaths: dep.bundledSourceFilepaths
40
+ };
41
+ }
42
+ removeContentScriptEntry(contentHash) {
43
+ if (shouldUseDevBrowserScriptCache()) {
44
+ return;
45
+ }
46
+ fileSystem.remove(this.getContentScriptEntryPath(contentHash));
47
+ }
5
48
  async processGrouped(deps) {
6
49
  if (deps.length === 0) {
7
50
  return [];
@@ -10,109 +53,92 @@ class ContentScriptProcessor extends BaseScriptProcessor {
10
53
  if (!shouldBundle || deps.some((dep) => dep.inline)) {
11
54
  return Promise.all(deps.map((dep) => this.process(dep)));
12
55
  }
13
- const tempDir = path.join(
14
- this.appConfig.absolutePaths.distDir,
15
- `grouped-script-entries-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`
16
- );
17
- fileSystem.ensureDir(tempDir);
56
+ let tempEntries = [];
18
57
  try {
19
- const tempEntries = deps.map((dep, index) => {
20
- const entryName = dep.groupedBundle?.entryName ?? dep.name ?? `grouped-script-${index}`;
21
- const tempFilepath = path.join(tempDir, `${entryName}.js`);
58
+ tempEntries = deps.map((dep) => {
59
+ const contentHash = this.generateHash(dep.content);
60
+ const tempFilepath = this.getContentScriptEntryPath(contentHash);
22
61
  fileSystem.write(tempFilepath, dep.content);
23
62
  return {
24
63
  dep,
25
- entryName,
64
+ contentHash,
26
65
  tempFilepath
27
66
  };
28
67
  });
29
- const primaryDep = deps[0];
30
68
  const outputPaths = await this.bundleScripts({
31
- ...this.getBundlerOptions(primaryDep),
32
- entries: tempEntries.map(({ entryName, tempFilepath }) => ({
33
- entryName,
69
+ ...this.getGroupedBundlerOptions(deps),
70
+ entries: tempEntries.map(({ dep, contentHash, tempFilepath }) => ({
71
+ entryName: dep.groupedBundle?.entryName ?? dep.name ?? contentHash,
34
72
  entrypoint: tempFilepath
35
73
  })),
36
74
  outdir: this.getAssetsDir(),
37
75
  minify: this.isProduction,
38
76
  naming: "[name]-[hash].[ext]"
39
77
  });
40
- return tempEntries.map(({ dep, entryName }) => {
78
+ return tempEntries.map(({ dep, contentHash }) => {
79
+ const entryName = dep.groupedBundle?.entryName ?? dep.name ?? contentHash;
41
80
  const bundledFilePath = outputPaths.get(entryName);
42
81
  if (!bundledFilePath) {
43
82
  throw new Error(`Missing grouped bundle output for ${entryName}`);
44
83
  }
45
- return {
46
- filepath: bundledFilePath,
47
- content: dep.inline ? fileSystem.readFileSync(bundledFilePath).toString() : void 0,
48
- kind: "script",
49
- position: dep.position,
50
- attributes: dep.attributes,
51
- inline: dep.inline,
52
- excludeFromHtml: dep.excludeFromHtml,
53
- packageRole: dep.packageRole,
54
- groupedBundle: dep.groupedBundle,
55
- bundledSourceFilepaths: dep.bundledSourceFilepaths
56
- };
84
+ return this.toProcessedAsset(
85
+ dep,
86
+ bundledFilePath,
87
+ dep.inline ? fileSystem.readFileSync(bundledFilePath).toString() : void 0
88
+ );
57
89
  });
58
90
  } finally {
59
- fileSystem.remove(tempDir);
91
+ for (const { contentHash } of tempEntries) {
92
+ this.removeContentScriptEntry(contentHash);
93
+ }
60
94
  }
61
95
  }
62
- async process(dep) {
63
- const hash = this.generateHash(dep.content);
64
- const filename = dep.name ? `${dep.name}.js` : `script-${hash}.js`;
65
- const shouldBundle = this.shouldBundle(dep);
66
- const filepath = path.join(this.getAssetsDir(), "scripts", filename);
67
- if (!shouldBundle) {
68
- if (!dep.inline) fileSystem.write(filepath, dep.content);
69
- const unbundledProcessedAsset = {
70
- filepath,
71
- content: dep.inline ? dep.content : void 0,
72
- kind: "script",
73
- position: dep.position,
74
- attributes: dep.attributes,
75
- inline: dep.inline,
76
- excludeFromHtml: dep.excludeFromHtml,
77
- packageRole: dep.packageRole,
78
- groupedBundle: dep.groupedBundle,
79
- bundledSourceFilepaths: dep.bundledSourceFilepaths
80
- };
81
- this.writeCacheFile(filename, unbundledProcessedAsset);
82
- return unbundledProcessedAsset;
83
- }
84
- if (dep.content) {
85
- const tempDir = this.appConfig.absolutePaths.distDir;
86
- fileSystem.ensureDir(tempDir);
87
- const tempFileName = path.join(
88
- tempDir,
89
- `${path.parse(filename).name}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp.js`
90
- );
91
- fileSystem.write(tempFileName, dep.content);
92
- const bundledFilePath = await this.bundleScript({
93
- entrypoint: tempFileName,
94
- outdir: this.getAssetsDir(),
95
- minify: this.isProduction,
96
- naming: `${path.parse(filename).name}-[hash].[ext]`,
97
- ...this.getBundlerOptions(dep)
98
- });
99
- const processedAsset = {
100
- filepath: bundledFilePath,
101
- content: dep.inline ? fileSystem.readFileSync(bundledFilePath).toString() : void 0,
102
- kind: "script",
103
- position: dep.position,
104
- attributes: dep.attributes,
105
- inline: dep.inline,
106
- excludeFromHtml: dep.excludeFromHtml,
107
- packageRole: dep.packageRole,
108
- groupedBundle: dep.groupedBundle,
109
- bundledSourceFilepaths: dep.bundledSourceFilepaths
96
+ getGroupedBundlerOptions(deps) {
97
+ const primaryDep = deps[0];
98
+ const options = this.getBundlerOptions(primaryDep);
99
+ if (deps.some((dep) => dep.bundleOptions?.splitting === false)) {
100
+ return {
101
+ ...options,
102
+ splitting: false
110
103
  };
111
- fileSystem.remove(tempFileName);
112
- this.writeCacheFile(filename, processedAsset);
113
- return processedAsset;
114
104
  }
115
- throw new Error("No content found for script asset");
105
+ return options;
106
+ }
107
+ async process(dep) {
108
+ const shouldBundle = this.shouldBundle(dep);
109
+ const cacheKey = this.createContentScriptCacheKey(dep, shouldBundle);
110
+ return this.getOrProcess(cacheKey, async () => {
111
+ const hash = this.generateHash(dep.content);
112
+ const filename = dep.name ? `${dep.name}.js` : `script-${hash}.js`;
113
+ const filepath = path.join(this.getAssetsDir(), "scripts", filename);
114
+ if (!shouldBundle) {
115
+ if (!dep.inline) {
116
+ fileSystem.write(filepath, dep.content);
117
+ }
118
+ return this.toProcessedAsset(dep, filepath, dep.inline ? dep.content : void 0);
119
+ }
120
+ if (!dep.content) {
121
+ throw new Error("No content found for script asset");
122
+ }
123
+ const entryPath = this.getContentScriptEntryPath(hash);
124
+ fileSystem.write(entryPath, dep.content);
125
+ try {
126
+ const bundledFilePath = await this.bundleScript({
127
+ entrypoint: entryPath,
128
+ outdir: this.getAssetsDir(),
129
+ minify: this.isProduction,
130
+ naming: `${path.parse(filename).name}-[hash].[ext]`,
131
+ ...this.getBundlerOptions(dep)
132
+ });
133
+ return this.toProcessedAsset(
134
+ dep,
135
+ bundledFilePath,
136
+ dep.inline ? fileSystem.readFileSync(bundledFilePath).toString() : void 0
137
+ );
138
+ } finally {
139
+ this.removeContentScriptEntry(hash);
140
+ }
141
+ });
116
142
  }
117
143
  }
118
144
  export {
@@ -0,0 +1,4 @@
1
+ import type { AnyIntegrationPlugin } from '../../../plugins/integration-plugin.js';
2
+ import type { EcoPagesAppConfig } from '../../../types/internal-types.js';
3
+ /** Resolves the integration plugin that owns one asset-processing batch key. */
4
+ export declare function resolveIntegrationPluginForProcessingKey(appConfig: EcoPagesAppConfig, processingKey: string): AnyIntegrationPlugin | undefined;
@@ -0,0 +1,17 @@
1
+ function resolveIntegrationPluginForProcessingKey(appConfig, processingKey) {
2
+ if (!appConfig.integrations?.length) {
3
+ return void 0;
4
+ }
5
+ const exactMatch = appConfig.integrations.find((integration) => integration.name === processingKey);
6
+ if (exactMatch) {
7
+ return exactMatch;
8
+ }
9
+ const baseName = processingKey.split(":")[0];
10
+ if (!baseName || baseName === processingKey) {
11
+ return void 0;
12
+ }
13
+ return appConfig.integrations.find((integration) => integration.name === baseName);
14
+ }
15
+ export {
16
+ resolveIntegrationPluginForProcessingKey
17
+ };
@@ -2,7 +2,6 @@ import type { AssetProcessor } from './processor.interface.js';
2
2
  import type { AssetDefinition, ProcessedAsset } from './assets.types.js';
3
3
  type ProcessUngroupedDependencyOptions = {
4
4
  dep: AssetDefinition;
5
- key: string;
6
5
  depKey: string;
7
6
  getCachedAsset: (dep: AssetDefinition, depKey: string) => ProcessedAsset | null;
8
7
  getProcessor: (dep: AssetDefinition) => AssetProcessor | undefined;
@@ -1,8 +1,8 @@
1
1
  import { fileSystem } from "@ecopages/file-system";
2
+ import { finalizeProcessedAsset } from "./finalize-processed-asset.js";
2
3
  async function processUngroupedDependency(options) {
3
4
  const {
4
5
  dep,
5
- key,
6
6
  depKey,
7
7
  getCachedAsset,
8
8
  getProcessor,
@@ -14,7 +14,7 @@ async function processUngroupedDependency(options) {
14
14
  } = options;
15
15
  const cached = getCachedAsset(dep, depKey);
16
16
  if (cached) {
17
- return { key, ...cached };
17
+ return finalizeProcessedAsset(cached, resolveProcessedAssetSrcUrl);
18
18
  }
19
19
  const processor = getProcessor(dep);
20
20
  if (!processor) {
@@ -27,14 +27,9 @@ async function processUngroupedDependency(options) {
27
27
  }
28
28
  try {
29
29
  const processed = await processor.process(dep);
30
- const srcUrl = resolveProcessedAssetSrcUrl(processed);
31
- const processedWithKey = {
32
- key,
33
- ...processed,
34
- srcUrl
35
- };
36
- setCachedAsset(dep, depKey, processedWithKey);
37
- return processedWithKey;
30
+ const finalized = finalizeProcessedAsset(processed, resolveProcessedAssetSrcUrl);
31
+ setCachedAsset(dep, depKey, finalized);
32
+ return finalized;
38
33
  } catch (error) {
39
34
  logProcessingError(dep, error);
40
35
  return null;
@@ -2,6 +2,9 @@ import { getAppBrowserBuildPlugins, getAppTranspileOptions } from "../../build/b
2
2
  import { requireBuildRuntime } from "../../build/build-runtime.js";
3
3
  import { mergeEcoBuildPlugins } from "../../build/build-manifest.js";
4
4
  import { getAppSourceTransforms } from "../../plugins/source-transform.js";
5
+ import { startupTrace } from "../../diagnostics/startup-trace.js";
6
+ import { requestBuildDedupe } from "../../diagnostics/request-build-dedupe.js";
7
+ import { createBuildOptionsDedupeKey } from "../../build/deduping-build-executor.js";
5
8
  function resolveBrowserBundleExecutor(appConfig, profile, executor) {
6
9
  const buildRuntime = requireBuildRuntime(appConfig);
7
10
  if (executor === "hmr" && (profile === "hmr-entrypoint" || profile === "hmr-runtime")) {
@@ -38,12 +41,17 @@ class BrowserBundleService {
38
41
  sourceTransforms: getAppSourceTransforms(this.appConfig)
39
42
  };
40
43
  const buildExecutor = resolveBrowserBundleExecutor(this.appConfig, profile, executor);
41
- return await buildExecutor.build(request);
44
+ const dedupeKey = createBuildOptionsDedupeKey(request);
45
+ return requestBuildDedupe.dedupeBuild(dedupeKey, async () => {
46
+ const result = await buildExecutor.build(request);
47
+ startupTrace.recordBrowserBundle(result.outputs);
48
+ return result;
49
+ });
42
50
  }
43
51
  async bundleGroupedEntries(entries, options) {
44
52
  const request = {
45
53
  ...options,
46
- entrypoints: entries.map((entry) => entry.entrypoint)
54
+ entrypoints: Object.fromEntries(entries.map((entry) => [entry.entryName, entry.entrypoint]))
47
55
  };
48
56
  return this.bundle(request);
49
57
  }