From 7021f2902ac516d1cb6b1fe96f99959a31f6c166 Mon Sep 17 00:00:00 2001 From: xusd320 Date: Thu, 6 Aug 2026 11:08:01 +0800 Subject: [PATCH] refactor(ev): organize internal build domains --- AGENT.md | 6 +- AGENTS.md | 6 +- ARCHITECTURE.md | 20 + e2e/cases/render-modes.ts | 2 +- .../bundler-utoopack/tests/adapter.test.ts | 4 +- .../bundler-webpack/tests/adapter.test.ts | 8 +- .../build/{ => analysis}/module-exports.ts | 0 .../{ => analysis}/page-module-exports.ts | 2 +- .../build/{ => analysis}/ppr-regions.ts | 4 +- .../shared.ts => analysis/route-module.ts} | 2 +- .../build/{ => analysis}/rsc-refs.ts | 18 +- .../build/{ => analysis}/server-fns.ts | 4 +- .../{ => analysis}/server-route-exports.ts | 0 .../build/{ => analysis}/static-imports.ts | 0 .../{bundler-config.ts => bundler/config.ts} | 6 +- .../{bundler.ts => bundler/contracts.ts} | 4 +- .../output-files.ts} | 4 +- packages/ev/src/_internal/build/commands.ts | 2987 +---------------- .../{ => config-loading}/config-module.ts | 14 +- .../page-config-module.ts | 6 +- .../build-entry-conventions.ts | 0 .../output-path-conventions.ts | 2 +- .../{ => conventions}/page-document-output.ts | 0 .../page-rendering-contract.ts | 0 .../page-route-conventions.ts | 0 .../{ => conventions}/route-conventions.ts | 0 .../build/{ => conventions}/route-order.ts | 0 .../server-route-conventions.ts | 0 .../api-process.ts} | 0 .../build/{dev-runtime.ts => dev/runtime.ts} | 4 +- .../build/{dev-watch.ts => dev/watch.ts} | 6 +- .../{ => discovery}/convention-config.ts | 10 +- .../build/{ => discovery}/page-routes.ts | 22 +- .../{ => discovery}/server-conventions.ts | 16 +- .../build/{ => discovery}/server-routes.ts | 20 +- .../generated-contributions.ts | 24 +- .../sources}/client-entry-source.ts | 0 .../sources}/page-wrapper-contribution.ts | 0 .../sources}/react-server-page-source.ts | 0 .../src/_internal/build/graph/config-route.ts | 16 +- packages/ev/src/_internal/build/graph/core.ts | 10 +- .../build/graph/create-core-graph.ts | 1275 +++++++ .../ev/src/_internal/build/graph/index.ts | 1337 +------- .../build/graph/source-resolution.ts | 2 +- .../ev/src/_internal/build/graph/types.ts | 67 + packages/ev/src/_internal/build/index.ts | 127 +- .../analyze-and-materialize.ts | 21 +- .../_internal/build/operations/commands.ts | 2972 ++++++++++++++++ .../build/{ => operations}/inspect.ts | 28 +- .../{ => output}/build-output-ownership.ts | 0 .../build/{ => output}/build-output-paths.ts | 0 .../build/{ => output}/build-result.ts | 2 +- .../build/{ => output}/framework-output.ts | 34 +- .../build/{ => output}/framework-runtime.ts | 0 .../html}/framework-html-document.ts | 8 +- .../build/{ => output/html}/html-transform.ts | 6 +- .../_internal/build/{ => output/html}/html.ts | 2 +- .../{ => output/html}/page-metadata-html.ts | 2 +- .../html}/server-document-shell.ts | 6 +- .../build/{ => output}/output-path-safety.ts | 0 .../build/{ => output}/owned-file-output.ts | 0 .../{ => output}/portable-artifact-path.ts | 0 .../production-output-transaction.ts | 0 .../_internal/build/plan/create-build-plan.ts | 1151 +++++++ packages/ev/src/_internal/build/plan/index.ts | 1187 +------ packages/ev/src/_internal/build/plan/types.ts | 32 + .../deployment-output-reservations.ts | 8 +- .../lifecycle.ts} | 10 +- .../settings.ts} | 12 +- .../build/transforms/client/index.ts | 4 +- .../src/_internal/build/transforms/index.ts | 82 +- .../_internal/build/transforms/server-file.ts | 76 + .../build/transforms/server/index.ts | 2 +- .../src/_internal/build/transforms/types.ts | 4 + .../build/{ => typegen}/page-route-types.ts | 8 +- .../build/{ => typegen}/plugin-types.ts | 2 +- packages/ev/src/build-tools/index.ts | 4 +- packages/ev/src/config/index.ts | 6 +- packages/ev/src/deployment/index.ts | 12 +- packages/ev/src/deployment/output-files.ts | 4 +- .../ev/tests/build-tools-graph-plan.test.ts | 2 +- packages/ev/tests/build-tools-html.test.ts | 4 +- .../build-tools-output-path-safety.test.ts | 2 +- .../build-tools-page-route-types.test.ts | 4 +- .../ev/tests/bundler-capabilities.test.ts | 2 +- .../ev/tests/commands-api-restart.test.ts | 2 +- packages/ev/tests/commands.test.ts | 24 +- packages/ev/tests/config-route.test.ts | 6 +- packages/ev/tests/config.test.ts | 4 +- packages/ev/tests/deployment.test.ts | 6 +- packages/ev/tests/dev-api-process.test.ts | 2 +- packages/ev/tests/dev-runtime.test.ts | 2 +- packages/ev/tests/dev-watch.test.ts | 2 +- packages/ev/tests/framework-runtime.test.ts | 2 +- packages/ev/tests/package-surface.test.ts | 54 + packages/ev/tests/page-config-module.test.ts | 4 +- packages/ev/tests/plugin-lifecycle.test.ts | 2 +- packages/ev/tests/plugin-settings.test.ts | 6 +- packages/ev/tests/plugin-types.test.ts | 2 +- 99 files changed, 5992 insertions(+), 5860 deletions(-) rename packages/ev/src/_internal/build/{ => analysis}/module-exports.ts (100%) rename packages/ev/src/_internal/build/{ => analysis}/page-module-exports.ts (95%) rename packages/ev/src/_internal/build/{ => analysis}/ppr-regions.ts (99%) rename packages/ev/src/_internal/build/{routes/shared.ts => analysis/route-module.ts} (97%) rename packages/ev/src/_internal/build/{ => analysis}/rsc-refs.ts (98%) rename packages/ev/src/_internal/build/{ => analysis}/server-fns.ts (99%) rename packages/ev/src/_internal/build/{ => analysis}/server-route-exports.ts (100%) rename packages/ev/src/_internal/build/{ => analysis}/static-imports.ts (100%) rename packages/ev/src/_internal/build/{bundler-config.ts => bundler/config.ts} (81%) rename packages/ev/src/_internal/build/{bundler.ts => bundler/contracts.ts} (99%) rename packages/ev/src/_internal/build/{bundler-output-files.ts => bundler/output-files.ts} (97%) rename packages/ev/src/_internal/build/{ => config-loading}/config-module.ts (98%) rename packages/ev/src/_internal/build/{ => config-loading}/page-config-module.ts (98%) rename packages/ev/src/_internal/build/{ => conventions}/build-entry-conventions.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/output-path-conventions.ts (98%) rename packages/ev/src/_internal/build/{ => conventions}/page-document-output.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/page-rendering-contract.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/page-route-conventions.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/route-conventions.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/route-order.ts (100%) rename packages/ev/src/_internal/build/{ => conventions}/server-route-conventions.ts (100%) rename packages/ev/src/_internal/build/{dev-api-process.ts => dev/api-process.ts} (100%) rename packages/ev/src/_internal/build/{dev-runtime.ts => dev/runtime.ts} (99%) rename packages/ev/src/_internal/build/{dev-watch.ts => dev/watch.ts} (99%) rename packages/ev/src/_internal/build/{ => discovery}/convention-config.ts (96%) rename packages/ev/src/_internal/build/{ => discovery}/page-routes.ts (98%) rename packages/ev/src/_internal/build/{ => discovery}/server-conventions.ts (98%) rename packages/ev/src/_internal/build/{ => discovery}/server-routes.ts (97%) rename packages/ev/src/_internal/build/{ => generated-ir}/generated-contributions.ts (99%) rename packages/ev/src/_internal/build/{generated => generated-ir/sources}/client-entry-source.ts (100%) rename packages/ev/src/_internal/build/{generated => generated-ir/sources}/page-wrapper-contribution.ts (100%) rename packages/ev/src/_internal/build/{generated => generated-ir/sources}/react-server-page-source.ts (100%) create mode 100644 packages/ev/src/_internal/build/graph/create-core-graph.ts create mode 100644 packages/ev/src/_internal/build/graph/types.ts rename packages/ev/src/_internal/build/{ => operations}/analyze-and-materialize.ts (85%) create mode 100644 packages/ev/src/_internal/build/operations/commands.ts rename packages/ev/src/_internal/build/{ => operations}/inspect.ts (94%) rename packages/ev/src/_internal/build/{ => output}/build-output-ownership.ts (100%) rename packages/ev/src/_internal/build/{ => output}/build-output-paths.ts (100%) rename packages/ev/src/_internal/build/{ => output}/build-result.ts (90%) rename packages/ev/src/_internal/build/{ => output}/framework-output.ts (97%) rename packages/ev/src/_internal/build/{ => output}/framework-runtime.ts (100%) rename packages/ev/src/_internal/build/{ => output/html}/framework-html-document.ts (92%) rename packages/ev/src/_internal/build/{ => output/html}/html-transform.ts (92%) rename packages/ev/src/_internal/build/{ => output/html}/html.ts (98%) rename packages/ev/src/_internal/build/{ => output/html}/page-metadata-html.ts (97%) rename packages/ev/src/_internal/build/{ => output/html}/server-document-shell.ts (96%) rename packages/ev/src/_internal/build/{ => output}/output-path-safety.ts (100%) rename packages/ev/src/_internal/build/{ => output}/owned-file-output.ts (100%) rename packages/ev/src/_internal/build/{ => output}/portable-artifact-path.ts (100%) rename packages/ev/src/_internal/build/{ => output}/production-output-transaction.ts (100%) create mode 100644 packages/ev/src/_internal/build/plan/create-build-plan.ts create mode 100644 packages/ev/src/_internal/build/plan/types.ts rename packages/ev/src/_internal/build/{ => plugins}/deployment-output-reservations.ts (93%) rename packages/ev/src/_internal/build/{plugin-lifecycle.ts => plugins/lifecycle.ts} (98%) rename packages/ev/src/_internal/build/{plugin-settings.ts => plugins/settings.ts} (96%) create mode 100644 packages/ev/src/_internal/build/transforms/server-file.ts create mode 100644 packages/ev/src/_internal/build/transforms/types.ts rename packages/ev/src/_internal/build/{ => typegen}/page-route-types.ts (96%) rename packages/ev/src/_internal/build/{ => typegen}/plugin-types.ts (99%) diff --git a/AGENT.md b/AGENT.md index 61681338..a1cba757 100644 --- a/AGENT.md +++ b/AGENT.md @@ -101,11 +101,11 @@ npm --workspace @evjs/ev test -- tests/build-tools-graph-plan.test.ts | Surface | Primary implementation | Focused validation | | --- | --- | --- | -| Page convention and graph | `packages/ev/src/_internal/build/page-route-conventions.ts`, `page-routes.ts`, `graph/*`, `plan/*` | `npx turbo run test --filter=@evjs/ev` | -| Server routes and middleware | `server-route-conventions.ts`, `server-routes.ts`, `server-conventions.ts` | `npx turbo run test --filter=@evjs/ev` | +| Page convention and graph | `packages/ev/src/_internal/build/conventions/page-route-conventions.ts`, `discovery/page-routes.ts`, `graph/*`, `plan/*` | `npx turbo run test --filter=@evjs/ev` | +| Server routes and middleware | `conventions/server-route-conventions.ts`, `discovery/server-routes.ts`, `discovery/server-conventions.ts` | `npx turbo run test --filter=@evjs/ev` | | Config and package surface | `packages/ev/src/config`, package manifests | `npx turbo run test --filter=@evjs/ev` | | Shared contracts | `packages/shared/src/manifest` | `npx turbo run test --filter=@evjs/shared` | -| Server functions | `packages/ev/src/_internal/build/server-fns.ts`, client/server function runtimes | `npx turbo run test --filter=@evjs/client --filter=@evjs/server` | +| Server functions | `packages/ev/src/_internal/build/analysis/server-fns.ts`, client/server function runtimes | `npx turbo run test --filter=@evjs/client --filter=@evjs/server` | | SSR, SSG, PPR, RSC | graph/plan, client RSC, server framework rendering | `npx turbo run test --filter=@evjs/ev --filter=@evjs/client --filter=@evjs/server` | | Bundler mapping | `packages/bundler-*/src/adapter` and manifest generators | corresponding bundler package test | | Documentation | `docs/docs`, Chinese translations, package/example READMEs | `npm run lint`, `npm --workspace evjs-docs run build`, `git diff --check` | diff --git a/AGENTS.md b/AGENTS.md index 634451a0..b7ec9c88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,11 @@ server routes, examples, or scaffolds change. | Concern | Canonical implementation | Discovery / coverage | | --- | --- | --- | -| Page markers and URL segments | `packages/ev/src/_internal/build/page-route-conventions.ts` | `page-routes.ts`, `packages/ev/tests/build-tools-page-routes.test.ts` | -| Server request-route markers and URL segments | `packages/ev/src/_internal/build/server-route-conventions.ts` | `server-routes.ts`, `packages/ev/tests/build-tools-server-routes.test.ts` | +| Page markers and URL segments | `packages/ev/src/_internal/build/conventions/page-route-conventions.ts` | `discovery/page-routes.ts`, `packages/ev/tests/build-tools-page-routes.test.ts` | +| Server request-route markers and URL segments | `packages/ev/src/_internal/build/conventions/server-route-conventions.ts` | `discovery/server-routes.ts`, `packages/ev/tests/build-tools-server-routes.test.ts` | | Public config and SPA/MPA selection | `packages/ev/src/config/index.ts` | `packages/ev/tests/config.test.ts` | | Semantic graph and build plan | `packages/ev/src/_internal/build/graph/*`, `plan/*` | `packages/ev/tests/build-tools-graph-plan.test.ts` | -| Typed plugin settings and generated Page types | `packages/ev/src/config/plugins.ts`, `_internal/build/plugin-settings.ts`, `plugin-types.ts` | `packages/ev/tests/plugin-settings.test.ts`, `plugin-types.test.ts` | +| Typed plugin settings and generated Page types | `packages/ev/src/config/plugins.ts`, `_internal/build/plugins/settings.ts`, `typegen/plugin-types.ts` | `packages/ev/tests/plugin-settings.test.ts`, `plugin-types.test.ts` | | Shared graph/output contracts | `packages/shared/src/manifest` | `packages/shared/tests/manifest.test.ts` | ## Architecture Rules diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2117a7f0..9e9e8e61 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,6 +85,26 @@ Programmatic `createApp()`, client route trees, and server `createRoute()` declarations are runtime primitives; framework convention discovery does not scan them. +## Internal Build Layout + +`packages/ev/src/_internal/build` is grouped by capability instead of build +phase. Its root contains only the curated `index.ts`, the command façade, and +the small shared `types.ts` / `utils.ts` modules. + +| Domain | Responsibility | +| --- | --- | +| `analysis`, `discovery`, `conventions` | Parse framework semantics, discover positive anchors, and own filesystem naming rules. | +| `graph`, `plan` | Project discovered semantics into `CoreGraph` and `BuildPlan`; each `index.ts` is a thin façade over implementation and contract modules. | +| `generated-ir`, `typegen` | Materialize `.ev` inputs and authored-source declaration files. | +| `bundler`, `plugins` | Define adapter contracts and orchestrate plugin settings and lifecycle state. | +| `output` | Validate, link, transform, and transactionally publish framework-owned output, including HTML. | +| `config-loading`, `operations`, `dev` | Load observed configuration, coordinate commands, and own development runtime/watch state. | +| `transforms` | Rewrite client and server modules at bundler boundaries. | + +Domain implementations import focused leaf modules. They do not import a +domain's broad `index.ts` façade internally; façades exist for stable consumers +and should not become implementation owners. + ## Build Flow ```mermaid diff --git a/e2e/cases/render-modes.ts b/e2e/cases/render-modes.ts index 8ee9e06c..5f99770a 100644 --- a/e2e/cases/render-modes.ts +++ b/e2e/cases/render-modes.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { PageMetadata } from "@evjs/shared/manifest"; import { expect, type Page } from "@playwright/test"; -import { createRscPageBuildEntryName } from "../../packages/ev/src/_internal/build/build-entry-conventions.js"; +import { createRscPageBuildEntryName } from "../../packages/ev/src/_internal/build/conventions/build-entry-conventions.js"; import { createExampleTest } from "../fixtures"; const exampleDir = path.resolve( diff --git a/packages/bundler-utoopack/tests/adapter.test.ts b/packages/bundler-utoopack/tests/adapter.test.ts index a3d4e28d..80613c92 100644 --- a/packages/bundler-utoopack/tests/adapter.test.ts +++ b/packages/bundler-utoopack/tests/adapter.test.ts @@ -28,8 +28,8 @@ import { } from "@evjs/shared/manifest"; import type { ConfigComplete } from "@utoo/pack"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/convention-config.js"; -import { createClientRuntime } from "../../ev/src/_internal/build/framework-runtime.js"; +import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/discovery/convention-config.js"; +import { createClientRuntime } from "../../ev/src/_internal/build/output/framework-runtime.js"; import { utoopackAdapter, __testing as utoopackAdapterTesting, diff --git a/packages/bundler-webpack/tests/adapter.test.ts b/packages/bundler-webpack/tests/adapter.test.ts index 8c1d47c6..abdb6c22 100644 --- a/packages/bundler-webpack/tests/adapter.test.ts +++ b/packages/bundler-webpack/tests/adapter.test.ts @@ -28,14 +28,14 @@ import { import { Volume } from "memfs"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Compiler } from "webpack"; -import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/convention-config.js"; -import { createPageClientBuildEntryName } from "../../ev/src/_internal/build/build-entry-conventions.js"; -import { linkAndEmitBuildOutput } from "../../ev/src/_internal/build/framework-output.js"; +import { withPageRoutingDefaults } from "../../ev/esm/_internal/build/discovery/convention-config.js"; +import { createPageClientBuildEntryName } from "../../ev/src/_internal/build/conventions/build-entry-conventions.js"; +import { linkAndEmitBuildOutput } from "../../ev/src/_internal/build/output/framework-output.js"; import { createClientRuntime, createFrameworkRuntime, type FrameworkRuntimeOutput, -} from "../../ev/src/_internal/build/framework-runtime.js"; +} from "../../ev/src/_internal/build/output/framework-runtime.js"; import type { WebpackConfigs } from "../src/adapter/create-config.js"; import { __testing as webpackAdapterTesting } from "../src/adapter/index.js"; import { __testing as serverPublicAssetTesting } from "../src/adapter/server-public-assets.js"; diff --git a/packages/ev/src/_internal/build/module-exports.ts b/packages/ev/src/_internal/build/analysis/module-exports.ts similarity index 100% rename from packages/ev/src/_internal/build/module-exports.ts rename to packages/ev/src/_internal/build/analysis/module-exports.ts diff --git a/packages/ev/src/_internal/build/page-module-exports.ts b/packages/ev/src/_internal/build/analysis/page-module-exports.ts similarity index 95% rename from packages/ev/src/_internal/build/page-module-exports.ts rename to packages/ev/src/_internal/build/analysis/page-module-exports.ts index 82495419..ce6264bd 100644 --- a/packages/ev/src/_internal/build/page-module-exports.ts +++ b/packages/ev/src/_internal/build/analysis/page-module-exports.ts @@ -1,5 +1,5 @@ import { collectModuleExportNames } from "./module-exports.js"; -import { parseRouteModuleWithError } from "./routes/shared.js"; +import { parseRouteModuleWithError } from "./route-module.js"; const PAGE_RENDERING_CONFIG_EXPORTS = [ "render", diff --git a/packages/ev/src/_internal/build/ppr-regions.ts b/packages/ev/src/_internal/build/analysis/ppr-regions.ts similarity index 99% rename from packages/ev/src/_internal/build/ppr-regions.ts rename to packages/ev/src/_internal/build/analysis/ppr-regions.ts index 716db297..7b5d1999 100644 --- a/packages/ev/src/_internal/build/ppr-regions.ts +++ b/packages/ev/src/_internal/build/analysis/ppr-regions.ts @@ -9,6 +9,7 @@ import type { ModuleItem, ObjectExpression, } from "@swc/types"; +import { toPosixPath } from "../utils.js"; import { collectExportedVariableValueAnalysis, collectModuleExportNames, @@ -20,8 +21,7 @@ import { parseRouteModule, parseRouteModuleWithError, type RouteAst, -} from "./routes/shared.js"; -import { toPosixPath } from "./utils.js"; +} from "./route-module.js"; export interface PprRegionAnalysis { regions: Record; diff --git a/packages/ev/src/_internal/build/routes/shared.ts b/packages/ev/src/_internal/build/analysis/route-module.ts similarity index 97% rename from packages/ev/src/_internal/build/routes/shared.ts rename to packages/ev/src/_internal/build/analysis/route-module.ts index bbc2cdc5..0f39e3c8 100644 --- a/packages/ev/src/_internal/build/routes/shared.ts +++ b/packages/ev/src/_internal/build/analysis/route-module.ts @@ -1,6 +1,6 @@ import { parseSync } from "@swc/core"; import type { Expression, KeyValueProperty, MethodProperty } from "@swc/types"; -import { collectModuleExportNames } from "../module-exports.js"; +import { collectModuleExportNames } from "./module-exports.js"; export type RouteAst = ReturnType; diff --git a/packages/ev/src/_internal/build/rsc-refs.ts b/packages/ev/src/_internal/build/analysis/rsc-refs.ts similarity index 98% rename from packages/ev/src/_internal/build/rsc-refs.ts rename to packages/ev/src/_internal/build/analysis/rsc-refs.ts index 51964a89..9c7b73b2 100644 --- a/packages/ev/src/_internal/build/rsc-refs.ts +++ b/packages/ev/src/_internal/build/analysis/rsc-refs.ts @@ -6,24 +6,24 @@ import type { } from "@evjs/shared/manifest"; import { parseSync } from "@swc/core"; import type { ModuleItem } from "@swc/types"; +import type { TransformResult } from "../transforms/types.js"; +import { + CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, + detectConflictingFrameworkDirectives, + detectFrameworkDirective, + detectUseServer, + hashServerFunction, +} from "../utils.js"; import { collectModuleExportNames, formatModuleExportName, } from "./module-exports.js"; -import { formatParseErrorMessage } from "./routes/shared.js"; +import { formatParseErrorMessage } from "./route-module.js"; import { analyzeServerFunctionExportsFromAst, formatServerFunctionParseDiagnostic, isServerFunctionParseDiagnostic, } from "./server-fns.js"; -import type { TransformResult } from "./transforms/index.js"; -import { - CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, - detectConflictingFrameworkDirectives, - detectFrameworkDirective, - detectUseServer, - hashServerFunction, -} from "./utils.js"; type RscReferenceAst = ReturnType; diff --git a/packages/ev/src/_internal/build/server-fns.ts b/packages/ev/src/_internal/build/analysis/server-fns.ts similarity index 99% rename from packages/ev/src/_internal/build/server-fns.ts rename to packages/ev/src/_internal/build/analysis/server-fns.ts index cfe64639..cabf418b 100644 --- a/packages/ev/src/_internal/build/server-fns.ts +++ b/packages/ev/src/_internal/build/analysis/server-fns.ts @@ -7,12 +7,12 @@ import type { Param, Pattern, } from "@swc/types"; +import { detectUseServer } from "../utils.js"; import { getIdentifierExportName, getModuleExportName, } from "./module-exports.js"; -import { formatParseErrorMessage } from "./routes/shared.js"; -import { detectUseServer } from "./utils.js"; +import { formatParseErrorMessage } from "./route-module.js"; type ServerFunctionAst = ReturnType; diff --git a/packages/ev/src/_internal/build/server-route-exports.ts b/packages/ev/src/_internal/build/analysis/server-route-exports.ts similarity index 100% rename from packages/ev/src/_internal/build/server-route-exports.ts rename to packages/ev/src/_internal/build/analysis/server-route-exports.ts diff --git a/packages/ev/src/_internal/build/static-imports.ts b/packages/ev/src/_internal/build/analysis/static-imports.ts similarity index 100% rename from packages/ev/src/_internal/build/static-imports.ts rename to packages/ev/src/_internal/build/analysis/static-imports.ts diff --git a/packages/ev/src/_internal/build/bundler-config.ts b/packages/ev/src/_internal/build/bundler/config.ts similarity index 81% rename from packages/ev/src/_internal/build/bundler-config.ts rename to packages/ev/src/_internal/build/bundler/config.ts index d7f6cc70..598cd5aa 100644 --- a/packages/ev/src/_internal/build/bundler-config.ts +++ b/packages/ev/src/_internal/build/bundler/config.ts @@ -1,6 +1,6 @@ -import type { ResolvedConfig } from "../../config/index.js"; -import { resolveBundlerConfig } from "../../config/index.js"; -import type { BundlerAdapter } from "./bundler.js"; +import type { ResolvedConfig } from "../../../config/index.js"; +import { resolveBundlerConfig } from "../../../config/index.js"; +import type { BundlerAdapter } from "./contracts.js"; export function resolveBundler( configBundler: BundlerAdapter | undefined, diff --git a/packages/ev/src/_internal/build/bundler.ts b/packages/ev/src/_internal/build/bundler/contracts.ts similarity index 99% rename from packages/ev/src/_internal/build/bundler.ts rename to packages/ev/src/_internal/build/bundler/contracts.ts index 4e67a2a6..3b9ec800 100644 --- a/packages/ev/src/_internal/build/bundler.ts +++ b/packages/ev/src/_internal/build/bundler/contracts.ts @@ -7,8 +7,8 @@ import { assertPortableRelativeBrowserArtifactPath, assertServerRelativeArtifactPath, } from "@evjs/shared/manifest"; -import type { ResolvedFrameworkConfig } from "../../config/index.js"; -import type { PluginHooks } from "../../plugin/index.js"; +import type { ResolvedFrameworkConfig } from "../../../config/index.js"; +import type { PluginHooks } from "../../../plugin/index.js"; export interface BundlerEmittedFiles { client?: readonly string[]; diff --git a/packages/ev/src/_internal/build/bundler-output-files.ts b/packages/ev/src/_internal/build/bundler/output-files.ts similarity index 97% rename from packages/ev/src/_internal/build/bundler-output-files.ts rename to packages/ev/src/_internal/build/bundler/output-files.ts index 739da84a..dac48cc4 100644 --- a/packages/ev/src/_internal/build/bundler-output-files.ts +++ b/packages/ev/src/_internal/build/bundler/output-files.ts @@ -1,11 +1,11 @@ import type { BuildPlan } from "@evjs/shared/manifest"; -import type { BundlerEmittedFiles } from "./bundler.js"; import { assertPortableRelativeArtifactPath, assertPortableRelativeBrowserArtifactPath, canonicalPortableArtifactPathKey, portableArtifactPathsConflict, -} from "./portable-artifact-path.js"; +} from "../output/portable-artifact-path.js"; +import type { BundlerEmittedFiles } from "./contracts.js"; interface PortableOutputFile { fileName: string; diff --git a/packages/ev/src/_internal/build/commands.ts b/packages/ev/src/_internal/build/commands.ts index 33854868..62616875 100644 --- a/packages/ev/src/_internal/build/commands.ts +++ b/packages/ev/src/_internal/build/commands.ts @@ -1,2971 +1,20 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import type { - BuildPlan, - BuildPlanUpdate, - CoreGraph, -} from "@evjs/shared/manifest"; -import { getLogger } from "@logtape/logtape"; -import { execa } from "execa"; -import { - CONFIG_DEFAULTS, - type Config, - type ResolvedConfig, - type ResolvedFrameworkConfig, - resolveBundlerConfig, - resolveConfig, -} from "../../config/index.js"; -import type { - CliFlags, - PluginHooks, - PluginSetupContext, -} from "../../plugin/index.js"; -import { analyzeAndMaterializeFrameworkIR } from "./analyze-and-materialize.js"; -import { createBuildResult } from "./build-result.js"; -import { - type BundlerAdapter, - type BundlerBuildFacts, - type BundlerDevController, - type BundlerDevGeneration, - type BundlerDevUpdateTransition, - preflightBundlerBuild, - preflightBundlerDevUpdate, -} from "./bundler.js"; -import { resolveBundler, withActiveBundler } from "./bundler-config.js"; -import { - withPageRoutingDefaults, - withServerConventionDefaults, - withServerRouteDiscovery, -} from "./convention-config.js"; -import { DevApiProcessController } from "./dev-api-process.js"; -import { - API_READY_MARKER, - type ApiProcess, - acquireDevSessionLock, - acquireProjectOperationLock, - assertNoActiveDevDistLock, - assertNoActiveDevSessionLock, - type DevPortReservation, - type DevRuntimeRelease, - findDevServerBundlePath, - forwardApiOutput, - type ProjectOperation, - reserveDevPorts, - stopApiProcess, - waitForApiReady, - writeDevDistLock, -} from "./dev-runtime.js"; -import { - collectRouteDirectoryWatchState, - collectServerRouteWatchState, - collectWatchFilesChangedSince, - createWatchFilesPlan, - listConfigDependencyFiles, - type PreparedWatchFilesPlan, - prepareWatchFilesPlan, - type RouteDirectoryWatchState, - resolveInitialDevWatchMode, - type WatchFilesPlan, - watchFiles, -} from "./dev-watch.js"; -import { - createFrameworkOutputSnapshot, - type FrameworkOutputSnapshot, - linkAndEmitBuildOutput, - validateHtmlTemplates, -} from "./framework-output.js"; -import { - type createFrameworkRuntime, - serializeFrameworkRuntimeExpression, -} from "./framework-runtime.js"; -import { GENERATED_IR_DIR } from "./generated-contributions.js"; -import type { createCoreGraph } from "./graph/index.js"; -import { - removeOwnedOutputFile, - writeOwnedOutputFile, -} from "./owned-file-output.js"; -import { CANONICAL_PAGE_ROUTE_ROOT } from "./page-route-conventions.js"; -import { - collectGeneratedPageRouteTypeFiles, - getPageRouteTypesPath, - isGeneratedPageRouteTypesFile, -} from "./page-route-types.js"; -import { type CreateBuildPlanOptions, diffBuildPlan } from "./plan/index.js"; -import { - collectPluginHooks, - hasSamePluginIdentity, - orderPluginsByDependencies, - rethrowAfterCleanup, - runAfterBuildHooks, - runCleanupTasks, - runConfigureHooks, - runDisposeHooks, - snapshotPluginFlags, -} from "./plugin-lifecycle.js"; -import { - collectPluginSettingsRegistry, - resolvePluginSettingsState, -} from "./plugin-settings.js"; -import { - collectGeneratedPluginTypeFiles, - getPluginTypesPath, - isGeneratedPluginTypesFile, -} from "./plugin-types.js"; -import { createProductionOutputTransaction } from "./production-output-transaction.js"; -import { CANONICAL_SERVER_ROUTE_ROOT } from "./server-route-conventions.js"; -import { isInsideCwd } from "./utils.js"; - -type MutablePluginSetupContext = Omit< - PluginSetupContext, - "config" -> & { - config: ResolvedFrameworkConfig; -}; - -interface DevCycleTracker { - beginCycle(): () => void; - waitForIdle(): Promise; -} - -interface DevPluginExecutionSnapshot extends DevCycleTracker { - readonly hooks: PluginHooks[]; - readonly context: MutablePluginSetupContext; -} - -interface ScheduledDevChangeSnapshot { - forceConfigReload: boolean; - snapshot: string; -} - -/** Record one observed dependency state and report whether it needs an update. */ -export function recordDevChangeSnapshot( - previousChanges: Map, - file: string, - snapshot: string | undefined, - forceConfigReload: boolean, -): boolean { - if (snapshot === undefined) { - previousChanges.delete(file); - return true; - } - const previous = previousChanges.get(file); - // A candidate watcher may upgrade the same file state from a route - // invalidation to a required config reload; that stronger retry stays. - if ( - previous?.snapshot === snapshot && - (previous.forceConfigReload || !forceConfigReload) - ) { - return false; - } - previousChanges.set(file, { forceConfigReload, snapshot }); - return true; -} - -interface DevFrameworkOutputTransaction { - beginCycle(): (() => void) | undefined; - capture(): Promise; - deferAfterBuild(run: () => Promise): void; - prepareCommit(): Promise; - commit(): void; - runAfterBuild(): Promise; - restore(): Promise; -} - -function createDevCycleTracker(): DevCycleTracker { - let inFlightCycles = 0; - let resolveIdle: (() => void) | undefined; - let idle: Promise | undefined; - - return { - beginCycle() { - inFlightCycles += 1; - let released = false; - return () => { - if (released) return; - released = true; - inFlightCycles -= 1; - if (inFlightCycles !== 0) return; - resolveIdle?.(); - resolveIdle = undefined; - idle = undefined; - }; - }, - waitForIdle() { - if (inFlightCycles === 0) return Promise.resolve(); - idle ??= new Promise((resolve) => { - resolveIdle = resolve; - }); - return idle; - }, - }; -} - -function createDevFrameworkOutputTransaction( - cwd: string, - plans: readonly BuildPlan[], -): DevFrameworkOutputTransaction { - const cycles = createDevCycleTracker(); - let snapshot: Promise | undefined; - let capturedSnapshot: FrameworkOutputSnapshot | undefined; - let phase: "open" | "settling" | "settled" = "open"; - let committed = false; - let deferredAfterBuild: (() => Promise) | undefined; - - function seal(): void { - if (phase === "open") phase = "settling"; - } - - return { - beginCycle() { - if (phase !== "open") return undefined; - return cycles.beginCycle(); - }, - async capture() { - // A registered cycle may reach the FIFO after settlement starts. The - // transaction waits its lease, so that cycle must still capture the - // pre-publication state before it writes canonical output. - snapshot ??= createFrameworkOutputSnapshot(cwd, plans); - capturedSnapshot = await snapshot; - }, - deferAfterBuild(run) { - if (phase === "settled") { - throw new Error( - "[evjs] Cannot defer afterBuild after the framework output transaction settles.", - ); - } - // Only the last output written inside the transaction becomes canonical. - deferredAfterBuild = run; - }, - async prepareCommit() { - if (phase === "settled") return; - seal(); - await cycles.waitForIdle(); - capturedSnapshot = await snapshot; - }, - commit() { - if (phase === "settled") return; - if (phase !== "settling") { - throw new Error( - "[evjs] Cannot commit framework output before preparing the transaction.", - ); - } - capturedSnapshot?.commit(); - committed = true; - phase = "settled"; - }, - async runAfterBuild() { - if (phase !== "settled" || !committed) { - throw new Error( - "[evjs] Cannot run deferred afterBuild before committing framework output.", - ); - } - const run = deferredAfterBuild; - deferredAfterBuild = undefined; - await run?.(); - }, - async restore() { - if (phase === "settled") return; - seal(); - await cycles.waitForIdle(); - try { - capturedSnapshot = await snapshot; - } catch { - // Capture is read-only. Its rejection is already the update failure, - // so there is no framework output to restore here. - deferredAfterBuild = undefined; - phase = "settled"; - return; - } - deferredAfterBuild = undefined; - await capturedSnapshot?.restore(); - phase = "settled"; - }, - }; -} - -function createDevPluginExecutionSnapshot( - hooks: PluginHooks[], - context: MutablePluginSetupContext, -): DevPluginExecutionSnapshot { - return { - hooks: [...hooks], - context, - ...createDevCycleTracker(), - }; -} - -const logger = getLogger(["evjs", "ev"]); - -const DEV_PAGE_RENDER_PROXY_HEADER = "x-evjs-dev-page-render"; -const DEV_DIST_DIR = "dist"; - -interface InitialDevWatchPlans { - config: PreparedWatchFilesPlan; - configDependencies: ReadonlyMap; - pageRoutes?: PreparedWatchFilesPlan; - serverRoutes?: PreparedWatchFilesPlan; -} - -const runtimeExitCleanups = new Set<() => void>(); - -function runRuntimeExitCleanups(): void { - for (const cleanup of [...runtimeExitCleanups].reverse()) cleanup(); -} - -function registerRuntimeExitCleanup(cleanup: () => void): () => void { - if (runtimeExitCleanups.size === 0) { - process.once("exit", runRuntimeExitCleanups); - } - runtimeExitCleanups.add(cleanup); - - return () => { - runtimeExitCleanups.delete(cleanup); - if (runtimeExitCleanups.size === 0) { - process.off("exit", runRuntimeExitCleanups); - } - }; -} - -async function releaseRegisteredRuntimeLock( - release: DevRuntimeRelease, - unregisterExitCleanup: () => void, -): Promise { - try { - await release(); - } finally { - unregisterExitCleanup(); - } -} - -async function withRegisteredRuntimeLock(options: { - release: DevRuntimeRelease; - unregisterExitCleanup: () => void; - run: () => Promise; - cleanupErrorMessage: string; -}): Promise { - let result: T; - try { - result = await options.run(); - } catch (error) { - return rethrowAfterCleanup( - error, - () => - releaseRegisteredRuntimeLock( - options.release, - options.unregisterExitCleanup, - ), - options.cleanupErrorMessage, - ); - } - await releaseRegisteredRuntimeLock( - options.release, - options.unregisterExitCleanup, - ); - return result; -} - -async function withProjectOperationLock( - cwd: string, - operation: ProjectOperation, - run: () => Promise, -): Promise { - const release = await acquireProjectOperationLock(cwd, operation); - const unregisterExitCleanup = registerRuntimeExitCleanup(() => - release.sync(), - ); - return withRegisteredRuntimeLock({ - release, - unregisterExitCleanup, - run, - cleanupErrorMessage: `[evjs] ${operation} failed and its project operation lock cleanup also failed.`, - }); -} - -export interface DevOptions { - cwd?: string; - bundler?: BundlerAdapter; - /** Adapter used only when neither config nor options selects a bundler. */ - fallbackBundler?: BundlerAdapter; - flags?: CliFlags; - /** Reload the initial config through loadConfig inside the watcher handshake. */ - reloadInitialConfig?: boolean; - loadConfig?: ( - cwd: string, - context?: { - onDependency(file: string): void; - }, - ) => - | Config - | undefined - | Promise | undefined>; -} - -export interface BuildOptions { - cwd?: string; - bundler?: BundlerAdapter; - flags?: CliFlags; -} - -export interface PrepareFrameworkBuildOptions { - cwd?: string; - flags?: CliFlags; - mode?: "development" | "production"; - bundler?: BundlerAdapter; - requireBundler?: boolean; -} - -export interface PreparedFrameworkBuild { - cwd: string; - mode: "development" | "production"; - config: ResolvedFrameworkConfig; - fileDependencies: string[]; - pluginWatchFiles: string[]; - dispose(): Promise; -} - +export type { + BuildOptions, + DevOptions, + InspectBuildEntry, + InspectDiagnostic, + InspectFrameworkBuildOptions, + InspectFrameworkBuildResult, + InspectHtmlDocument, + InspectPageRoute, + InspectRouteFile, + PreparedFrameworkBuild, + PrepareFrameworkBuildOptions, +} from "./operations/commands.js"; export { - type InspectBuildEntry, - type InspectDiagnostic, - type InspectFrameworkBuildOptions, - type InspectFrameworkBuildResult, - type InspectHtmlDocument, - type InspectPageRoute, - type InspectRouteFile, + build, + dev, inspectFrameworkBuild, -} from "./inspect.js"; - -interface InternalPrepareFrameworkBuildOptions - extends PrepareFrameworkBuildOptions { - plan?: CreateBuildPlanOptions; -} - -interface InternalPreparedFrameworkBuild - extends PreparedFrameworkBuild { - graph: CoreGraph; - plan: BuildPlan; - hooks: PluginHooks[]; - pluginContext: PluginSetupContext; -} - -interface GeneratedDevStateSnapshot { - commit(): Promise; - restore(): Promise; -} - -interface DevApiRuntimeState { - config: ResolvedFrameworkConfig; - frameworkRuntime: ReturnType | undefined; - plan: BuildPlan; - serverEntry: string | undefined; -} - -async function createGeneratedDevStateSnapshot( - cwd: string, -): Promise { - const snapshotRoot = await fs.promises.mkdtemp( - path.join(os.tmpdir(), "evjs-dev-state-"), - ); - const generatedIrPath = path.resolve(cwd, GENERATED_IR_DIR); - const generatedIrSnapshot = path.join(snapshotRoot, "generated-ir"); - const generatedTypeFiles = new Map(); - - try { - const hadGeneratedIr = await pathExists(generatedIrPath); - if (hadGeneratedIr) { - await fs.promises.cp(generatedIrPath, generatedIrSnapshot, { - recursive: true, - }); - } - - await captureGeneratedTypeFile( - generatedTypeFiles, - getPageRouteTypesPath(cwd).file, - await collectGeneratedPageRouteTypeFiles(cwd), - ); - await captureGeneratedTypeFile( - generatedTypeFiles, - getPluginTypesPath(cwd).file, - await collectGeneratedPluginTypeFiles(cwd), - ); - - let settled = false; - return { - async commit() { - if (settled) return; - settled = true; - await removeDevStateSnapshot(snapshotRoot); - }, - async restore() { - if (settled) return; - settled = true; - try { - await runCleanupTasks([ - () => - restoreGeneratedIr( - cwd, - generatedIrPath, - generatedIrSnapshot, - hadGeneratedIr, - ), - () => restoreGeneratedTypes(cwd, generatedTypeFiles), - ]); - } finally { - await removeDevStateSnapshot(snapshotRoot); - } - }, - }; - } catch (error) { - return rethrowAfterCleanup( - error, - () => fs.promises.rm(snapshotRoot, { force: true, recursive: true }), - "[evjs] Failed to capture generated dev state and remove its incomplete snapshot.", - ); - } -} - -async function captureGeneratedTypeFile( - snapshot: Map, - canonicalFile: string, - generatedFiles: readonly string[], -): Promise { - if (!(await pathExists(canonicalFile))) { - snapshot.set(canonicalFile, undefined); - return; - } - const absoluteCanonicalFile = path.resolve(canonicalFile); - if ( - generatedFiles.some( - (generatedFile) => path.resolve(generatedFile) === absoluteCanonicalFile, - ) - ) { - snapshot.set(canonicalFile, await fs.promises.readFile(canonicalFile)); - } -} - -async function restoreGeneratedIr( - cwd: string, - generatedIrPath: string, - generatedIrSnapshot: string, - hadGeneratedIr: boolean, -): Promise { - if (!hadGeneratedIr) { - await fs.promises.rm(generatedIrPath, { force: true, recursive: true }); - return; - } - - const restoreRoot = await fs.promises.mkdtemp( - path.join(cwd, `${GENERATED_IR_DIR}-restore-`), - ); - const restoredIrPath = path.join(restoreRoot, "generated-ir"); - try { - await fs.promises.cp(generatedIrSnapshot, restoredIrPath, { - recursive: true, - }); - await fs.promises.rm(generatedIrPath, { force: true, recursive: true }); - await fs.promises.rename(restoredIrPath, generatedIrPath); - } finally { - await fs.promises.rm(restoreRoot, { force: true, recursive: true }); - } -} - -async function restoreGeneratedTypes( - cwd: string, - generatedTypeFiles: ReadonlyMap, -): Promise { - const currentGeneratedFiles = await collectGeneratedPageRouteTypeFiles(cwd); - currentGeneratedFiles.push(...(await collectGeneratedPluginTypeFiles(cwd))); - const filesToRestore = new Set([ - ...currentGeneratedFiles, - ...generatedTypeFiles.keys(), - ]); - await runCleanupTasks( - [...filesToRestore].map( - (file) => () => - restoreGeneratedTypeFile(cwd, file, generatedTypeFiles.get(file)), - ), - ); -} - -async function restoreGeneratedTypeFile( - cwd: string, - file: string, - source: Buffer | undefined, -): Promise { - if (await pathExists(file)) { - if (!(await isGeneratedFrameworkTypeFile(cwd, file))) { - logger.warn`Generated types rollback preserved a user-owned or symbolic-link file at ${file}; the previous framework declaration was not restored.`; - return; - } - await removeOwnedOutputFile(cwd, file, "Generated types rollback"); - } - - if (source === undefined) return; - if (await pathExists(file)) { - logger.warn`Generated types rollback preserved a file created concurrently at ${file}; the previous framework declaration was not restored.`; - return; - } - await writeOwnedOutputFile(cwd, file, source, "Generated types rollback"); -} - -async function isGeneratedFrameworkTypeFile( - cwd: string, - file: string, -): Promise { - const absoluteFile = path.resolve(file); - if (absoluteFile === path.resolve(getPageRouteTypesPath(cwd).file)) { - return isGeneratedPageRouteTypesFile(file); - } - if (absoluteFile === path.resolve(getPluginTypesPath(cwd).file)) { - return isGeneratedPluginTypesFile(file); - } - return false; -} - -async function pathExists(file: string): Promise { - try { - await fs.promises.lstat(file); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; - throw error; - } -} - -async function removeDevStateSnapshot(snapshotRoot: string): Promise { - try { - await fs.promises.rm(snapshotRoot, { force: true, recursive: true }); - } catch (error) { - logger.warn`Unable to remove generated dev state snapshot ${snapshotRoot}: ${error}`; - } -} - -function reportGraphDiagnostics(analysis: { - diagnostics: Array<{ - level: "warning" | "error"; - message: string; - file?: string; - line?: number; - column?: number; - }>; -}): void { - const errors: string[] = []; - - for (const diagnostic of analysis.diagnostics) { - const message = formatGraphDiagnostic(diagnostic); - if (diagnostic.level === "error") { - errors.push(message); - } else { - logger.warn`${message}`; - } - } - - if (errors.length > 0) { - throw new Error( - ["[evjs] CoreGraph analysis failed.", ...errors].join("\n"), - ); - } -} - -function formatGraphDiagnostic(diagnostic: { - message: string; - file?: string; - line?: number; - column?: number; -}): string { - const location = [ - diagnostic.file, - diagnostic.line === undefined - ? undefined - : diagnostic.column === undefined - ? String(diagnostic.line) - : `${diagnostic.line}:${diagnostic.column}`, - ] - .filter(Boolean) - .join(":"); - - return location ? `${location} - ${diagnostic.message}` : diagnostic.message; -} - -async function prepareInternalFrameworkBuild( - userConfig?: Config, - options: InternalPrepareFrameworkBuildOptions = {}, -): Promise> { - const cwd = options.cwd ?? process.cwd(); - const mode = options.mode ?? "production"; - const flags = snapshotPluginFlags(options.flags); - const configuredConfig = await runConfigureHooks(userConfig, { - mode, - cwd, - flags, - }); - const pageResolvedConfig = await withPageRoutingDefaults( - resolveConfig(configuredConfig), - configuredConfig, - cwd, - ); - const rawResolvedConfig = await withServerRouteDiscovery( - pageResolvedConfig, - cwd, - ); - const conventionResolvedConfig = await withServerConventionDefaults( - rawResolvedConfig, - cwd, - ); - const resolvedConfig = { - ...conventionResolvedConfig, - plugins: orderPluginsByDependencies(conventionResolvedConfig.plugins), - }; - - const optionBundler = resolveBundlerConfig( - options.bundler, - "options.bundler", - ); - const bundler = optionBundler ?? resolvedConfig.bundler ?? undefined; - if (options.requireBundler && !bundler) { - throw new Error( - "[evjs] No bundler configured. Pass a bundler adapter in ev.config.ts or through dev/build options.", - ); - } - const baseConfig = bundler - ? withActiveBundler(resolvedConfig, bundler) - : resolvedConfig; - const { - registry: pluginSettings, - applicationSettings: applicationPluginSettings, - } = resolvePluginSettingsState(baseConfig); - const config = baseConfig; - const pluginWatchFiles = new Set(); - const pluginContext: MutablePluginSetupContext = { - mode, - cwd, - config, - flags, - logger, - addWatchFile(file) { - pluginWatchFiles.add(path.resolve(cwd, file)); - }, - }; - const hooks = await collectPluginHooks(config.plugins, pluginContext); - let disposed = false; - const dispose = async () => { - if (disposed) return; - disposed = true; - await runDisposeHooks(hooks, pluginContext); - }; - - try { - validateHtmlTemplates(cwd, config); - const { analysis, plan } = await analyzeAndMaterializeFrameworkIR({ - cwd, - mode, - config, - pluginContext, - pluginSettings, - applicationPluginSettings, - plan: options.plan, - onAnalysis: reportGraphDiagnostics, - }); - - return { - cwd, - mode, - config, - graph: analysis.graph, - plan, - hooks, - pluginContext, - fileDependencies: analysis.fileDependencies, - pluginWatchFiles: [...pluginWatchFiles].sort(), - dispose, - }; - } catch (err) { - return rethrowAfterCleanup( - err, - dispose, - "[evjs] Framework preparation failed and plugin cleanup also failed.", - ); - } -} - -export async function prepareFrameworkBuild( - userConfig?: Config, - options: PrepareFrameworkBuildOptions = {}, -): Promise> { - const cwd = options.cwd ?? process.cwd(); - return withProjectOperationLock(cwd, "prepare", async () => { - const prepared = await prepareInternalFrameworkBuild(userConfig, options); - return { - cwd: prepared.cwd, - mode: prepared.mode, - config: prepared.config, - fileDependencies: prepared.fileDependencies, - pluginWatchFiles: prepared.pluginWatchFiles, - dispose: prepared.dispose, - }; - }); -} - -function formatDevServerReady( - context: { origin: string }, - config: Pick, - plan: Pick, -): string { - const pageUrls = formatDevPageUrls(context.origin, config, plan); - const lines = [ - "App listening at:", - ...formatDevServerAddresses(context.origin), - ]; - if (pageUrls) { - lines.push( - " Pages:", - ...pageUrls.map((page) => ` ${page.pageId}: ${page.url}`), - ); - } - return lines.join("\n"); -} - -function formatDevServerAddresses(origin: string): string[] { - const addresses = [` Local: ${origin}`]; - try { - const networkUrl = new URL(origin); - if (networkUrl.hostname !== "localhost") return addresses; - const networkAddress = Object.values(os.networkInterfaces()) - .flatMap((entries) => entries ?? []) - .find((entry) => entry.family === "IPv4" && !entry.internal); - if (!networkAddress) return addresses; - networkUrl.hostname = networkAddress.address; - addresses.push(` Network: ${networkUrl.origin}`); - } catch { - // Custom bundler adapters may provide a non-standard origin string. - } - return addresses; -} - -function formatDevPageUrls( - origin: string, - config: Pick, - plan: Pick, -): { pageId: string; url: string }[] | undefined { - if (config.routing?.mode !== "mpa") return undefined; - - const htmlPageIds = new Set(); - const pageUrls = plan.html.flatMap((document) => { - const pageId = document.owner.pageId; - if (!pageId) return []; - htmlPageIds.add(pageId); - return [ - { - pageId, - url: formatDevUrl(origin, `/${document.fileName}`), - }, - ]; - }); - - for (const route of config.routing.routes) { - if (route.kind === "layout" || htmlPageIds.has(route.id)) continue; - pageUrls.push({ - pageId: route.id, - url: formatDevUrl(origin, route.path), - }); - } - - return pageUrls.length > 0 ? pageUrls : undefined; -} - -function formatDevUrl(origin: string, pathname: string): string { - const pathWithLeadingSlash = pathname.startsWith("/") - ? pathname - : `/${pathname}`; - return `${origin}${encodeURI(pathWithLeadingSlash)}`; -} - -function withReservedDevPorts( - config: ResolvedConfig, - ports: DevPortReservation, -): ResolvedConfig { - return { - ...config, - dev: { - ...config.dev, - port: ports.client.port, - }, - server: { - ...config.server, - dev: { - ...config.server.dev, - port: ports.server.port, - }, - }, - }; -} - -function logDevPortSelection(ports: DevPortReservation): void { - const changes = [ - ports.client.port === ports.client.requestedPort - ? undefined - : `client ${ports.client.requestedPort} -> ${ports.client.port}`, - ports.server.port === ports.server.requestedPort - ? undefined - : `server ${ports.server.requestedPort} -> ${ports.server.port}`, - ].filter((change): change is string => Boolean(change)); - - if (changes.length > 0) { - logger.warn`Configured dev ports are unavailable; reserved ${changes.join(", ")} for this session.`; - } -} - -function prepareDevWatchPlan( - dependencies: readonly string[], -): PreparedWatchFilesPlan { - return prepareWatchFilesPlan( - createWatchFilesPlan(dependencies, new Set(dependencies)), - ); -} - -function listConfiguredAnalysisWatchDependencies( - cwd: string, - config: ResolvedConfig, -): string[] { - return (config.routing?.dependencies ?? []).map((dependency) => - path.resolve(cwd, dependency), - ); -} - -function listInitialRouteWatchDependencies( - root: string, - state: RouteDirectoryWatchState, -): string[] { - if (state.unsafeBoundary !== root) return state.dependencies; - try { - if (fs.lstatSync(root).isSymbolicLink()) return state.dependencies; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") return [root]; - throw error; - } - return state.dependencies; -} - -export async function dev( - userConfig?: Config, - options?: DevOptions, -): Promise { - const cwd = options?.cwd ?? process.cwd(); - const releaseDevSessionLock = await acquireDevSessionLock(cwd); - const unregisterDevSessionExitCleanup = registerRuntimeExitCleanup(() => - releaseDevSessionLock.sync(), - ); - return withRegisteredRuntimeLock({ - release: releaseDevSessionLock, - unregisterExitCleanup: unregisterDevSessionExitCleanup, - run: () => - withProjectOperationLock(cwd, "dev", () => runDev(userConfig, options)), - cleanupErrorMessage: - "[evjs] Dev failed and its session lock cleanup also failed.", - }); -} - -async function runDev( - userConfig?: Config, - options?: DevOptions, -): Promise { - const cwd = options?.cwd ?? process.cwd(); - const flags = snapshotPluginFlags(options?.flags); - process.env.NODE_ENV ??= "development"; - const initialConfigWatchPlan = prepareDevWatchPlan( - listConfigDependencyFiles(cwd), - ); - const initialConfigDependencyBaselines = new Map< - string, - PreparedWatchFilesPlan - >(); - const configLoader = options?.loadConfig; - const shouldLoadInitialConfig = - configLoader !== undefined && - (options?.reloadInitialConfig === true || userConfig === undefined); - const initialUserConfig = - shouldLoadInitialConfig && configLoader - ? await configLoader(cwd, { - onDependency(file) { - const absolute = path.resolve(file); - if (initialConfigDependencyBaselines.has(absolute)) return; - initialConfigDependencyBaselines.set( - absolute, - prepareDevWatchPlan([absolute]), - ); - }, - }) - : userConfig; - const configuredConfig = await runConfigureHooks(initialUserConfig, { - mode: "development", - cwd, - flags, - }); - const baseResolvedConfig = resolveConfig(configuredConfig); - const pageRoot = baseResolvedConfig.application - ? path.resolve(cwd, baseResolvedConfig.application.pageRoot) - : path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); - const serverRoot = path.resolve(cwd, CANONICAL_SERVER_ROUTE_ROOT); - const [initialPageRouteWatchState, initialServerRouteWatchState] = - await Promise.all([ - baseResolvedConfig.application || - (baseResolvedConfig.conventions !== false && - configuredConfig?.routing !== undefined) - ? collectRouteDirectoryWatchState(cwd, pageRoot) - : undefined, - baseResolvedConfig.conventions !== false - ? collectRouteDirectoryWatchState(cwd, serverRoot) - : undefined, - ]); - const initialDevWatchPlans: InitialDevWatchPlans = { - config: initialConfigWatchPlan, - configDependencies: initialConfigDependencyBaselines, - ...(initialPageRouteWatchState - ? { - pageRoutes: prepareDevWatchPlan( - listInitialRouteWatchDependencies( - pageRoot, - initialPageRouteWatchState, - ), - ), - } - : {}), - ...(initialServerRouteWatchState - ? { - serverRoutes: prepareDevWatchPlan( - listInitialRouteWatchDependencies( - serverRoot, - initialServerRouteWatchState, - ), - ), - } - : {}), - }; - const pageResolvedConfig = await withPageRoutingDefaults( - baseResolvedConfig, - configuredConfig, - cwd, - ); - const rawResolvedConfig = await withServerRouteDiscovery( - pageResolvedConfig, - cwd, - ); - const conventionResolvedConfig = await withServerConventionDefaults( - rawResolvedConfig, - cwd, - ); - const serverRouteWatchState = await collectServerRouteWatchState( - cwd, - baseResolvedConfig, - ); - const requestedConfig = { - ...conventionResolvedConfig, - plugins: orderPluginsByDependencies(conventionResolvedConfig.plugins), - }; - const devPorts = await reserveDevPorts( - cwd, - requestedConfig.dev.port, - requestedConfig.server.dev.port, - ); - const resolvedConfig = withReservedDevPorts(requestedConfig, devPorts); - const unregisterDevPortsExitCleanup = registerRuntimeExitCleanup(() => - devPorts.releaseSync(), - ); - - try { - await runDevSession( - initialUserConfig, - configuredConfig, - options, - cwd, - flags, - resolvedConfig, - devPorts, - initialPageRouteWatchState ?? { dependencies: [] }, - serverRouteWatchState, - initialDevWatchPlans, - ); - } finally { - try { - await devPorts.release(); - } finally { - unregisterDevPortsExitCleanup(); - } - } -} - -async function runDevSession( - userConfig: Config | undefined, - configuredConfig: Config | undefined, - options: DevOptions | undefined, - cwd: string, - flags: PluginSetupContext["flags"], - resolvedConfig: ResolvedConfig, - devPorts: DevPortReservation, - initialPageRouteWatchState: RouteDirectoryWatchState, - initialServerRouteWatchState: RouteDirectoryWatchState, - initialDevWatchPlans: InitialDevWatchPlans, -): Promise { - type WatchGeneration = { - readonly key: string; - readonly stop: () => void; - }; - type CandidateWatchGeneration = WatchGeneration & { - readonly dependencies: ReadonlySet; - readonly requiresConfigReload: boolean; - }; - - logDevPortSelection(devPorts); - - const bundler = resolveBundler( - resolvedConfig.bundler ?? options?.fallbackBundler, - options?.bundler, - ); - const baseActiveConfig = withActiveBundler(resolvedConfig, bundler); - const initialPluginSettingsState = - resolvePluginSettingsState(baseActiveConfig); - let activePluginSettings = initialPluginSettingsState.registry; - let activeApplicationPluginSettings = - initialPluginSettingsState.applicationSettings; - let activeConfiguredConfig = configuredConfig; - let activeConfig = baseActiveConfig; - let activePageRouteWatchState = initialPageRouteWatchState; - let activeServerRouteWatchState = initialServerRouteWatchState; - const activeConfigWatchFiles = new Set( - initialDevWatchPlans.configDependencies.keys(), - ); - - const pluginWatchFiles = new Set(); - const bundlerConfigWatchFiles = new Set(); - let reportWatchRegistrationFailure: ((failure: unknown) => void) | undefined; - const captureWatchBaseline = (file: string) => { - try { - return prepareWatchFilesPlan( - createWatchFilesPlan([file], new Set([file])), - ); - } catch (error) { - reportWatchRegistrationFailure?.(error); - throw error; - } - }; - const pendingPluginWatchBaselines = new Map(); - const pendingBundlerWatchBaselines = new Map< - string, - PreparedWatchFilesPlan - >(); - let onPluginWatchFileAdded: - | ((baseline: PreparedWatchFilesPlan) => void) - | undefined; - let onBundlerConfigWatchFileAdded: - | ((baseline: PreparedWatchFilesPlan) => void) - | undefined; - const addWatchFile = (file: string) => { - const absolute = path.resolve(cwd, file); - if (pluginWatchFiles.has(absolute)) return; - const baseline = captureWatchBaseline(absolute); - pluginWatchFiles.add(absolute); - if (onPluginWatchFileAdded) { - onPluginWatchFileAdded(baseline); - } else { - pendingPluginWatchBaselines.set(absolute, baseline); - } - }; - const addBundlerConfigWatchFile = (file: string) => { - const absolute = path.resolve(cwd, file); - if (bundlerConfigWatchFiles.has(absolute)) return; - const baseline = captureWatchBaseline(absolute); - bundlerConfigWatchFiles.add(absolute); - if (onBundlerConfigWatchFileAdded) { - onBundlerConfigWatchFileAdded(baseline); - } else { - pendingBundlerWatchBaselines.set(absolute, baseline); - } - }; - let pluginContextRetired = false; - let retirePluginContext = () => { - pluginContextRetired = true; - }; - let pluginCtx: MutablePluginSetupContext = { - mode: "development", - cwd, - config: activeConfig, - flags, - logger, - addWatchFile(file) { - if (pluginContextRetired) return; - addWatchFile(file); - }, - }; - let hooks: PluginHooks[]; - try { - hooks = await collectPluginHooks( - activeConfig.plugins, - pluginCtx, - retirePluginContext, - ); - } catch (error) { - retirePluginContext(); - throw error; - } - let activePluginExecution = createDevPluginExecutionSnapshot( - hooks, - pluginCtx, - ); - const activeObservedSourceDependencies = new Set(); - const initialAnalysisWatchBaselines: PreparedWatchFilesPlan[] = []; - const initialSourceBaselines = new Map(); - const captureInitialSourceRead = (file: string) => { - const absolute = path.resolve(file); - if (initialSourceBaselines.has(absolute)) return; - initialSourceBaselines.set(absolute, captureWatchBaseline(absolute)); - }; - const captureInitialObservedSourceDependency = (file: string) => { - const absolute = path.resolve(file); - activeObservedSourceDependencies.add(absolute); - captureInitialSourceRead(absolute); - }; - let activeAnalysis: Awaited>; - let activePlan: BuildPlan; - try { - validateHtmlTemplates(cwd, activeConfig); - const configuredAnalysisDependencies = - listConfiguredAnalysisWatchDependencies(cwd, activeConfig); - if (configuredAnalysisDependencies.length > 0) { - initialAnalysisWatchBaselines.push( - prepareDevWatchPlan(configuredAnalysisDependencies), - ); - } - const materialized = await analyzeAndMaterializeFrameworkIR({ - cwd, - mode: "development", - config: activeConfig, - pluginContext: pluginCtx, - pluginSettings: activePluginSettings, - applicationPluginSettings: activeApplicationPluginSettings, - plan: { distDir: DEV_DIST_DIR }, - beforeSourceRead: captureInitialSourceRead, - onSourceDependency: captureInitialObservedSourceDependency, - onAnalysis(analysis) { - reportGraphDiagnostics(analysis); - const dependencies = analysis.fileDependencies; - initialAnalysisWatchBaselines.push(prepareDevWatchPlan(dependencies)); - }, - }); - activeAnalysis = materialized.analysis; - activePlan = materialized.plan; - await assertNoActiveDevDistLock(cwd, activePlan.distDir); - } catch (error) { - return rethrowAfterCleanup( - error, - async () => { - const execution = activePluginExecution; - retirePluginContext(); - await execution.waitForIdle(); - await runDisposeHooks(execution.hooks, execution.context); - }, - "[evjs] Dev initialization failed and plugin cleanup also failed.", - ); - } - let restartQueue: Promise = Promise.resolve(); - let devUpdateQueue: Promise = Promise.resolve(); - let outputCycleQueue: Promise = Promise.resolve(); - type DevBundlerGenerationState = { - analysis: Awaited>; - config: ResolvedFrameworkConfig; - frameworkOutputTransaction: DevFrameworkOutputTransaction | undefined; - plan: BuildPlan; - pluginExecution: DevPluginExecutionSnapshot; - frameworkRuntime: ReturnType | undefined; - serverEntry: string | undefined; - }; - type DevBundlerGenerationRecord = { - status: "active" | "blocked" | "retired" | "staged"; - state?: DevBundlerGenerationState; - }; - const bundlerGenerations = new WeakMap< - BundlerDevGeneration, - DevBundlerGenerationRecord - >(); - const createBundlerGeneration = (): BundlerDevGeneration => { - const generation = Object.freeze({}) as BundlerDevGeneration; - bundlerGenerations.set(generation, { status: "staged" }); - return generation; - }; - const activateBundlerGeneration = ( - generation: BundlerDevGeneration, - state: DevBundlerGenerationState, - blocked = false, - ) => { - const record = bundlerGenerations.get(generation); - if (!record) { - throw new Error( - `[evjs] Bundler "${bundler.name}" used an unknown development generation.`, - ); - } - if (record.status !== "staged") { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by activating a ${record.status} generation. Each update generation must be activated exactly once.`, - ); - } - record.status = blocked ? "blocked" : "active"; - record.state = state; - }; - const blockBundlerGeneration = (generation: BundlerDevGeneration) => { - const record = bundlerGenerations.get(generation); - if (!record || record.status !== "active") { - throw new Error( - `[evjs] Bundler "${bundler.name}" cannot block a development generation that is not active.`, - ); - } - record.status = "blocked"; - }; - const unblockBundlerGeneration = (generation: BundlerDevGeneration) => { - const record = bundlerGenerations.get(generation); - if (!record || record.status !== "blocked") { - throw new Error( - `[evjs] Bundler "${bundler.name}" cannot resume a development generation that is not blocked.`, - ); - } - record.status = "active"; - }; - const retireBundlerGeneration = (generation: BundlerDevGeneration) => { - const record = bundlerGenerations.get(generation); - if (!record || record.status === "retired") return; - record.status = "retired"; - record.state = undefined; - }; - const getBundlerGenerationState = ( - generation: BundlerDevGeneration, - allowBlocked = false, - ): DevBundlerGenerationState => { - const record = bundlerGenerations.get(generation); - if (!record) { - throw new Error( - `[evjs] Bundler "${bundler.name}" published development output for an unknown generation.`, - ); - } - if (record.status === "staged") { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output before activating its staged generation.`, - ); - } - if (record.status === "blocked" && !allowBlocked) { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output while generated inputs were in transition.`, - ); - } - if (record.status === "retired") { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output for a retired generation.`, - ); - } - if (!record.state) { - throw new Error( - `[evjs] Bundler "${bundler.name}" has an active development generation without a framework snapshot.`, - ); - } - return record.state; - }; - const getBundlerGenerationStateForFacts = ( - generation: BundlerDevGeneration, - ): DevBundlerGenerationState | undefined => { - const record = bundlerGenerations.get(generation); - if (!record) { - throw new Error( - `[evjs] Bundler "${bundler.name}" published development output for an unknown generation.`, - ); - } - if (record.status === "staged") { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output before activating its staged generation.`, - ); - } - // A compile that was valid when reserved may finish after its generation - // was blocked or retired. This is an expected producer race, not a new - // canonical output cycle. - if (record.status === "blocked" || record.status === "retired") { - return undefined; - } - if (!record.state) { - throw new Error( - `[evjs] Bundler "${bundler.name}" has an active development generation without a framework snapshot.`, - ); - } - return record.state; - }; - const initialBundlerGeneration = createBundlerGeneration(); - let activeBundlerGeneration = initialBundlerGeneration; - activateBundlerGeneration(initialBundlerGeneration, { - analysis: activeAnalysis, - config: activeConfig, - frameworkOutputTransaction: undefined, - plan: activePlan, - pluginExecution: activePluginExecution, - frameworkRuntime: undefined, - serverEntry: undefined, - }); - const enqueueOutputCycle = (run: () => Promise): Promise => { - const cycle = outputCycleQueue.then(run, run); - outputCycleQueue = cycle.then( - () => {}, - () => {}, - ); - return cycle; - }; - let devController: BundlerDevController | undefined; - let releaseDevDistLock: DevRuntimeRelease | undefined; - let unregisterDevDistExitCleanup = () => {}; - let devDependencyWatcher: WatchGeneration | undefined; - let candidateDependencyWatcher: CandidateWatchGeneration | undefined; - let debounceTimer: ReturnType | undefined; - const pendingDevChanges = new Set(); - const lastScheduledDevChanges = new Map(); - let pendingForcedConfigReload = false; - let devSessionEnding = false; - const expectedApiExits = new WeakSet(); - const apiProcessController = new DevApiProcessController({ - expectExit(process) { - expectedApiExits.add(process); - }, - requestStop(process) { - process.kill(); - }, - stop: stopApiProcess, - }); - let resolveShutdown: (() => void) | undefined; - const waitForShutdown = new Promise((resolve) => { - resolveShutdown = resolve; - }); - let resolveDevWatchFailure: ((error: Error) => void) | undefined; - const waitForDevWatchFailure = new Promise((resolve) => { - resolveDevWatchFailure = resolve; - }); - let devWatchFailed = false; - let devWatchMode = resolveInitialDevWatchMode(); - const reportDevWatchFailure = (failure: unknown) => { - if (devWatchFailed) return; - devWatchFailed = true; - const error = - failure instanceof Error ? failure : new Error(String(failure)); - resolveDevWatchFailure?.(error); - }; - reportWatchRegistrationFailure = reportDevWatchFailure; - const reportDevWatchFallback = (error: Error) => { - if (devWatchMode === "polling") return; - devWatchMode = "polling"; - logger.warn`Development dependency event watchers are unavailable; falling back to polling: ${error}`; - }; - const stopWatcherGroup = (stop: () => void) => { - try { - stop(); - } catch (error) { - reportDevWatchFailure(error); - throw error; - } - }; - - const stopApiOnParentShutdown = () => { - apiProcessController.requestStop(); - resolveShutdown?.(); - }; - - process.once("SIGINT", stopApiOnParentShutdown); - process.once("SIGTERM", stopApiOnParentShutdown); - - const restartApiServer = async ( - state: DevApiRuntimeState, - ): Promise => { - const serverBundlePath = await findDevServerBundlePath( - cwd, - state.plan.output.serverDir, - state.serverEntry, - ); - if (!serverBundlePath) return false; - - const serverPort = - state.config.server?.dev?.port ?? CONFIG_DEFAULTS.serverPort; - - const devRootDir = path.resolve(cwd, state.plan.distDir); - const bootstrapPath = path.join(devRootDir, "_dev_start.cjs"); - try { - await writeOwnedOutputFile( - cwd, - bootstrapPath, - [ - `(async () => {`, - `const path = require("node:path");`, - `const { pathToFileURL } = require("node:url");`, - `globalThis.__EVJS_FRAMEWORK_RUNTIME__ = ${serializeFrameworkRuntimeExpression(state.frameworkRuntime)};`, - `globalThis.__EVJS_DEV_PAGE_RENDER_PROXY_HEADER__ = ${JSON.stringify(DEV_PAGE_RENDER_PROXY_HEADER)};`, - `const serverDir = path.dirname(${JSON.stringify(serverBundlePath)});`, - `globalThis.__EVJS_SERVER_MODULE_LOADER__ = async (asset) => { const mod = await import(pathToFileURL(path.resolve(serverDir, asset)).href); const nested = mod && typeof mod.default === "object" ? mod.default : undefined; return nested && ("default" in nested || "render" in nested) ? nested : mod; };`, - `const serverModule = await import(${JSON.stringify(pathToFileURL(serverBundlePath).href)});`, - `const handler = serverModule.default?.default ?? serverModule.default ?? serverModule;`, - `const { serve } = require("@evjs/ev/_internal/server/node");`, - `const server = serve({ fetch: handler.fetch }, { port: ${serverPort}, host: "0.0.0.0", https: ${JSON.stringify(state.config.server?.dev?.https ?? false)} });`, - `const ready = () => console.log(${JSON.stringify(API_READY_MARKER)});`, - `if (server.listening) ready(); else server.once("listening", ready);`, - `server.once("error", (err) => { console.error(err); process.exit(1); });`, - `})().catch((err) => { console.error(err); process.exit(1); });`, - ].join("\n"), - "Dev server bootstrap output", - ); - - if (apiProcessController.process) { - logger.info`Restarting API server...`; - } - logger.info`Server bundle detected, starting API...`; - await apiProcessController.replace(() => { - const child = execa("node", [bootstrapPath], { - stdio: ["inherit", "pipe", "pipe"], - env: { ...process.env, NODE_ENV: "development" }, - }); - forwardApiOutput(child); - child.catch((err) => { - if (expectedApiExits.has(child)) return; - if (apiProcessController.clearUnexpectedExit(child)) { - logger.error`API server process exited unexpectedly: ${err}`; - } - }); - return child; - }, waitForApiReady); - const serverProtocol = state.config.server.dev.https ? "https" : "http"; - const serverOrigin = `${serverProtocol}://localhost:${serverPort}`; - logger.info`${["API server listening at:", ...formatDevServerAddresses(serverOrigin)].join("\n")}`; - return true; - } catch (err) { - logger.error`Server runtime failed: ${err}`; - throw err; - } - }; - - const handleServerBundleReady = async (generation: BundlerDevGeneration) => { - if (devSessionEnding) return; - const generationState = getBundlerGenerationState(generation); - const state: DevApiRuntimeState = { - config: generationState.config, - frameworkRuntime: generationState.frameworkRuntime, - plan: generationState.plan, - serverEntry: generationState.serverEntry, - }; - restartQueue = restartQueue - .catch(() => {}) - .then(() => { - if (devSessionEnding) return; - return restartApiServer(state); - }) - .then(() => {}); - await restartQueue; - }; - - const loadCurrentConfig = async ( - reloadConfiguredConfig: boolean, - onRouteWatchState?: (state: { - page: RouteDirectoryWatchState; - server: RouteDirectoryWatchState; - }) => void, - onConfigDependency?: (file: string) => void, - ) => { - const nextConfiguredConfig = reloadConfiguredConfig - ? await runConfigureHooks( - options?.loadConfig - ? await options.loadConfig(cwd, { - onDependency(file) { - onConfigDependency?.(file); - }, - }) - : userConfig, - { - mode: "development", - cwd, - flags, - }, - ) - : activeConfiguredConfig; - const nextBaseResolvedConfig = resolveConfig(nextConfiguredConfig); - const nextPageRoot = nextBaseResolvedConfig.application - ? path.resolve(cwd, nextBaseResolvedConfig.application.pageRoot) - : path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); - const shouldWatchPageRoutes = - Boolean(nextBaseResolvedConfig.application) || - (nextBaseResolvedConfig.conventions !== false && - nextConfiguredConfig?.routing !== undefined); - const [pageRouteWatchState, serverRouteWatchState] = await Promise.all([ - shouldWatchPageRoutes - ? collectRouteDirectoryWatchState(cwd, nextPageRoot) - : { dependencies: [] }, - collectServerRouteWatchState(cwd, nextBaseResolvedConfig), - ]); - onRouteWatchState?.({ - page: pageRouteWatchState, - server: serverRouteWatchState, - }); - const nextPageResolvedConfig = await withPageRoutingDefaults( - nextBaseResolvedConfig, - nextConfiguredConfig, - cwd, - ); - const nextRawResolvedConfig = await withServerRouteDiscovery( - nextPageResolvedConfig, - cwd, - ); - const nextConventionResolvedConfig = await withServerConventionDefaults( - nextRawResolvedConfig, - cwd, - ); - const nextResolvedConfig = { - ...nextConventionResolvedConfig, - plugins: orderPluginsByDependencies(nextConventionResolvedConfig.plugins), - }; - - if ( - nextResolvedConfig.dev.port !== devPorts.client.requestedPort || - nextResolvedConfig.server.dev.port !== devPorts.server.requestedPort - ) { - throw new Error( - "[evjs] dev.port or server.dev.port changed while ev dev is running. Restart ev dev to apply the new ports.", - ); - } - - return { - configuredConfig: nextConfiguredConfig, - config: withActiveBundler( - withReservedDevPorts(nextResolvedConfig, devPorts), - bundler, - ), - serverRouteWatchState, - }; - }; - - const stagePluginHooks = async ( - nextConfig: typeof activeConfig, - onWatchFileAdded?: (file: string, baseline: PreparedWatchFilesPlan) => void, - ) => { - const previousHooks = [...hooks]; - const previousPluginWatchFiles = [...pluginWatchFiles]; - const previousPluginCtx = pluginCtx; - const retirePreviousPluginContext = retirePluginContext; - const previousPluginExecution = activePluginExecution; - const nextPluginWatchFiles = new Set(); - const nextPluginWatchBaselines = new Map(); - let activated = false; - let nextPluginContextRetired = false; - const retireNextPluginContext = () => { - nextPluginContextRetired = true; - }; - let settlement: "committed" | "pending" | "rolled-back" = "pending"; - const nextPluginCtx: MutablePluginSetupContext = { - ...pluginCtx, - config: nextConfig, - addWatchFile(file) { - if (nextPluginContextRetired) return; - const absolute = path.resolve(cwd, file); - if (settlement === "committed") { - addWatchFile(absolute); - return; - } - if (settlement === "rolled-back") return; - if (nextPluginWatchFiles.has(absolute)) return; - const baseline = captureWatchBaseline(absolute); - nextPluginWatchBaselines.set(absolute, baseline); - nextPluginWatchFiles.add(absolute); - if (activated) pluginWatchFiles.add(absolute); - onWatchFileAdded?.(absolute, baseline); - }, - }; - let nextHooks: PluginHooks[]; - try { - nextHooks = await collectPluginHooks( - nextConfig.plugins, - nextPluginCtx, - retireNextPluginContext, - ); - } catch (error) { - settlement = "rolled-back"; - retireNextPluginContext(); - throw error; - } - const nextPluginExecution = createDevPluginExecutionSnapshot( - nextHooks, - nextPluginCtx, - ); - - return { - pluginContext: nextPluginCtx, - watchBaselines: nextPluginWatchBaselines, - activate() { - if (activated || settlement !== "pending") return; - hooks.splice(0, hooks.length, ...nextHooks); - pluginWatchFiles.clear(); - for (const file of nextPluginWatchFiles) { - pluginWatchFiles.add(file); - } - pluginCtx = nextPluginCtx; - retirePluginContext = retireNextPluginContext; - activePluginExecution = nextPluginExecution; - activated = true; - }, - async commit() { - if (settlement !== "pending") return; - if (!activated) { - throw new Error( - "[evjs] Cannot commit plugin hooks before activating the staged snapshot.", - ); - } - settlement = "committed"; - retirePreviousPluginContext(); - await previousPluginExecution.waitForIdle(); - await runDisposeHooks(previousHooks, previousPluginCtx); - }, - async rollback() { - if (settlement !== "pending") return; - settlement = "rolled-back"; - retireNextPluginContext(); - if (activated) { - hooks.splice(0, hooks.length, ...previousHooks); - pluginWatchFiles.clear(); - for (const file of previousPluginWatchFiles) { - pluginWatchFiles.add(file); - } - pluginCtx = previousPluginCtx; - retirePluginContext = retirePreviousPluginContext; - activePluginExecution = previousPluginExecution; - } - await nextPluginExecution.waitForIdle(); - await runDisposeHooks(nextHooks, nextPluginCtx); - }, - }; - }; - - const listUnsafeRouteWatchBoundaries = ( - ...states: Array - ) => - states.flatMap((state) => - state?.unsafeBoundary ? [state.unsafeBoundary] : [], - ); - const filterUnsafeRouteWatchDependencies = ( - dependencies: Iterable, - unsafeBoundaries: readonly string[], - ) => - [...dependencies].filter((file) => - unsafeBoundaries.every( - (unsafeBoundary) => !isInsideCwd(unsafeBoundary, file), - ), - ); - const listDevDependencyWatchFiles = () => { - const unsafeBoundaries = listUnsafeRouteWatchBoundaries( - activePageRouteWatchState, - activeServerRouteWatchState, - ); - return filterUnsafeRouteWatchDependencies( - [ - ...listConfigDependencyFiles(cwd), - ...activeConfigWatchFiles, - ...activeAnalysis.fileDependencies, - ...activeObservedSourceDependencies, - ...activePageRouteWatchState.dependencies, - ...activeServerRouteWatchState.dependencies, - ...pluginWatchFiles, - ...bundlerConfigWatchFiles, - ].map((file) => path.resolve(file)), - unsafeBoundaries, - ); - }; - - const createDevDependencyWatchTopology = () => { - const dependencies = listDevDependencyWatchFiles(); - const recoverableMissingTargets = new Set(dependencies); - return createWatchFilesPlan(dependencies, recoverableMissingTargets); - }; - - const refreshDevDependencyWatchers = ( - reconcileFrom?: PreparedWatchFilesPlan, - ): PreparedWatchFilesPlan | undefined => { - if (devSessionEnding || devWatchFailed) return undefined; - let nextWatchTopology: WatchFilesPlan; - try { - nextWatchTopology = createDevDependencyWatchTopology(); - } catch (error) { - reportDevWatchFailure(error); - return undefined; - } - const topologyChanged = nextWatchTopology.key !== devDependencyWatcher?.key; - if (!topologyChanged && !reconcileFrom) return undefined; - - let nextWatchPlan: PreparedWatchFilesPlan; - try { - nextWatchPlan = prepareWatchFilesPlan(nextWatchTopology); - } catch (error) { - reportDevWatchFailure(error); - return undefined; - } - - if (topologyChanged) { - const previous = devDependencyWatcher; - let nextStop: () => void; - try { - nextStop = watchFiles(nextWatchPlan, scheduleDevUpdate, { - mode: devWatchMode, - onError: reportDevWatchFailure, - onFallback: reportDevWatchFallback, - }); - } catch { - return undefined; - } - devDependencyWatcher = { - key: nextWatchPlan.key, - stop: nextStop, - }; - if (previous) { - try { - stopWatcherGroup(previous.stop); - } catch { - return nextWatchPlan; - } - } - } - - if (reconcileFrom) { - for (const file of collectWatchFilesChangedSince( - reconcileFrom, - nextWatchPlan, - )) { - scheduleDevUpdate(file); - } - } - return nextWatchPlan; - }; - - const reconcileRegisteredWatchBaselines = ( - baselines: Iterable, - ) => { - const previousPlans = [...baselines]; - if (previousPlans.length === 0) return; - const dependencies = [ - ...new Set(previousPlans.flatMap((plan) => [...plan.logicalTargets])), - ]; - let currentPlan: PreparedWatchFilesPlan; - try { - currentPlan = prepareDevWatchPlan(dependencies); - } catch (error) { - reportDevWatchFailure(error); - return; - } - for (const previous of previousPlans) { - for (const file of collectWatchFilesChangedSince(previous, currentPlan)) { - scheduleDevUpdate(file); - } - } - }; - - const clearDevDependencyWatcher = () => { - const current = devDependencyWatcher; - devDependencyWatcher = undefined; - if (current) stopWatcherGroup(current.stop); - }; - - const clearCandidateDependencyWatcher = () => { - const current = candidateDependencyWatcher; - candidateDependencyWatcher = undefined; - if (current) stopWatcherGroup(current.stop); - }; - const retireCandidateDependencyWatcher = () => { - try { - clearCandidateDependencyWatcher(); - } catch { - // stopWatcherGroup already promoted the close failure to the session. - } - }; - - const watchCandidateDependencies = ( - candidateDependencies: readonly string[] | undefined, - reconcileFrom: Iterable = [], - requiresConfigReload = false, - ) => { - const reconciliationPlans = [...reconcileFrom]; - // A failure before candidate dependency discovery produced no replacement - // state. Keep the previous candidate watcher alive until a later attempt - // either resolves a new state or commits successfully. - const resolvedDependencies = - candidateDependencies ?? - (reconciliationPlans.length > 0 && candidateDependencyWatcher - ? [...candidateDependencyWatcher.dependencies] - : undefined); - if (!resolvedDependencies) return; - if (resolvedDependencies.length === 0) { - clearCandidateDependencyWatcher(); - return; - } - - const dependencies = [ - ...new Set(resolvedDependencies.map((file) => path.resolve(file))), - ]; - const candidateRequiresConfigReload = - requiresConfigReload || - candidateDependencyWatcher?.requiresConfigReload === true; - let currentMainWatchDependencies: ReadonlySet = new Set(); - try { - const currentMainTopology = createDevDependencyWatchTopology(); - if (currentMainTopology.key === devDependencyWatcher?.key) { - currentMainWatchDependencies = new Set( - currentMainTopology.logicalTargets, - ); - } - } catch (error) { - reportDevWatchFailure(error); - return; - } - const watchDependencies = dependencies.filter( - (file) => !currentMainWatchDependencies.has(file), - ); - const recoverableMissingTargets = new Set(watchDependencies); - let nextWatchTopology: WatchFilesPlan; - try { - nextWatchTopology = createWatchFilesPlan( - watchDependencies, - recoverableMissingTargets, - ); - } catch (error) { - reportDevWatchFailure(error); - return; - } - const nextWatchKey = JSON.stringify([ - [...dependencies].sort(), - candidateRequiresConfigReload, - nextWatchTopology.key, - ]); - const topologyChanged = nextWatchKey !== candidateDependencyWatcher?.key; - if (!topologyChanged && reconciliationPlans.length === 0) return; - - let nextWatchPlan: PreparedWatchFilesPlan; - try { - nextWatchPlan = prepareWatchFilesPlan(nextWatchTopology); - } catch (error) { - reportDevWatchFailure(error); - return; - } - - if (devSessionEnding || devWatchFailed) return; - if (topologyChanged) { - const previous = candidateDependencyWatcher; - let stop: () => void; - try { - stop = watchFiles( - nextWatchPlan, - (file) => scheduleDevUpdate(file, candidateRequiresConfigReload), - { - mode: devWatchMode, - onError: reportDevWatchFailure, - onFallback: reportDevWatchFallback, - }, - ); - } catch { - return; - } - candidateDependencyWatcher = { - dependencies: new Set(dependencies), - key: nextWatchKey, - requiresConfigReload: candidateRequiresConfigReload, - stop, - }; - if (previous) { - try { - stopWatcherGroup(previous.stop); - } catch { - return; - } - } - } - if (reconciliationPlans.length > 0) { - let currentReconcilePlan: PreparedWatchFilesPlan; - try { - currentReconcilePlan = prepareDevWatchPlan(dependencies); - } catch (error) { - reportDevWatchFailure(error); - return; - } - for (const previous of reconciliationPlans) { - for (const file of collectWatchFilesChangedSince( - previous, - currentReconcilePlan, - )) { - scheduleDevUpdate(file, candidateRequiresConfigReload); - } - } - } - }; - - const captureDevDependencyWatchPlan = () => { - try { - return prepareWatchFilesPlan(createDevDependencyWatchTopology()); - } catch (error) { - reportDevWatchFailure(error); - return undefined; - } - }; - - const captureCandidateDependencyWatchPlan = () => { - if (!candidateDependencyWatcher) return undefined; - try { - const dependencies = [...candidateDependencyWatcher.dependencies]; - return prepareDevWatchPlan(dependencies); - } catch (error) { - reportDevWatchFailure(error); - return undefined; - } - }; - - const commitStagedPluginHooks = async ( - stagedPluginHooks: Awaited> | undefined, - reconcileFrom?: PreparedWatchFilesPlan, - additionalBaselines: Iterable = [], - ) => { - const reconciliationBaselines = [ - ...(stagedPluginHooks?.watchBaselines.values() ?? []), - ...additionalBaselines, - ]; - try { - await stagedPluginHooks?.commit(); - } catch (error) { - logger.warn`Framework plan update was applied, but previous plugin cleanup failed: ${error}`; - } finally { - refreshDevDependencyWatchers(reconcileFrom); - reconcileRegisteredWatchBaselines(reconciliationBaselines); - } - }; - - const handleDevDependencyChange = async ( - changedFiles: readonly string[], - forceConfigReload = false, - devWatchReconcileFrom?: PreparedWatchFilesPlan, - candidateWatchReconcileFrom?: PreparedWatchFilesPlan, - ) => { - if (devSessionEnding || devWatchFailed) return; - let currentDevWatchReconcileFrom = devWatchReconcileFrom; - const configDependencyFiles = new Set([ - ...listConfigDependencyFiles(cwd), - ...activeConfigWatchFiles, - ]); - const isFrameworkConfigChange = changedFiles.some((file) => - configDependencyFiles.has(file), - ); - const isBundlerConfigChange = changedFiles.some((file) => - bundlerConfigWatchFiles.has(file), - ); - const requiresBundlerConfigReload = - forceConfigReload || isFrameworkConfigChange || isBundlerConfigChange; - const reason: BuildPlanUpdate["reason"] = requiresBundlerConfigReload - ? "config" - : "route-declaration"; - let stagedPluginHooks: - | Awaited> - | undefined; - let generatedStateSnapshot: GeneratedDevStateSnapshot | undefined; - let bundlerUpdateTransition: BundlerDevUpdateTransition | undefined; - let frameworkOutputTransaction: DevFrameworkOutputTransaction | undefined; - let previousBundlerGeneration: BundlerDevGeneration | undefined; - let candidateBundlerGeneration: BundlerDevGeneration | undefined; - let candidateGenerationActivated = false; - let candidateGenerationCommitted = false; - let postCommitFailure: { error: unknown } | undefined; - function recordPostCommitFailure(error: unknown): void { - postCommitFailure = postCommitFailure - ? { - error: new AggregateError( - [postCommitFailure.error, error], - "[evjs] Multiple post-commit development tasks failed.", - ), - } - : { error }; - } - let candidateRestoreFailed = false; - let candidatePageRouteWatchState: RouteDirectoryWatchState | undefined; - let candidateServerRouteWatchState: RouteDirectoryWatchState | undefined; - let candidateAnalysisWatchDependencies: readonly string[] | undefined; - const candidatePluginWatchDependencies = new Set(); - const candidatePluginWatchBaselines = new Map< - string, - PreparedWatchFilesPlan - >(); - const candidateConfigDependencies = new Set(); - const candidateConfigBaselines = new Map(); - const candidateTransientSourceDependencies = new Set(); - const candidateObservedSourceDependencies = new Set(); - const candidateSourceBaselines = new Map(); - const candidateRouteWatchBaselines: PreparedWatchFilesPlan[] = []; - let candidateWatchCoverageComplete = false; - let candidateWatcherReady = false; - let candidateConfigRegistrationsReconciled = true; - let candidatePluginRegistrationsReconciled = true; - let candidateSourcesReconciled = true; - let currentCandidateWatchReconcileFrom = candidateWatchReconcileFrom; - const collectCandidateWatchDependencies = () => { - const unsafeBoundaries = listUnsafeRouteWatchBoundaries( - candidatePageRouteWatchState, - candidateServerRouteWatchState, - ); - if (!requiresBundlerConfigReload) { - return filterUnsafeRouteWatchDependencies( - [ - ...(candidatePageRouteWatchState?.dependencies ?? []), - ...(candidateServerRouteWatchState?.dependencies ?? []), - ...(candidateDependencyWatcher?.dependencies ?? []), - ...candidateTransientSourceDependencies, - ...candidateObservedSourceDependencies, - ], - unsafeBoundaries, - ); - } - if (!candidatePageRouteWatchState || !candidateServerRouteWatchState) { - return candidateConfigDependencies.size > 0 - ? [ - ...(candidateDependencyWatcher?.dependencies ?? []), - ...candidateConfigDependencies, - ] - : undefined; - } - return filterUnsafeRouteWatchDependencies( - [ - ...candidatePageRouteWatchState.dependencies, - ...candidateServerRouteWatchState.dependencies, - ...(candidateWatchCoverageComplete - ? [] - : (candidateDependencyWatcher?.dependencies ?? [])), - ...candidateConfigDependencies, - ...candidatePluginWatchDependencies, - ...candidateTransientSourceDependencies, - ...candidateObservedSourceDependencies, - ...(candidateAnalysisWatchDependencies ?? []), - ], - unsafeBoundaries, - ); - }; - const listPendingCandidateWatchBaselines = () => [ - ...candidateConfigBaselines.values(), - ...candidatePluginWatchBaselines.values(), - ...candidateSourceBaselines.values(), - ...candidateRouteWatchBaselines, - ]; - const clearPendingCandidateWatchBaselines = () => { - candidateConfigBaselines.clear(); - candidatePluginWatchBaselines.clear(); - candidateSourceBaselines.clear(); - candidateRouteWatchBaselines.length = 0; - }; - const synchronizeCandidateWatcher = ( - registrationBaselines: Iterable = [], - ) => { - candidateWatcherReady = true; - const dependencies = collectCandidateWatchDependencies(); - const reconciliationPlans = [ - ...(currentCandidateWatchReconcileFrom - ? [currentCandidateWatchReconcileFrom] - : []), - ...registrationBaselines, - ]; - watchCandidateDependencies( - dependencies, - reconciliationPlans, - requiresBundlerConfigReload, - ); - currentCandidateWatchReconcileFrom = undefined; - candidateConfigRegistrationsReconciled = true; - candidatePluginRegistrationsReconciled = true; - candidateSourcesReconciled = true; - }; - const ensureCandidateWatcher = () => { - if ( - !candidateWatcherReady || - !candidateConfigRegistrationsReconciled || - !candidatePluginRegistrationsReconciled || - !candidateSourcesReconciled - ) { - synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); - clearPendingCandidateWatchBaselines(); - } - }; - const synchronizeCandidateAnalysisDependencies = ( - analysis: Awaited>, - coverageComplete = false, - ) => { - const unsafeBoundaries = [ - candidatePageRouteWatchState?.unsafeBoundary, - candidateServerRouteWatchState?.unsafeBoundary, - ].filter((boundary): boundary is string => Boolean(boundary)); - candidateAnalysisWatchDependencies = analysis.fileDependencies.filter( - (file) => - unsafeBoundaries.every( - (unsafeBoundary) => !isInsideCwd(unsafeBoundary, file), - ), - ); - candidateWatchCoverageComplete = coverageComplete; - synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); - clearPendingCandidateWatchBaselines(); - }; - const captureCandidateSourceRead = (file: string) => { - const absolute = path.resolve(file); - candidateTransientSourceDependencies.add(absolute); - candidateSourcesReconciled = false; - if (candidateSourceBaselines.has(absolute)) return; - candidateSourceBaselines.set(absolute, captureWatchBaseline(absolute)); - }; - const captureCandidateObservedSourceDependency = (file: string) => { - const absolute = path.resolve(file); - candidateObservedSourceDependencies.add(absolute); - captureCandidateSourceRead(absolute); - }; - const captureCandidateConfigDependency = (file: string) => { - const absolute = path.resolve(file); - candidateConfigDependencies.add(absolute); - candidateConfigRegistrationsReconciled = false; - if (candidateConfigBaselines.has(absolute)) return; - candidateConfigBaselines.set(absolute, captureWatchBaseline(absolute)); - }; - const commitCandidateConfigDependencies = () => { - if (!requiresBundlerConfigReload) return; - activeConfigWatchFiles.clear(); - for (const file of candidateConfigDependencies) { - activeConfigWatchFiles.add(file); - } - }; - const commitCandidateObservedSourceDependencies = () => { - activeObservedSourceDependencies.clear(); - for (const file of candidateObservedSourceDependencies) { - activeObservedSourceDependencies.add(file); - } - }; - const rollbackCandidatePluginGeneration = () => { - if (!candidateGenerationCommitted && candidateBundlerGeneration) { - retireBundlerGeneration(candidateBundlerGeneration); - if (previousBundlerGeneration) { - activeBundlerGeneration = previousBundlerGeneration; - } - } - return stagedPluginHooks?.rollback(); - }; - const selectAndResumeBundlerUpdateTransition = async ( - outcome: "accept" | "rollback", - ) => { - const transition = bundlerUpdateTransition; - if (!transition) return; - try { - if (outcome === "accept") await transition.accept(); - else await transition.rollback(); - } catch (error) { - if (outcome === "rollback") reportDevWatchFailure(error); - throw error; - } - unblockBundlerGeneration(activeBundlerGeneration); - try { - await transition.resume(); - } catch (error) { - blockBundlerGeneration(activeBundlerGeneration); - if (outcome === "rollback") reportDevWatchFailure(error); - throw error; - } - }; - const prepareBundlerUpdateTransitionFinalization = async () => { - const transition = bundlerUpdateTransition; - if (!transition) return; - await transition.prepareFinalize(); - }; - const finalizeBundlerUpdateTransition = () => { - const transition = bundlerUpdateTransition; - if (!transition) return; - try { - const result: unknown = transition.finalize(); - if ( - result !== null && - (typeof result === "object" || typeof result === "function") && - typeof (result as { then?: unknown }).then === "function" - ) { - // Absorb a later rejection after reporting the synchronous contract - // violation. Waiting here could deadlock a fully committed update. - void Promise.resolve(result).catch(() => {}); - throw new Error( - `[evjs] Bundler "${bundler.name}" returned a Promise from development transition finalize(). finalize() must synchronously release the committed update boundary.`, - ); - } - } finally { - bundlerUpdateTransition = undefined; - } - }; - const rollbackCandidateState = () => - runCleanupTasks([ - rollbackCandidatePluginGeneration, - async () => { - if (candidateRestoreFailed) { - throw new Error( - "[evjs] The previous generated development state did not restore completely; the bundler update boundary remains closed.", - ); - } - const restoreErrors: unknown[] = []; - try { - await frameworkOutputTransaction?.restore(); - } catch (error) { - restoreErrors.push(error); - } - try { - await generatedStateSnapshot?.restore(); - } catch (error) { - restoreErrors.push(error); - } - if (restoreErrors.length > 0) { - candidateRestoreFailed = true; - const error = new AggregateError( - restoreErrors, - "[evjs] Unable to restore the previous generated development state; the bundler update boundary remains closed.", - ); - reportDevWatchFailure(error); - throw error; - } - getBundlerGenerationState( - activeBundlerGeneration, - true, - ).frameworkOutputTransaction = undefined; - await selectAndResumeBundlerUpdateTransition("rollback"); - await prepareBundlerUpdateTransitionFinalization(); - finalizeBundlerUpdateTransition(); - }, - ]); - const retireResolvedCandidateWatcher = () => { - if ( - requiresBundlerConfigReload || - candidateDependencyWatcher?.requiresConfigReload !== true - ) { - retireCandidateDependencyWatcher(); - } - }; - - try { - const { - configuredConfig: nextConfiguredConfig, - config: baseNextConfig, - serverRouteWatchState: nextServerRouteWatchState, - } = await loadCurrentConfig( - requiresBundlerConfigReload, - requiresBundlerConfigReload - ? ({ page, server }) => { - candidatePageRouteWatchState = page; - candidateServerRouteWatchState = server; - synchronizeCandidateWatcher(); - } - : ({ page, server }) => { - candidatePageRouteWatchState = page; - candidateServerRouteWatchState = server; - const routeDependencies = [ - ...page.dependencies, - ...server.dependencies, - ]; - if (routeDependencies.length > 0) { - candidateRouteWatchBaselines.push( - prepareDevWatchPlan(routeDependencies), - ); - } - activePageRouteWatchState = page; - activeServerRouteWatchState = server; - currentDevWatchReconcileFrom = - refreshDevDependencyWatchers(currentDevWatchReconcileFrom) ?? - currentDevWatchReconcileFrom; - }, - requiresBundlerConfigReload - ? captureCandidateConfigDependency - : undefined, - ); - if ( - !hasSamePluginIdentity(activeConfig.plugins, baseNextConfig.plugins) - ) { - await rollbackCandidateState(); - logger.warn`Plugin configuration changed. Please restart ev dev to apply plugin additions, removals, or reordering.`; - return; - } - - const nextPluginSettings = requiresBundlerConfigReload - ? collectPluginSettingsRegistry(baseNextConfig.plugins) - : activePluginSettings; - let nextApplicationPluginSettings = activeApplicationPluginSettings; - const nextConfig: ResolvedFrameworkConfig = baseNextConfig; - if (requiresBundlerConfigReload) { - const nextPluginSettingsState = resolvePluginSettingsState( - baseNextConfig, - nextPluginSettings, - ); - nextApplicationPluginSettings = - nextPluginSettingsState.applicationSettings; - } - - validateHtmlTemplates(cwd, nextConfig); - if (requiresBundlerConfigReload) { - stagedPluginHooks = await stagePluginHooks( - nextConfig, - (file, baseline) => { - candidatePluginWatchDependencies.add(file); - candidatePluginWatchBaselines.set(file, baseline); - candidatePluginRegistrationsReconciled = false; - }, - ); - synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); - clearPendingCandidateWatchBaselines(); - const configuredAnalysisDependencies = - listConfiguredAnalysisWatchDependencies(cwd, nextConfig); - if (configuredAnalysisDependencies.length > 0) { - candidateAnalysisWatchDependencies = configuredAnalysisDependencies; - synchronizeCandidateWatcher([ - prepareDevWatchPlan(configuredAnalysisDependencies), - ]); - } - } - const updateController = devController; - if (!updateController) { - await rollbackCandidateState(); - ensureCandidateWatcher(); - logger.warn`The selected bundler does not expose a dev controller. Please restart ev dev to apply framework plan changes.`; - return; - } - generatedStateSnapshot = await createGeneratedDevStateSnapshot(cwd); - // Reserve the adapter boundary before analysis writes any live `.ev` - // input. Built-in adapters suppress transition-time build results until - // updatePlan either adopts the final candidate or Core restores the - // previous generated snapshot and cancels the transition. - bundlerUpdateTransition = await updateController.beginUpdate(); - blockBundlerGeneration(activeBundlerGeneration); - const { analysis: nextAnalysis, plan: nextPlan } = - await analyzeAndMaterializeFrameworkIR({ - cwd, - mode: "development", - config: nextConfig, - pluginContext: - stagedPluginHooks?.pluginContext ?? - ({ - ...pluginCtx, - config: nextConfig, - } satisfies PluginSetupContext), - pluginSettings: nextPluginSettings, - applicationPluginSettings: nextApplicationPluginSettings, - plan: { distDir: DEV_DIST_DIR }, - beforeSourceRead: captureCandidateSourceRead, - onSourceDependency: captureCandidateObservedSourceDependency, - onAnalysis(analysis) { - if (requiresBundlerConfigReload) { - synchronizeCandidateAnalysisDependencies(analysis); - } - reportGraphDiagnostics(analysis); - }, - }); - if (requiresBundlerConfigReload) { - synchronizeCandidateAnalysisDependencies(nextAnalysis, true); - } - const update = diffBuildPlan(activePlan, nextPlan, reason); - const updateTransition = bundlerUpdateTransition; - if (!updateTransition) { - throw new Error( - `[evjs] Bundler "${bundler.name}" did not establish a development update boundary before candidate framework input was materialized.`, - ); - } - - const previousConfig = activeConfig; - const previousConfiguredConfig = activeConfiguredConfig; - const previousPluginSettings = activePluginSettings; - const previousApplicationPluginSettings = activeApplicationPluginSettings; - const previousAnalysis = activeAnalysis; - const previousPlan = activePlan; - const priorBundlerGeneration = activeBundlerGeneration; - previousBundlerGeneration = priorBundlerGeneration; - const previousBundlerGenerationState = getBundlerGenerationState( - priorBundlerGeneration, - true, - ); - const nextBundlerGeneration = createBundlerGeneration(); - candidateBundlerGeneration = nextBundlerGeneration; - - preflightBundlerBuild(bundler, nextPlan); - preflightBundlerDevUpdate(bundler, update); - frameworkOutputTransaction = createDevFrameworkOutputTransaction(cwd, [ - previousPlan, - nextPlan, - ]); - - let activationAttempted = false; - const activateCandidateGeneration = () => { - if (activationAttempted) { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by calling updatePlan().activate() more than once.`, - ); - } - activationAttempted = true; - if (activeBundlerGeneration !== priorBundlerGeneration) { - throw new Error( - `[evjs] Bundler "${bundler.name}" tried to activate a development generation after its previous generation was replaced.`, - ); - } - - stagedPluginHooks?.activate(); - activeConfiguredConfig = nextConfiguredConfig; - activeConfig = nextConfig; - activePluginSettings = nextPluginSettings; - activeApplicationPluginSettings = nextApplicationPluginSettings; - activeAnalysis = nextAnalysis; - activePlan = nextPlan; - pluginCtx.config = nextConfig; - previousBundlerGenerationState.frameworkOutputTransaction = - frameworkOutputTransaction; - activateBundlerGeneration( - nextBundlerGeneration, - { - analysis: nextAnalysis, - config: nextConfig, - frameworkOutputTransaction, - plan: nextPlan, - pluginExecution: activePluginExecution, - frameworkRuntime: undefined, - serverEntry: undefined, - }, - true, - ); - activeBundlerGeneration = nextBundlerGeneration; - candidateGenerationActivated = true; - }; - - try { - await updateController.updatePlan(update, { - config: nextConfig, - configChanged: requiresBundlerConfigReload, - transition: updateTransition, - generation: nextBundlerGeneration, - activate: activateCandidateGeneration, - }); - if (!candidateGenerationActivated) { - throw new Error( - `[evjs] Bundler "${bundler.name}" violated the development generation contract by completing updatePlan() without calling activate().`, - ); - } - await selectAndResumeBundlerUpdateTransition("accept"); - await frameworkOutputTransaction.prepareCommit(); - await prepareBundlerUpdateTransitionFinalization(); - frameworkOutputTransaction.commit(); - previousBundlerGenerationState.frameworkOutputTransaction = undefined; - getBundlerGenerationState( - nextBundlerGeneration, - true, - ).frameworkOutputTransaction = undefined; - candidateGenerationCommitted = true; - retireBundlerGeneration(priorBundlerGeneration); - try { - await frameworkOutputTransaction.runAfterBuild(); - } catch (error) { - recordPostCommitFailure(error); - } - try { - finalizeBundlerUpdateTransition(); - } catch (error) { - // Canonical output and generation ownership are already committed. - // Finish committing the candidate snapshot, then fail the dev - // session instead of attempting an impossible mixed-state rollback. - recordPostCommitFailure(error); - } - } catch (err) { - activeConfiguredConfig = previousConfiguredConfig; - activeConfig = previousConfig; - activePluginSettings = previousPluginSettings; - activeApplicationPluginSettings = previousApplicationPluginSettings; - activeAnalysis = previousAnalysis; - activePlan = previousPlan; - if (!stagedPluginHooks) { - pluginCtx.config = previousConfig; - } - try { - await rollbackCandidateState(); - } catch (rollbackError) { - ensureCandidateWatcher(); - throw new AggregateError( - [err, rollbackError], - "[evjs] Framework plan update failed and dev state rollback also failed.", - { cause: err }, - ); - } - ensureCandidateWatcher(); - logger.warn`Unable to apply framework plan update without restart: ${err}`; - return; - } - activePageRouteWatchState = - candidatePageRouteWatchState ?? activePageRouteWatchState; - activeServerRouteWatchState = nextServerRouteWatchState; - commitCandidateConfigDependencies(); - commitCandidateObservedSourceDependencies(); - await commitStagedPluginHooks( - stagedPluginHooks, - currentDevWatchReconcileFrom, - [...candidateRouteWatchBaselines, ...candidateSourceBaselines.values()], - ); - await generatedStateSnapshot.commit(); - retireResolvedCandidateWatcher(); - if (postCommitFailure) { - reportDevWatchFailure(postCommitFailure.error); - } - } catch (err) { - return rethrowAfterCleanup( - err, - async () => { - try { - await rollbackCandidateState(); - } finally { - ensureCandidateWatcher(); - } - }, - "[evjs] Framework dev state update failed and rollback also failed.", - ); - } - }; - - function overlapsCandidateWatchDependency(changedFile: string): boolean { - if (!candidateDependencyWatcher?.requiresConfigReload) return false; - for (const dependency of candidateDependencyWatcher.dependencies) { - if ( - isInsideCwd(dependency, changedFile) || - isInsideCwd(changedFile, dependency) - ) { - return true; - } - } - return false; - } - - function scheduleDevUpdate(changedFile: string, forceConfigReload = false) { - if (devSessionEnding || devWatchFailed) return; - pendingDevChanges.add(changedFile); - pendingForcedConfigReload ||= - forceConfigReload || overlapsCandidateWatchDependency(changedFile); - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = undefined; - const changedFiles = [...pendingDevChanges]; - const shouldForceConfigReload = pendingForcedConfigReload; - pendingDevChanges.clear(); - pendingForcedConfigReload = false; - const devWatchReconcileFrom = captureDevDependencyWatchPlan(); - const candidateWatchReconcileFrom = captureCandidateDependencyWatchPlan(); - if (devWatchFailed) return; - const changedSnapshotPlans = [ - devWatchReconcileFrom, - candidateWatchReconcileFrom, - ].filter((plan): plan is PreparedWatchFilesPlan => Boolean(plan)); - const changedFilesWithNewSnapshots = changedFiles.filter((file) => { - const snapshot = changedSnapshotPlans - .find((plan) => plan.baselineSnapshots.has(file)) - ?.baselineSnapshots.get(file); - return recordDevChangeSnapshot( - lastScheduledDevChanges, - file, - snapshot, - shouldForceConfigReload, - ); - }); - if (changedFilesWithNewSnapshots.length === 0) return; - devUpdateQueue = devUpdateQueue - .catch(() => {}) - .then(() => - handleDevDependencyChange( - changedFilesWithNewSnapshots, - shouldForceConfigReload, - devWatchReconcileFrom, - candidateWatchReconcileFrom, - ), - ) - .catch((err) => { - logger.warn`Failed to update framework dev state: ${err}`; - }); - }, 50); - } - - const cleanupDev = async () => { - devSessionEnding = true; - if (debounceTimer) clearTimeout(debounceTimer); - pendingDevChanges.clear(); - lastScheduledDevChanges.clear(); - pendingForcedConfigReload = false; - onPluginWatchFileAdded = undefined; - onBundlerConfigWatchFileAdded = undefined; - reportWatchRegistrationFailure = undefined; - pendingPluginWatchBaselines.clear(); - pendingBundlerWatchBaselines.clear(); - await runCleanupTasks([ - () => clearDevDependencyWatcher(), - () => clearCandidateDependencyWatcher(), - () => devController?.close?.(), - () => devUpdateQueue.catch(() => {}), - () => outputCycleQueue, - () => restartQueue.catch(() => {}), - () => apiProcessController.stop(), - async () => { - try { - await releaseDevDistLock?.(); - } finally { - unregisterDevDistExitCleanup(); - } - }, - () => { - process.off("SIGINT", stopApiOnParentShutdown); - process.off("SIGTERM", stopApiOnParentShutdown); - }, - async () => { - const execution = activePluginExecution; - retireBundlerGeneration(activeBundlerGeneration); - retirePluginContext(); - await execution.waitForIdle(); - await runDisposeHooks(execution.hooks, execution.context); - }, - ]); - }; - - try { - const startupWatchPlan = prepareWatchFilesPlan( - createDevDependencyWatchTopology(), - ); - preflightBundlerBuild(bundler, activePlan); - devController = - (await bundler.dev({ - config: activeConfig, - cwd, - generation: initialBundlerGeneration, - hooks, - plan: activePlan, - addWatchFile: addBundlerConfigWatchFile, - callbacks: { - onDevServerReady(context) { - logger.info`${formatDevServerReady( - context, - activeConfig, - activePlan, - )}`; - }, - async onBuildFacts(generation, bundlerFacts, options) { - const { isRebuild } = options; - const generationState = - getBundlerGenerationStateForFacts(generation); - if (!generationState) return "discarded"; - const cycleConfig = generationState.config; - const cycleAnalysis = generationState.analysis; - const cyclePlan = generationState.plan; - const cyclePluginExecution = generationState.pluginExecution; - const cycleFrameworkOutputTransaction = - generationState.frameworkOutputTransaction; - const releaseFrameworkOutputTransactionCycle = - cycleFrameworkOutputTransaction?.beginCycle(); - if ( - cycleFrameworkOutputTransaction && - !releaseFrameworkOutputTransactionCycle - ) { - return "discarded"; - } - const cycleHooks = [...cyclePluginExecution.hooks]; - const cyclePluginContext: PluginSetupContext = { - ...cyclePluginExecution.context, - config: cycleConfig, - }; - const releaseCycle = cyclePluginExecution.beginCycle(); - return enqueueOutputCycle(async () => { - try { - await cycleFrameworkOutputTransaction?.capture(); - const outputSnapshot = await createFrameworkOutputSnapshot( - cwd, - [cyclePlan], - ); - let linkedOutput: Awaited< - ReturnType> - >; - try { - linkedOutput = await linkAndEmitBuildOutput({ - bundlerFacts, - graph: cycleAnalysis.graph, - plan: cyclePlan, - config: cycleConfig, - cwd, - hooks: cycleHooks, - pluginCtx: cyclePluginContext, - isRebuild, - }); - } catch (error) { - return rethrowAfterCleanup( - error, - () => outputSnapshot.restore(), - "[evjs] Framework output cycle failed and canonical output rollback also failed.", - ); - } - outputSnapshot.commit(); - generationState.frameworkRuntime = - linkedOutput.frameworkRuntime; - generationState.serverEntry = linkedOutput.output.server.entry; - async function notifyAfterBuild(): Promise { - await runAfterBuildHooks( - cycleHooks, - createBuildResult(linkedOutput.output, isRebuild, { - frameworkRuntime: linkedOutput.frameworkRuntime, - }), - { cwd, emittedFiles: bundlerFacts.emittedFiles }, - ); - } - if (cycleFrameworkOutputTransaction) { - cycleFrameworkOutputTransaction.deferAfterBuild( - notifyAfterBuild, - ); - } else { - await notifyAfterBuild(); - } - return "published" as const; - } finally { - releaseFrameworkOutputTransactionCycle?.(); - releaseCycle(); - } - }); - }, - onServerBundleReady: handleServerBundleReady, - }, - })) ?? undefined; - releaseDevDistLock = await writeDevDistLock(cwd, activePlan.distDir); - unregisterDevDistExitCleanup = registerRuntimeExitCleanup(() => - releaseDevDistLock?.sync(), - ); - refreshDevDependencyWatchers(startupWatchPlan); - reconcileRegisteredWatchBaselines([ - initialDevWatchPlans.config, - ...initialDevWatchPlans.configDependencies.values(), - ...(initialDevWatchPlans.pageRoutes - ? [initialDevWatchPlans.pageRoutes] - : []), - ...(initialDevWatchPlans.serverRoutes - ? [initialDevWatchPlans.serverRoutes] - : []), - ...initialAnalysisWatchBaselines, - ...initialSourceBaselines.values(), - ]); - reconcileRegisteredWatchBaselines([ - ...pendingPluginWatchBaselines.values(), - ...pendingBundlerWatchBaselines.values(), - ]); - pendingPluginWatchBaselines.clear(); - pendingBundlerWatchBaselines.clear(); - onPluginWatchFileAdded = (baseline) => { - refreshDevDependencyWatchers(baseline); - }; - onBundlerConfigWatchFileAdded = (baseline) => { - refreshDevDependencyWatchers(baseline); - }; - const waitForSessionEnd = devController?.done - ? Promise.race([waitForShutdown, devController.done]) - : waitForShutdown; - await Promise.race([ - waitForSessionEnd, - waitForDevWatchFailure.then((error) => { - throw error; - }), - ]); - } catch (error) { - return rethrowAfterCleanup( - error, - cleanupDev, - "[evjs] Dev failed and cleanup also failed.", - ); - } - await cleanupDev(); -} - -export async function build( - userConfig?: Config, - options?: BuildOptions, -): Promise { - const cwd = options?.cwd ?? process.cwd(); - await assertNoActiveDevSessionLock(cwd); - return withProjectOperationLock(cwd, "build", () => - runBuild(userConfig, options, cwd), - ); -} - -async function runBuild( - userConfig: Config | undefined, - options: BuildOptions | undefined, - cwd: string, -): Promise { - process.env.NODE_ENV ??= "production"; - const prepared = await prepareInternalFrameworkBuild(userConfig, { - cwd, - mode: "production", - bundler: options?.bundler, - flags: options?.flags, - requireBundler: true, - }); - const bundler = prepared.config.bundler; - if (!bundler) { - await prepared.dispose(); - throw new Error( - "[evjs] No bundler configured. Pass a bundler adapter in ev.config.ts or through dev/build options.", - ); - } - try { - await assertNoActiveDevDistLock(cwd, prepared.plan.distDir); - preflightBundlerBuild(bundler, prepared.plan); - // Reject symbolic-link leaves at framework-owned canonical paths before - // staging. The snapshot is validation-only in production; whole-tree - // rollback is owned by the transaction below. - const canonicalOutputValidation = await createFrameworkOutputSnapshot(cwd, [ - prepared.plan, - ]); - canonicalOutputValidation.commit(); - // Compile and link the complete production tree in staging. Canonical - // output is replaced only after every pre-afterBuild phase succeeds, so a - // failed clean/compile/link/write cannot mix generations. - const outputTransaction = await createProductionOutputTransaction( - cwd, - prepared.plan, - ); - let bundlerFacts: BundlerBuildFacts | undefined; - let linkedOutput: - | Awaited>> - | undefined; - try { - bundlerFacts = await bundler.build({ - config: prepared.config, - cwd, - hooks: prepared.hooks, - plan: outputTransaction.buildPlan, - addWatchFile: prepared.pluginContext.addWatchFile, - }); - linkedOutput = await linkAndEmitBuildOutput({ - bundlerFacts, - graph: prepared.graph, - plan: prepared.plan, - config: prepared.config, - cwd, - hooks: prepared.hooks, - pluginCtx: prepared.pluginContext, - isRebuild: false, - emissionPaths: outputTransaction.outputPaths, - }); - await outputTransaction.publish(); - } catch (error) { - await rethrowAfterCleanup( - error, - () => outputTransaction.rollback(), - "[evjs] Production output failed and staging rollback also failed.", - ); - } - if (!bundlerFacts || !linkedOutput) { - throw new Error("[evjs] Production framework output was not linked."); - } - await runAfterBuildHooks( - prepared.hooks, - createBuildResult(linkedOutput.output, false, { - frameworkRuntime: linkedOutput.frameworkRuntime, - }), - { cwd, emittedFiles: bundlerFacts.emittedFiles }, - ); - } catch (error) { - return rethrowAfterCleanup( - error, - prepared.dispose, - "[evjs] Build failed and plugin cleanup also failed.", - ); - } - await prepared.dispose(); -} + prepareFrameworkBuild, + recordDevChangeSnapshot, +} from "./operations/commands.js"; diff --git a/packages/ev/src/_internal/build/config-module.ts b/packages/ev/src/_internal/build/config-loading/config-module.ts similarity index 98% rename from packages/ev/src/_internal/build/config-module.ts rename to packages/ev/src/_internal/build/config-loading/config-module.ts index cc41d04f..a7deacf2 100644 --- a/packages/ev/src/_internal/build/config-module.ts +++ b/packages/ev/src/_internal/build/config-loading/config-module.ts @@ -3,11 +3,11 @@ import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createJiti } from "jiti"; -import type { Config } from "../../config/index.js"; -import { extractRuntimeModuleReferences } from "./static-imports.js"; +import type { Config } from "../../../config/index.js"; +import { extractRuntimeModuleReferences } from "../analysis/static-imports.js"; const requireFromLoader = createRequire(import.meta.url); -const evPackageManifest = requireFromLoader("../../../package.json") as { +const evPackageManifest = requireFromLoader("../../../../package.json") as { exports: Record; }; const currentEvPackageAliases = resolveCurrentEvPackageAliases(); @@ -1203,10 +1203,14 @@ function safeRealpath(file: string): string { } function resolveCurrentEvPackageEntry(): string { - const sourceEntry = fileURLToPath(new URL("../../index.ts", import.meta.url)); + const sourceEntry = fileURLToPath( + new URL("../../../index.ts", import.meta.url), + ); if (fs.existsSync(sourceEntry)) return sourceEntry; - const builtEntry = fileURLToPath(new URL("../../index.js", import.meta.url)); + const builtEntry = fileURLToPath( + new URL("../../../index.js", import.meta.url), + ); if (fs.existsSync(builtEntry)) return builtEntry; return requireFromLoader.resolve("@evjs/ev"); diff --git a/packages/ev/src/_internal/build/page-config-module.ts b/packages/ev/src/_internal/build/config-loading/page-config-module.ts similarity index 98% rename from packages/ev/src/_internal/build/page-config-module.ts rename to packages/ev/src/_internal/build/config-loading/page-config-module.ts index 9a8081af..7877838c 100644 --- a/packages/ev/src/_internal/build/page-config-module.ts +++ b/packages/ev/src/_internal/build/config-loading/page-config-module.ts @@ -17,16 +17,16 @@ import { assertPageMetadata, clonePageMetadata } from "@evjs/shared/manifest"; import type { PageAnchorMetadata, PageRouteDiscoveryMetadata, -} from "../../config/index.js"; +} from "../../../config/index.js"; import { type ResolvedPagePluginOptionsInput, resolvePagePluginOptions, -} from "../../config/plugins.js"; +} from "../../../config/plugins.js"; +import { validatePageRenderingContract } from "../conventions/page-rendering-contract.js"; import { clearStaticConfigModuleCache, loadStaticConfigModule, } from "./config-module.js"; -import { validatePageRenderingContract } from "./page-rendering-contract.js"; const PAGE_CONFIG_FIELDS = new Set([ "render", diff --git a/packages/ev/src/_internal/build/build-entry-conventions.ts b/packages/ev/src/_internal/build/conventions/build-entry-conventions.ts similarity index 100% rename from packages/ev/src/_internal/build/build-entry-conventions.ts rename to packages/ev/src/_internal/build/conventions/build-entry-conventions.ts diff --git a/packages/ev/src/_internal/build/output-path-conventions.ts b/packages/ev/src/_internal/build/conventions/output-path-conventions.ts similarity index 98% rename from packages/ev/src/_internal/build/output-path-conventions.ts rename to packages/ev/src/_internal/build/conventions/output-path-conventions.ts index 67833753..8f26f5fe 100644 --- a/packages/ev/src/_internal/build/output-path-conventions.ts +++ b/packages/ev/src/_internal/build/conventions/output-path-conventions.ts @@ -1,7 +1,7 @@ import { assertPortableRelativeArtifactPath, canonicalPortableArtifactPathKey, -} from "./portable-artifact-path.js"; +} from "../output/portable-artifact-path.js"; export interface FrameworkOutputDirectories { client: string; diff --git a/packages/ev/src/_internal/build/page-document-output.ts b/packages/ev/src/_internal/build/conventions/page-document-output.ts similarity index 100% rename from packages/ev/src/_internal/build/page-document-output.ts rename to packages/ev/src/_internal/build/conventions/page-document-output.ts diff --git a/packages/ev/src/_internal/build/page-rendering-contract.ts b/packages/ev/src/_internal/build/conventions/page-rendering-contract.ts similarity index 100% rename from packages/ev/src/_internal/build/page-rendering-contract.ts rename to packages/ev/src/_internal/build/conventions/page-rendering-contract.ts diff --git a/packages/ev/src/_internal/build/page-route-conventions.ts b/packages/ev/src/_internal/build/conventions/page-route-conventions.ts similarity index 100% rename from packages/ev/src/_internal/build/page-route-conventions.ts rename to packages/ev/src/_internal/build/conventions/page-route-conventions.ts diff --git a/packages/ev/src/_internal/build/route-conventions.ts b/packages/ev/src/_internal/build/conventions/route-conventions.ts similarity index 100% rename from packages/ev/src/_internal/build/route-conventions.ts rename to packages/ev/src/_internal/build/conventions/route-conventions.ts diff --git a/packages/ev/src/_internal/build/route-order.ts b/packages/ev/src/_internal/build/conventions/route-order.ts similarity index 100% rename from packages/ev/src/_internal/build/route-order.ts rename to packages/ev/src/_internal/build/conventions/route-order.ts diff --git a/packages/ev/src/_internal/build/server-route-conventions.ts b/packages/ev/src/_internal/build/conventions/server-route-conventions.ts similarity index 100% rename from packages/ev/src/_internal/build/server-route-conventions.ts rename to packages/ev/src/_internal/build/conventions/server-route-conventions.ts diff --git a/packages/ev/src/_internal/build/dev-api-process.ts b/packages/ev/src/_internal/build/dev/api-process.ts similarity index 100% rename from packages/ev/src/_internal/build/dev-api-process.ts rename to packages/ev/src/_internal/build/dev/api-process.ts diff --git a/packages/ev/src/_internal/build/dev-runtime.ts b/packages/ev/src/_internal/build/dev/runtime.ts similarity index 99% rename from packages/ev/src/_internal/build/dev-runtime.ts rename to packages/ev/src/_internal/build/dev/runtime.ts index ca0b428f..75945965 100644 --- a/packages/ev/src/_internal/build/dev-runtime.ts +++ b/packages/ev/src/_internal/build/dev/runtime.ts @@ -10,8 +10,8 @@ import { removeOwnedOutputFile, removeOwnedOutputFileSync, writeOwnedOutputFile, -} from "./owned-file-output.js"; -import { isInsideCwd } from "./utils.js"; +} from "../output/owned-file-output.js"; +import { isInsideCwd } from "../utils.js"; export type ApiProcess = ReturnType; diff --git a/packages/ev/src/_internal/build/dev-watch.ts b/packages/ev/src/_internal/build/dev/watch.ts similarity index 99% rename from packages/ev/src/_internal/build/dev-watch.ts rename to packages/ev/src/_internal/build/dev/watch.ts index 8a902259..3972abe7 100644 --- a/packages/ev/src/_internal/build/dev-watch.ts +++ b/packages/ev/src/_internal/build/dev/watch.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import type { ResolvedConfig } from "../../config/index.js"; -import { CANONICAL_SERVER_ROUTE_ROOT } from "./server-route-conventions.js"; -import { isInsideCwd, isRealPathInsideCwd } from "./utils.js"; +import type { ResolvedConfig } from "../../../config/index.js"; +import { CANONICAL_SERVER_ROUTE_ROOT } from "../conventions/server-route-conventions.js"; +import { isInsideCwd, isRealPathInsideCwd } from "../utils.js"; export interface RouteDirectoryWatchState { dependencies: string[]; diff --git a/packages/ev/src/_internal/build/convention-config.ts b/packages/ev/src/_internal/build/discovery/convention-config.ts similarity index 96% rename from packages/ev/src/_internal/build/convention-config.ts rename to packages/ev/src/_internal/build/discovery/convention-config.ts index 22b3619b..ffa95fe1 100644 --- a/packages/ev/src/_internal/build/convention-config.ts +++ b/packages/ev/src/_internal/build/discovery/convention-config.ts @@ -1,20 +1,21 @@ import path from "node:path"; import type { CoreGraph } from "@evjs/shared/manifest"; import { getLogger } from "@logtape/logtape"; -import type { Config, ResolvedConfig } from "../../config/index.js"; -import { removeOwnedOutputFile } from "./owned-file-output.js"; +import type { Config, ResolvedConfig } from "../../../config/index.js"; import { CANONICAL_PAGE_ROUTE_ROOT, PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY, PAGE_ROUTE_CONVENTION_DOCS_URL, -} from "./page-route-conventions.js"; +} from "../conventions/page-route-conventions.js"; +import { CANONICAL_SERVER_ROUTE_ROOT } from "../conventions/server-route-conventions.js"; +import { removeOwnedOutputFile } from "../output/owned-file-output.js"; import { collectGeneratedPageRouteTypeFiles, createPageRouteNodesFromCoreGraph, generatePageRouteTypes, getPageRouteTypesPath, writePageRouteTypesIfChanged, -} from "./page-route-types.js"; +} from "../typegen/page-route-types.js"; import { discoverPageRoutes, type PageRouteDiscovery } from "./page-routes.js"; import { applyRouteScopedMiddlewares, @@ -22,7 +23,6 @@ import { discoverServerConventions, type ServerConventionDiscovery, } from "./server-conventions.js"; -import { CANONICAL_SERVER_ROUTE_ROOT } from "./server-route-conventions.js"; import { discoverServerRoutes, type ServerRouteDiscovery, diff --git a/packages/ev/src/_internal/build/page-routes.ts b/packages/ev/src/_internal/build/discovery/page-routes.ts similarity index 98% rename from packages/ev/src/_internal/build/page-routes.ts rename to packages/ev/src/_internal/build/discovery/page-routes.ts index 1ab42aec..922fa518 100644 --- a/packages/ev/src/_internal/build/page-routes.ts +++ b/packages/ev/src/_internal/build/discovery/page-routes.ts @@ -5,8 +5,13 @@ import type { PageAnchorMetadata, PageRouteDiscoveryMetadata, PageRoutingMode, -} from "../../config/index.js"; -import { collectModuleExportNames } from "./module-exports.js"; +} from "../../../config/index.js"; +import { collectModuleExportNames } from "../analysis/module-exports.js"; +import { + formatParseErrorMessage, + hasDefaultExport, + parseRouteModuleWithError, +} from "../analysis/route-module.js"; import { CANONICAL_PAGE_ROUTE_ROOT, findPageRouteSegmentConventionViolation, @@ -19,25 +24,20 @@ import { routeIdPathFromSegments, routePathFromSegments, routeShapeFromSegments, -} from "./page-route-conventions.js"; +} from "../conventions/page-route-conventions.js"; import { isRouteGroupSegment, isRouteSourceModuleFile, normalizeRouteConventionPath, -} from "./route-conventions.js"; -import { sortRoutesBySpecificity } from "./route-order.js"; -import { - formatParseErrorMessage, - hasDefaultExport, - parseRouteModuleWithError, -} from "./routes/shared.js"; +} from "../conventions/route-conventions.js"; +import { sortRoutesBySpecificity } from "../conventions/route-order.js"; import { deriveRouteIdFromPath, isInsideCwd, isRealPathInsideCwd, toPosixPath, toProjectPath, -} from "./utils.js"; +} from "../utils.js"; export interface DiscoverPageRoutesOptions { mode: PageRoutingMode; diff --git a/packages/ev/src/_internal/build/server-conventions.ts b/packages/ev/src/_internal/build/discovery/server-conventions.ts similarity index 98% rename from packages/ev/src/_internal/build/server-conventions.ts rename to packages/ev/src/_internal/build/discovery/server-conventions.ts index a8b219dc..0d372cd9 100644 --- a/packages/ev/src/_internal/build/server-conventions.ts +++ b/packages/ev/src/_internal/build/discovery/server-conventions.ts @@ -2,19 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { ServerMiddlewareNode } from "@evjs/shared/manifest"; import type { Expression, ModuleItem } from "@swc/types"; -import { collectModuleExportNames } from "./module-exports.js"; +import { collectModuleExportNames } from "../analysis/module-exports.js"; +import { + formatParseErrorMessage, + parseRouteModuleWithError, +} from "../analysis/route-module.js"; import { isRouteSourceModuleFile, normalizeRouteConventionPath, type RouteSegmentConventionViolation, -} from "./route-conventions.js"; -import { - formatParseErrorMessage, - parseRouteModuleWithError, -} from "./routes/shared.js"; -import { findServerRouteSegmentConventionViolation } from "./server-route-conventions.js"; +} from "../conventions/route-conventions.js"; +import { findServerRouteSegmentConventionViolation } from "../conventions/server-route-conventions.js"; +import { isInsideCwd, isRealPathInsideCwd, toPosixPath } from "../utils.js"; import type { DiscoveredServerRouteNode } from "./server-routes.js"; -import { isInsideCwd, isRealPathInsideCwd, toPosixPath } from "./utils.js"; /** Fixed global middleware anchor for framework server conventions. */ export const CANONICAL_SERVER_MIDDLEWARE_FILE = "./src/middleware.ts"; diff --git a/packages/ev/src/_internal/build/server-routes.ts b/packages/ev/src/_internal/build/discovery/server-routes.ts similarity index 97% rename from packages/ev/src/_internal/build/server-routes.ts rename to packages/ev/src/_internal/build/discovery/server-routes.ts index 899b04fb..2113d691 100644 --- a/packages/ev/src/_internal/build/server-routes.ts +++ b/packages/ev/src/_internal/build/discovery/server-routes.ts @@ -5,25 +5,25 @@ import type { ServerMiddlewareNode, ServerRouteNode, } from "@evjs/shared/manifest"; -import { collectModuleExportNames } from "./module-exports.js"; -import { - isRouteSourceModuleFile, - type RouteSegmentConventionViolation, -} from "./route-conventions.js"; -import { sortRoutesBySpecificity } from "./route-order.js"; +import { collectModuleExportNames } from "../analysis/module-exports.js"; import { formatParseErrorMessage, hasDefaultExport, parseRouteModuleWithError, -} from "./routes/shared.js"; +} from "../analysis/route-module.js"; +import { validateServerRouteMethodExports } from "../analysis/server-route-exports.js"; +import { + isRouteSourceModuleFile, + type RouteSegmentConventionViolation, +} from "../conventions/route-conventions.js"; +import { sortRoutesBySpecificity } from "../conventions/route-order.js"; import { findServerRouteSegmentConventionViolation, parseServerRouteAnchorFile, SERVER_ROUTE_ENTRY_LABEL, serverRoutePathFromSegments, -} from "./server-route-conventions.js"; -import { validateServerRouteMethodExports } from "./server-route-exports.js"; -import { isInsideCwd, isRealPathInsideCwd, toPosixPath } from "./utils.js"; +} from "../conventions/server-route-conventions.js"; +import { isInsideCwd, isRealPathInsideCwd, toPosixPath } from "../utils.js"; export interface DiscoverServerRoutesOptions { dir: string; diff --git a/packages/ev/src/_internal/build/generated-contributions.ts b/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts similarity index 99% rename from packages/ev/src/_internal/build/generated-contributions.ts rename to packages/ev/src/_internal/build/generated-ir/generated-contributions.ts index 98916205..edb7afa7 100644 --- a/packages/ev/src/_internal/build/generated-contributions.ts +++ b/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts @@ -28,7 +28,7 @@ import type { ServerMiddlewareNode, } from "@evjs/shared/manifest"; import { assertPluginId } from "@evjs/shared/manifest"; -import type { ResolvedFrameworkConfig } from "../../config/index.js"; +import type { ResolvedFrameworkConfig } from "../../../config/index.js"; import type { EmitApi, FrameworkApplicationEntryView, @@ -44,25 +44,25 @@ import type { HtmlDocumentInfo, Plugin, PluginSetupContext, -} from "../../plugin/index.js"; +} from "../../../plugin/index.js"; import { type InternalPluginEmitIRContext, pluginEmitIRScopeFactory, type ScopedPluginEmitIRContext, -} from "../../plugin/internal.js"; +} from "../../../plugin/internal.js"; +import { + reserveUniquePortableArtifactPath, + sanitizePortableArtifactPathSegment, +} from "../output/portable-artifact-path.js"; +import { createPluginConfigView } from "../plugins/lifecycle.js"; +import { toPosixPath } from "../utils.js"; import { createOriginalClientEntryFacadeSource, createPagesAppEntryMainSource, createReactComponentPageEntryMainSource, -} from "./generated/client-entry-source.js"; -import { applyPageWrapperContributions } from "./generated/page-wrapper-contribution.js"; -import { createReactServerPageEntrySource } from "./generated/react-server-page-source.js"; -import { createPluginConfigView } from "./plugin-lifecycle.js"; -import { - reserveUniquePortableArtifactPath, - sanitizePortableArtifactPathSegment, -} from "./portable-artifact-path.js"; -import { toPosixPath } from "./utils.js"; +} from "./sources/client-entry-source.js"; +import { applyPageWrapperContributions } from "./sources/page-wrapper-contribution.js"; +import { createReactServerPageEntrySource } from "./sources/react-server-page-source.js"; export const GENERATED_IR_DIR = ".ev"; export const GENERATED_IR_MANIFEST = "manifest.json"; diff --git a/packages/ev/src/_internal/build/generated/client-entry-source.ts b/packages/ev/src/_internal/build/generated-ir/sources/client-entry-source.ts similarity index 100% rename from packages/ev/src/_internal/build/generated/client-entry-source.ts rename to packages/ev/src/_internal/build/generated-ir/sources/client-entry-source.ts diff --git a/packages/ev/src/_internal/build/generated/page-wrapper-contribution.ts b/packages/ev/src/_internal/build/generated-ir/sources/page-wrapper-contribution.ts similarity index 100% rename from packages/ev/src/_internal/build/generated/page-wrapper-contribution.ts rename to packages/ev/src/_internal/build/generated-ir/sources/page-wrapper-contribution.ts diff --git a/packages/ev/src/_internal/build/generated/react-server-page-source.ts b/packages/ev/src/_internal/build/generated-ir/sources/react-server-page-source.ts similarity index 100% rename from packages/ev/src/_internal/build/generated/react-server-page-source.ts rename to packages/ev/src/_internal/build/generated-ir/sources/react-server-page-source.ts diff --git a/packages/ev/src/_internal/build/graph/config-route.ts b/packages/ev/src/_internal/build/graph/config-route.ts index 7911a933..efd8fdac 100644 --- a/packages/ev/src/_internal/build/graph/config-route.ts +++ b/packages/ev/src/_internal/build/graph/config-route.ts @@ -25,20 +25,19 @@ import { isCoreRoutePatternPrefix, } from "@evjs/shared/manifest"; import type { ResolvedConfigRoute } from "../../../config/index.js"; -import { DEFAULT_PAGE_RENDER_MODE } from "../page-rendering-contract.js"; +import { + formatParseErrorMessage, + hasDefaultExport, + parseRouteModuleWithError, +} from "../analysis/route-module.js"; +import { DEFAULT_PAGE_RENDER_MODE } from "../conventions/page-rendering-contract.js"; import { PAGE_CONFIG_FILES, PAGE_CONFIG_LABEL, PAGE_ENTRY_BASENAME, PAGE_ENTRY_LABEL, -} from "../page-route-conventions.js"; -import { - formatParseErrorMessage, - hasDefaultExport, - parseRouteModuleWithError, -} from "../routes/shared.js"; +} from "../conventions/page-route-conventions.js"; import { isInsideCwd, isRealPathInsideCwd, toPosixPath } from "../utils.js"; -import type { GraphConfig } from "./index.js"; import { isMissingSourcePathError, isProjectSourceModule, @@ -46,6 +45,7 @@ import { registerProjectSourceDependencies, registerProjectSourceResolutionCandidates, } from "./source-resolution.js"; +import type { GraphConfig } from "./types.js"; const CONFIG_ROUTE_PRODUCER = { kind: "provider", diff --git a/packages/ev/src/_internal/build/graph/core.ts b/packages/ev/src/_internal/build/graph/core.ts index e44302be..18e08918 100644 --- a/packages/ev/src/_internal/build/graph/core.ts +++ b/packages/ev/src/_internal/build/graph/core.ts @@ -18,11 +18,11 @@ import { clonePageMetadata, PAGE_ANCHOR_PROVIDER_ID, } from "@evjs/shared/manifest"; -import type { ResolvedPageFileConfig } from "../page-config-module.js"; -import { createStaticPageDocumentOutput } from "../page-document-output.js"; -import { resolvePageRenderMode } from "../page-rendering-contract.js"; -import { CANONICAL_PAGE_ROUTE_ROOT } from "../page-route-conventions.js"; -import type { GraphConfig } from "./index.js"; +import type { ResolvedPageFileConfig } from "../config-loading/page-config-module.js"; +import { createStaticPageDocumentOutput } from "../conventions/page-document-output.js"; +import { resolvePageRenderMode } from "../conventions/page-rendering-contract.js"; +import { CANONICAL_PAGE_ROUTE_ROOT } from "../conventions/page-route-conventions.js"; +import type { GraphConfig } from "./types.js"; const PAGE_ANCHOR_PRODUCER = { kind: "provider", diff --git a/packages/ev/src/_internal/build/graph/create-core-graph.ts b/packages/ev/src/_internal/build/graph/create-core-graph.ts new file mode 100644 index 00000000..43a2654a --- /dev/null +++ b/packages/ev/src/_internal/build/graph/create-core-graph.ts @@ -0,0 +1,1275 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + getPageRouteParamSegmentValidationError, + getPathPatternValidationError, + type PageRouteParamSegmentValidationError, + type PathPatternValidationError, + serverRoutePathShapeFromPath, +} from "@evjs/shared"; +import type { + ClientReferenceNode, + CoreGraph, + PageRouteNode, + PprConfig, + PrerenderConfig, + ServerFunctionNode, + ServerReferenceNode, + ServerRouteNode, +} from "@evjs/shared/manifest"; +import { assertCoreGraph } from "@evjs/shared/manifest"; +import { analyzePageModuleExports } from "../analysis/page-module-exports.js"; +import { + extractPprRegionModuleConfig, + extractPprRegions, +} from "../analysis/ppr-regions.js"; +import { + extractRscReferences, + hasBlockingReferenceParseDiagnostic, +} from "../analysis/rsc-refs.js"; +import { + analyzeServerFunctionExports, + type ServerFunctionExportAnalysis, +} from "../analysis/server-fns.js"; +import { extractRuntimeModuleSpecifiers } from "../analysis/static-imports.js"; +import { + resolveCorePageConfigModules, + resolvePageConfigModules, +} from "../config-loading/page-config-module.js"; +import { getPageBuildContractViolation } from "../conventions/page-rendering-contract.js"; +import { + CANONICAL_PAGE_ROUTE_ROOT, + routePathShapeFromPath, +} from "../conventions/page-route-conventions.js"; +import { sortRoutesBySpecificity } from "../conventions/route-order.js"; +import { CANONICAL_SERVER_ROUTE_ROOT } from "../conventions/server-route-conventions.js"; +import { + applyPluginSettings, + collectPluginSettingsRegistry, +} from "../plugins/settings.js"; +import { + detectUseServer, + hashServerFunction, + isInsideCwd, + isRealPathInsideCwd, + toPosixPath, +} from "../utils.js"; +import { + collectConfigRouteCoreSourceModules, + createConfigRouteGraph, +} from "./config-route.js"; +import { applyResolvedPageConfigs, createPageAnchorGraph } from "./core.js"; +import { + isMissingSourcePathError, + isProjectSourceModule, + registerProjectSourceResolutionCandidates, +} from "./source-resolution.js"; +import type { + CreateCoreGraphOptions, + Diagnostic, + GraphAnalysisResult, + GraphConfig, +} from "./types.js"; + +interface FrameworkAnalysisFacts { + rootDir: string; + serverFunctions: ServerFunctionNode[]; + serverRoutes: ServerRouteNode[]; + clientReferences?: ClientReferenceNode[]; + serverReferences?: ServerReferenceNode[]; +} + +const DEFAULT_SOURCE_ALIAS = "@/"; + +interface FrameworkSourceFiles { + analysisFiles: string[]; + explicitDependencyFiles: Set; + diagnostics: Diagnostic[]; +} + +type PprRegionConfigMap = NonNullable; + +export async function createCoreGraph( + config: GraphConfig, + cwd: string, + options: CreateCoreGraphOptions = {}, +): Promise { + const configRouteGraph = config.application + ? await createConfigRouteGraph( + config, + cwd, + options.beforeSourceRead, + options.onSourceDependency, + ) + : undefined; + const pageConfigs = + options.pageConfigs ?? + (hasPageAnchorDiscovery(config) + ? await resolvePageConfigModules(cwd, config.routing.metadata, { + beforeSourceRead: options.beforeSourceRead, + onSourceDependency: options.onSourceDependency, + }) + : configRouteGraph + ? await resolveCorePageConfigModules(cwd, configRouteGraph, { + beforeSourceRead: options.beforeSourceRead, + onSourceDependency: options.onSourceDependency, + }) + : { pages: {}, dependencies: [] }); + const diagnostics: Diagnostic[] = []; + const configuredPageRoutes = validateConfiguredPageRoutes( + config, + diagnostics, + ); + + const facts: FrameworkAnalysisFacts = { + rootDir: cwd, + serverFunctions: [], + serverRoutes: [], + }; + + const sourceCache = new Map(); + const sourceFiles = await collectFrameworkSourceFiles( + config, + cwd, + sourceCache, + options.resolve?.alias, + configRouteGraph + ? collectConfigRouteCoreSourceModules(configRouteGraph) + : [], + options.beforeSourceRead, + options.onSourceDependency, + ); + diagnostics.push(...sourceFiles.diagnostics); + // Watch explicit graph roots and files that already declare framework + // semantics. Ordinary component edits should stay on the bundler HMR path. + // If a plain component starts declaring routes/server functions later, a + // configured route/server root or config change should introduce it into the + // watched framework graph set. + const fileDependencies = new Set(sourceFiles.explicitDependencyFiles); + if (config.routing) { + const pageRoot = path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); + for (const dir of await collectRouteDirectories(cwd, pageRoot)) { + fileDependencies.add(dir); + } + for (const dependency of config.routing.dependencies ?? []) { + fileDependencies.add(path.resolve(cwd, dependency)); + } + } + for (const dependency of pageConfigs.dependencies) { + fileDependencies.add(dependency); + } + if (config.server.routes) { + const routingDir = path.resolve(cwd, CANONICAL_SERVER_ROUTE_ROOT); + for (const dir of await collectRouteDirectories(cwd, routingDir)) { + fileDependencies.add(dir); + } + } + for (const middleware of [ + ...(config.server.conventions?.globalMiddlewares ?? []), + ...(config.server.conventions?.routeMiddlewares ?? []), + ]) { + fileDependencies.add(path.resolve(cwd, middleware.module)); + } + const serverRoutes = new Map(); + const serverRoutePathOwners = new Map(); + const serverRouteShapeOwners = new Map(); + const serverFileRouteModules = new Set( + (config.server.routes ?? []).map((route) => + path.resolve(cwd, route.module), + ), + ); + const serverConventionModules = new Set( + [ + ...(config.server.conventions?.globalMiddlewares ?? []), + ...(config.server.conventions?.routeMiddlewares ?? []), + ].map((middleware) => path.resolve(cwd, middleware.module)), + ); + const serverFunctions: ServerFunctionNode[] = []; + const clientReferences = new Map(); + const serverReferences = new Map(); + const configuredServerRoutePublication = validateServerRouteNodePublication( + config.server.routes ?? [], + serverRoutePathOwners, + serverRouteShapeOwners, + ); + diagnostics.push(...configuredServerRoutePublication.diagnostics); + for (const node of configuredServerRoutePublication.nodes) { + serverRoutePathOwners.set(node.path, node); + serverRouteShapeOwners.set(serverRoutePathShapeFromPath(node.path), node); + serverRoutes.set(node.id, node); + } + + for (const file of sourceFiles.analysisFiles) { + const source = await readFrameworkSource( + file, + sourceCache, + options.beforeSourceRead, + ); + if (source === undefined) continue; + if ( + sourceFiles.explicitDependencyFiles.has(file) || + isFrameworkDependencySource(source) + ) { + fileDependencies.add(file); + } + const sourceRel = toPosixPath(path.relative(cwd, file)); + const usesServerDirective = detectUseServer(source); + const rscReferenceAnalysis = extractRscReferences(source, sourceRel); + const hasRscReferenceDiagnostics = + rscReferenceAnalysis.diagnostics.length > 0; + diagnostics.push( + ...rscReferenceAnalysis.diagnostics.map((diagnostic) => ({ + ...diagnostic, + file: sourceRel, + })), + ); + + if (serverFileRouteModules.has(file) || serverConventionModules.has(file)) { + continue; + } + + if (hasBlockingReferenceParseDiagnostic(rscReferenceAnalysis)) { + continue; + } + + let serverFunctionAnalysis: ServerFunctionExportAnalysis = { + exports: [], + diagnostics: [], + }; + if (!(usesServerDirective && hasRscReferenceDiagnostics)) { + serverFunctionAnalysis = analyzeServerFunctionExports(source); + } + const hasServerFunctionDiagnostics = + serverFunctionAnalysis.diagnostics.length > 0; + diagnostics.push( + ...serverFunctionAnalysis.diagnostics.map((diagnostic) => ({ + ...diagnostic, + file: sourceRel, + })), + ); + + if (hasRscReferenceDiagnostics || hasServerFunctionDiagnostics) { + continue; + } + + for (const reference of rscReferenceAnalysis.clientReferences) { + clientReferences.set(reference.id, reference); + } + for (const reference of rscReferenceAnalysis.serverReferences) { + serverReferences.set(reference.id, reference); + } + for (const { exportName } of serverFunctionAnalysis.exports) { + serverFunctions.push({ + id: hashServerFunction(sourceRel, exportName), + module: sourceRel, + exportName, + }); + } + } + + facts.serverRoutes = [...serverRoutes.values()]; + facts.serverFunctions = serverFunctions; + facts.clientReferences = [...clientReferences.values()]; + facts.serverReferences = [...serverReferences.values()]; + + let graph = configRouteGraph + ? applyResolvedPageConfigs( + withAnalysisFacts(configRouteGraph, facts), + pageConfigs.pages, + ) + : hasPageAnchorDiscovery(config) + ? createPageAnchorGraph( + config, + { + ...facts, + routes: configuredPageRoutes, + }, + pageConfigs.pages, + ) + : createEmptyCoreGraph(facts); + await mergePprRegionsIntoCoreGraph( + graph, + cwd, + sourceCache, + diagnostics, + fileDependencies, + options.resolve?.alias, + options.beforeSourceRead, + options.onSourceDependency, + ); + validateCoreGraphPageContracts(graph, diagnostics); + await diagnosePageModuleRouteLifecycleExports( + graph, + cwd, + sourceCache, + diagnostics, + options.beforeSourceRead, + options.onSourceDependency, + ); + const pluginSettings = + options.pluginSettings ?? collectPluginSettingsRegistry([]); + graph = applyPluginSettings(graph, pluginSettings, { + applicationSettings: options.applicationPluginSettings, + canonicalPages: pageConfigs.pages, + session: options.pluginSettingsSession, + }); + assertCoreGraph(graph, "resolved CoreGraph"); + + return { + graph, + diagnostics, + fileDependencies: [...fileDependencies].sort(), + }; +} + +function hasPageAnchorDiscovery( + config: Pick, +): config is Pick & { + routing: NonNullable; +} { + return Boolean(config.routing && !config.application); +} + +function withAnalysisFacts( + graph: CoreGraph, + facts: FrameworkAnalysisFacts, +): CoreGraph { + return { + ...graph, + serverFunctions: facts.serverFunctions, + serverRoutes: facts.serverRoutes, + ...(facts.clientReferences + ? { clientReferences: facts.clientReferences } + : {}), + ...(facts.serverReferences + ? { serverReferences: facts.serverReferences } + : {}), + }; +} + +function createEmptyCoreGraph(facts: FrameworkAnalysisFacts): CoreGraph { + const graph: CoreGraph = { + rootDir: facts.rootDir, + applications: {}, + pages: {}, + routes: [], + documents: {}, + plugins: { entries: {} }, + serverFunctions: facts.serverFunctions, + serverRoutes: facts.serverRoutes, + ...(facts.clientReferences + ? { clientReferences: facts.clientReferences } + : {}), + ...(facts.serverReferences + ? { serverReferences: facts.serverReferences } + : {}), + }; + assertCoreGraph(graph, "resolved CoreGraph"); + return graph; +} + +async function mergePprRegionsIntoCoreGraph( + graph: CoreGraph, + cwd: string, + sourceCache: Map, + diagnostics: Diagnostic[], + fileDependencies: Set, + aliases?: Record, + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise { + for (const page of Object.values(graph.pages)) { + const ppr = page.ppr ?? derivePprConfig(page.prerender); + if (!ppr) continue; + const root = await resolveProjectSourceAbsolute( + cwd, + page.source.module, + onSourceDependency, + ); + if (!root) continue; + const analysis = await collectPprRegionsFromPageClosure( + cwd, + root, + sourceCache, + fileDependencies, + aliases, + beforeSourceRead, + onSourceDependency, + ); + diagnostics.push(...analysis.diagnostics); + if (Object.keys(analysis.regions).length === 0) { + page.ppr = ppr; + continue; + } + const resolved = await resolvePprRegionComponents( + cwd, + analysis.regions, + sourceCache, + beforeSourceRead, + onSourceDependency, + ); + diagnostics.push(...resolved.diagnostics); + page.ppr = { + ...ppr, + regions: { + ...(ppr.regions ?? {}), + ...resolved.regions, + }, + }; + } +} + +function validateCoreGraphPageContracts( + graph: CoreGraph, + diagnostics: Diagnostic[], +): void { + for (const page of Object.values(graph.pages)) { + const file = page.source.module; + if (hasErrorDiagnosticForFile(diagnostics, file)) continue; + const renderingError = getPageBuildContractViolation(`Page "${page.id}"`, { + ...page, + component: page.source.module, + }); + if (renderingError) { + diagnostics.push({ + level: "error", + file, + message: renderingError, + }); + } + } +} + +function validateServerRouteNodePublication( + routes: ServerRouteNode[], + serverRoutePathOwners: Map, + serverRouteShapeOwners: Map, +): { nodes: ServerRouteNode[]; diagnostics: Diagnostic[] } { + const nodes: ServerRouteNode[] = []; + const diagnostics: Diagnostic[] = []; + const pendingPathOwners = new Map(serverRoutePathOwners); + const pendingShapeOwners = new Map(serverRouteShapeOwners); + + for (const route of routes) { + const existing = pendingPathOwners.get(route.path); + if (existing) { + diagnostics.push({ + level: "error", + file: route.module, + message: + `Server route path "${route.path}" is already declared by ${existing.module}. ` + + "Declare all HTTP methods for a path in one server file route module.", + }); + continue; + } + const routeShape = serverRoutePathShapeFromPath(route.path); + const existingShapeOwner = pendingShapeOwners.get(routeShape); + if (existingShapeOwner) { + diagnostics.push({ + level: "error", + file: route.module, + message: + `Server route path "${route.path}" has the same route shape as ${existingShapeOwner.module} (${existingShapeOwner.path}). ` + + "Use one route handler per URL shape.", + }); + continue; + } + pendingPathOwners.set(route.path, route); + pendingShapeOwners.set(routeShape, route); + nodes.push({ + id: route.id, + module: route.module, + path: route.path, + methods: route.methods, + }); + } + + return { nodes, diagnostics }; +} + +async function diagnosePageModuleRouteLifecycleExports( + graph: CoreGraph, + cwd: string, + sourceCache: Map, + diagnostics: Diagnostic[], + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise { + for (const page of Object.values(graph.pages)) { + const absolute = await resolveProjectSourceAbsolute( + cwd, + page.source.module, + onSourceDependency, + ); + if (!absolute) continue; + + const source = await readFrameworkSource( + absolute, + sourceCache, + beforeSourceRead, + ); + if (source === undefined) continue; + + const exports = analyzePageModuleExports(source); + if (exports.renderingConfig.length > 0) { + diagnostics.push({ + level: "error", + file: toPosixPath(path.relative(cwd, absolute)), + message: `Page "${page.id}" exports rendering configuration ${exports.renderingConfig.map((name) => `"${name}"`).join(", ")} from its component module. Define these fields in the adjacent page.config.ts module; Page component exports are runtime values, not build configuration.`, + }); + } + if (page.render !== "csr" && exports.routeLifecycle.length > 0) { + diagnostics.push({ + level: "error", + file: toPosixPath(path.relative(cwd, absolute)), + message: `Page "${page.id}" uses render "${page.render}" and exports browser-only route lifecycle ${exports.routeLifecycle.map((name) => `"${name}"`).join(", ")}. Non-CSR Pages cannot use browser-only route lifecycle exports. Remove these exports or use render: "csr".`, + }); + } + } +} + +function derivePprConfig( + prerender: PrerenderConfig | undefined, +): PprConfig | undefined { + if (!prerender || prerender === true || !prerender.partial) { + return undefined; + } + return { + delivery: prerender.delivery ?? "merge", + ...(prerender.revalidate !== undefined + ? { revalidate: prerender.revalidate } + : {}), + }; +} + +async function collectPprRegionsFromPageClosure( + cwd: string, + root: string, + sourceCache: Map, + fileDependencies: Set, + aliases?: Record, + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise<{ + regions: PprRegionConfigMap; + diagnostics: Diagnostic[]; +}> { + const visited = new Set(); + const regions: PprRegionConfigMap = {}; + const diagnostics: Diagnostic[] = []; + + async function visit(file: string) { + if (visited.has(file)) return; + visited.add(file); + fileDependencies.add(file); + + const source = await readFrameworkSource( + file, + sourceCache, + beforeSourceRead, + ); + if (source === undefined) return; + + const sourceRel = toPosixPath(path.relative(cwd, file)); + const analysis = extractPprRegions(source, sourceRel); + for (const diagnostic of analysis.diagnostics) { + diagnostics.push({ + ...diagnostic, + file: sourceRel, + }); + } + + for (const [id, region] of Object.entries(analysis.regions)) { + if (regions[id]) { + diagnostics.push({ + level: "error", + file: sourceRel, + message: `Duplicate internal PPR region id "${id}" in the same PPR page component tree.`, + }); + continue; + } + regions[id] = region; + } + + for (const specifier of extractStaticImportSpecifiers(source, aliases)) { + const dependency = await resolveSourceImport( + cwd, + file, + specifier, + aliases, + onSourceDependency, + ); + if (dependency) { + await visit(dependency); + } + } + } + + await visit(root); + + return { + regions, + diagnostics, + }; +} + +async function resolvePprRegionComponents( + cwd: string, + regions: PprRegionConfigMap, + sourceCache: Map, + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise<{ + regions: PprRegionConfigMap; + diagnostics: Diagnostic[]; +}> { + const resolved: PprRegionConfigMap = {}; + const diagnostics: Diagnostic[] = []; + + for (const [id, region] of Object.entries(regions)) { + const component = await resolveProjectSourcePath( + cwd, + region.component, + onSourceDependency, + ); + const moduleConfig = await readPprRegionModuleConfig( + cwd, + component, + sourceCache, + beforeSourceRead, + onSourceDependency, + ); + diagnostics.push(...moduleConfig.diagnostics); + resolved[id] = { + ...moduleConfig.config, + ...region, + component, + }; + } + + return { regions: resolved, diagnostics }; +} + +async function readPprRegionModuleConfig( + cwd: string, + component: string, + sourceCache: Map, + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise<{ + config: Partial>; + diagnostics: Diagnostic[]; +}> { + const empty = { config: {}, diagnostics: [] }; + if (!component.startsWith(".")) return empty; + const absolute = await resolveProjectSourceAbsolute( + cwd, + component, + onSourceDependency, + ); + if (!absolute) return empty; + + const source = await readFrameworkSource( + absolute, + sourceCache, + beforeSourceRead, + ); + if (source === undefined) return empty; + + const analysis = extractPprRegionModuleConfig(source); + const file = toPosixPath(path.relative(cwd, absolute)); + return { + config: analysis.config, + diagnostics: analysis.diagnostics.map((diagnostic) => ({ + ...diagnostic, + file, + })), + }; +} + +async function resolveProjectSourceAbsolute( + cwd: string, + sourcePath: string, + onSourceDependency?: (file: string) => void, +): Promise { + if (!sourcePath.startsWith(".")) return undefined; + return resolveSourcePath( + cwd, + path.resolve(cwd, sourcePath), + onSourceDependency, + ); +} + +async function resolveProjectSourcePath( + cwd: string, + sourcePath: string, + onSourceDependency?: (file: string) => void, +): Promise { + if (!sourcePath.startsWith(".")) return sourcePath; + const resolved = await resolveSourcePath( + cwd, + path.resolve(cwd, sourcePath), + onSourceDependency, + ); + return resolved + ? `./${toPosixPath(path.relative(cwd, resolved))}` + : sourcePath; +} + +function normalizePublicRoutePath(routePath: string): string { + return routePath.startsWith("/") ? routePath : `/${routePath}`; +} + +function hasErrorDiagnosticForFile( + diagnostics: Diagnostic[], + file: string, +): boolean { + return diagnostics.some( + (diagnostic) => diagnostic.level === "error" && diagnostic.file === file, + ); +} + +function validateConfiguredPageRoutes( + config: Pick, + diagnostics: Diagnostic[], +): PageRouteNode[] { + if (!config.routing) return []; + + const routeByPath = new Map(); + const routeByShape = new Map(); + const routeById = new Map(); + const validRoutes: PageRouteNode[] = []; + + for (const route of config.routing.routes) { + if ( + route.kind !== undefined && + route.kind !== "page" && + route.kind !== "layout" + ) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: `Configured page route "${route.id}" kind must be "page" or "layout".`, + }); + continue; + } + const routePathError = getConfiguredPageRoutePathValidationError( + route.path, + ); + if (routePathError) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: `Configured page route path ${formatConfiguredPageRoutePathValue(route.path)} ${formatConfiguredPageRoutePathValidationError(routePathError)}`, + }); + continue; + } + + const normalizedPath = normalizePublicRoutePath(route.path); + const paramError = + getConfiguredPageRouteParamValidationError(normalizedPath); + if (paramError) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: `Configured page route path "${normalizedPath}" ${formatConfiguredPageRouteParamValidationError(paramError)}`, + }); + continue; + } + const normalizedRoute: PageRouteNode = { + ...route, + path: normalizedPath, + }; + const isLayoutRoute = normalizedRoute.kind === "layout"; + + if (!isLayoutRoute) { + const previousPathOwner = routeByPath.get(normalizedPath); + if (previousPathOwner) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: + `Configured page route path "${normalizedPath}" is already declared by ${previousPathOwner.module}. ` + + "Keep one page route per URL path.", + }); + continue; + } + routeByPath.set(normalizedPath, normalizedRoute); + + const routeShape = routePathShapeFromPath(normalizedPath).key; + const previousShapeOwner = routeByShape.get(routeShape); + if (previousShapeOwner) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: + `Configured page route path "${normalizedPath}" has the same route shape as ` + + `${previousShapeOwner.module} (${normalizePublicRoutePath(previousShapeOwner.path)}). ` + + "Use one dynamic param name for each URL shape.", + }); + continue; + } + routeByShape.set(routeShape, normalizedRoute); + } + + const previousIdOwner = routeById.get(route.id); + if (previousIdOwner) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: + `Configured page route id "${route.id}" for path "${normalizedPath}" is already used by ` + + `${previousIdOwner.module} (${normalizePublicRoutePath(previousIdOwner.path)}). ` + + "Route ids must be unique because they drive page ids and build entries.", + }); + continue; + } + routeById.set(route.id, normalizedRoute); + validRoutes.push(normalizedRoute); + } + + return sortRoutesBySpecificity( + validRoutes.filter((route) => { + if (!route.parentId) return true; + const parent = routeById.get(route.parentId); + if (!parent) { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: `Configured page route "${route.id}" parentId "${route.parentId}" does not match another route id.`, + }); + return false; + } + if (parent.kind !== "layout") { + diagnostics.push({ + level: "error", + file: toDiagnosticModulePath(route.module), + message: `Configured page route "${route.id}" parentId "${route.parentId}" must reference a layout route.`, + }); + return false; + } + return true; + }), + ); +} + +function getConfiguredPageRoutePathValidationError( + routePath: string, +): PathPatternValidationError | undefined { + const initialError = getPathPatternValidationError(routePath); + if (!initialError) return undefined; + if (initialError !== "missing-leading-slash") return initialError; + + return getPathPatternValidationError(normalizePublicRoutePath(routePath)); +} + +function getConfiguredPageRouteParamValidationError( + routePath: string, +): PageRouteParamSegmentValidationError | undefined { + return getPageRouteParamSegmentValidationError( + normalizePublicRoutePath(routePath), + ); +} + +function formatConfiguredPageRoutePathValue(value: unknown): string { + return typeof value === "string" ? `"${value}"` : String(value); +} + +function formatConfiguredPageRoutePathValidationError( + error: PathPatternValidationError, +): string { + switch (error) { + case "empty": + return "must be a non-empty string."; + case "missing-leading-slash": + return 'must start with "/".'; + case "whitespace": + return "must not contain whitespace."; + case "query-or-hash": + return "must not include a query string or hash."; + } +} + +function formatConfiguredPageRouteParamValidationError( + error: PageRouteParamSegmentValidationError, +): string { + switch (error.error) { + case "empty": + return `contains dynamic segment "${error.segment}" without a param name.`; + case "reserved": + return `uses reserved dynamic param name "${error.name}" in segment "${error.segment}". Use a safe application-specific name.`; + case "duplicate": + return `uses duplicate dynamic param name "${error.name}" in segment "${error.segment}". Use unique param names within one route path.`; + case "duplicate-wildcard": + return `contains more than one wildcard segment "${error.segment}". Use at most one wildcard segment in a route path.`; + case "star-wildcard": + return 'uses "*" as a wildcard segment. Use "$" for page route splats.'; + } +} + +function toDiagnosticModulePath(module: string): string { + return module.replace(/^\.\//, ""); +} + +async function collectRouteDirectories( + cwd: string, + root: string, +): Promise { + const dirs = new Set([root]); + + try { + if (!(await isRealPathInsideCwd(cwd, root))) return [...dirs]; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return [...dirs]; + throw error; + } + + async function visit(current: string) { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return; + throw err; + } + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + const absolute = path.join(current, entry.name); + dirs.add(absolute); + await visit(absolute); + } + } + + await visit(root); + return [...dirs].sort(); +} + +async function collectFrameworkSourceFiles( + config: GraphConfig, + cwd: string, + sourceCache: Map, + aliases?: Record, + providerSourceModules: string[] = [], + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +): Promise { + const files = new Set(); + const roots = new Set(); + const explicitDependencyRoots = new Set(); + const diagnostics: Diagnostic[] = []; + + for (const module of providerSourceModules) { + await addConfiguredSource( + roots, + cwd, + module, + `Application-route module "${module}"`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + } + + for (const route of config.routing?.routes ?? []) { + await addConfiguredSource( + roots, + cwd, + route.module, + `Page route "${route.id}" module`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + await addConfiguredSource( + roots, + cwd, + route.errorModule, + `Page route "${route.id}" error boundary module`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + await addConfiguredSource( + roots, + cwd, + route.notFoundModule, + `Page route "${route.id}" not-found boundary module`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + } + for (const route of config.server.routes ?? []) { + await addConfiguredSource( + roots, + cwd, + route.module, + `Server route "${route.path}" module`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + } + for (const middleware of [ + ...(config.server.conventions?.globalMiddlewares ?? []), + ...(config.server.conventions?.routeMiddlewares ?? []), + ]) { + await addConfiguredSource( + roots, + cwd, + middleware.module, + `Server middleware "${middleware.module}" module`, + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + } + await addConfiguredSource( + roots, + cwd, + config.routing?.rootModule, + "SPA root layout module", + diagnostics, + explicitDependencyRoots, + onSourceDependency, + ); + for (const root of roots) { + await collectStaticImportClosure( + files, + cwd, + root, + sourceCache, + aliases, + beforeSourceRead, + onSourceDependency, + ); + } + + return { + analysisFiles: [...files].sort(), + explicitDependencyFiles: explicitDependencyRoots, + diagnostics, + }; +} + +async function addConfiguredSource( + files: Set, + cwd: string, + filePath: string | undefined, + label: string, + diagnostics: Diagnostic[], + explicitDependencyFiles?: Set, + onSourceDependency?: (file: string) => void, +): Promise { + if (!filePath) return; + const absolute = path.resolve(cwd, filePath); + const file = getConfiguredSourceDiagnosticFile(cwd, filePath, absolute); + if (isInsideCwd(cwd, absolute)) onSourceDependency?.(absolute); + + let stat: Awaited>; + try { + stat = await fs.stat(absolute); + } catch (error) { + if (!isMissingSourcePathError(error)) throw error; + diagnostics.push({ + level: "error", + file, + message: `${label} source file not found.`, + }); + return undefined; + } + + if (!stat.isFile()) { + diagnostics.push({ + level: "error", + file, + message: `${label} source path must be a file.`, + }); + return undefined; + } + + if (!isProjectSourceModule(absolute)) { + diagnostics.push({ + level: "error", + file, + message: `${label} source file must use .ts, .tsx, .js, or .jsx.`, + }); + return undefined; + } + + files.add(absolute); + explicitDependencyFiles?.add(absolute); + return absolute; +} + +function getConfiguredSourceDiagnosticFile( + cwd: string, + filePath: string, + absolute: string, +): string { + if (filePath.startsWith(".")) { + return toPosixPath(path.relative(cwd, absolute)); + } + return toPosixPath(filePath); +} + +async function collectStaticImportClosure( + files: Set, + cwd: string, + file: string, + sourceCache: Map, + aliases?: Record, + beforeSourceRead?: (file: string) => void, + onSourceDependency?: (file: string) => void, +) { + if (files.has(file)) return; + files.add(file); + + const source = await readFrameworkSource(file, sourceCache, beforeSourceRead); + if (source === undefined) return; + + for (const specifier of extractStaticImportSpecifiers(source, aliases)) { + const dependency = await resolveSourceImport( + cwd, + file, + specifier, + aliases, + onSourceDependency, + ); + if (dependency) { + await collectStaticImportClosure( + files, + cwd, + dependency, + sourceCache, + aliases, + beforeSourceRead, + onSourceDependency, + ); + } + } +} + +async function readFrameworkSource( + file: string, + sourceCache: Map, + beforeSourceRead?: (file: string) => void, +): Promise { + const cached = sourceCache.get(file); + if (cached !== undefined) return cached; + + // Keep observer failures fail-closed while preserving source-discovery + // tolerance for files that disappear during graph traversal. + beforeSourceRead?.(file); + try { + const source = await fs.readFile(file, "utf-8"); + sourceCache.set(file, source); + return source; + } catch (error) { + if (isMissingSourcePathError(error)) return undefined; + throw error; + } +} + +function extractStaticImportSpecifiers( + source: string, + aliases?: Record, +): string[] { + return extractRuntimeModuleSpecifiers(source).filter((specifier) => + isLocalSourceImportSpecifier(specifier, aliases), + ); +} + +function isLocalSourceImportSpecifier( + specifier: string, + aliases?: Record, +): boolean { + return ( + specifier.startsWith(".") || + specifier === DEFAULT_SOURCE_ALIAS.slice(0, -1) || + specifier.startsWith(DEFAULT_SOURCE_ALIAS) || + findMatchingSourceAlias(specifier, aliases) !== undefined + ); +} + +function isFrameworkDependencySource(source: string): boolean { + return /^\s*["']use (client|server)["']/m.test(source.slice(0, 200)); +} + +async function resolveSourceImport( + cwd: string, + fromFile: string, + specifier: string, + aliases?: Record, + onSourceDependency?: (file: string) => void, +): Promise { + const alias = findMatchingSourceAlias(specifier, aliases); + if (alias) { + const suffix = specifier.slice(alias.specifier.length).replace(/^\//, ""); + return resolveSourcePath( + cwd, + path.resolve(cwd, alias.replacement, suffix), + onSourceDependency, + ); + } + if (specifier === DEFAULT_SOURCE_ALIAS.slice(0, -1)) { + return resolveSourcePath(cwd, path.resolve(cwd, "src"), onSourceDependency); + } + if (specifier.startsWith(DEFAULT_SOURCE_ALIAS)) { + return resolveSourcePath( + cwd, + path.resolve(cwd, "src", specifier.slice(DEFAULT_SOURCE_ALIAS.length)), + onSourceDependency, + ); + } + return resolveSourcePath( + cwd, + path.resolve(path.dirname(fromFile), specifier), + onSourceDependency, + ); +} + +function findMatchingSourceAlias( + specifier: string, + aliases?: Record, +): { specifier: string; replacement: string } | undefined { + if (!aliases) return undefined; + let match: { specifier: string; replacement: string } | undefined; + for (const [alias, replacement] of Object.entries(aliases)) { + if (specifier !== alias && !specifier.startsWith(`${alias}/`)) continue; + if (!match || alias.length > match.specifier.length) { + match = { specifier: alias, replacement }; + } + } + return match; +} + +async function resolveSourcePath( + cwd: string, + base: string, + onSourceDependency?: (file: string) => void, +): Promise { + const projectCandidates = registerProjectSourceResolutionCandidates( + cwd, + base, + onSourceDependency, + ); + + for (const candidate of projectCandidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile() && isProjectSourceModule(candidate)) { + return candidate; + } + } catch (error) { + if (!isMissingSourcePathError(error)) throw error; + // Non-source imports are handled by the bundler. Graph analysis only + // follows local framework-relevant TypeScript/JavaScript modules. + } + } + + return undefined; +} diff --git a/packages/ev/src/_internal/build/graph/index.ts b/packages/ev/src/_internal/build/graph/index.ts index b90baa1e..3ef513af 100644 --- a/packages/ev/src/_internal/build/graph/index.ts +++ b/packages/ev/src/_internal/build/graph/index.ts @@ -1,1330 +1,7 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { - getPageRouteParamSegmentValidationError, - getPathPatternValidationError, - type PageRouteParamSegmentValidationError, - type PathPatternValidationError, - serverRoutePathShapeFromPath, -} from "@evjs/shared"; -import type { - ClientReferenceNode, - CoreApplicationPluginSettings, - CoreGraph, - PageRouteNode, - PprConfig, - PrerenderConfig, - ServerFunctionNode, - ServerMiddlewareNode, - ServerReferenceNode, - ServerRouteNode, -} from "@evjs/shared/manifest"; -import { assertCoreGraph } from "@evjs/shared/manifest"; -import type { - PageRouteDiscoveryMetadata, - ResolvedConfigRouteApplication, -} from "../../../config/index.js"; -import { - type ResolvedPageFileConfigs, - resolveCorePageConfigModules, - resolvePageConfigModules, -} from "../page-config-module.js"; -import { analyzePageModuleExports } from "../page-module-exports.js"; -import { getPageBuildContractViolation } from "../page-rendering-contract.js"; -import { - CANONICAL_PAGE_ROUTE_ROOT, - routePathShapeFromPath, -} from "../page-route-conventions.js"; -import { - applyPluginSettings, - collectPluginSettingsRegistry, - type PluginSettingsRegistry, - type PluginSettingsResolutionSession, -} from "../plugin-settings.js"; -import { - extractPprRegionModuleConfig, - extractPprRegions, -} from "../ppr-regions.js"; -import { sortRoutesBySpecificity } from "../route-order.js"; -import { - extractRscReferences, - hasBlockingReferenceParseDiagnostic, -} from "../rsc-refs.js"; -import { - analyzeServerFunctionExports, - type ServerFunctionExportAnalysis, -} from "../server-fns.js"; -import { CANONICAL_SERVER_ROUTE_ROOT } from "../server-route-conventions.js"; -import type { DiscoveredServerRouteNode } from "../server-routes.js"; -import { extractRuntimeModuleSpecifiers } from "../static-imports.js"; -import { - detectUseServer, - hashServerFunction, - isInsideCwd, - isRealPathInsideCwd, - toPosixPath, -} from "../utils.js"; -import { - collectConfigRouteCoreSourceModules, - createConfigRouteGraph, -} from "./config-route.js"; -import { applyResolvedPageConfigs, createPageAnchorGraph } from "./core.js"; -import { - isMissingSourcePathError, - isProjectSourceModule, - registerProjectSourceResolutionCandidates, -} from "./source-resolution.js"; - -export interface GraphAnalysisResult { - graph: CoreGraph; - diagnostics: Diagnostic[]; - fileDependencies: string[]; -} - -export interface CreateCoreGraphOptions { - resolve?: { - alias?: Record; - }; - pluginSettings?: PluginSettingsRegistry; - /** Application plugin settings resolved before plugin setup. */ - applicationPluginSettings?: CoreApplicationPluginSettings; - /** Canonical Page configs pre-evaluated once for alias convergence. */ - pageConfigs?: ResolvedPageFileConfigs; - /** Page plugin setting snapshots reused during one alias-convergence analysis. */ - pluginSettingsSession?: PluginSettingsResolutionSession; - /** @internal Captures a baseline immediately before framework source reads. */ - beforeSourceRead?: (file: string) => void; - /** @internal Reports source dependencies discovered by delegated loaders. */ - onSourceDependency?: (file: string) => void; -} - -interface FrameworkAnalysisFacts { - rootDir: string; - serverFunctions: ServerFunctionNode[]; - serverRoutes: ServerRouteNode[]; - clientReferences?: ClientReferenceNode[]; - serverReferences?: ServerReferenceNode[]; -} - -export interface Diagnostic { - level: "warning" | "error"; - message: string; - file?: string; - line?: number; - column?: number; -} - -export interface GraphConfig { - application?: ResolvedConfigRouteApplication; - routing?: { - mode: "spa" | "mpa"; - html: string; - mount: string; - routes: PageRouteNode[]; - rootModule?: string; - metadata?: PageRouteDiscoveryMetadata; - dependencies?: string[]; - }; - server: { - routes?: DiscoveredServerRouteNode[]; - conventions?: { - globalMiddlewares: ServerMiddlewareNode[]; - routeMiddlewares: ServerMiddlewareNode[]; - }; - }; -} - -const DEFAULT_SOURCE_ALIAS = "@/"; - -interface FrameworkSourceFiles { - analysisFiles: string[]; - explicitDependencyFiles: Set; - diagnostics: Diagnostic[]; -} - -type PprRegionConfigMap = NonNullable; - -export async function createCoreGraph( - config: GraphConfig, - cwd: string, - options: CreateCoreGraphOptions = {}, -): Promise { - const configRouteGraph = config.application - ? await createConfigRouteGraph( - config, - cwd, - options.beforeSourceRead, - options.onSourceDependency, - ) - : undefined; - const pageConfigs = - options.pageConfigs ?? - (hasPageAnchorDiscovery(config) - ? await resolvePageConfigModules(cwd, config.routing.metadata, { - beforeSourceRead: options.beforeSourceRead, - onSourceDependency: options.onSourceDependency, - }) - : configRouteGraph - ? await resolveCorePageConfigModules(cwd, configRouteGraph, { - beforeSourceRead: options.beforeSourceRead, - onSourceDependency: options.onSourceDependency, - }) - : { pages: {}, dependencies: [] }); - const diagnostics: Diagnostic[] = []; - const configuredPageRoutes = validateConfiguredPageRoutes( - config, - diagnostics, - ); - - const facts: FrameworkAnalysisFacts = { - rootDir: cwd, - serverFunctions: [], - serverRoutes: [], - }; - - const sourceCache = new Map(); - const sourceFiles = await collectFrameworkSourceFiles( - config, - cwd, - sourceCache, - options.resolve?.alias, - configRouteGraph - ? collectConfigRouteCoreSourceModules(configRouteGraph) - : [], - options.beforeSourceRead, - options.onSourceDependency, - ); - diagnostics.push(...sourceFiles.diagnostics); - // Watch explicit graph roots and files that already declare framework - // semantics. Ordinary component edits should stay on the bundler HMR path. - // If a plain component starts declaring routes/server functions later, a - // configured route/server root or config change should introduce it into the - // watched framework graph set. - const fileDependencies = new Set(sourceFiles.explicitDependencyFiles); - if (config.routing) { - const pageRoot = path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); - for (const dir of await collectRouteDirectories(cwd, pageRoot)) { - fileDependencies.add(dir); - } - for (const dependency of config.routing.dependencies ?? []) { - fileDependencies.add(path.resolve(cwd, dependency)); - } - } - for (const dependency of pageConfigs.dependencies) { - fileDependencies.add(dependency); - } - if (config.server.routes) { - const routingDir = path.resolve(cwd, CANONICAL_SERVER_ROUTE_ROOT); - for (const dir of await collectRouteDirectories(cwd, routingDir)) { - fileDependencies.add(dir); - } - } - for (const middleware of [ - ...(config.server.conventions?.globalMiddlewares ?? []), - ...(config.server.conventions?.routeMiddlewares ?? []), - ]) { - fileDependencies.add(path.resolve(cwd, middleware.module)); - } - const serverRoutes = new Map(); - const serverRoutePathOwners = new Map(); - const serverRouteShapeOwners = new Map(); - const serverFileRouteModules = new Set( - (config.server.routes ?? []).map((route) => - path.resolve(cwd, route.module), - ), - ); - const serverConventionModules = new Set( - [ - ...(config.server.conventions?.globalMiddlewares ?? []), - ...(config.server.conventions?.routeMiddlewares ?? []), - ].map((middleware) => path.resolve(cwd, middleware.module)), - ); - const serverFunctions: ServerFunctionNode[] = []; - const clientReferences = new Map(); - const serverReferences = new Map(); - const configuredServerRoutePublication = validateServerRouteNodePublication( - config.server.routes ?? [], - serverRoutePathOwners, - serverRouteShapeOwners, - ); - diagnostics.push(...configuredServerRoutePublication.diagnostics); - for (const node of configuredServerRoutePublication.nodes) { - serverRoutePathOwners.set(node.path, node); - serverRouteShapeOwners.set(serverRoutePathShapeFromPath(node.path), node); - serverRoutes.set(node.id, node); - } - - for (const file of sourceFiles.analysisFiles) { - const source = await readFrameworkSource( - file, - sourceCache, - options.beforeSourceRead, - ); - if (source === undefined) continue; - if ( - sourceFiles.explicitDependencyFiles.has(file) || - isFrameworkDependencySource(source) - ) { - fileDependencies.add(file); - } - const sourceRel = toPosixPath(path.relative(cwd, file)); - const usesServerDirective = detectUseServer(source); - const rscReferenceAnalysis = extractRscReferences(source, sourceRel); - const hasRscReferenceDiagnostics = - rscReferenceAnalysis.diagnostics.length > 0; - diagnostics.push( - ...rscReferenceAnalysis.diagnostics.map((diagnostic) => ({ - ...diagnostic, - file: sourceRel, - })), - ); - - if (serverFileRouteModules.has(file) || serverConventionModules.has(file)) { - continue; - } - - if (hasBlockingReferenceParseDiagnostic(rscReferenceAnalysis)) { - continue; - } - - let serverFunctionAnalysis: ServerFunctionExportAnalysis = { - exports: [], - diagnostics: [], - }; - if (!(usesServerDirective && hasRscReferenceDiagnostics)) { - serverFunctionAnalysis = analyzeServerFunctionExports(source); - } - const hasServerFunctionDiagnostics = - serverFunctionAnalysis.diagnostics.length > 0; - diagnostics.push( - ...serverFunctionAnalysis.diagnostics.map((diagnostic) => ({ - ...diagnostic, - file: sourceRel, - })), - ); - - if (hasRscReferenceDiagnostics || hasServerFunctionDiagnostics) { - continue; - } - - for (const reference of rscReferenceAnalysis.clientReferences) { - clientReferences.set(reference.id, reference); - } - for (const reference of rscReferenceAnalysis.serverReferences) { - serverReferences.set(reference.id, reference); - } - for (const { exportName } of serverFunctionAnalysis.exports) { - serverFunctions.push({ - id: hashServerFunction(sourceRel, exportName), - module: sourceRel, - exportName, - }); - } - } - - facts.serverRoutes = [...serverRoutes.values()]; - facts.serverFunctions = serverFunctions; - facts.clientReferences = [...clientReferences.values()]; - facts.serverReferences = [...serverReferences.values()]; - - let graph = configRouteGraph - ? applyResolvedPageConfigs( - withAnalysisFacts(configRouteGraph, facts), - pageConfigs.pages, - ) - : hasPageAnchorDiscovery(config) - ? createPageAnchorGraph( - config, - { - ...facts, - routes: configuredPageRoutes, - }, - pageConfigs.pages, - ) - : createEmptyCoreGraph(facts); - await mergePprRegionsIntoCoreGraph( - graph, - cwd, - sourceCache, - diagnostics, - fileDependencies, - options.resolve?.alias, - options.beforeSourceRead, - options.onSourceDependency, - ); - validateCoreGraphPageContracts(graph, diagnostics); - await diagnosePageModuleRouteLifecycleExports( - graph, - cwd, - sourceCache, - diagnostics, - options.beforeSourceRead, - options.onSourceDependency, - ); - const pluginSettings = - options.pluginSettings ?? collectPluginSettingsRegistry([]); - graph = applyPluginSettings(graph, pluginSettings, { - applicationSettings: options.applicationPluginSettings, - canonicalPages: pageConfigs.pages, - session: options.pluginSettingsSession, - }); - assertCoreGraph(graph, "resolved CoreGraph"); - - return { - graph, - diagnostics, - fileDependencies: [...fileDependencies].sort(), - }; -} - -function hasPageAnchorDiscovery( - config: Pick, -): config is Pick & { - routing: NonNullable; -} { - return Boolean(config.routing && !config.application); -} - -function withAnalysisFacts( - graph: CoreGraph, - facts: FrameworkAnalysisFacts, -): CoreGraph { - return { - ...graph, - serverFunctions: facts.serverFunctions, - serverRoutes: facts.serverRoutes, - ...(facts.clientReferences - ? { clientReferences: facts.clientReferences } - : {}), - ...(facts.serverReferences - ? { serverReferences: facts.serverReferences } - : {}), - }; -} - -function createEmptyCoreGraph(facts: FrameworkAnalysisFacts): CoreGraph { - const graph: CoreGraph = { - rootDir: facts.rootDir, - applications: {}, - pages: {}, - routes: [], - documents: {}, - plugins: { entries: {} }, - serverFunctions: facts.serverFunctions, - serverRoutes: facts.serverRoutes, - ...(facts.clientReferences - ? { clientReferences: facts.clientReferences } - : {}), - ...(facts.serverReferences - ? { serverReferences: facts.serverReferences } - : {}), - }; - assertCoreGraph(graph, "resolved CoreGraph"); - return graph; -} - -async function mergePprRegionsIntoCoreGraph( - graph: CoreGraph, - cwd: string, - sourceCache: Map, - diagnostics: Diagnostic[], - fileDependencies: Set, - aliases?: Record, - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise { - for (const page of Object.values(graph.pages)) { - const ppr = page.ppr ?? derivePprConfig(page.prerender); - if (!ppr) continue; - const root = await resolveProjectSourceAbsolute( - cwd, - page.source.module, - onSourceDependency, - ); - if (!root) continue; - const analysis = await collectPprRegionsFromPageClosure( - cwd, - root, - sourceCache, - fileDependencies, - aliases, - beforeSourceRead, - onSourceDependency, - ); - diagnostics.push(...analysis.diagnostics); - if (Object.keys(analysis.regions).length === 0) { - page.ppr = ppr; - continue; - } - const resolved = await resolvePprRegionComponents( - cwd, - analysis.regions, - sourceCache, - beforeSourceRead, - onSourceDependency, - ); - diagnostics.push(...resolved.diagnostics); - page.ppr = { - ...ppr, - regions: { - ...(ppr.regions ?? {}), - ...resolved.regions, - }, - }; - } -} - -function validateCoreGraphPageContracts( - graph: CoreGraph, - diagnostics: Diagnostic[], -): void { - for (const page of Object.values(graph.pages)) { - const file = page.source.module; - if (hasErrorDiagnosticForFile(diagnostics, file)) continue; - const renderingError = getPageBuildContractViolation(`Page "${page.id}"`, { - ...page, - component: page.source.module, - }); - if (renderingError) { - diagnostics.push({ - level: "error", - file, - message: renderingError, - }); - } - } -} - -function validateServerRouteNodePublication( - routes: ServerRouteNode[], - serverRoutePathOwners: Map, - serverRouteShapeOwners: Map, -): { nodes: ServerRouteNode[]; diagnostics: Diagnostic[] } { - const nodes: ServerRouteNode[] = []; - const diagnostics: Diagnostic[] = []; - const pendingPathOwners = new Map(serverRoutePathOwners); - const pendingShapeOwners = new Map(serverRouteShapeOwners); - - for (const route of routes) { - const existing = pendingPathOwners.get(route.path); - if (existing) { - diagnostics.push({ - level: "error", - file: route.module, - message: - `Server route path "${route.path}" is already declared by ${existing.module}. ` + - "Declare all HTTP methods for a path in one server file route module.", - }); - continue; - } - const routeShape = serverRoutePathShapeFromPath(route.path); - const existingShapeOwner = pendingShapeOwners.get(routeShape); - if (existingShapeOwner) { - diagnostics.push({ - level: "error", - file: route.module, - message: - `Server route path "${route.path}" has the same route shape as ${existingShapeOwner.module} (${existingShapeOwner.path}). ` + - "Use one route handler per URL shape.", - }); - continue; - } - pendingPathOwners.set(route.path, route); - pendingShapeOwners.set(routeShape, route); - nodes.push({ - id: route.id, - module: route.module, - path: route.path, - methods: route.methods, - }); - } - - return { nodes, diagnostics }; -} - -async function diagnosePageModuleRouteLifecycleExports( - graph: CoreGraph, - cwd: string, - sourceCache: Map, - diagnostics: Diagnostic[], - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise { - for (const page of Object.values(graph.pages)) { - const absolute = await resolveProjectSourceAbsolute( - cwd, - page.source.module, - onSourceDependency, - ); - if (!absolute) continue; - - const source = await readFrameworkSource( - absolute, - sourceCache, - beforeSourceRead, - ); - if (source === undefined) continue; - - const exports = analyzePageModuleExports(source); - if (exports.renderingConfig.length > 0) { - diagnostics.push({ - level: "error", - file: toPosixPath(path.relative(cwd, absolute)), - message: `Page "${page.id}" exports rendering configuration ${exports.renderingConfig.map((name) => `"${name}"`).join(", ")} from its component module. Define these fields in the adjacent page.config.ts module; Page component exports are runtime values, not build configuration.`, - }); - } - if (page.render !== "csr" && exports.routeLifecycle.length > 0) { - diagnostics.push({ - level: "error", - file: toPosixPath(path.relative(cwd, absolute)), - message: `Page "${page.id}" uses render "${page.render}" and exports browser-only route lifecycle ${exports.routeLifecycle.map((name) => `"${name}"`).join(", ")}. Non-CSR Pages cannot use browser-only route lifecycle exports. Remove these exports or use render: "csr".`, - }); - } - } -} - -function derivePprConfig( - prerender: PrerenderConfig | undefined, -): PprConfig | undefined { - if (!prerender || prerender === true || !prerender.partial) { - return undefined; - } - return { - delivery: prerender.delivery ?? "merge", - ...(prerender.revalidate !== undefined - ? { revalidate: prerender.revalidate } - : {}), - }; -} - -async function collectPprRegionsFromPageClosure( - cwd: string, - root: string, - sourceCache: Map, - fileDependencies: Set, - aliases?: Record, - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise<{ - regions: PprRegionConfigMap; - diagnostics: Diagnostic[]; -}> { - const visited = new Set(); - const regions: PprRegionConfigMap = {}; - const diagnostics: Diagnostic[] = []; - - async function visit(file: string) { - if (visited.has(file)) return; - visited.add(file); - fileDependencies.add(file); - - const source = await readFrameworkSource( - file, - sourceCache, - beforeSourceRead, - ); - if (source === undefined) return; - - const sourceRel = toPosixPath(path.relative(cwd, file)); - const analysis = extractPprRegions(source, sourceRel); - for (const diagnostic of analysis.diagnostics) { - diagnostics.push({ - ...diagnostic, - file: sourceRel, - }); - } - - for (const [id, region] of Object.entries(analysis.regions)) { - if (regions[id]) { - diagnostics.push({ - level: "error", - file: sourceRel, - message: `Duplicate internal PPR region id "${id}" in the same PPR page component tree.`, - }); - continue; - } - regions[id] = region; - } - - for (const specifier of extractStaticImportSpecifiers(source, aliases)) { - const dependency = await resolveSourceImport( - cwd, - file, - specifier, - aliases, - onSourceDependency, - ); - if (dependency) { - await visit(dependency); - } - } - } - - await visit(root); - - return { - regions, - diagnostics, - }; -} - -async function resolvePprRegionComponents( - cwd: string, - regions: PprRegionConfigMap, - sourceCache: Map, - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise<{ - regions: PprRegionConfigMap; - diagnostics: Diagnostic[]; -}> { - const resolved: PprRegionConfigMap = {}; - const diagnostics: Diagnostic[] = []; - - for (const [id, region] of Object.entries(regions)) { - const component = await resolveProjectSourcePath( - cwd, - region.component, - onSourceDependency, - ); - const moduleConfig = await readPprRegionModuleConfig( - cwd, - component, - sourceCache, - beforeSourceRead, - onSourceDependency, - ); - diagnostics.push(...moduleConfig.diagnostics); - resolved[id] = { - ...moduleConfig.config, - ...region, - component, - }; - } - - return { regions: resolved, diagnostics }; -} - -async function readPprRegionModuleConfig( - cwd: string, - component: string, - sourceCache: Map, - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise<{ - config: Partial>; - diagnostics: Diagnostic[]; -}> { - const empty = { config: {}, diagnostics: [] }; - if (!component.startsWith(".")) return empty; - const absolute = await resolveProjectSourceAbsolute( - cwd, - component, - onSourceDependency, - ); - if (!absolute) return empty; - - const source = await readFrameworkSource( - absolute, - sourceCache, - beforeSourceRead, - ); - if (source === undefined) return empty; - - const analysis = extractPprRegionModuleConfig(source); - const file = toPosixPath(path.relative(cwd, absolute)); - return { - config: analysis.config, - diagnostics: analysis.diagnostics.map((diagnostic) => ({ - ...diagnostic, - file, - })), - }; -} - -async function resolveProjectSourceAbsolute( - cwd: string, - sourcePath: string, - onSourceDependency?: (file: string) => void, -): Promise { - if (!sourcePath.startsWith(".")) return undefined; - return resolveSourcePath( - cwd, - path.resolve(cwd, sourcePath), - onSourceDependency, - ); -} - -async function resolveProjectSourcePath( - cwd: string, - sourcePath: string, - onSourceDependency?: (file: string) => void, -): Promise { - if (!sourcePath.startsWith(".")) return sourcePath; - const resolved = await resolveSourcePath( - cwd, - path.resolve(cwd, sourcePath), - onSourceDependency, - ); - return resolved - ? `./${toPosixPath(path.relative(cwd, resolved))}` - : sourcePath; -} - -function normalizePublicRoutePath(routePath: string): string { - return routePath.startsWith("/") ? routePath : `/${routePath}`; -} - -function hasErrorDiagnosticForFile( - diagnostics: Diagnostic[], - file: string, -): boolean { - return diagnostics.some( - (diagnostic) => diagnostic.level === "error" && diagnostic.file === file, - ); -} - -function validateConfiguredPageRoutes( - config: Pick, - diagnostics: Diagnostic[], -): PageRouteNode[] { - if (!config.routing) return []; - - const routeByPath = new Map(); - const routeByShape = new Map(); - const routeById = new Map(); - const validRoutes: PageRouteNode[] = []; - - for (const route of config.routing.routes) { - if ( - route.kind !== undefined && - route.kind !== "page" && - route.kind !== "layout" - ) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: `Configured page route "${route.id}" kind must be "page" or "layout".`, - }); - continue; - } - const routePathError = getConfiguredPageRoutePathValidationError( - route.path, - ); - if (routePathError) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: `Configured page route path ${formatConfiguredPageRoutePathValue(route.path)} ${formatConfiguredPageRoutePathValidationError(routePathError)}`, - }); - continue; - } - - const normalizedPath = normalizePublicRoutePath(route.path); - const paramError = - getConfiguredPageRouteParamValidationError(normalizedPath); - if (paramError) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: `Configured page route path "${normalizedPath}" ${formatConfiguredPageRouteParamValidationError(paramError)}`, - }); - continue; - } - const normalizedRoute: PageRouteNode = { - ...route, - path: normalizedPath, - }; - const isLayoutRoute = normalizedRoute.kind === "layout"; - - if (!isLayoutRoute) { - const previousPathOwner = routeByPath.get(normalizedPath); - if (previousPathOwner) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: - `Configured page route path "${normalizedPath}" is already declared by ${previousPathOwner.module}. ` + - "Keep one page route per URL path.", - }); - continue; - } - routeByPath.set(normalizedPath, normalizedRoute); - - const routeShape = routePathShapeFromPath(normalizedPath).key; - const previousShapeOwner = routeByShape.get(routeShape); - if (previousShapeOwner) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: - `Configured page route path "${normalizedPath}" has the same route shape as ` + - `${previousShapeOwner.module} (${normalizePublicRoutePath(previousShapeOwner.path)}). ` + - "Use one dynamic param name for each URL shape.", - }); - continue; - } - routeByShape.set(routeShape, normalizedRoute); - } - - const previousIdOwner = routeById.get(route.id); - if (previousIdOwner) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: - `Configured page route id "${route.id}" for path "${normalizedPath}" is already used by ` + - `${previousIdOwner.module} (${normalizePublicRoutePath(previousIdOwner.path)}). ` + - "Route ids must be unique because they drive page ids and build entries.", - }); - continue; - } - routeById.set(route.id, normalizedRoute); - validRoutes.push(normalizedRoute); - } - - return sortRoutesBySpecificity( - validRoutes.filter((route) => { - if (!route.parentId) return true; - const parent = routeById.get(route.parentId); - if (!parent) { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: `Configured page route "${route.id}" parentId "${route.parentId}" does not match another route id.`, - }); - return false; - } - if (parent.kind !== "layout") { - diagnostics.push({ - level: "error", - file: toDiagnosticModulePath(route.module), - message: `Configured page route "${route.id}" parentId "${route.parentId}" must reference a layout route.`, - }); - return false; - } - return true; - }), - ); -} - -function getConfiguredPageRoutePathValidationError( - routePath: string, -): PathPatternValidationError | undefined { - const initialError = getPathPatternValidationError(routePath); - if (!initialError) return undefined; - if (initialError !== "missing-leading-slash") return initialError; - - return getPathPatternValidationError(normalizePublicRoutePath(routePath)); -} - -function getConfiguredPageRouteParamValidationError( - routePath: string, -): PageRouteParamSegmentValidationError | undefined { - return getPageRouteParamSegmentValidationError( - normalizePublicRoutePath(routePath), - ); -} - -function formatConfiguredPageRoutePathValue(value: unknown): string { - return typeof value === "string" ? `"${value}"` : String(value); -} - -function formatConfiguredPageRoutePathValidationError( - error: PathPatternValidationError, -): string { - switch (error) { - case "empty": - return "must be a non-empty string."; - case "missing-leading-slash": - return 'must start with "/".'; - case "whitespace": - return "must not contain whitespace."; - case "query-or-hash": - return "must not include a query string or hash."; - } -} - -function formatConfiguredPageRouteParamValidationError( - error: PageRouteParamSegmentValidationError, -): string { - switch (error.error) { - case "empty": - return `contains dynamic segment "${error.segment}" without a param name.`; - case "reserved": - return `uses reserved dynamic param name "${error.name}" in segment "${error.segment}". Use a safe application-specific name.`; - case "duplicate": - return `uses duplicate dynamic param name "${error.name}" in segment "${error.segment}". Use unique param names within one route path.`; - case "duplicate-wildcard": - return `contains more than one wildcard segment "${error.segment}". Use at most one wildcard segment in a route path.`; - case "star-wildcard": - return 'uses "*" as a wildcard segment. Use "$" for page route splats.'; - } -} - -function toDiagnosticModulePath(module: string): string { - return module.replace(/^\.\//, ""); -} - -async function collectRouteDirectories( - cwd: string, - root: string, -): Promise { - const dirs = new Set([root]); - - try { - if (!(await isRealPathInsideCwd(cwd, root))) return [...dirs]; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") return [...dirs]; - throw error; - } - - async function visit(current: string) { - let entries: import("node:fs").Dirent[]; - try { - entries = await fs.readdir(current, { withFileTypes: true }); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTDIR") return; - throw err; - } - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith(".")) continue; - const absolute = path.join(current, entry.name); - dirs.add(absolute); - await visit(absolute); - } - } - - await visit(root); - return [...dirs].sort(); -} - -async function collectFrameworkSourceFiles( - config: GraphConfig, - cwd: string, - sourceCache: Map, - aliases?: Record, - providerSourceModules: string[] = [], - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -): Promise { - const files = new Set(); - const roots = new Set(); - const explicitDependencyRoots = new Set(); - const diagnostics: Diagnostic[] = []; - - for (const module of providerSourceModules) { - await addConfiguredSource( - roots, - cwd, - module, - `Application-route module "${module}"`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - } - - for (const route of config.routing?.routes ?? []) { - await addConfiguredSource( - roots, - cwd, - route.module, - `Page route "${route.id}" module`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - await addConfiguredSource( - roots, - cwd, - route.errorModule, - `Page route "${route.id}" error boundary module`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - await addConfiguredSource( - roots, - cwd, - route.notFoundModule, - `Page route "${route.id}" not-found boundary module`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - } - for (const route of config.server.routes ?? []) { - await addConfiguredSource( - roots, - cwd, - route.module, - `Server route "${route.path}" module`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - } - for (const middleware of [ - ...(config.server.conventions?.globalMiddlewares ?? []), - ...(config.server.conventions?.routeMiddlewares ?? []), - ]) { - await addConfiguredSource( - roots, - cwd, - middleware.module, - `Server middleware "${middleware.module}" module`, - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - } - await addConfiguredSource( - roots, - cwd, - config.routing?.rootModule, - "SPA root layout module", - diagnostics, - explicitDependencyRoots, - onSourceDependency, - ); - for (const root of roots) { - await collectStaticImportClosure( - files, - cwd, - root, - sourceCache, - aliases, - beforeSourceRead, - onSourceDependency, - ); - } - - return { - analysisFiles: [...files].sort(), - explicitDependencyFiles: explicitDependencyRoots, - diagnostics, - }; -} - -async function addConfiguredSource( - files: Set, - cwd: string, - filePath: string | undefined, - label: string, - diagnostics: Diagnostic[], - explicitDependencyFiles?: Set, - onSourceDependency?: (file: string) => void, -): Promise { - if (!filePath) return; - const absolute = path.resolve(cwd, filePath); - const file = getConfiguredSourceDiagnosticFile(cwd, filePath, absolute); - if (isInsideCwd(cwd, absolute)) onSourceDependency?.(absolute); - - let stat: Awaited>; - try { - stat = await fs.stat(absolute); - } catch (error) { - if (!isMissingSourcePathError(error)) throw error; - diagnostics.push({ - level: "error", - file, - message: `${label} source file not found.`, - }); - return undefined; - } - - if (!stat.isFile()) { - diagnostics.push({ - level: "error", - file, - message: `${label} source path must be a file.`, - }); - return undefined; - } - - if (!isProjectSourceModule(absolute)) { - diagnostics.push({ - level: "error", - file, - message: `${label} source file must use .ts, .tsx, .js, or .jsx.`, - }); - return undefined; - } - - files.add(absolute); - explicitDependencyFiles?.add(absolute); - return absolute; -} - -function getConfiguredSourceDiagnosticFile( - cwd: string, - filePath: string, - absolute: string, -): string { - if (filePath.startsWith(".")) { - return toPosixPath(path.relative(cwd, absolute)); - } - return toPosixPath(filePath); -} - -async function collectStaticImportClosure( - files: Set, - cwd: string, - file: string, - sourceCache: Map, - aliases?: Record, - beforeSourceRead?: (file: string) => void, - onSourceDependency?: (file: string) => void, -) { - if (files.has(file)) return; - files.add(file); - - const source = await readFrameworkSource(file, sourceCache, beforeSourceRead); - if (source === undefined) return; - - for (const specifier of extractStaticImportSpecifiers(source, aliases)) { - const dependency = await resolveSourceImport( - cwd, - file, - specifier, - aliases, - onSourceDependency, - ); - if (dependency) { - await collectStaticImportClosure( - files, - cwd, - dependency, - sourceCache, - aliases, - beforeSourceRead, - onSourceDependency, - ); - } - } -} - -async function readFrameworkSource( - file: string, - sourceCache: Map, - beforeSourceRead?: (file: string) => void, -): Promise { - const cached = sourceCache.get(file); - if (cached !== undefined) return cached; - - // Keep observer failures fail-closed while preserving source-discovery - // tolerance for files that disappear during graph traversal. - beforeSourceRead?.(file); - try { - const source = await fs.readFile(file, "utf-8"); - sourceCache.set(file, source); - return source; - } catch (error) { - if (isMissingSourcePathError(error)) return undefined; - throw error; - } -} - -function extractStaticImportSpecifiers( - source: string, - aliases?: Record, -): string[] { - return extractRuntimeModuleSpecifiers(source).filter((specifier) => - isLocalSourceImportSpecifier(specifier, aliases), - ); -} - -function isLocalSourceImportSpecifier( - specifier: string, - aliases?: Record, -): boolean { - return ( - specifier.startsWith(".") || - specifier === DEFAULT_SOURCE_ALIAS.slice(0, -1) || - specifier.startsWith(DEFAULT_SOURCE_ALIAS) || - findMatchingSourceAlias(specifier, aliases) !== undefined - ); -} - -function isFrameworkDependencySource(source: string): boolean { - return /^\s*["']use (client|server)["']/m.test(source.slice(0, 200)); -} - -async function resolveSourceImport( - cwd: string, - fromFile: string, - specifier: string, - aliases?: Record, - onSourceDependency?: (file: string) => void, -): Promise { - const alias = findMatchingSourceAlias(specifier, aliases); - if (alias) { - const suffix = specifier.slice(alias.specifier.length).replace(/^\//, ""); - return resolveSourcePath( - cwd, - path.resolve(cwd, alias.replacement, suffix), - onSourceDependency, - ); - } - if (specifier === DEFAULT_SOURCE_ALIAS.slice(0, -1)) { - return resolveSourcePath(cwd, path.resolve(cwd, "src"), onSourceDependency); - } - if (specifier.startsWith(DEFAULT_SOURCE_ALIAS)) { - return resolveSourcePath( - cwd, - path.resolve(cwd, "src", specifier.slice(DEFAULT_SOURCE_ALIAS.length)), - onSourceDependency, - ); - } - return resolveSourcePath( - cwd, - path.resolve(path.dirname(fromFile), specifier), - onSourceDependency, - ); -} - -function findMatchingSourceAlias( - specifier: string, - aliases?: Record, -): { specifier: string; replacement: string } | undefined { - if (!aliases) return undefined; - let match: { specifier: string; replacement: string } | undefined; - for (const [alias, replacement] of Object.entries(aliases)) { - if (specifier !== alias && !specifier.startsWith(`${alias}/`)) continue; - if (!match || alias.length > match.specifier.length) { - match = { specifier: alias, replacement }; - } - } - return match; -} - -async function resolveSourcePath( - cwd: string, - base: string, - onSourceDependency?: (file: string) => void, -): Promise { - const projectCandidates = registerProjectSourceResolutionCandidates( - cwd, - base, - onSourceDependency, - ); - - for (const candidate of projectCandidates) { - try { - const stat = await fs.stat(candidate); - if (stat.isFile() && isProjectSourceModule(candidate)) { - return candidate; - } - } catch (error) { - if (!isMissingSourcePathError(error)) throw error; - // Non-source imports are handled by the bundler. Graph analysis only - // follows local framework-relevant TypeScript/JavaScript modules. - } - } - - return undefined; -} +export { createCoreGraph } from "./create-core-graph.js"; +export type { + CreateCoreGraphOptions, + Diagnostic, + GraphAnalysisResult, + GraphConfig, +} from "./types.js"; diff --git a/packages/ev/src/_internal/build/graph/source-resolution.ts b/packages/ev/src/_internal/build/graph/source-resolution.ts index d48c0e5a..4ba7f657 100644 --- a/packages/ev/src/_internal/build/graph/source-resolution.ts +++ b/packages/ev/src/_internal/build/graph/source-resolution.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { ROUTE_SOURCE_EXTENSIONS } from "../route-conventions.js"; +import { ROUTE_SOURCE_EXTENSIONS } from "../conventions/route-conventions.js"; import { isInsideCwd } from "../utils.js"; export const PROJECT_SOURCE_EXTENSIONS = ROUTE_SOURCE_EXTENSIONS; diff --git a/packages/ev/src/_internal/build/graph/types.ts b/packages/ev/src/_internal/build/graph/types.ts new file mode 100644 index 00000000..57d9cb77 --- /dev/null +++ b/packages/ev/src/_internal/build/graph/types.ts @@ -0,0 +1,67 @@ +import type { + CoreApplicationPluginSettings, + CoreGraph, + PageRouteNode, + ServerMiddlewareNode, +} from "@evjs/shared/manifest"; +import type { + PageRouteDiscoveryMetadata, + ResolvedConfigRouteApplication, +} from "../../../config/index.js"; +import type { ResolvedPageFileConfigs } from "../config-loading/page-config-module.js"; +import type { DiscoveredServerRouteNode } from "../discovery/server-routes.js"; +import type { + PluginSettingsRegistry, + PluginSettingsResolutionSession, +} from "../plugins/settings.js"; + +export interface GraphAnalysisResult { + graph: CoreGraph; + diagnostics: Diagnostic[]; + fileDependencies: string[]; +} + +export interface CreateCoreGraphOptions { + resolve?: { + alias?: Record; + }; + pluginSettings?: PluginSettingsRegistry; + /** Application plugin settings resolved before plugin setup. */ + applicationPluginSettings?: CoreApplicationPluginSettings; + /** Canonical Page configs pre-evaluated once for alias convergence. */ + pageConfigs?: ResolvedPageFileConfigs; + /** Page plugin setting snapshots reused during one alias-convergence analysis. */ + pluginSettingsSession?: PluginSettingsResolutionSession; + /** @internal Captures a baseline immediately before framework source reads. */ + beforeSourceRead?: (file: string) => void; + /** @internal Reports source dependencies discovered by delegated loaders. */ + onSourceDependency?: (file: string) => void; +} + +export interface Diagnostic { + level: "warning" | "error"; + message: string; + file?: string; + line?: number; + column?: number; +} + +export interface GraphConfig { + application?: ResolvedConfigRouteApplication; + routing?: { + mode: "spa" | "mpa"; + html: string; + mount: string; + routes: PageRouteNode[]; + rootModule?: string; + metadata?: PageRouteDiscoveryMetadata; + dependencies?: string[]; + }; + server: { + routes?: DiscoveredServerRouteNode[]; + conventions?: { + globalMiddlewares: ServerMiddlewareNode[]; + routeMiddlewares: ServerMiddlewareNode[]; + }; + }; +} diff --git a/packages/ev/src/_internal/build/index.ts b/packages/ev/src/_internal/build/index.ts index e26aef04..a5f291a5 100644 --- a/packages/ev/src/_internal/build/index.ts +++ b/packages/ev/src/_internal/build/index.ts @@ -2,7 +2,16 @@ * Bundler-agnostic build utilities for the ev framework. */ -export { resolveBuildOutputPaths } from "./build-output-paths.js"; +export type { + RscReferenceAnalysis, + TransformRscClientFileOptions, +} from "./analysis/rsc-refs.js"; +export { + detectUseClient, + extractRscReferences, + transformRscClientFile, +} from "./analysis/rsc-refs.js"; +export { extractServerFunctionExports } from "./analysis/server-fns.js"; export { type BundlerAdapter, type BundlerBuildCapability, @@ -23,8 +32,8 @@ export { isEmptyBuildPlanUpdate, resolveBundlerClientEntryAssets, resolveBundlerServerEntryAssets, -} from "./bundler.js"; -export { assertBundlerEmittedFiles } from "./bundler-output-files.js"; +} from "./bundler/contracts.js"; +export { assertBundlerEmittedFiles } from "./bundler/output-files.js"; export { type BuildOptions, build, @@ -42,14 +51,40 @@ export { type PrepareFrameworkBuildOptions, prepareFrameworkBuild, } from "./commands.js"; -export type { LoadConfigFileOptions } from "./config-module.js"; -export { loadConfigFile } from "./config-module.js"; +export type { LoadConfigFileOptions } from "./config-loading/config-module.js"; +export { loadConfigFile } from "./config-loading/config-module.js"; +export type { + DiscoverPageRoutesOptions, + PageComponentExportAnalysis, + PageComponentExportKind, + PageRouteDiscovery, + PageRouteDiscoveryDiagnostic, +} from "./discovery/page-routes.js"; +export { + analyzePageComponentExports, + discoverPageRoutes, +} from "./discovery/page-routes.js"; +export type { + DiscoverServerConventionsOptions, + ServerConventionDiagnostic, + ServerConventionDiscovery, +} from "./discovery/server-conventions.js"; +export { + applyRouteScopedMiddlewares, + discoverServerConventions, +} from "./discovery/server-conventions.js"; +export type { + DiscoverServerRoutesOptions, + ServerRouteDiscovery, + ServerRouteDiscoveryDiagnostic, +} from "./discovery/server-routes.js"; +export { discoverServerRoutes } from "./discovery/server-routes.js"; export { applyHtmlTagContributions, GENERATED_IR_DIR, GENERATED_IR_MANIFEST, materializeFrameworkIR, -} from "./generated-contributions.js"; +} from "./generated-ir/generated-contributions.js"; export type { CreateCoreGraphOptions, Diagnostic, @@ -57,88 +92,56 @@ export type { GraphConfig, } from "./graph/index.js"; export { createCoreGraph } from "./graph/index.js"; -export type { GenerateHtmlOptions, HtmlAsset } from "./html.js"; -export { generateHtml, validateHtmlTemplate } from "./html.js"; -export type { BuildHtmlOptions } from "./html-transform.js"; -export { buildHtml } from "./html-transform.js"; -export type { ResolvedBuildOutputPaths } from "./output-path-safety.js"; +export { resolveBuildOutputPaths } from "./output/build-output-paths.js"; +export type { GenerateHtmlOptions, HtmlAsset } from "./output/html/html.js"; +export { generateHtml, validateHtmlTemplate } from "./output/html/html.js"; +export type { BuildHtmlOptions } from "./output/html/html-transform.js"; +export { buildHtml } from "./output/html/html-transform.js"; +export type { ResolvedBuildOutputPaths } from "./output/output-path-safety.js"; export { assertSafeBuildOutputPaths, assertSafeBuildOwnedOutputPath, assertSafeBundlerCleanOutputPath, -} from "./output-path-safety.js"; +} from "./output/output-path-safety.js"; export { removeOwnedOutputFile, writeOwnedOutputFile, -} from "./owned-file-output.js"; -export type { GeneratePageRouteTypesOptions } from "./page-route-types.js"; -export { generatePageRouteTypes } from "./page-route-types.js"; -export type { - DiscoverPageRoutesOptions, - PageComponentExportAnalysis, - PageComponentExportKind, - PageRouteDiscovery, - PageRouteDiscoveryDiagnostic, -} from "./page-routes.js"; +} from "./output/owned-file-output.js"; export { - analyzePageComponentExports, - discoverPageRoutes, -} from "./page-routes.js"; + assertPortableRelativeArtifactPath, + assertPortableRelativeBrowserArtifactPath, + canonicalPortableArtifactPathKey, + portableArtifactPathsConflict, +} from "./output/portable-artifact-path.js"; export type { BuildPlanConfig, CreateBuildPlanOptions, } from "./plan/index.js"; export { createBuildPlan, diffBuildPlan } from "./plan/index.js"; -export { createPluginConfigView } from "./plugin-lifecycle.js"; +export { createPluginConfigView } from "./plugins/lifecycle.js"; export type { PluginSettingsRegistry, PluginSettingsResolutionSession, ResolvedPluginSettingsState, -} from "./plugin-settings.js"; +} from "./plugins/settings.js"; export { applyPluginSettings, collectPluginSettingsRegistry, createPluginSettingsResolutionSession, resolvePluginSettingsState, -} from "./plugin-settings.js"; +} from "./plugins/settings.js"; +export type { TransformResult } from "./transforms/index.js"; +export { transformServerFile } from "./transforms/index.js"; +export type { GeneratePageRouteTypesOptions } from "./typegen/page-route-types.js"; +export { generatePageRouteTypes } from "./typegen/page-route-types.js"; export type { GeneratePluginTypesOptions, SyncPluginTypesOptions, -} from "./plugin-types.js"; -export { generatePluginTypes, syncPluginTypes } from "./plugin-types.js"; -export { - assertPortableRelativeArtifactPath, - assertPortableRelativeBrowserArtifactPath, - canonicalPortableArtifactPathKey, - portableArtifactPathsConflict, -} from "./portable-artifact-path.js"; -export type { - RscReferenceAnalysis, - TransformRscClientFileOptions, -} from "./rsc-refs.js"; +} from "./typegen/plugin-types.js"; export { - detectUseClient, - extractRscReferences, - transformRscClientFile, -} from "./rsc-refs.js"; -export type { - DiscoverServerConventionsOptions, - ServerConventionDiagnostic, - ServerConventionDiscovery, -} from "./server-conventions.js"; -export { - applyRouteScopedMiddlewares, - discoverServerConventions, -} from "./server-conventions.js"; -export { extractServerFunctionExports } from "./server-fns.js"; -export type { - DiscoverServerRoutesOptions, - ServerRouteDiscovery, - ServerRouteDiscoveryDiagnostic, -} from "./server-routes.js"; -export { discoverServerRoutes } from "./server-routes.js"; -export type { TransformResult } from "./transforms/index.js"; -export { transformServerFile } from "./transforms/index.js"; + generatePluginTypes, + syncPluginTypes, +} from "./typegen/plugin-types.js"; export type { RouteModuleInfo, TransformOptions, diff --git a/packages/ev/src/_internal/build/analyze-and-materialize.ts b/packages/ev/src/_internal/build/operations/analyze-and-materialize.ts similarity index 85% rename from packages/ev/src/_internal/build/analyze-and-materialize.ts rename to packages/ev/src/_internal/build/operations/analyze-and-materialize.ts index 632dc683..966b72f5 100644 --- a/packages/ev/src/_internal/build/analyze-and-materialize.ts +++ b/packages/ev/src/_internal/build/operations/analyze-and-materialize.ts @@ -3,22 +3,23 @@ import type { BuildPlan, CoreApplicationPluginSettings, } from "@evjs/shared/manifest"; -import type { ResolvedFrameworkConfig } from "../../config/index.js"; -import type { PluginSetupContext } from "../../plugin/index.js"; -import { syncPageRouteTypesFromCoreGraph } from "./convention-config.js"; -import { materializeFrameworkIR } from "./generated-contributions.js"; -import { createCoreGraph, type GraphAnalysisResult } from "./graph/index.js"; -import { resolvePageConfigModules } from "./page-config-module.js"; +import type { ResolvedFrameworkConfig } from "../../../config/index.js"; +import type { PluginSetupContext } from "../../../plugin/index.js"; +import { resolvePageConfigModules } from "../config-loading/page-config-module.js"; +import { syncPageRouteTypesFromCoreGraph } from "../discovery/convention-config.js"; +import { materializeFrameworkIR } from "../generated-ir/generated-contributions.js"; +import { createCoreGraph } from "../graph/create-core-graph.js"; +import type { GraphAnalysisResult } from "../graph/types.js"; import { - type CreateBuildPlanOptions, createBuildGenerationId, createBuildPlan, -} from "./plan/index.js"; +} from "../plan/create-build-plan.js"; +import type { CreateBuildPlanOptions } from "../plan/types.js"; import { createPluginSettingsResolutionSession, type PluginSettingsRegistry, -} from "./plugin-settings.js"; -import { syncPluginTypes } from "./plugin-types.js"; +} from "../plugins/settings.js"; +import { syncPluginTypes } from "../typegen/plugin-types.js"; export interface AnalyzeAndMaterializeOptions { cwd: string; diff --git a/packages/ev/src/_internal/build/operations/commands.ts b/packages/ev/src/_internal/build/operations/commands.ts new file mode 100644 index 00000000..36952e30 --- /dev/null +++ b/packages/ev/src/_internal/build/operations/commands.ts @@ -0,0 +1,2972 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { + BuildPlan, + BuildPlanUpdate, + CoreGraph, +} from "@evjs/shared/manifest"; +import { getLogger } from "@logtape/logtape"; +import { execa } from "execa"; +import { + CONFIG_DEFAULTS, + type Config, + type ResolvedConfig, + type ResolvedFrameworkConfig, + resolveBundlerConfig, + resolveConfig, +} from "../../../config/index.js"; +import type { + CliFlags, + PluginHooks, + PluginSetupContext, +} from "../../../plugin/index.js"; +import { resolveBundler, withActiveBundler } from "../bundler/config.js"; +import { + type BundlerAdapter, + type BundlerBuildFacts, + type BundlerDevController, + type BundlerDevGeneration, + type BundlerDevUpdateTransition, + preflightBundlerBuild, + preflightBundlerDevUpdate, +} from "../bundler/contracts.js"; +import { CANONICAL_PAGE_ROUTE_ROOT } from "../conventions/page-route-conventions.js"; +import { CANONICAL_SERVER_ROUTE_ROOT } from "../conventions/server-route-conventions.js"; +import { DevApiProcessController } from "../dev/api-process.js"; +import { + API_READY_MARKER, + type ApiProcess, + acquireDevSessionLock, + acquireProjectOperationLock, + assertNoActiveDevDistLock, + assertNoActiveDevSessionLock, + type DevPortReservation, + type DevRuntimeRelease, + findDevServerBundlePath, + forwardApiOutput, + type ProjectOperation, + reserveDevPorts, + stopApiProcess, + waitForApiReady, + writeDevDistLock, +} from "../dev/runtime.js"; +import { + collectRouteDirectoryWatchState, + collectServerRouteWatchState, + collectWatchFilesChangedSince, + createWatchFilesPlan, + listConfigDependencyFiles, + type PreparedWatchFilesPlan, + prepareWatchFilesPlan, + type RouteDirectoryWatchState, + resolveInitialDevWatchMode, + type WatchFilesPlan, + watchFiles, +} from "../dev/watch.js"; +import { + withPageRoutingDefaults, + withServerConventionDefaults, + withServerRouteDiscovery, +} from "../discovery/convention-config.js"; +import { GENERATED_IR_DIR } from "../generated-ir/generated-contributions.js"; +import type { createCoreGraph } from "../graph/create-core-graph.js"; +import { createBuildResult } from "../output/build-result.js"; +import { + createFrameworkOutputSnapshot, + type FrameworkOutputSnapshot, + linkAndEmitBuildOutput, + validateHtmlTemplates, +} from "../output/framework-output.js"; +import { + type createFrameworkRuntime, + serializeFrameworkRuntimeExpression, +} from "../output/framework-runtime.js"; +import { + removeOwnedOutputFile, + writeOwnedOutputFile, +} from "../output/owned-file-output.js"; +import { createProductionOutputTransaction } from "../output/production-output-transaction.js"; +import { diffBuildPlan } from "../plan/create-build-plan.js"; +import type { CreateBuildPlanOptions } from "../plan/types.js"; +import { + collectPluginHooks, + hasSamePluginIdentity, + orderPluginsByDependencies, + rethrowAfterCleanup, + runAfterBuildHooks, + runCleanupTasks, + runConfigureHooks, + runDisposeHooks, + snapshotPluginFlags, +} from "../plugins/lifecycle.js"; +import { + collectPluginSettingsRegistry, + resolvePluginSettingsState, +} from "../plugins/settings.js"; +import { + collectGeneratedPageRouteTypeFiles, + getPageRouteTypesPath, + isGeneratedPageRouteTypesFile, +} from "../typegen/page-route-types.js"; +import { + collectGeneratedPluginTypeFiles, + getPluginTypesPath, + isGeneratedPluginTypesFile, +} from "../typegen/plugin-types.js"; +import { isInsideCwd } from "../utils.js"; +import { analyzeAndMaterializeFrameworkIR } from "./analyze-and-materialize.js"; + +type MutablePluginSetupContext = Omit< + PluginSetupContext, + "config" +> & { + config: ResolvedFrameworkConfig; +}; + +interface DevCycleTracker { + beginCycle(): () => void; + waitForIdle(): Promise; +} + +interface DevPluginExecutionSnapshot extends DevCycleTracker { + readonly hooks: PluginHooks[]; + readonly context: MutablePluginSetupContext; +} + +interface ScheduledDevChangeSnapshot { + forceConfigReload: boolean; + snapshot: string; +} + +/** Record one observed dependency state and report whether it needs an update. */ +export function recordDevChangeSnapshot( + previousChanges: Map, + file: string, + snapshot: string | undefined, + forceConfigReload: boolean, +): boolean { + if (snapshot === undefined) { + previousChanges.delete(file); + return true; + } + const previous = previousChanges.get(file); + // A candidate watcher may upgrade the same file state from a route + // invalidation to a required config reload; that stronger retry stays. + if ( + previous?.snapshot === snapshot && + (previous.forceConfigReload || !forceConfigReload) + ) { + return false; + } + previousChanges.set(file, { forceConfigReload, snapshot }); + return true; +} + +interface DevFrameworkOutputTransaction { + beginCycle(): (() => void) | undefined; + capture(): Promise; + deferAfterBuild(run: () => Promise): void; + prepareCommit(): Promise; + commit(): void; + runAfterBuild(): Promise; + restore(): Promise; +} + +function createDevCycleTracker(): DevCycleTracker { + let inFlightCycles = 0; + let resolveIdle: (() => void) | undefined; + let idle: Promise | undefined; + + return { + beginCycle() { + inFlightCycles += 1; + let released = false; + return () => { + if (released) return; + released = true; + inFlightCycles -= 1; + if (inFlightCycles !== 0) return; + resolveIdle?.(); + resolveIdle = undefined; + idle = undefined; + }; + }, + waitForIdle() { + if (inFlightCycles === 0) return Promise.resolve(); + idle ??= new Promise((resolve) => { + resolveIdle = resolve; + }); + return idle; + }, + }; +} + +function createDevFrameworkOutputTransaction( + cwd: string, + plans: readonly BuildPlan[], +): DevFrameworkOutputTransaction { + const cycles = createDevCycleTracker(); + let snapshot: Promise | undefined; + let capturedSnapshot: FrameworkOutputSnapshot | undefined; + let phase: "open" | "settling" | "settled" = "open"; + let committed = false; + let deferredAfterBuild: (() => Promise) | undefined; + + function seal(): void { + if (phase === "open") phase = "settling"; + } + + return { + beginCycle() { + if (phase !== "open") return undefined; + return cycles.beginCycle(); + }, + async capture() { + // A registered cycle may reach the FIFO after settlement starts. The + // transaction waits its lease, so that cycle must still capture the + // pre-publication state before it writes canonical output. + snapshot ??= createFrameworkOutputSnapshot(cwd, plans); + capturedSnapshot = await snapshot; + }, + deferAfterBuild(run) { + if (phase === "settled") { + throw new Error( + "[evjs] Cannot defer afterBuild after the framework output transaction settles.", + ); + } + // Only the last output written inside the transaction becomes canonical. + deferredAfterBuild = run; + }, + async prepareCommit() { + if (phase === "settled") return; + seal(); + await cycles.waitForIdle(); + capturedSnapshot = await snapshot; + }, + commit() { + if (phase === "settled") return; + if (phase !== "settling") { + throw new Error( + "[evjs] Cannot commit framework output before preparing the transaction.", + ); + } + capturedSnapshot?.commit(); + committed = true; + phase = "settled"; + }, + async runAfterBuild() { + if (phase !== "settled" || !committed) { + throw new Error( + "[evjs] Cannot run deferred afterBuild before committing framework output.", + ); + } + const run = deferredAfterBuild; + deferredAfterBuild = undefined; + await run?.(); + }, + async restore() { + if (phase === "settled") return; + seal(); + await cycles.waitForIdle(); + try { + capturedSnapshot = await snapshot; + } catch { + // Capture is read-only. Its rejection is already the update failure, + // so there is no framework output to restore here. + deferredAfterBuild = undefined; + phase = "settled"; + return; + } + deferredAfterBuild = undefined; + await capturedSnapshot?.restore(); + phase = "settled"; + }, + }; +} + +function createDevPluginExecutionSnapshot( + hooks: PluginHooks[], + context: MutablePluginSetupContext, +): DevPluginExecutionSnapshot { + return { + hooks: [...hooks], + context, + ...createDevCycleTracker(), + }; +} + +const logger = getLogger(["evjs", "ev"]); + +const DEV_PAGE_RENDER_PROXY_HEADER = "x-evjs-dev-page-render"; +const DEV_DIST_DIR = "dist"; + +interface InitialDevWatchPlans { + config: PreparedWatchFilesPlan; + configDependencies: ReadonlyMap; + pageRoutes?: PreparedWatchFilesPlan; + serverRoutes?: PreparedWatchFilesPlan; +} + +const runtimeExitCleanups = new Set<() => void>(); + +function runRuntimeExitCleanups(): void { + for (const cleanup of [...runtimeExitCleanups].reverse()) cleanup(); +} + +function registerRuntimeExitCleanup(cleanup: () => void): () => void { + if (runtimeExitCleanups.size === 0) { + process.once("exit", runRuntimeExitCleanups); + } + runtimeExitCleanups.add(cleanup); + + return () => { + runtimeExitCleanups.delete(cleanup); + if (runtimeExitCleanups.size === 0) { + process.off("exit", runRuntimeExitCleanups); + } + }; +} + +async function releaseRegisteredRuntimeLock( + release: DevRuntimeRelease, + unregisterExitCleanup: () => void, +): Promise { + try { + await release(); + } finally { + unregisterExitCleanup(); + } +} + +async function withRegisteredRuntimeLock(options: { + release: DevRuntimeRelease; + unregisterExitCleanup: () => void; + run: () => Promise; + cleanupErrorMessage: string; +}): Promise { + let result: T; + try { + result = await options.run(); + } catch (error) { + return rethrowAfterCleanup( + error, + () => + releaseRegisteredRuntimeLock( + options.release, + options.unregisterExitCleanup, + ), + options.cleanupErrorMessage, + ); + } + await releaseRegisteredRuntimeLock( + options.release, + options.unregisterExitCleanup, + ); + return result; +} + +async function withProjectOperationLock( + cwd: string, + operation: ProjectOperation, + run: () => Promise, +): Promise { + const release = await acquireProjectOperationLock(cwd, operation); + const unregisterExitCleanup = registerRuntimeExitCleanup(() => + release.sync(), + ); + return withRegisteredRuntimeLock({ + release, + unregisterExitCleanup, + run, + cleanupErrorMessage: `[evjs] ${operation} failed and its project operation lock cleanup also failed.`, + }); +} + +export interface DevOptions { + cwd?: string; + bundler?: BundlerAdapter; + /** Adapter used only when neither config nor options selects a bundler. */ + fallbackBundler?: BundlerAdapter; + flags?: CliFlags; + /** Reload the initial config through loadConfig inside the watcher handshake. */ + reloadInitialConfig?: boolean; + loadConfig?: ( + cwd: string, + context?: { + onDependency(file: string): void; + }, + ) => + | Config + | undefined + | Promise | undefined>; +} + +export interface BuildOptions { + cwd?: string; + bundler?: BundlerAdapter; + flags?: CliFlags; +} + +export interface PrepareFrameworkBuildOptions { + cwd?: string; + flags?: CliFlags; + mode?: "development" | "production"; + bundler?: BundlerAdapter; + requireBundler?: boolean; +} + +export interface PreparedFrameworkBuild { + cwd: string; + mode: "development" | "production"; + config: ResolvedFrameworkConfig; + fileDependencies: string[]; + pluginWatchFiles: string[]; + dispose(): Promise; +} + +export { + type InspectBuildEntry, + type InspectDiagnostic, + type InspectFrameworkBuildOptions, + type InspectFrameworkBuildResult, + type InspectHtmlDocument, + type InspectPageRoute, + type InspectRouteFile, + inspectFrameworkBuild, +} from "./inspect.js"; + +interface InternalPrepareFrameworkBuildOptions + extends PrepareFrameworkBuildOptions { + plan?: CreateBuildPlanOptions; +} + +interface InternalPreparedFrameworkBuild + extends PreparedFrameworkBuild { + graph: CoreGraph; + plan: BuildPlan; + hooks: PluginHooks[]; + pluginContext: PluginSetupContext; +} + +interface GeneratedDevStateSnapshot { + commit(): Promise; + restore(): Promise; +} + +interface DevApiRuntimeState { + config: ResolvedFrameworkConfig; + frameworkRuntime: ReturnType | undefined; + plan: BuildPlan; + serverEntry: string | undefined; +} + +async function createGeneratedDevStateSnapshot( + cwd: string, +): Promise { + const snapshotRoot = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "evjs-dev-state-"), + ); + const generatedIrPath = path.resolve(cwd, GENERATED_IR_DIR); + const generatedIrSnapshot = path.join(snapshotRoot, "generated-ir"); + const generatedTypeFiles = new Map(); + + try { + const hadGeneratedIr = await pathExists(generatedIrPath); + if (hadGeneratedIr) { + await fs.promises.cp(generatedIrPath, generatedIrSnapshot, { + recursive: true, + }); + } + + await captureGeneratedTypeFile( + generatedTypeFiles, + getPageRouteTypesPath(cwd).file, + await collectGeneratedPageRouteTypeFiles(cwd), + ); + await captureGeneratedTypeFile( + generatedTypeFiles, + getPluginTypesPath(cwd).file, + await collectGeneratedPluginTypeFiles(cwd), + ); + + let settled = false; + return { + async commit() { + if (settled) return; + settled = true; + await removeDevStateSnapshot(snapshotRoot); + }, + async restore() { + if (settled) return; + settled = true; + try { + await runCleanupTasks([ + () => + restoreGeneratedIr( + cwd, + generatedIrPath, + generatedIrSnapshot, + hadGeneratedIr, + ), + () => restoreGeneratedTypes(cwd, generatedTypeFiles), + ]); + } finally { + await removeDevStateSnapshot(snapshotRoot); + } + }, + }; + } catch (error) { + return rethrowAfterCleanup( + error, + () => fs.promises.rm(snapshotRoot, { force: true, recursive: true }), + "[evjs] Failed to capture generated dev state and remove its incomplete snapshot.", + ); + } +} + +async function captureGeneratedTypeFile( + snapshot: Map, + canonicalFile: string, + generatedFiles: readonly string[], +): Promise { + if (!(await pathExists(canonicalFile))) { + snapshot.set(canonicalFile, undefined); + return; + } + const absoluteCanonicalFile = path.resolve(canonicalFile); + if ( + generatedFiles.some( + (generatedFile) => path.resolve(generatedFile) === absoluteCanonicalFile, + ) + ) { + snapshot.set(canonicalFile, await fs.promises.readFile(canonicalFile)); + } +} + +async function restoreGeneratedIr( + cwd: string, + generatedIrPath: string, + generatedIrSnapshot: string, + hadGeneratedIr: boolean, +): Promise { + if (!hadGeneratedIr) { + await fs.promises.rm(generatedIrPath, { force: true, recursive: true }); + return; + } + + const restoreRoot = await fs.promises.mkdtemp( + path.join(cwd, `${GENERATED_IR_DIR}-restore-`), + ); + const restoredIrPath = path.join(restoreRoot, "generated-ir"); + try { + await fs.promises.cp(generatedIrSnapshot, restoredIrPath, { + recursive: true, + }); + await fs.promises.rm(generatedIrPath, { force: true, recursive: true }); + await fs.promises.rename(restoredIrPath, generatedIrPath); + } finally { + await fs.promises.rm(restoreRoot, { force: true, recursive: true }); + } +} + +async function restoreGeneratedTypes( + cwd: string, + generatedTypeFiles: ReadonlyMap, +): Promise { + const currentGeneratedFiles = await collectGeneratedPageRouteTypeFiles(cwd); + currentGeneratedFiles.push(...(await collectGeneratedPluginTypeFiles(cwd))); + const filesToRestore = new Set([ + ...currentGeneratedFiles, + ...generatedTypeFiles.keys(), + ]); + await runCleanupTasks( + [...filesToRestore].map( + (file) => () => + restoreGeneratedTypeFile(cwd, file, generatedTypeFiles.get(file)), + ), + ); +} + +async function restoreGeneratedTypeFile( + cwd: string, + file: string, + source: Buffer | undefined, +): Promise { + if (await pathExists(file)) { + if (!(await isGeneratedFrameworkTypeFile(cwd, file))) { + logger.warn`Generated types rollback preserved a user-owned or symbolic-link file at ${file}; the previous framework declaration was not restored.`; + return; + } + await removeOwnedOutputFile(cwd, file, "Generated types rollback"); + } + + if (source === undefined) return; + if (await pathExists(file)) { + logger.warn`Generated types rollback preserved a file created concurrently at ${file}; the previous framework declaration was not restored.`; + return; + } + await writeOwnedOutputFile(cwd, file, source, "Generated types rollback"); +} + +async function isGeneratedFrameworkTypeFile( + cwd: string, + file: string, +): Promise { + const absoluteFile = path.resolve(file); + if (absoluteFile === path.resolve(getPageRouteTypesPath(cwd).file)) { + return isGeneratedPageRouteTypesFile(file); + } + if (absoluteFile === path.resolve(getPluginTypesPath(cwd).file)) { + return isGeneratedPluginTypesFile(file); + } + return false; +} + +async function pathExists(file: string): Promise { + try { + await fs.promises.lstat(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function removeDevStateSnapshot(snapshotRoot: string): Promise { + try { + await fs.promises.rm(snapshotRoot, { force: true, recursive: true }); + } catch (error) { + logger.warn`Unable to remove generated dev state snapshot ${snapshotRoot}: ${error}`; + } +} + +function reportGraphDiagnostics(analysis: { + diagnostics: Array<{ + level: "warning" | "error"; + message: string; + file?: string; + line?: number; + column?: number; + }>; +}): void { + const errors: string[] = []; + + for (const diagnostic of analysis.diagnostics) { + const message = formatGraphDiagnostic(diagnostic); + if (diagnostic.level === "error") { + errors.push(message); + } else { + logger.warn`${message}`; + } + } + + if (errors.length > 0) { + throw new Error( + ["[evjs] CoreGraph analysis failed.", ...errors].join("\n"), + ); + } +} + +function formatGraphDiagnostic(diagnostic: { + message: string; + file?: string; + line?: number; + column?: number; +}): string { + const location = [ + diagnostic.file, + diagnostic.line === undefined + ? undefined + : diagnostic.column === undefined + ? String(diagnostic.line) + : `${diagnostic.line}:${diagnostic.column}`, + ] + .filter(Boolean) + .join(":"); + + return location ? `${location} - ${diagnostic.message}` : diagnostic.message; +} + +async function prepareInternalFrameworkBuild( + userConfig?: Config, + options: InternalPrepareFrameworkBuildOptions = {}, +): Promise> { + const cwd = options.cwd ?? process.cwd(); + const mode = options.mode ?? "production"; + const flags = snapshotPluginFlags(options.flags); + const configuredConfig = await runConfigureHooks(userConfig, { + mode, + cwd, + flags, + }); + const pageResolvedConfig = await withPageRoutingDefaults( + resolveConfig(configuredConfig), + configuredConfig, + cwd, + ); + const rawResolvedConfig = await withServerRouteDiscovery( + pageResolvedConfig, + cwd, + ); + const conventionResolvedConfig = await withServerConventionDefaults( + rawResolvedConfig, + cwd, + ); + const resolvedConfig = { + ...conventionResolvedConfig, + plugins: orderPluginsByDependencies(conventionResolvedConfig.plugins), + }; + + const optionBundler = resolveBundlerConfig( + options.bundler, + "options.bundler", + ); + const bundler = optionBundler ?? resolvedConfig.bundler ?? undefined; + if (options.requireBundler && !bundler) { + throw new Error( + "[evjs] No bundler configured. Pass a bundler adapter in ev.config.ts or through dev/build options.", + ); + } + const baseConfig = bundler + ? withActiveBundler(resolvedConfig, bundler) + : resolvedConfig; + const { + registry: pluginSettings, + applicationSettings: applicationPluginSettings, + } = resolvePluginSettingsState(baseConfig); + const config = baseConfig; + const pluginWatchFiles = new Set(); + const pluginContext: MutablePluginSetupContext = { + mode, + cwd, + config, + flags, + logger, + addWatchFile(file) { + pluginWatchFiles.add(path.resolve(cwd, file)); + }, + }; + const hooks = await collectPluginHooks(config.plugins, pluginContext); + let disposed = false; + const dispose = async () => { + if (disposed) return; + disposed = true; + await runDisposeHooks(hooks, pluginContext); + }; + + try { + validateHtmlTemplates(cwd, config); + const { analysis, plan } = await analyzeAndMaterializeFrameworkIR({ + cwd, + mode, + config, + pluginContext, + pluginSettings, + applicationPluginSettings, + plan: options.plan, + onAnalysis: reportGraphDiagnostics, + }); + + return { + cwd, + mode, + config, + graph: analysis.graph, + plan, + hooks, + pluginContext, + fileDependencies: analysis.fileDependencies, + pluginWatchFiles: [...pluginWatchFiles].sort(), + dispose, + }; + } catch (err) { + return rethrowAfterCleanup( + err, + dispose, + "[evjs] Framework preparation failed and plugin cleanup also failed.", + ); + } +} + +export async function prepareFrameworkBuild( + userConfig?: Config, + options: PrepareFrameworkBuildOptions = {}, +): Promise> { + const cwd = options.cwd ?? process.cwd(); + return withProjectOperationLock(cwd, "prepare", async () => { + const prepared = await prepareInternalFrameworkBuild(userConfig, options); + return { + cwd: prepared.cwd, + mode: prepared.mode, + config: prepared.config, + fileDependencies: prepared.fileDependencies, + pluginWatchFiles: prepared.pluginWatchFiles, + dispose: prepared.dispose, + }; + }); +} + +function formatDevServerReady( + context: { origin: string }, + config: Pick, + plan: Pick, +): string { + const pageUrls = formatDevPageUrls(context.origin, config, plan); + const lines = [ + "App listening at:", + ...formatDevServerAddresses(context.origin), + ]; + if (pageUrls) { + lines.push( + " Pages:", + ...pageUrls.map((page) => ` ${page.pageId}: ${page.url}`), + ); + } + return lines.join("\n"); +} + +function formatDevServerAddresses(origin: string): string[] { + const addresses = [` Local: ${origin}`]; + try { + const networkUrl = new URL(origin); + if (networkUrl.hostname !== "localhost") return addresses; + const networkAddress = Object.values(os.networkInterfaces()) + .flatMap((entries) => entries ?? []) + .find((entry) => entry.family === "IPv4" && !entry.internal); + if (!networkAddress) return addresses; + networkUrl.hostname = networkAddress.address; + addresses.push(` Network: ${networkUrl.origin}`); + } catch { + // Custom bundler adapters may provide a non-standard origin string. + } + return addresses; +} + +function formatDevPageUrls( + origin: string, + config: Pick, + plan: Pick, +): { pageId: string; url: string }[] | undefined { + if (config.routing?.mode !== "mpa") return undefined; + + const htmlPageIds = new Set(); + const pageUrls = plan.html.flatMap((document) => { + const pageId = document.owner.pageId; + if (!pageId) return []; + htmlPageIds.add(pageId); + return [ + { + pageId, + url: formatDevUrl(origin, `/${document.fileName}`), + }, + ]; + }); + + for (const route of config.routing.routes) { + if (route.kind === "layout" || htmlPageIds.has(route.id)) continue; + pageUrls.push({ + pageId: route.id, + url: formatDevUrl(origin, route.path), + }); + } + + return pageUrls.length > 0 ? pageUrls : undefined; +} + +function formatDevUrl(origin: string, pathname: string): string { + const pathWithLeadingSlash = pathname.startsWith("/") + ? pathname + : `/${pathname}`; + return `${origin}${encodeURI(pathWithLeadingSlash)}`; +} + +function withReservedDevPorts( + config: ResolvedConfig, + ports: DevPortReservation, +): ResolvedConfig { + return { + ...config, + dev: { + ...config.dev, + port: ports.client.port, + }, + server: { + ...config.server, + dev: { + ...config.server.dev, + port: ports.server.port, + }, + }, + }; +} + +function logDevPortSelection(ports: DevPortReservation): void { + const changes = [ + ports.client.port === ports.client.requestedPort + ? undefined + : `client ${ports.client.requestedPort} -> ${ports.client.port}`, + ports.server.port === ports.server.requestedPort + ? undefined + : `server ${ports.server.requestedPort} -> ${ports.server.port}`, + ].filter((change): change is string => Boolean(change)); + + if (changes.length > 0) { + logger.warn`Configured dev ports are unavailable; reserved ${changes.join(", ")} for this session.`; + } +} + +function prepareDevWatchPlan( + dependencies: readonly string[], +): PreparedWatchFilesPlan { + return prepareWatchFilesPlan( + createWatchFilesPlan(dependencies, new Set(dependencies)), + ); +} + +function listConfiguredAnalysisWatchDependencies( + cwd: string, + config: ResolvedConfig, +): string[] { + return (config.routing?.dependencies ?? []).map((dependency) => + path.resolve(cwd, dependency), + ); +} + +function listInitialRouteWatchDependencies( + root: string, + state: RouteDirectoryWatchState, +): string[] { + if (state.unsafeBoundary !== root) return state.dependencies; + try { + if (fs.lstatSync(root).isSymbolicLink()) return state.dependencies; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") return [root]; + throw error; + } + return state.dependencies; +} + +export async function dev( + userConfig?: Config, + options?: DevOptions, +): Promise { + const cwd = options?.cwd ?? process.cwd(); + const releaseDevSessionLock = await acquireDevSessionLock(cwd); + const unregisterDevSessionExitCleanup = registerRuntimeExitCleanup(() => + releaseDevSessionLock.sync(), + ); + return withRegisteredRuntimeLock({ + release: releaseDevSessionLock, + unregisterExitCleanup: unregisterDevSessionExitCleanup, + run: () => + withProjectOperationLock(cwd, "dev", () => runDev(userConfig, options)), + cleanupErrorMessage: + "[evjs] Dev failed and its session lock cleanup also failed.", + }); +} + +async function runDev( + userConfig?: Config, + options?: DevOptions, +): Promise { + const cwd = options?.cwd ?? process.cwd(); + const flags = snapshotPluginFlags(options?.flags); + process.env.NODE_ENV ??= "development"; + const initialConfigWatchPlan = prepareDevWatchPlan( + listConfigDependencyFiles(cwd), + ); + const initialConfigDependencyBaselines = new Map< + string, + PreparedWatchFilesPlan + >(); + const configLoader = options?.loadConfig; + const shouldLoadInitialConfig = + configLoader !== undefined && + (options?.reloadInitialConfig === true || userConfig === undefined); + const initialUserConfig = + shouldLoadInitialConfig && configLoader + ? await configLoader(cwd, { + onDependency(file) { + const absolute = path.resolve(file); + if (initialConfigDependencyBaselines.has(absolute)) return; + initialConfigDependencyBaselines.set( + absolute, + prepareDevWatchPlan([absolute]), + ); + }, + }) + : userConfig; + const configuredConfig = await runConfigureHooks(initialUserConfig, { + mode: "development", + cwd, + flags, + }); + const baseResolvedConfig = resolveConfig(configuredConfig); + const pageRoot = baseResolvedConfig.application + ? path.resolve(cwd, baseResolvedConfig.application.pageRoot) + : path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); + const serverRoot = path.resolve(cwd, CANONICAL_SERVER_ROUTE_ROOT); + const [initialPageRouteWatchState, initialServerRouteWatchState] = + await Promise.all([ + baseResolvedConfig.application || + (baseResolvedConfig.conventions !== false && + configuredConfig?.routing !== undefined) + ? collectRouteDirectoryWatchState(cwd, pageRoot) + : undefined, + baseResolvedConfig.conventions !== false + ? collectRouteDirectoryWatchState(cwd, serverRoot) + : undefined, + ]); + const initialDevWatchPlans: InitialDevWatchPlans = { + config: initialConfigWatchPlan, + configDependencies: initialConfigDependencyBaselines, + ...(initialPageRouteWatchState + ? { + pageRoutes: prepareDevWatchPlan( + listInitialRouteWatchDependencies( + pageRoot, + initialPageRouteWatchState, + ), + ), + } + : {}), + ...(initialServerRouteWatchState + ? { + serverRoutes: prepareDevWatchPlan( + listInitialRouteWatchDependencies( + serverRoot, + initialServerRouteWatchState, + ), + ), + } + : {}), + }; + const pageResolvedConfig = await withPageRoutingDefaults( + baseResolvedConfig, + configuredConfig, + cwd, + ); + const rawResolvedConfig = await withServerRouteDiscovery( + pageResolvedConfig, + cwd, + ); + const conventionResolvedConfig = await withServerConventionDefaults( + rawResolvedConfig, + cwd, + ); + const serverRouteWatchState = await collectServerRouteWatchState( + cwd, + baseResolvedConfig, + ); + const requestedConfig = { + ...conventionResolvedConfig, + plugins: orderPluginsByDependencies(conventionResolvedConfig.plugins), + }; + const devPorts = await reserveDevPorts( + cwd, + requestedConfig.dev.port, + requestedConfig.server.dev.port, + ); + const resolvedConfig = withReservedDevPorts(requestedConfig, devPorts); + const unregisterDevPortsExitCleanup = registerRuntimeExitCleanup(() => + devPorts.releaseSync(), + ); + + try { + await runDevSession( + initialUserConfig, + configuredConfig, + options, + cwd, + flags, + resolvedConfig, + devPorts, + initialPageRouteWatchState ?? { dependencies: [] }, + serverRouteWatchState, + initialDevWatchPlans, + ); + } finally { + try { + await devPorts.release(); + } finally { + unregisterDevPortsExitCleanup(); + } + } +} + +async function runDevSession( + userConfig: Config | undefined, + configuredConfig: Config | undefined, + options: DevOptions | undefined, + cwd: string, + flags: PluginSetupContext["flags"], + resolvedConfig: ResolvedConfig, + devPorts: DevPortReservation, + initialPageRouteWatchState: RouteDirectoryWatchState, + initialServerRouteWatchState: RouteDirectoryWatchState, + initialDevWatchPlans: InitialDevWatchPlans, +): Promise { + type WatchGeneration = { + readonly key: string; + readonly stop: () => void; + }; + type CandidateWatchGeneration = WatchGeneration & { + readonly dependencies: ReadonlySet; + readonly requiresConfigReload: boolean; + }; + + logDevPortSelection(devPorts); + + const bundler = resolveBundler( + resolvedConfig.bundler ?? options?.fallbackBundler, + options?.bundler, + ); + const baseActiveConfig = withActiveBundler(resolvedConfig, bundler); + const initialPluginSettingsState = + resolvePluginSettingsState(baseActiveConfig); + let activePluginSettings = initialPluginSettingsState.registry; + let activeApplicationPluginSettings = + initialPluginSettingsState.applicationSettings; + let activeConfiguredConfig = configuredConfig; + let activeConfig = baseActiveConfig; + let activePageRouteWatchState = initialPageRouteWatchState; + let activeServerRouteWatchState = initialServerRouteWatchState; + const activeConfigWatchFiles = new Set( + initialDevWatchPlans.configDependencies.keys(), + ); + + const pluginWatchFiles = new Set(); + const bundlerConfigWatchFiles = new Set(); + let reportWatchRegistrationFailure: ((failure: unknown) => void) | undefined; + const captureWatchBaseline = (file: string) => { + try { + return prepareWatchFilesPlan( + createWatchFilesPlan([file], new Set([file])), + ); + } catch (error) { + reportWatchRegistrationFailure?.(error); + throw error; + } + }; + const pendingPluginWatchBaselines = new Map(); + const pendingBundlerWatchBaselines = new Map< + string, + PreparedWatchFilesPlan + >(); + let onPluginWatchFileAdded: + | ((baseline: PreparedWatchFilesPlan) => void) + | undefined; + let onBundlerConfigWatchFileAdded: + | ((baseline: PreparedWatchFilesPlan) => void) + | undefined; + const addWatchFile = (file: string) => { + const absolute = path.resolve(cwd, file); + if (pluginWatchFiles.has(absolute)) return; + const baseline = captureWatchBaseline(absolute); + pluginWatchFiles.add(absolute); + if (onPluginWatchFileAdded) { + onPluginWatchFileAdded(baseline); + } else { + pendingPluginWatchBaselines.set(absolute, baseline); + } + }; + const addBundlerConfigWatchFile = (file: string) => { + const absolute = path.resolve(cwd, file); + if (bundlerConfigWatchFiles.has(absolute)) return; + const baseline = captureWatchBaseline(absolute); + bundlerConfigWatchFiles.add(absolute); + if (onBundlerConfigWatchFileAdded) { + onBundlerConfigWatchFileAdded(baseline); + } else { + pendingBundlerWatchBaselines.set(absolute, baseline); + } + }; + let pluginContextRetired = false; + let retirePluginContext = () => { + pluginContextRetired = true; + }; + let pluginCtx: MutablePluginSetupContext = { + mode: "development", + cwd, + config: activeConfig, + flags, + logger, + addWatchFile(file) { + if (pluginContextRetired) return; + addWatchFile(file); + }, + }; + let hooks: PluginHooks[]; + try { + hooks = await collectPluginHooks( + activeConfig.plugins, + pluginCtx, + retirePluginContext, + ); + } catch (error) { + retirePluginContext(); + throw error; + } + let activePluginExecution = createDevPluginExecutionSnapshot( + hooks, + pluginCtx, + ); + const activeObservedSourceDependencies = new Set(); + const initialAnalysisWatchBaselines: PreparedWatchFilesPlan[] = []; + const initialSourceBaselines = new Map(); + const captureInitialSourceRead = (file: string) => { + const absolute = path.resolve(file); + if (initialSourceBaselines.has(absolute)) return; + initialSourceBaselines.set(absolute, captureWatchBaseline(absolute)); + }; + const captureInitialObservedSourceDependency = (file: string) => { + const absolute = path.resolve(file); + activeObservedSourceDependencies.add(absolute); + captureInitialSourceRead(absolute); + }; + let activeAnalysis: Awaited>; + let activePlan: BuildPlan; + try { + validateHtmlTemplates(cwd, activeConfig); + const configuredAnalysisDependencies = + listConfiguredAnalysisWatchDependencies(cwd, activeConfig); + if (configuredAnalysisDependencies.length > 0) { + initialAnalysisWatchBaselines.push( + prepareDevWatchPlan(configuredAnalysisDependencies), + ); + } + const materialized = await analyzeAndMaterializeFrameworkIR({ + cwd, + mode: "development", + config: activeConfig, + pluginContext: pluginCtx, + pluginSettings: activePluginSettings, + applicationPluginSettings: activeApplicationPluginSettings, + plan: { distDir: DEV_DIST_DIR }, + beforeSourceRead: captureInitialSourceRead, + onSourceDependency: captureInitialObservedSourceDependency, + onAnalysis(analysis) { + reportGraphDiagnostics(analysis); + const dependencies = analysis.fileDependencies; + initialAnalysisWatchBaselines.push(prepareDevWatchPlan(dependencies)); + }, + }); + activeAnalysis = materialized.analysis; + activePlan = materialized.plan; + await assertNoActiveDevDistLock(cwd, activePlan.distDir); + } catch (error) { + return rethrowAfterCleanup( + error, + async () => { + const execution = activePluginExecution; + retirePluginContext(); + await execution.waitForIdle(); + await runDisposeHooks(execution.hooks, execution.context); + }, + "[evjs] Dev initialization failed and plugin cleanup also failed.", + ); + } + let restartQueue: Promise = Promise.resolve(); + let devUpdateQueue: Promise = Promise.resolve(); + let outputCycleQueue: Promise = Promise.resolve(); + type DevBundlerGenerationState = { + analysis: Awaited>; + config: ResolvedFrameworkConfig; + frameworkOutputTransaction: DevFrameworkOutputTransaction | undefined; + plan: BuildPlan; + pluginExecution: DevPluginExecutionSnapshot; + frameworkRuntime: ReturnType | undefined; + serverEntry: string | undefined; + }; + type DevBundlerGenerationRecord = { + status: "active" | "blocked" | "retired" | "staged"; + state?: DevBundlerGenerationState; + }; + const bundlerGenerations = new WeakMap< + BundlerDevGeneration, + DevBundlerGenerationRecord + >(); + const createBundlerGeneration = (): BundlerDevGeneration => { + const generation = Object.freeze({}) as BundlerDevGeneration; + bundlerGenerations.set(generation, { status: "staged" }); + return generation; + }; + const activateBundlerGeneration = ( + generation: BundlerDevGeneration, + state: DevBundlerGenerationState, + blocked = false, + ) => { + const record = bundlerGenerations.get(generation); + if (!record) { + throw new Error( + `[evjs] Bundler "${bundler.name}" used an unknown development generation.`, + ); + } + if (record.status !== "staged") { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by activating a ${record.status} generation. Each update generation must be activated exactly once.`, + ); + } + record.status = blocked ? "blocked" : "active"; + record.state = state; + }; + const blockBundlerGeneration = (generation: BundlerDevGeneration) => { + const record = bundlerGenerations.get(generation); + if (!record || record.status !== "active") { + throw new Error( + `[evjs] Bundler "${bundler.name}" cannot block a development generation that is not active.`, + ); + } + record.status = "blocked"; + }; + const unblockBundlerGeneration = (generation: BundlerDevGeneration) => { + const record = bundlerGenerations.get(generation); + if (!record || record.status !== "blocked") { + throw new Error( + `[evjs] Bundler "${bundler.name}" cannot resume a development generation that is not blocked.`, + ); + } + record.status = "active"; + }; + const retireBundlerGeneration = (generation: BundlerDevGeneration) => { + const record = bundlerGenerations.get(generation); + if (!record || record.status === "retired") return; + record.status = "retired"; + record.state = undefined; + }; + const getBundlerGenerationState = ( + generation: BundlerDevGeneration, + allowBlocked = false, + ): DevBundlerGenerationState => { + const record = bundlerGenerations.get(generation); + if (!record) { + throw new Error( + `[evjs] Bundler "${bundler.name}" published development output for an unknown generation.`, + ); + } + if (record.status === "staged") { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output before activating its staged generation.`, + ); + } + if (record.status === "blocked" && !allowBlocked) { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output while generated inputs were in transition.`, + ); + } + if (record.status === "retired") { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output for a retired generation.`, + ); + } + if (!record.state) { + throw new Error( + `[evjs] Bundler "${bundler.name}" has an active development generation without a framework snapshot.`, + ); + } + return record.state; + }; + const getBundlerGenerationStateForFacts = ( + generation: BundlerDevGeneration, + ): DevBundlerGenerationState | undefined => { + const record = bundlerGenerations.get(generation); + if (!record) { + throw new Error( + `[evjs] Bundler "${bundler.name}" published development output for an unknown generation.`, + ); + } + if (record.status === "staged") { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by publishing output before activating its staged generation.`, + ); + } + // A compile that was valid when reserved may finish after its generation + // was blocked or retired. This is an expected producer race, not a new + // canonical output cycle. + if (record.status === "blocked" || record.status === "retired") { + return undefined; + } + if (!record.state) { + throw new Error( + `[evjs] Bundler "${bundler.name}" has an active development generation without a framework snapshot.`, + ); + } + return record.state; + }; + const initialBundlerGeneration = createBundlerGeneration(); + let activeBundlerGeneration = initialBundlerGeneration; + activateBundlerGeneration(initialBundlerGeneration, { + analysis: activeAnalysis, + config: activeConfig, + frameworkOutputTransaction: undefined, + plan: activePlan, + pluginExecution: activePluginExecution, + frameworkRuntime: undefined, + serverEntry: undefined, + }); + const enqueueOutputCycle = (run: () => Promise): Promise => { + const cycle = outputCycleQueue.then(run, run); + outputCycleQueue = cycle.then( + () => {}, + () => {}, + ); + return cycle; + }; + let devController: BundlerDevController | undefined; + let releaseDevDistLock: DevRuntimeRelease | undefined; + let unregisterDevDistExitCleanup = () => {}; + let devDependencyWatcher: WatchGeneration | undefined; + let candidateDependencyWatcher: CandidateWatchGeneration | undefined; + let debounceTimer: ReturnType | undefined; + const pendingDevChanges = new Set(); + const lastScheduledDevChanges = new Map(); + let pendingForcedConfigReload = false; + let devSessionEnding = false; + const expectedApiExits = new WeakSet(); + const apiProcessController = new DevApiProcessController({ + expectExit(process) { + expectedApiExits.add(process); + }, + requestStop(process) { + process.kill(); + }, + stop: stopApiProcess, + }); + let resolveShutdown: (() => void) | undefined; + const waitForShutdown = new Promise((resolve) => { + resolveShutdown = resolve; + }); + let resolveDevWatchFailure: ((error: Error) => void) | undefined; + const waitForDevWatchFailure = new Promise((resolve) => { + resolveDevWatchFailure = resolve; + }); + let devWatchFailed = false; + let devWatchMode = resolveInitialDevWatchMode(); + const reportDevWatchFailure = (failure: unknown) => { + if (devWatchFailed) return; + devWatchFailed = true; + const error = + failure instanceof Error ? failure : new Error(String(failure)); + resolveDevWatchFailure?.(error); + }; + reportWatchRegistrationFailure = reportDevWatchFailure; + const reportDevWatchFallback = (error: Error) => { + if (devWatchMode === "polling") return; + devWatchMode = "polling"; + logger.warn`Development dependency event watchers are unavailable; falling back to polling: ${error}`; + }; + const stopWatcherGroup = (stop: () => void) => { + try { + stop(); + } catch (error) { + reportDevWatchFailure(error); + throw error; + } + }; + + const stopApiOnParentShutdown = () => { + apiProcessController.requestStop(); + resolveShutdown?.(); + }; + + process.once("SIGINT", stopApiOnParentShutdown); + process.once("SIGTERM", stopApiOnParentShutdown); + + const restartApiServer = async ( + state: DevApiRuntimeState, + ): Promise => { + const serverBundlePath = await findDevServerBundlePath( + cwd, + state.plan.output.serverDir, + state.serverEntry, + ); + if (!serverBundlePath) return false; + + const serverPort = + state.config.server?.dev?.port ?? CONFIG_DEFAULTS.serverPort; + + const devRootDir = path.resolve(cwd, state.plan.distDir); + const bootstrapPath = path.join(devRootDir, "_dev_start.cjs"); + try { + await writeOwnedOutputFile( + cwd, + bootstrapPath, + [ + `(async () => {`, + `const path = require("node:path");`, + `const { pathToFileURL } = require("node:url");`, + `globalThis.__EVJS_FRAMEWORK_RUNTIME__ = ${serializeFrameworkRuntimeExpression(state.frameworkRuntime)};`, + `globalThis.__EVJS_DEV_PAGE_RENDER_PROXY_HEADER__ = ${JSON.stringify(DEV_PAGE_RENDER_PROXY_HEADER)};`, + `const serverDir = path.dirname(${JSON.stringify(serverBundlePath)});`, + `globalThis.__EVJS_SERVER_MODULE_LOADER__ = async (asset) => { const mod = await import(pathToFileURL(path.resolve(serverDir, asset)).href); const nested = mod && typeof mod.default === "object" ? mod.default : undefined; return nested && ("default" in nested || "render" in nested) ? nested : mod; };`, + `const serverModule = await import(${JSON.stringify(pathToFileURL(serverBundlePath).href)});`, + `const handler = serverModule.default?.default ?? serverModule.default ?? serverModule;`, + `const { serve } = require("@evjs/ev/_internal/server/node");`, + `const server = serve({ fetch: handler.fetch }, { port: ${serverPort}, host: "0.0.0.0", https: ${JSON.stringify(state.config.server?.dev?.https ?? false)} });`, + `const ready = () => console.log(${JSON.stringify(API_READY_MARKER)});`, + `if (server.listening) ready(); else server.once("listening", ready);`, + `server.once("error", (err) => { console.error(err); process.exit(1); });`, + `})().catch((err) => { console.error(err); process.exit(1); });`, + ].join("\n"), + "Dev server bootstrap output", + ); + + if (apiProcessController.process) { + logger.info`Restarting API server...`; + } + logger.info`Server bundle detected, starting API...`; + await apiProcessController.replace(() => { + const child = execa("node", [bootstrapPath], { + stdio: ["inherit", "pipe", "pipe"], + env: { ...process.env, NODE_ENV: "development" }, + }); + forwardApiOutput(child); + child.catch((err) => { + if (expectedApiExits.has(child)) return; + if (apiProcessController.clearUnexpectedExit(child)) { + logger.error`API server process exited unexpectedly: ${err}`; + } + }); + return child; + }, waitForApiReady); + const serverProtocol = state.config.server.dev.https ? "https" : "http"; + const serverOrigin = `${serverProtocol}://localhost:${serverPort}`; + logger.info`${["API server listening at:", ...formatDevServerAddresses(serverOrigin)].join("\n")}`; + return true; + } catch (err) { + logger.error`Server runtime failed: ${err}`; + throw err; + } + }; + + const handleServerBundleReady = async (generation: BundlerDevGeneration) => { + if (devSessionEnding) return; + const generationState = getBundlerGenerationState(generation); + const state: DevApiRuntimeState = { + config: generationState.config, + frameworkRuntime: generationState.frameworkRuntime, + plan: generationState.plan, + serverEntry: generationState.serverEntry, + }; + restartQueue = restartQueue + .catch(() => {}) + .then(() => { + if (devSessionEnding) return; + return restartApiServer(state); + }) + .then(() => {}); + await restartQueue; + }; + + const loadCurrentConfig = async ( + reloadConfiguredConfig: boolean, + onRouteWatchState?: (state: { + page: RouteDirectoryWatchState; + server: RouteDirectoryWatchState; + }) => void, + onConfigDependency?: (file: string) => void, + ) => { + const nextConfiguredConfig = reloadConfiguredConfig + ? await runConfigureHooks( + options?.loadConfig + ? await options.loadConfig(cwd, { + onDependency(file) { + onConfigDependency?.(file); + }, + }) + : userConfig, + { + mode: "development", + cwd, + flags, + }, + ) + : activeConfiguredConfig; + const nextBaseResolvedConfig = resolveConfig(nextConfiguredConfig); + const nextPageRoot = nextBaseResolvedConfig.application + ? path.resolve(cwd, nextBaseResolvedConfig.application.pageRoot) + : path.resolve(cwd, CANONICAL_PAGE_ROUTE_ROOT); + const shouldWatchPageRoutes = + Boolean(nextBaseResolvedConfig.application) || + (nextBaseResolvedConfig.conventions !== false && + nextConfiguredConfig?.routing !== undefined); + const [pageRouteWatchState, serverRouteWatchState] = await Promise.all([ + shouldWatchPageRoutes + ? collectRouteDirectoryWatchState(cwd, nextPageRoot) + : { dependencies: [] }, + collectServerRouteWatchState(cwd, nextBaseResolvedConfig), + ]); + onRouteWatchState?.({ + page: pageRouteWatchState, + server: serverRouteWatchState, + }); + const nextPageResolvedConfig = await withPageRoutingDefaults( + nextBaseResolvedConfig, + nextConfiguredConfig, + cwd, + ); + const nextRawResolvedConfig = await withServerRouteDiscovery( + nextPageResolvedConfig, + cwd, + ); + const nextConventionResolvedConfig = await withServerConventionDefaults( + nextRawResolvedConfig, + cwd, + ); + const nextResolvedConfig = { + ...nextConventionResolvedConfig, + plugins: orderPluginsByDependencies(nextConventionResolvedConfig.plugins), + }; + + if ( + nextResolvedConfig.dev.port !== devPorts.client.requestedPort || + nextResolvedConfig.server.dev.port !== devPorts.server.requestedPort + ) { + throw new Error( + "[evjs] dev.port or server.dev.port changed while ev dev is running. Restart ev dev to apply the new ports.", + ); + } + + return { + configuredConfig: nextConfiguredConfig, + config: withActiveBundler( + withReservedDevPorts(nextResolvedConfig, devPorts), + bundler, + ), + serverRouteWatchState, + }; + }; + + const stagePluginHooks = async ( + nextConfig: typeof activeConfig, + onWatchFileAdded?: (file: string, baseline: PreparedWatchFilesPlan) => void, + ) => { + const previousHooks = [...hooks]; + const previousPluginWatchFiles = [...pluginWatchFiles]; + const previousPluginCtx = pluginCtx; + const retirePreviousPluginContext = retirePluginContext; + const previousPluginExecution = activePluginExecution; + const nextPluginWatchFiles = new Set(); + const nextPluginWatchBaselines = new Map(); + let activated = false; + let nextPluginContextRetired = false; + const retireNextPluginContext = () => { + nextPluginContextRetired = true; + }; + let settlement: "committed" | "pending" | "rolled-back" = "pending"; + const nextPluginCtx: MutablePluginSetupContext = { + ...pluginCtx, + config: nextConfig, + addWatchFile(file) { + if (nextPluginContextRetired) return; + const absolute = path.resolve(cwd, file); + if (settlement === "committed") { + addWatchFile(absolute); + return; + } + if (settlement === "rolled-back") return; + if (nextPluginWatchFiles.has(absolute)) return; + const baseline = captureWatchBaseline(absolute); + nextPluginWatchBaselines.set(absolute, baseline); + nextPluginWatchFiles.add(absolute); + if (activated) pluginWatchFiles.add(absolute); + onWatchFileAdded?.(absolute, baseline); + }, + }; + let nextHooks: PluginHooks[]; + try { + nextHooks = await collectPluginHooks( + nextConfig.plugins, + nextPluginCtx, + retireNextPluginContext, + ); + } catch (error) { + settlement = "rolled-back"; + retireNextPluginContext(); + throw error; + } + const nextPluginExecution = createDevPluginExecutionSnapshot( + nextHooks, + nextPluginCtx, + ); + + return { + pluginContext: nextPluginCtx, + watchBaselines: nextPluginWatchBaselines, + activate() { + if (activated || settlement !== "pending") return; + hooks.splice(0, hooks.length, ...nextHooks); + pluginWatchFiles.clear(); + for (const file of nextPluginWatchFiles) { + pluginWatchFiles.add(file); + } + pluginCtx = nextPluginCtx; + retirePluginContext = retireNextPluginContext; + activePluginExecution = nextPluginExecution; + activated = true; + }, + async commit() { + if (settlement !== "pending") return; + if (!activated) { + throw new Error( + "[evjs] Cannot commit plugin hooks before activating the staged snapshot.", + ); + } + settlement = "committed"; + retirePreviousPluginContext(); + await previousPluginExecution.waitForIdle(); + await runDisposeHooks(previousHooks, previousPluginCtx); + }, + async rollback() { + if (settlement !== "pending") return; + settlement = "rolled-back"; + retireNextPluginContext(); + if (activated) { + hooks.splice(0, hooks.length, ...previousHooks); + pluginWatchFiles.clear(); + for (const file of previousPluginWatchFiles) { + pluginWatchFiles.add(file); + } + pluginCtx = previousPluginCtx; + retirePluginContext = retirePreviousPluginContext; + activePluginExecution = previousPluginExecution; + } + await nextPluginExecution.waitForIdle(); + await runDisposeHooks(nextHooks, nextPluginCtx); + }, + }; + }; + + const listUnsafeRouteWatchBoundaries = ( + ...states: Array + ) => + states.flatMap((state) => + state?.unsafeBoundary ? [state.unsafeBoundary] : [], + ); + const filterUnsafeRouteWatchDependencies = ( + dependencies: Iterable, + unsafeBoundaries: readonly string[], + ) => + [...dependencies].filter((file) => + unsafeBoundaries.every( + (unsafeBoundary) => !isInsideCwd(unsafeBoundary, file), + ), + ); + const listDevDependencyWatchFiles = () => { + const unsafeBoundaries = listUnsafeRouteWatchBoundaries( + activePageRouteWatchState, + activeServerRouteWatchState, + ); + return filterUnsafeRouteWatchDependencies( + [ + ...listConfigDependencyFiles(cwd), + ...activeConfigWatchFiles, + ...activeAnalysis.fileDependencies, + ...activeObservedSourceDependencies, + ...activePageRouteWatchState.dependencies, + ...activeServerRouteWatchState.dependencies, + ...pluginWatchFiles, + ...bundlerConfigWatchFiles, + ].map((file) => path.resolve(file)), + unsafeBoundaries, + ); + }; + + const createDevDependencyWatchTopology = () => { + const dependencies = listDevDependencyWatchFiles(); + const recoverableMissingTargets = new Set(dependencies); + return createWatchFilesPlan(dependencies, recoverableMissingTargets); + }; + + const refreshDevDependencyWatchers = ( + reconcileFrom?: PreparedWatchFilesPlan, + ): PreparedWatchFilesPlan | undefined => { + if (devSessionEnding || devWatchFailed) return undefined; + let nextWatchTopology: WatchFilesPlan; + try { + nextWatchTopology = createDevDependencyWatchTopology(); + } catch (error) { + reportDevWatchFailure(error); + return undefined; + } + const topologyChanged = nextWatchTopology.key !== devDependencyWatcher?.key; + if (!topologyChanged && !reconcileFrom) return undefined; + + let nextWatchPlan: PreparedWatchFilesPlan; + try { + nextWatchPlan = prepareWatchFilesPlan(nextWatchTopology); + } catch (error) { + reportDevWatchFailure(error); + return undefined; + } + + if (topologyChanged) { + const previous = devDependencyWatcher; + let nextStop: () => void; + try { + nextStop = watchFiles(nextWatchPlan, scheduleDevUpdate, { + mode: devWatchMode, + onError: reportDevWatchFailure, + onFallback: reportDevWatchFallback, + }); + } catch { + return undefined; + } + devDependencyWatcher = { + key: nextWatchPlan.key, + stop: nextStop, + }; + if (previous) { + try { + stopWatcherGroup(previous.stop); + } catch { + return nextWatchPlan; + } + } + } + + if (reconcileFrom) { + for (const file of collectWatchFilesChangedSince( + reconcileFrom, + nextWatchPlan, + )) { + scheduleDevUpdate(file); + } + } + return nextWatchPlan; + }; + + const reconcileRegisteredWatchBaselines = ( + baselines: Iterable, + ) => { + const previousPlans = [...baselines]; + if (previousPlans.length === 0) return; + const dependencies = [ + ...new Set(previousPlans.flatMap((plan) => [...plan.logicalTargets])), + ]; + let currentPlan: PreparedWatchFilesPlan; + try { + currentPlan = prepareDevWatchPlan(dependencies); + } catch (error) { + reportDevWatchFailure(error); + return; + } + for (const previous of previousPlans) { + for (const file of collectWatchFilesChangedSince(previous, currentPlan)) { + scheduleDevUpdate(file); + } + } + }; + + const clearDevDependencyWatcher = () => { + const current = devDependencyWatcher; + devDependencyWatcher = undefined; + if (current) stopWatcherGroup(current.stop); + }; + + const clearCandidateDependencyWatcher = () => { + const current = candidateDependencyWatcher; + candidateDependencyWatcher = undefined; + if (current) stopWatcherGroup(current.stop); + }; + const retireCandidateDependencyWatcher = () => { + try { + clearCandidateDependencyWatcher(); + } catch { + // stopWatcherGroup already promoted the close failure to the session. + } + }; + + const watchCandidateDependencies = ( + candidateDependencies: readonly string[] | undefined, + reconcileFrom: Iterable = [], + requiresConfigReload = false, + ) => { + const reconciliationPlans = [...reconcileFrom]; + // A failure before candidate dependency discovery produced no replacement + // state. Keep the previous candidate watcher alive until a later attempt + // either resolves a new state or commits successfully. + const resolvedDependencies = + candidateDependencies ?? + (reconciliationPlans.length > 0 && candidateDependencyWatcher + ? [...candidateDependencyWatcher.dependencies] + : undefined); + if (!resolvedDependencies) return; + if (resolvedDependencies.length === 0) { + clearCandidateDependencyWatcher(); + return; + } + + const dependencies = [ + ...new Set(resolvedDependencies.map((file) => path.resolve(file))), + ]; + const candidateRequiresConfigReload = + requiresConfigReload || + candidateDependencyWatcher?.requiresConfigReload === true; + let currentMainWatchDependencies: ReadonlySet = new Set(); + try { + const currentMainTopology = createDevDependencyWatchTopology(); + if (currentMainTopology.key === devDependencyWatcher?.key) { + currentMainWatchDependencies = new Set( + currentMainTopology.logicalTargets, + ); + } + } catch (error) { + reportDevWatchFailure(error); + return; + } + const watchDependencies = dependencies.filter( + (file) => !currentMainWatchDependencies.has(file), + ); + const recoverableMissingTargets = new Set(watchDependencies); + let nextWatchTopology: WatchFilesPlan; + try { + nextWatchTopology = createWatchFilesPlan( + watchDependencies, + recoverableMissingTargets, + ); + } catch (error) { + reportDevWatchFailure(error); + return; + } + const nextWatchKey = JSON.stringify([ + [...dependencies].sort(), + candidateRequiresConfigReload, + nextWatchTopology.key, + ]); + const topologyChanged = nextWatchKey !== candidateDependencyWatcher?.key; + if (!topologyChanged && reconciliationPlans.length === 0) return; + + let nextWatchPlan: PreparedWatchFilesPlan; + try { + nextWatchPlan = prepareWatchFilesPlan(nextWatchTopology); + } catch (error) { + reportDevWatchFailure(error); + return; + } + + if (devSessionEnding || devWatchFailed) return; + if (topologyChanged) { + const previous = candidateDependencyWatcher; + let stop: () => void; + try { + stop = watchFiles( + nextWatchPlan, + (file) => scheduleDevUpdate(file, candidateRequiresConfigReload), + { + mode: devWatchMode, + onError: reportDevWatchFailure, + onFallback: reportDevWatchFallback, + }, + ); + } catch { + return; + } + candidateDependencyWatcher = { + dependencies: new Set(dependencies), + key: nextWatchKey, + requiresConfigReload: candidateRequiresConfigReload, + stop, + }; + if (previous) { + try { + stopWatcherGroup(previous.stop); + } catch { + return; + } + } + } + if (reconciliationPlans.length > 0) { + let currentReconcilePlan: PreparedWatchFilesPlan; + try { + currentReconcilePlan = prepareDevWatchPlan(dependencies); + } catch (error) { + reportDevWatchFailure(error); + return; + } + for (const previous of reconciliationPlans) { + for (const file of collectWatchFilesChangedSince( + previous, + currentReconcilePlan, + )) { + scheduleDevUpdate(file, candidateRequiresConfigReload); + } + } + } + }; + + const captureDevDependencyWatchPlan = () => { + try { + return prepareWatchFilesPlan(createDevDependencyWatchTopology()); + } catch (error) { + reportDevWatchFailure(error); + return undefined; + } + }; + + const captureCandidateDependencyWatchPlan = () => { + if (!candidateDependencyWatcher) return undefined; + try { + const dependencies = [...candidateDependencyWatcher.dependencies]; + return prepareDevWatchPlan(dependencies); + } catch (error) { + reportDevWatchFailure(error); + return undefined; + } + }; + + const commitStagedPluginHooks = async ( + stagedPluginHooks: Awaited> | undefined, + reconcileFrom?: PreparedWatchFilesPlan, + additionalBaselines: Iterable = [], + ) => { + const reconciliationBaselines = [ + ...(stagedPluginHooks?.watchBaselines.values() ?? []), + ...additionalBaselines, + ]; + try { + await stagedPluginHooks?.commit(); + } catch (error) { + logger.warn`Framework plan update was applied, but previous plugin cleanup failed: ${error}`; + } finally { + refreshDevDependencyWatchers(reconcileFrom); + reconcileRegisteredWatchBaselines(reconciliationBaselines); + } + }; + + const handleDevDependencyChange = async ( + changedFiles: readonly string[], + forceConfigReload = false, + devWatchReconcileFrom?: PreparedWatchFilesPlan, + candidateWatchReconcileFrom?: PreparedWatchFilesPlan, + ) => { + if (devSessionEnding || devWatchFailed) return; + let currentDevWatchReconcileFrom = devWatchReconcileFrom; + const configDependencyFiles = new Set([ + ...listConfigDependencyFiles(cwd), + ...activeConfigWatchFiles, + ]); + const isFrameworkConfigChange = changedFiles.some((file) => + configDependencyFiles.has(file), + ); + const isBundlerConfigChange = changedFiles.some((file) => + bundlerConfigWatchFiles.has(file), + ); + const requiresBundlerConfigReload = + forceConfigReload || isFrameworkConfigChange || isBundlerConfigChange; + const reason: BuildPlanUpdate["reason"] = requiresBundlerConfigReload + ? "config" + : "route-declaration"; + let stagedPluginHooks: + | Awaited> + | undefined; + let generatedStateSnapshot: GeneratedDevStateSnapshot | undefined; + let bundlerUpdateTransition: BundlerDevUpdateTransition | undefined; + let frameworkOutputTransaction: DevFrameworkOutputTransaction | undefined; + let previousBundlerGeneration: BundlerDevGeneration | undefined; + let candidateBundlerGeneration: BundlerDevGeneration | undefined; + let candidateGenerationActivated = false; + let candidateGenerationCommitted = false; + let postCommitFailure: { error: unknown } | undefined; + function recordPostCommitFailure(error: unknown): void { + postCommitFailure = postCommitFailure + ? { + error: new AggregateError( + [postCommitFailure.error, error], + "[evjs] Multiple post-commit development tasks failed.", + ), + } + : { error }; + } + let candidateRestoreFailed = false; + let candidatePageRouteWatchState: RouteDirectoryWatchState | undefined; + let candidateServerRouteWatchState: RouteDirectoryWatchState | undefined; + let candidateAnalysisWatchDependencies: readonly string[] | undefined; + const candidatePluginWatchDependencies = new Set(); + const candidatePluginWatchBaselines = new Map< + string, + PreparedWatchFilesPlan + >(); + const candidateConfigDependencies = new Set(); + const candidateConfigBaselines = new Map(); + const candidateTransientSourceDependencies = new Set(); + const candidateObservedSourceDependencies = new Set(); + const candidateSourceBaselines = new Map(); + const candidateRouteWatchBaselines: PreparedWatchFilesPlan[] = []; + let candidateWatchCoverageComplete = false; + let candidateWatcherReady = false; + let candidateConfigRegistrationsReconciled = true; + let candidatePluginRegistrationsReconciled = true; + let candidateSourcesReconciled = true; + let currentCandidateWatchReconcileFrom = candidateWatchReconcileFrom; + const collectCandidateWatchDependencies = () => { + const unsafeBoundaries = listUnsafeRouteWatchBoundaries( + candidatePageRouteWatchState, + candidateServerRouteWatchState, + ); + if (!requiresBundlerConfigReload) { + return filterUnsafeRouteWatchDependencies( + [ + ...(candidatePageRouteWatchState?.dependencies ?? []), + ...(candidateServerRouteWatchState?.dependencies ?? []), + ...(candidateDependencyWatcher?.dependencies ?? []), + ...candidateTransientSourceDependencies, + ...candidateObservedSourceDependencies, + ], + unsafeBoundaries, + ); + } + if (!candidatePageRouteWatchState || !candidateServerRouteWatchState) { + return candidateConfigDependencies.size > 0 + ? [ + ...(candidateDependencyWatcher?.dependencies ?? []), + ...candidateConfigDependencies, + ] + : undefined; + } + return filterUnsafeRouteWatchDependencies( + [ + ...candidatePageRouteWatchState.dependencies, + ...candidateServerRouteWatchState.dependencies, + ...(candidateWatchCoverageComplete + ? [] + : (candidateDependencyWatcher?.dependencies ?? [])), + ...candidateConfigDependencies, + ...candidatePluginWatchDependencies, + ...candidateTransientSourceDependencies, + ...candidateObservedSourceDependencies, + ...(candidateAnalysisWatchDependencies ?? []), + ], + unsafeBoundaries, + ); + }; + const listPendingCandidateWatchBaselines = () => [ + ...candidateConfigBaselines.values(), + ...candidatePluginWatchBaselines.values(), + ...candidateSourceBaselines.values(), + ...candidateRouteWatchBaselines, + ]; + const clearPendingCandidateWatchBaselines = () => { + candidateConfigBaselines.clear(); + candidatePluginWatchBaselines.clear(); + candidateSourceBaselines.clear(); + candidateRouteWatchBaselines.length = 0; + }; + const synchronizeCandidateWatcher = ( + registrationBaselines: Iterable = [], + ) => { + candidateWatcherReady = true; + const dependencies = collectCandidateWatchDependencies(); + const reconciliationPlans = [ + ...(currentCandidateWatchReconcileFrom + ? [currentCandidateWatchReconcileFrom] + : []), + ...registrationBaselines, + ]; + watchCandidateDependencies( + dependencies, + reconciliationPlans, + requiresBundlerConfigReload, + ); + currentCandidateWatchReconcileFrom = undefined; + candidateConfigRegistrationsReconciled = true; + candidatePluginRegistrationsReconciled = true; + candidateSourcesReconciled = true; + }; + const ensureCandidateWatcher = () => { + if ( + !candidateWatcherReady || + !candidateConfigRegistrationsReconciled || + !candidatePluginRegistrationsReconciled || + !candidateSourcesReconciled + ) { + synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); + clearPendingCandidateWatchBaselines(); + } + }; + const synchronizeCandidateAnalysisDependencies = ( + analysis: Awaited>, + coverageComplete = false, + ) => { + const unsafeBoundaries = [ + candidatePageRouteWatchState?.unsafeBoundary, + candidateServerRouteWatchState?.unsafeBoundary, + ].filter((boundary): boundary is string => Boolean(boundary)); + candidateAnalysisWatchDependencies = analysis.fileDependencies.filter( + (file) => + unsafeBoundaries.every( + (unsafeBoundary) => !isInsideCwd(unsafeBoundary, file), + ), + ); + candidateWatchCoverageComplete = coverageComplete; + synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); + clearPendingCandidateWatchBaselines(); + }; + const captureCandidateSourceRead = (file: string) => { + const absolute = path.resolve(file); + candidateTransientSourceDependencies.add(absolute); + candidateSourcesReconciled = false; + if (candidateSourceBaselines.has(absolute)) return; + candidateSourceBaselines.set(absolute, captureWatchBaseline(absolute)); + }; + const captureCandidateObservedSourceDependency = (file: string) => { + const absolute = path.resolve(file); + candidateObservedSourceDependencies.add(absolute); + captureCandidateSourceRead(absolute); + }; + const captureCandidateConfigDependency = (file: string) => { + const absolute = path.resolve(file); + candidateConfigDependencies.add(absolute); + candidateConfigRegistrationsReconciled = false; + if (candidateConfigBaselines.has(absolute)) return; + candidateConfigBaselines.set(absolute, captureWatchBaseline(absolute)); + }; + const commitCandidateConfigDependencies = () => { + if (!requiresBundlerConfigReload) return; + activeConfigWatchFiles.clear(); + for (const file of candidateConfigDependencies) { + activeConfigWatchFiles.add(file); + } + }; + const commitCandidateObservedSourceDependencies = () => { + activeObservedSourceDependencies.clear(); + for (const file of candidateObservedSourceDependencies) { + activeObservedSourceDependencies.add(file); + } + }; + const rollbackCandidatePluginGeneration = () => { + if (!candidateGenerationCommitted && candidateBundlerGeneration) { + retireBundlerGeneration(candidateBundlerGeneration); + if (previousBundlerGeneration) { + activeBundlerGeneration = previousBundlerGeneration; + } + } + return stagedPluginHooks?.rollback(); + }; + const selectAndResumeBundlerUpdateTransition = async ( + outcome: "accept" | "rollback", + ) => { + const transition = bundlerUpdateTransition; + if (!transition) return; + try { + if (outcome === "accept") await transition.accept(); + else await transition.rollback(); + } catch (error) { + if (outcome === "rollback") reportDevWatchFailure(error); + throw error; + } + unblockBundlerGeneration(activeBundlerGeneration); + try { + await transition.resume(); + } catch (error) { + blockBundlerGeneration(activeBundlerGeneration); + if (outcome === "rollback") reportDevWatchFailure(error); + throw error; + } + }; + const prepareBundlerUpdateTransitionFinalization = async () => { + const transition = bundlerUpdateTransition; + if (!transition) return; + await transition.prepareFinalize(); + }; + const finalizeBundlerUpdateTransition = () => { + const transition = bundlerUpdateTransition; + if (!transition) return; + try { + const result: unknown = transition.finalize(); + if ( + result !== null && + (typeof result === "object" || typeof result === "function") && + typeof (result as { then?: unknown }).then === "function" + ) { + // Absorb a later rejection after reporting the synchronous contract + // violation. Waiting here could deadlock a fully committed update. + void Promise.resolve(result).catch(() => {}); + throw new Error( + `[evjs] Bundler "${bundler.name}" returned a Promise from development transition finalize(). finalize() must synchronously release the committed update boundary.`, + ); + } + } finally { + bundlerUpdateTransition = undefined; + } + }; + const rollbackCandidateState = () => + runCleanupTasks([ + rollbackCandidatePluginGeneration, + async () => { + if (candidateRestoreFailed) { + throw new Error( + "[evjs] The previous generated development state did not restore completely; the bundler update boundary remains closed.", + ); + } + const restoreErrors: unknown[] = []; + try { + await frameworkOutputTransaction?.restore(); + } catch (error) { + restoreErrors.push(error); + } + try { + await generatedStateSnapshot?.restore(); + } catch (error) { + restoreErrors.push(error); + } + if (restoreErrors.length > 0) { + candidateRestoreFailed = true; + const error = new AggregateError( + restoreErrors, + "[evjs] Unable to restore the previous generated development state; the bundler update boundary remains closed.", + ); + reportDevWatchFailure(error); + throw error; + } + getBundlerGenerationState( + activeBundlerGeneration, + true, + ).frameworkOutputTransaction = undefined; + await selectAndResumeBundlerUpdateTransition("rollback"); + await prepareBundlerUpdateTransitionFinalization(); + finalizeBundlerUpdateTransition(); + }, + ]); + const retireResolvedCandidateWatcher = () => { + if ( + requiresBundlerConfigReload || + candidateDependencyWatcher?.requiresConfigReload !== true + ) { + retireCandidateDependencyWatcher(); + } + }; + + try { + const { + configuredConfig: nextConfiguredConfig, + config: baseNextConfig, + serverRouteWatchState: nextServerRouteWatchState, + } = await loadCurrentConfig( + requiresBundlerConfigReload, + requiresBundlerConfigReload + ? ({ page, server }) => { + candidatePageRouteWatchState = page; + candidateServerRouteWatchState = server; + synchronizeCandidateWatcher(); + } + : ({ page, server }) => { + candidatePageRouteWatchState = page; + candidateServerRouteWatchState = server; + const routeDependencies = [ + ...page.dependencies, + ...server.dependencies, + ]; + if (routeDependencies.length > 0) { + candidateRouteWatchBaselines.push( + prepareDevWatchPlan(routeDependencies), + ); + } + activePageRouteWatchState = page; + activeServerRouteWatchState = server; + currentDevWatchReconcileFrom = + refreshDevDependencyWatchers(currentDevWatchReconcileFrom) ?? + currentDevWatchReconcileFrom; + }, + requiresBundlerConfigReload + ? captureCandidateConfigDependency + : undefined, + ); + if ( + !hasSamePluginIdentity(activeConfig.plugins, baseNextConfig.plugins) + ) { + await rollbackCandidateState(); + logger.warn`Plugin configuration changed. Please restart ev dev to apply plugin additions, removals, or reordering.`; + return; + } + + const nextPluginSettings = requiresBundlerConfigReload + ? collectPluginSettingsRegistry(baseNextConfig.plugins) + : activePluginSettings; + let nextApplicationPluginSettings = activeApplicationPluginSettings; + const nextConfig: ResolvedFrameworkConfig = baseNextConfig; + if (requiresBundlerConfigReload) { + const nextPluginSettingsState = resolvePluginSettingsState( + baseNextConfig, + nextPluginSettings, + ); + nextApplicationPluginSettings = + nextPluginSettingsState.applicationSettings; + } + + validateHtmlTemplates(cwd, nextConfig); + if (requiresBundlerConfigReload) { + stagedPluginHooks = await stagePluginHooks( + nextConfig, + (file, baseline) => { + candidatePluginWatchDependencies.add(file); + candidatePluginWatchBaselines.set(file, baseline); + candidatePluginRegistrationsReconciled = false; + }, + ); + synchronizeCandidateWatcher(listPendingCandidateWatchBaselines()); + clearPendingCandidateWatchBaselines(); + const configuredAnalysisDependencies = + listConfiguredAnalysisWatchDependencies(cwd, nextConfig); + if (configuredAnalysisDependencies.length > 0) { + candidateAnalysisWatchDependencies = configuredAnalysisDependencies; + synchronizeCandidateWatcher([ + prepareDevWatchPlan(configuredAnalysisDependencies), + ]); + } + } + const updateController = devController; + if (!updateController) { + await rollbackCandidateState(); + ensureCandidateWatcher(); + logger.warn`The selected bundler does not expose a dev controller. Please restart ev dev to apply framework plan changes.`; + return; + } + generatedStateSnapshot = await createGeneratedDevStateSnapshot(cwd); + // Reserve the adapter boundary before analysis writes any live `.ev` + // input. Built-in adapters suppress transition-time build results until + // updatePlan either adopts the final candidate or Core restores the + // previous generated snapshot and cancels the transition. + bundlerUpdateTransition = await updateController.beginUpdate(); + blockBundlerGeneration(activeBundlerGeneration); + const { analysis: nextAnalysis, plan: nextPlan } = + await analyzeAndMaterializeFrameworkIR({ + cwd, + mode: "development", + config: nextConfig, + pluginContext: + stagedPluginHooks?.pluginContext ?? + ({ + ...pluginCtx, + config: nextConfig, + } satisfies PluginSetupContext), + pluginSettings: nextPluginSettings, + applicationPluginSettings: nextApplicationPluginSettings, + plan: { distDir: DEV_DIST_DIR }, + beforeSourceRead: captureCandidateSourceRead, + onSourceDependency: captureCandidateObservedSourceDependency, + onAnalysis(analysis) { + if (requiresBundlerConfigReload) { + synchronizeCandidateAnalysisDependencies(analysis); + } + reportGraphDiagnostics(analysis); + }, + }); + if (requiresBundlerConfigReload) { + synchronizeCandidateAnalysisDependencies(nextAnalysis, true); + } + const update = diffBuildPlan(activePlan, nextPlan, reason); + const updateTransition = bundlerUpdateTransition; + if (!updateTransition) { + throw new Error( + `[evjs] Bundler "${bundler.name}" did not establish a development update boundary before candidate framework input was materialized.`, + ); + } + + const previousConfig = activeConfig; + const previousConfiguredConfig = activeConfiguredConfig; + const previousPluginSettings = activePluginSettings; + const previousApplicationPluginSettings = activeApplicationPluginSettings; + const previousAnalysis = activeAnalysis; + const previousPlan = activePlan; + const priorBundlerGeneration = activeBundlerGeneration; + previousBundlerGeneration = priorBundlerGeneration; + const previousBundlerGenerationState = getBundlerGenerationState( + priorBundlerGeneration, + true, + ); + const nextBundlerGeneration = createBundlerGeneration(); + candidateBundlerGeneration = nextBundlerGeneration; + + preflightBundlerBuild(bundler, nextPlan); + preflightBundlerDevUpdate(bundler, update); + frameworkOutputTransaction = createDevFrameworkOutputTransaction(cwd, [ + previousPlan, + nextPlan, + ]); + + let activationAttempted = false; + const activateCandidateGeneration = () => { + if (activationAttempted) { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by calling updatePlan().activate() more than once.`, + ); + } + activationAttempted = true; + if (activeBundlerGeneration !== priorBundlerGeneration) { + throw new Error( + `[evjs] Bundler "${bundler.name}" tried to activate a development generation after its previous generation was replaced.`, + ); + } + + stagedPluginHooks?.activate(); + activeConfiguredConfig = nextConfiguredConfig; + activeConfig = nextConfig; + activePluginSettings = nextPluginSettings; + activeApplicationPluginSettings = nextApplicationPluginSettings; + activeAnalysis = nextAnalysis; + activePlan = nextPlan; + pluginCtx.config = nextConfig; + previousBundlerGenerationState.frameworkOutputTransaction = + frameworkOutputTransaction; + activateBundlerGeneration( + nextBundlerGeneration, + { + analysis: nextAnalysis, + config: nextConfig, + frameworkOutputTransaction, + plan: nextPlan, + pluginExecution: activePluginExecution, + frameworkRuntime: undefined, + serverEntry: undefined, + }, + true, + ); + activeBundlerGeneration = nextBundlerGeneration; + candidateGenerationActivated = true; + }; + + try { + await updateController.updatePlan(update, { + config: nextConfig, + configChanged: requiresBundlerConfigReload, + transition: updateTransition, + generation: nextBundlerGeneration, + activate: activateCandidateGeneration, + }); + if (!candidateGenerationActivated) { + throw new Error( + `[evjs] Bundler "${bundler.name}" violated the development generation contract by completing updatePlan() without calling activate().`, + ); + } + await selectAndResumeBundlerUpdateTransition("accept"); + await frameworkOutputTransaction.prepareCommit(); + await prepareBundlerUpdateTransitionFinalization(); + frameworkOutputTransaction.commit(); + previousBundlerGenerationState.frameworkOutputTransaction = undefined; + getBundlerGenerationState( + nextBundlerGeneration, + true, + ).frameworkOutputTransaction = undefined; + candidateGenerationCommitted = true; + retireBundlerGeneration(priorBundlerGeneration); + try { + await frameworkOutputTransaction.runAfterBuild(); + } catch (error) { + recordPostCommitFailure(error); + } + try { + finalizeBundlerUpdateTransition(); + } catch (error) { + // Canonical output and generation ownership are already committed. + // Finish committing the candidate snapshot, then fail the dev + // session instead of attempting an impossible mixed-state rollback. + recordPostCommitFailure(error); + } + } catch (err) { + activeConfiguredConfig = previousConfiguredConfig; + activeConfig = previousConfig; + activePluginSettings = previousPluginSettings; + activeApplicationPluginSettings = previousApplicationPluginSettings; + activeAnalysis = previousAnalysis; + activePlan = previousPlan; + if (!stagedPluginHooks) { + pluginCtx.config = previousConfig; + } + try { + await rollbackCandidateState(); + } catch (rollbackError) { + ensureCandidateWatcher(); + throw new AggregateError( + [err, rollbackError], + "[evjs] Framework plan update failed and dev state rollback also failed.", + { cause: err }, + ); + } + ensureCandidateWatcher(); + logger.warn`Unable to apply framework plan update without restart: ${err}`; + return; + } + activePageRouteWatchState = + candidatePageRouteWatchState ?? activePageRouteWatchState; + activeServerRouteWatchState = nextServerRouteWatchState; + commitCandidateConfigDependencies(); + commitCandidateObservedSourceDependencies(); + await commitStagedPluginHooks( + stagedPluginHooks, + currentDevWatchReconcileFrom, + [...candidateRouteWatchBaselines, ...candidateSourceBaselines.values()], + ); + await generatedStateSnapshot.commit(); + retireResolvedCandidateWatcher(); + if (postCommitFailure) { + reportDevWatchFailure(postCommitFailure.error); + } + } catch (err) { + return rethrowAfterCleanup( + err, + async () => { + try { + await rollbackCandidateState(); + } finally { + ensureCandidateWatcher(); + } + }, + "[evjs] Framework dev state update failed and rollback also failed.", + ); + } + }; + + function overlapsCandidateWatchDependency(changedFile: string): boolean { + if (!candidateDependencyWatcher?.requiresConfigReload) return false; + for (const dependency of candidateDependencyWatcher.dependencies) { + if ( + isInsideCwd(dependency, changedFile) || + isInsideCwd(changedFile, dependency) + ) { + return true; + } + } + return false; + } + + function scheduleDevUpdate(changedFile: string, forceConfigReload = false) { + if (devSessionEnding || devWatchFailed) return; + pendingDevChanges.add(changedFile); + pendingForcedConfigReload ||= + forceConfigReload || overlapsCandidateWatchDependency(changedFile); + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = undefined; + const changedFiles = [...pendingDevChanges]; + const shouldForceConfigReload = pendingForcedConfigReload; + pendingDevChanges.clear(); + pendingForcedConfigReload = false; + const devWatchReconcileFrom = captureDevDependencyWatchPlan(); + const candidateWatchReconcileFrom = captureCandidateDependencyWatchPlan(); + if (devWatchFailed) return; + const changedSnapshotPlans = [ + devWatchReconcileFrom, + candidateWatchReconcileFrom, + ].filter((plan): plan is PreparedWatchFilesPlan => Boolean(plan)); + const changedFilesWithNewSnapshots = changedFiles.filter((file) => { + const snapshot = changedSnapshotPlans + .find((plan) => plan.baselineSnapshots.has(file)) + ?.baselineSnapshots.get(file); + return recordDevChangeSnapshot( + lastScheduledDevChanges, + file, + snapshot, + shouldForceConfigReload, + ); + }); + if (changedFilesWithNewSnapshots.length === 0) return; + devUpdateQueue = devUpdateQueue + .catch(() => {}) + .then(() => + handleDevDependencyChange( + changedFilesWithNewSnapshots, + shouldForceConfigReload, + devWatchReconcileFrom, + candidateWatchReconcileFrom, + ), + ) + .catch((err) => { + logger.warn`Failed to update framework dev state: ${err}`; + }); + }, 50); + } + + const cleanupDev = async () => { + devSessionEnding = true; + if (debounceTimer) clearTimeout(debounceTimer); + pendingDevChanges.clear(); + lastScheduledDevChanges.clear(); + pendingForcedConfigReload = false; + onPluginWatchFileAdded = undefined; + onBundlerConfigWatchFileAdded = undefined; + reportWatchRegistrationFailure = undefined; + pendingPluginWatchBaselines.clear(); + pendingBundlerWatchBaselines.clear(); + await runCleanupTasks([ + () => clearDevDependencyWatcher(), + () => clearCandidateDependencyWatcher(), + () => devController?.close?.(), + () => devUpdateQueue.catch(() => {}), + () => outputCycleQueue, + () => restartQueue.catch(() => {}), + () => apiProcessController.stop(), + async () => { + try { + await releaseDevDistLock?.(); + } finally { + unregisterDevDistExitCleanup(); + } + }, + () => { + process.off("SIGINT", stopApiOnParentShutdown); + process.off("SIGTERM", stopApiOnParentShutdown); + }, + async () => { + const execution = activePluginExecution; + retireBundlerGeneration(activeBundlerGeneration); + retirePluginContext(); + await execution.waitForIdle(); + await runDisposeHooks(execution.hooks, execution.context); + }, + ]); + }; + + try { + const startupWatchPlan = prepareWatchFilesPlan( + createDevDependencyWatchTopology(), + ); + preflightBundlerBuild(bundler, activePlan); + devController = + (await bundler.dev({ + config: activeConfig, + cwd, + generation: initialBundlerGeneration, + hooks, + plan: activePlan, + addWatchFile: addBundlerConfigWatchFile, + callbacks: { + onDevServerReady(context) { + logger.info`${formatDevServerReady( + context, + activeConfig, + activePlan, + )}`; + }, + async onBuildFacts(generation, bundlerFacts, options) { + const { isRebuild } = options; + const generationState = + getBundlerGenerationStateForFacts(generation); + if (!generationState) return "discarded"; + const cycleConfig = generationState.config; + const cycleAnalysis = generationState.analysis; + const cyclePlan = generationState.plan; + const cyclePluginExecution = generationState.pluginExecution; + const cycleFrameworkOutputTransaction = + generationState.frameworkOutputTransaction; + const releaseFrameworkOutputTransactionCycle = + cycleFrameworkOutputTransaction?.beginCycle(); + if ( + cycleFrameworkOutputTransaction && + !releaseFrameworkOutputTransactionCycle + ) { + return "discarded"; + } + const cycleHooks = [...cyclePluginExecution.hooks]; + const cyclePluginContext: PluginSetupContext = { + ...cyclePluginExecution.context, + config: cycleConfig, + }; + const releaseCycle = cyclePluginExecution.beginCycle(); + return enqueueOutputCycle(async () => { + try { + await cycleFrameworkOutputTransaction?.capture(); + const outputSnapshot = await createFrameworkOutputSnapshot( + cwd, + [cyclePlan], + ); + let linkedOutput: Awaited< + ReturnType> + >; + try { + linkedOutput = await linkAndEmitBuildOutput({ + bundlerFacts, + graph: cycleAnalysis.graph, + plan: cyclePlan, + config: cycleConfig, + cwd, + hooks: cycleHooks, + pluginCtx: cyclePluginContext, + isRebuild, + }); + } catch (error) { + return rethrowAfterCleanup( + error, + () => outputSnapshot.restore(), + "[evjs] Framework output cycle failed and canonical output rollback also failed.", + ); + } + outputSnapshot.commit(); + generationState.frameworkRuntime = + linkedOutput.frameworkRuntime; + generationState.serverEntry = linkedOutput.output.server.entry; + async function notifyAfterBuild(): Promise { + await runAfterBuildHooks( + cycleHooks, + createBuildResult(linkedOutput.output, isRebuild, { + frameworkRuntime: linkedOutput.frameworkRuntime, + }), + { cwd, emittedFiles: bundlerFacts.emittedFiles }, + ); + } + if (cycleFrameworkOutputTransaction) { + cycleFrameworkOutputTransaction.deferAfterBuild( + notifyAfterBuild, + ); + } else { + await notifyAfterBuild(); + } + return "published" as const; + } finally { + releaseFrameworkOutputTransactionCycle?.(); + releaseCycle(); + } + }); + }, + onServerBundleReady: handleServerBundleReady, + }, + })) ?? undefined; + releaseDevDistLock = await writeDevDistLock(cwd, activePlan.distDir); + unregisterDevDistExitCleanup = registerRuntimeExitCleanup(() => + releaseDevDistLock?.sync(), + ); + refreshDevDependencyWatchers(startupWatchPlan); + reconcileRegisteredWatchBaselines([ + initialDevWatchPlans.config, + ...initialDevWatchPlans.configDependencies.values(), + ...(initialDevWatchPlans.pageRoutes + ? [initialDevWatchPlans.pageRoutes] + : []), + ...(initialDevWatchPlans.serverRoutes + ? [initialDevWatchPlans.serverRoutes] + : []), + ...initialAnalysisWatchBaselines, + ...initialSourceBaselines.values(), + ]); + reconcileRegisteredWatchBaselines([ + ...pendingPluginWatchBaselines.values(), + ...pendingBundlerWatchBaselines.values(), + ]); + pendingPluginWatchBaselines.clear(); + pendingBundlerWatchBaselines.clear(); + onPluginWatchFileAdded = (baseline) => { + refreshDevDependencyWatchers(baseline); + }; + onBundlerConfigWatchFileAdded = (baseline) => { + refreshDevDependencyWatchers(baseline); + }; + const waitForSessionEnd = devController?.done + ? Promise.race([waitForShutdown, devController.done]) + : waitForShutdown; + await Promise.race([ + waitForSessionEnd, + waitForDevWatchFailure.then((error) => { + throw error; + }), + ]); + } catch (error) { + return rethrowAfterCleanup( + error, + cleanupDev, + "[evjs] Dev failed and cleanup also failed.", + ); + } + await cleanupDev(); +} + +export async function build( + userConfig?: Config, + options?: BuildOptions, +): Promise { + const cwd = options?.cwd ?? process.cwd(); + await assertNoActiveDevSessionLock(cwd); + return withProjectOperationLock(cwd, "build", () => + runBuild(userConfig, options, cwd), + ); +} + +async function runBuild( + userConfig: Config | undefined, + options: BuildOptions | undefined, + cwd: string, +): Promise { + process.env.NODE_ENV ??= "production"; + const prepared = await prepareInternalFrameworkBuild(userConfig, { + cwd, + mode: "production", + bundler: options?.bundler, + flags: options?.flags, + requireBundler: true, + }); + const bundler = prepared.config.bundler; + if (!bundler) { + await prepared.dispose(); + throw new Error( + "[evjs] No bundler configured. Pass a bundler adapter in ev.config.ts or through dev/build options.", + ); + } + try { + await assertNoActiveDevDistLock(cwd, prepared.plan.distDir); + preflightBundlerBuild(bundler, prepared.plan); + // Reject symbolic-link leaves at framework-owned canonical paths before + // staging. The snapshot is validation-only in production; whole-tree + // rollback is owned by the transaction below. + const canonicalOutputValidation = await createFrameworkOutputSnapshot(cwd, [ + prepared.plan, + ]); + canonicalOutputValidation.commit(); + // Compile and link the complete production tree in staging. Canonical + // output is replaced only after every pre-afterBuild phase succeeds, so a + // failed clean/compile/link/write cannot mix generations. + const outputTransaction = await createProductionOutputTransaction( + cwd, + prepared.plan, + ); + let bundlerFacts: BundlerBuildFacts | undefined; + let linkedOutput: + | Awaited>> + | undefined; + try { + bundlerFacts = await bundler.build({ + config: prepared.config, + cwd, + hooks: prepared.hooks, + plan: outputTransaction.buildPlan, + addWatchFile: prepared.pluginContext.addWatchFile, + }); + linkedOutput = await linkAndEmitBuildOutput({ + bundlerFacts, + graph: prepared.graph, + plan: prepared.plan, + config: prepared.config, + cwd, + hooks: prepared.hooks, + pluginCtx: prepared.pluginContext, + isRebuild: false, + emissionPaths: outputTransaction.outputPaths, + }); + await outputTransaction.publish(); + } catch (error) { + await rethrowAfterCleanup( + error, + () => outputTransaction.rollback(), + "[evjs] Production output failed and staging rollback also failed.", + ); + } + if (!bundlerFacts || !linkedOutput) { + throw new Error("[evjs] Production framework output was not linked."); + } + await runAfterBuildHooks( + prepared.hooks, + createBuildResult(linkedOutput.output, false, { + frameworkRuntime: linkedOutput.frameworkRuntime, + }), + { cwd, emittedFiles: bundlerFacts.emittedFiles }, + ); + } catch (error) { + return rethrowAfterCleanup( + error, + prepared.dispose, + "[evjs] Build failed and plugin cleanup also failed.", + ); + } + await prepared.dispose(); +} diff --git a/packages/ev/src/_internal/build/inspect.ts b/packages/ev/src/_internal/build/operations/inspect.ts similarity index 94% rename from packages/ev/src/_internal/build/inspect.ts rename to packages/ev/src/_internal/build/operations/inspect.ts index c4824f5d..f68b62bf 100644 --- a/packages/ev/src/_internal/build/inspect.ts +++ b/packages/ev/src/_internal/build/operations/inspect.ts @@ -10,36 +10,36 @@ import { type ResolvedConfig, resolveBundlerConfig, resolveConfig, -} from "../../config/index.js"; -import type { CliFlags, PluginSetupContext } from "../../plugin/index.js"; -import { analyzeAndMaterializeFrameworkIR } from "./analyze-and-materialize.js"; +} from "../../../config/index.js"; +import type { CliFlags, PluginSetupContext } from "../../../plugin/index.js"; +import { withActiveBundler } from "../bundler/config.js"; import { type BundlerAdapter, type BundlerCapabilities, type BundlerCapabilityGap, getBundlerBuildCapabilityGaps, -} from "./bundler.js"; -import { withActiveBundler } from "./bundler-config.js"; +} from "../bundler/contracts.js"; +import { CANONICAL_PAGE_ROUTE_ROOT } from "../conventions/page-route-conventions.js"; import { createNoPageRoutesFoundMessage, readRoutingConfig, withPageRoutingDefaults, withServerConventionDefaults, withServerRouteDiscovery, -} from "./convention-config.js"; -import { validateHtmlTemplates } from "./framework-output.js"; -import { createCoreGraph } from "./graph/index.js"; -import { CANONICAL_PAGE_ROUTE_ROOT } from "./page-route-conventions.js"; -import { createPageRouteNodesFromCoreGraph } from "./page-route-types.js"; -import type { PageRouteDiscovery } from "./page-routes.js"; +} from "../discovery/convention-config.js"; +import type { PageRouteDiscovery } from "../discovery/page-routes.js"; +import { createCoreGraph } from "../graph/create-core-graph.js"; +import { validateHtmlTemplates } from "../output/framework-output.js"; import { collectPluginHooks, orderPluginsByDependencies, runConfigureHooks, runDisposeHooks, -} from "./plugin-lifecycle.js"; -import { resolvePluginSettingsState } from "./plugin-settings.js"; -import { toProjectPath } from "./utils.js"; +} from "../plugins/lifecycle.js"; +import { resolvePluginSettingsState } from "../plugins/settings.js"; +import { createPageRouteNodesFromCoreGraph } from "../typegen/page-route-types.js"; +import { toProjectPath } from "../utils.js"; +import { analyzeAndMaterializeFrameworkIR } from "./analyze-and-materialize.js"; const logger = getLogger(["evjs", "ev"]); diff --git a/packages/ev/src/_internal/build/build-output-ownership.ts b/packages/ev/src/_internal/build/output/build-output-ownership.ts similarity index 100% rename from packages/ev/src/_internal/build/build-output-ownership.ts rename to packages/ev/src/_internal/build/output/build-output-ownership.ts diff --git a/packages/ev/src/_internal/build/build-output-paths.ts b/packages/ev/src/_internal/build/output/build-output-paths.ts similarity index 100% rename from packages/ev/src/_internal/build/build-output-paths.ts rename to packages/ev/src/_internal/build/output/build-output-paths.ts diff --git a/packages/ev/src/_internal/build/build-result.ts b/packages/ev/src/_internal/build/output/build-result.ts similarity index 90% rename from packages/ev/src/_internal/build/build-result.ts rename to packages/ev/src/_internal/build/output/build-result.ts index a27728a4..903c99fc 100644 --- a/packages/ev/src/_internal/build/build-result.ts +++ b/packages/ev/src/_internal/build/output/build-result.ts @@ -1,6 +1,6 @@ import type { BuildOutput } from "@evjs/shared/manifest"; import { createDeploymentMetadata } from "@evjs/shared/manifest"; -import type { BuildResult } from "../../plugin/index.js"; +import type { BuildResult } from "../../../plugin/index.js"; import type { FrameworkRuntimeOutput } from "./framework-runtime.js"; export function createBuildResult( diff --git a/packages/ev/src/_internal/build/framework-output.ts b/packages/ev/src/_internal/build/output/framework-output.ts similarity index 97% rename from packages/ev/src/_internal/build/framework-output.ts rename to packages/ev/src/_internal/build/output/framework-output.ts index 60551ef5..16605d6b 100644 --- a/packages/ev/src/_internal/build/framework-output.ts +++ b/packages/ev/src/_internal/build/output/framework-output.ts @@ -9,31 +9,37 @@ import { createDeploymentMetadata, linkBuildOutput, } from "@evjs/shared/manifest"; -import type { ResolvedConfig } from "../../config/index.js"; +import type { ResolvedConfig } from "../../../config/index.js"; import type { HtmlDocumentInfo, PluginHooks, PluginSetupContext, -} from "../../plugin/index.js"; +} from "../../../plugin/index.js"; +import { + assertBundlerBuildFactsContract, + type BundlerBuildFacts, +} from "../bundler/contracts.js"; +import { assertFrameworkHtmlOutputsAvailable } from "../bundler/output-files.js"; +import { assertAfterBuildDeploymentOutputsAvailable } from "../plugins/deployment-output-reservations.js"; +import { + runBeforeBuildHooks, + runCleanupTasks, + runTransformOutputHooks, +} from "../plugins/lifecycle.js"; import { assertBuildOutputOwnershipUnchanged, snapshotBuildOutputOwnership, } from "./build-output-ownership.js"; import { resolveBuildOutputPaths } from "./build-output-paths.js"; import { createBuildResult } from "./build-result.js"; -import { - assertBundlerBuildFactsContract, - type BundlerBuildFacts, -} from "./bundler.js"; -import { assertFrameworkHtmlOutputsAvailable } from "./bundler-output-files.js"; -import { assertAfterBuildDeploymentOutputsAvailable } from "./deployment-output-reservations.js"; -import { createFrameworkHtmlDocument } from "./framework-html-document.js"; import { createClientRuntime, createFrameworkRuntime, } from "./framework-runtime.js"; -import { type generateHtml, validateHtmlTemplate } from "./html.js"; -import { buildHtml } from "./html-transform.js"; +import { createFrameworkHtmlDocument } from "./html/framework-html-document.js"; +import { type generateHtml, validateHtmlTemplate } from "./html/html.js"; +import { buildHtml } from "./html/html-transform.js"; +import { compileServerDocumentShells } from "./html/server-document-shell.js"; import { assertSafeBuildOutputPaths, type ResolvedBuildOutputPaths, @@ -46,16 +52,10 @@ import { snapshotOwnedOutputFile, writeOwnedOutputFile, } from "./owned-file-output.js"; -import { - runBeforeBuildHooks, - runCleanupTasks, - runTransformOutputHooks, -} from "./plugin-lifecycle.js"; import { FRAMEWORK_DEPLOYMENT_METADATA_FILE_NAME, portableArtifactPathsConflict, } from "./portable-artifact-path.js"; -import { compileServerDocumentShells } from "./server-document-shell.js"; const RUNTIME_ONLY_BUNDLER_MANIFEST_FILES = [ "react-client-manifest.json", diff --git a/packages/ev/src/_internal/build/framework-runtime.ts b/packages/ev/src/_internal/build/output/framework-runtime.ts similarity index 100% rename from packages/ev/src/_internal/build/framework-runtime.ts rename to packages/ev/src/_internal/build/output/framework-runtime.ts diff --git a/packages/ev/src/_internal/build/framework-html-document.ts b/packages/ev/src/_internal/build/output/html/framework-html-document.ts similarity index 92% rename from packages/ev/src/_internal/build/framework-html-document.ts rename to packages/ev/src/_internal/build/output/html/framework-html-document.ts index 606be049..f77fe72f 100644 --- a/packages/ev/src/_internal/build/framework-html-document.ts +++ b/packages/ev/src/_internal/build/output/html/framework-html-document.ts @@ -1,9 +1,9 @@ import path from "node:path"; import type { BuildOutput, BuildPlan } from "@evjs/shared/manifest"; -import type { ResolvedConfig } from "../../config/index.js"; -import type { HtmlDocumentInfo } from "../../plugin/index.js"; -import type { createClientRuntime } from "./framework-runtime.js"; -import { applyHtmlTagContributions } from "./generated-contributions.js"; +import type { ResolvedConfig } from "../../../../config/index.js"; +import type { HtmlDocumentInfo } from "../../../../plugin/index.js"; +import { applyHtmlTagContributions } from "../../generated-ir/generated-contributions.js"; +import type { createClientRuntime } from "../framework-runtime.js"; import { generateHtml, type HtmlAsset } from "./html.js"; import { applyPageMetadataToHtmlDocument } from "./page-metadata-html.js"; diff --git a/packages/ev/src/_internal/build/html-transform.ts b/packages/ev/src/_internal/build/output/html/html-transform.ts similarity index 92% rename from packages/ev/src/_internal/build/html-transform.ts rename to packages/ev/src/_internal/build/output/html/html-transform.ts index ca95b829..4b5c18f8 100644 --- a/packages/ev/src/_internal/build/html-transform.ts +++ b/packages/ev/src/_internal/build/output/html/html-transform.ts @@ -4,9 +4,9 @@ import type { HtmlDocumentInfo, PluginHooks, PluginSetupContext, -} from "../../plugin/index.js"; -import { createBuildResult } from "./build-result.js"; -import { createLatePluginContext } from "./plugin-lifecycle.js"; +} from "../../../../plugin/index.js"; +import { createLatePluginContext } from "../../plugins/lifecycle.js"; +import { createBuildResult } from "../build-result.js"; export interface BuildHtmlOptions { /** Pre-parsed HTML document (from `generateHtml()`). */ diff --git a/packages/ev/src/_internal/build/html.ts b/packages/ev/src/_internal/build/output/html/html.ts similarity index 98% rename from packages/ev/src/_internal/build/html.ts rename to packages/ev/src/_internal/build/output/html/html.ts index 78867c6b..28fb8525 100644 --- a/packages/ev/src/_internal/build/html.ts +++ b/packages/ev/src/_internal/build/output/html/html.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { resolveBrowserAssetHref } from "@evjs/shared"; import { DOMParser } from "domparser-rs"; -import type { HtmlDocument } from "../../plugin/index.js"; +import type { HtmlDocument } from "../../../../plugin/index.js"; /** * A single asset descriptor — either a plain filename string or an object diff --git a/packages/ev/src/_internal/build/page-metadata-html.ts b/packages/ev/src/_internal/build/output/html/page-metadata-html.ts similarity index 97% rename from packages/ev/src/_internal/build/page-metadata-html.ts rename to packages/ev/src/_internal/build/output/html/page-metadata-html.ts index 3e319f01..74ac3f42 100644 --- a/packages/ev/src/_internal/build/page-metadata-html.ts +++ b/packages/ev/src/_internal/build/output/html/page-metadata-html.ts @@ -1,5 +1,5 @@ import type { PageMetadata } from "@evjs/shared/manifest"; -import type { HtmlDocument } from "../../plugin/index.js"; +import type { HtmlDocument } from "../../../../plugin/index.js"; const PAGE_METADATA_ATTRIBUTE = "data-evjs-page-metadata"; const PAGE_METADATA_BASELINE_ATTRIBUTE = "data-evjs-page-metadata-baseline"; diff --git a/packages/ev/src/_internal/build/server-document-shell.ts b/packages/ev/src/_internal/build/output/html/server-document-shell.ts similarity index 96% rename from packages/ev/src/_internal/build/server-document-shell.ts rename to packages/ev/src/_internal/build/output/html/server-document-shell.ts index 8311b633..3d008b1e 100644 --- a/packages/ev/src/_internal/build/server-document-shell.ts +++ b/packages/ev/src/_internal/build/output/html/server-document-shell.ts @@ -3,17 +3,17 @@ import type { BuildPlan, ServerDocumentShell, } from "@evjs/shared/manifest"; -import type { ResolvedConfig } from "../../config/index.js"; +import type { ResolvedConfig } from "../../../../config/index.js"; import type { HtmlDocumentInfo, PluginHooks, PluginSetupContext, -} from "../../plugin/index.js"; +} from "../../../../plugin/index.js"; +import { createClientRuntime } from "../framework-runtime.js"; import { CLIENT_RUNTIME_SCRIPT_ID, createFrameworkHtmlDocument, } from "./framework-html-document.js"; -import { createClientRuntime } from "./framework-runtime.js"; import type { generateHtml } from "./html.js"; import { buildHtml } from "./html-transform.js"; diff --git a/packages/ev/src/_internal/build/output-path-safety.ts b/packages/ev/src/_internal/build/output/output-path-safety.ts similarity index 100% rename from packages/ev/src/_internal/build/output-path-safety.ts rename to packages/ev/src/_internal/build/output/output-path-safety.ts diff --git a/packages/ev/src/_internal/build/owned-file-output.ts b/packages/ev/src/_internal/build/output/owned-file-output.ts similarity index 100% rename from packages/ev/src/_internal/build/owned-file-output.ts rename to packages/ev/src/_internal/build/output/owned-file-output.ts diff --git a/packages/ev/src/_internal/build/portable-artifact-path.ts b/packages/ev/src/_internal/build/output/portable-artifact-path.ts similarity index 100% rename from packages/ev/src/_internal/build/portable-artifact-path.ts rename to packages/ev/src/_internal/build/output/portable-artifact-path.ts diff --git a/packages/ev/src/_internal/build/production-output-transaction.ts b/packages/ev/src/_internal/build/output/production-output-transaction.ts similarity index 100% rename from packages/ev/src/_internal/build/production-output-transaction.ts rename to packages/ev/src/_internal/build/output/production-output-transaction.ts diff --git a/packages/ev/src/_internal/build/plan/create-build-plan.ts b/packages/ev/src/_internal/build/plan/create-build-plan.ts new file mode 100644 index 00000000..ab2fa92e --- /dev/null +++ b/packages/ev/src/_internal/build/plan/create-build-plan.ts @@ -0,0 +1,1151 @@ +import { randomUUID } from "node:crypto"; +import type { + AppRouteTarget, + BuildEntry, + BuildPlan, + BuildPlanUpdate, + CoreApplicationNode, + CoreClientRouteNode, + CoreGraph, + CorePageNode, + HtmlPlan, + PagesAppRouteNode, + ReactPageLayer, + RuntimePlan, + ServerBuildPlan, + ServerDocumentPlan, + ServerRenderPlan, +} from "@evjs/shared/manifest"; +import { clonePageMetadata } from "@evjs/shared/manifest"; +import { + createApplicationClientBuildEntryName, + createPageClientBuildEntryName, + createPageServerBuildEntryName, + createPprRegionBuildEntryName, + createPprShellBuildEntryName, + createRscPageBuildEntryName, + GENERATED_PAGES_APP_BUILD_ENTRY, + SERVER_RUNTIME_BUILD_ENTRY_NAME, +} from "../conventions/build-entry-conventions.js"; +import { assertFrameworkOutputOwnership } from "../conventions/output-path-conventions.js"; +import { createStaticPageDocumentOutput } from "../conventions/page-document-output.js"; +import { + isPartialPrerenderPage, + isRscPage, + validatePageBuildContract, +} from "../conventions/page-rendering-contract.js"; +import type { DiscoveredServerRouteNode } from "../discovery/server-routes.js"; +import { + assertPortableArtifactFileName, + assertPortableRelativeArtifactPath, + canonicalPortableArtifactPathKey, + portableArtifactPathsConflict, +} from "../output/portable-artifact-path.js"; +import { sanitizePageId } from "../utils.js"; +import { formatCoreRoutePattern } from "./route-pattern.js"; +import { + createRuntimeServerPlan, + validateClientServerRouteConflicts, + validateRuntimeEndpointConflicts, +} from "./runtime-server.js"; +import type { BuildPlanConfig, CreateBuildPlanOptions } from "./types.js"; + +const DEFAULT_PUBLIC_PATH: RuntimePlan["publicPath"] = "auto"; +const FRAMEWORK_SERVER_FETCH_ENTRY = "@evjs/ev/_internal/server/fetch"; +const FRAMEWORK_SPA_FALLBACK_OUTPUT_DIR = "__evjs"; +const DEFAULT_RESOLVE_ALIAS = { + "@": "./src", +} as const satisfies NonNullable["alias"]; + +interface BuildApplicationFacts { + id: string; + documentId: string; + html: string; + documentOutput: string; + documentAliases?: string[]; + mount?: string; + rootModule?: string; +} + +interface BuildPageFacts + extends Pick< + CorePageNode, + | "id" + | "render" + | "componentModel" + | "hydrate" + | "prerender" + | "ppr" + | "metadata" + > { + applicationId: string; + routingMode: CoreApplicationNode["routingMode"]; + scope: CorePageNode["source"]["scope"]; + routePath?: string; + routeId?: string; + component: string; + documentId?: string; + /** Source template of the selected Page or Application Document. */ + html: string; + documentOutput?: string; + documentAliases?: string[]; + /** Concrete Page-owned static output; Application fallback is not copied. */ + output?: string; + mount?: string; + layers?: ReactPageLayer[]; +} + +interface BuildRouteFacts extends PagesAppRouteNode { + appId: string; + pageId?: string; +} + +interface BuildPlanFacts { + apps: Record; + pages: Record; + routes: BuildRouteFacts[]; + devClientRoutes: BuildPlan["dev"]["clientRoutes"]; + serverRenderedRoutePaths: string[]; + serverFunctions: CoreGraph["serverFunctions"]; + serverRoutes: CoreGraph["serverRoutes"]; + clientReferences?: CoreGraph["clientReferences"]; +} + +/** + * Project semantic graph nodes into the build-only facts needed to create + * concrete entries and Documents. This projection enforces materialization + * constraints, but CoreGraph remains the source of semantic identity. + */ +function deriveBuildPlanFacts(graph: CoreGraph): BuildPlanFacts { + const apps: Record = {}; + const pages: Record = {}; + const routes: BuildRouteFacts[] = []; + const clientRoutes = graph.routes; + const routeById = new Map(clientRoutes.map((route) => [route.id, route])); + const routesByPageId = new Map(); + const pageDocumentByPageId = new Map< + string, + CoreGraph["documents"][string] + >(); + const applicationDocumentById = new Map< + string, + CoreGraph["documents"][string] + >(); + + for (const route of clientRoutes) { + if (route.target.kind === "page") { + const routes = routesByPageId.get(route.target.pageId) ?? []; + routes.push(route); + routesByPageId.set(route.target.pageId, routes); + } + } + for (const document of Object.values(graph.documents)) { + if (document.owner.kind === "page") { + const previous = pageDocumentByPageId.get(document.owner.pageId); + if (previous) { + throw new Error( + `[evjs] Page "${document.owner.pageId}" owns more than one Document: "${previous.id}" and "${document.id}".`, + ); + } + pageDocumentByPageId.set(document.owner.pageId, document); + } else if (document.owner.kind === "application") { + const previous = applicationDocumentById.get(document.applicationId); + if (previous) { + throw new Error( + `[evjs] Application "${document.applicationId}" owns more than one Application Document: "${previous.id}" and "${document.id}".`, + ); + } + applicationDocumentById.set(document.applicationId, document); + } + } + + for (const application of Object.values(graph.applications)) { + const hasClientRoutes = clientRoutes.some( + (route) => route.applicationId === application.id, + ); + if (application.routingMode !== "spa" || !hasClientRoutes) continue; + const document = applicationDocumentById.get(application.id); + if (!document) { + throw new Error( + `[evjs] Application "${application.id}" with client entry "${GENERATED_PAGES_APP_BUILD_ENTRY}" must own one Document.`, + ); + } + apps[application.id] = { + id: application.id, + documentId: document.id, + html: document.template, + documentOutput: document.output, + ...(document.aliases ? { documentAliases: [...document.aliases] } : {}), + ...(document.mount ? { mount: document.mount } : {}), + ...(application.layout ? { rootModule: application.layout } : {}), + }; + } + + for (const page of Object.values(graph.pages)) { + const application = graph.applications[page.applicationId]; + if (!application) { + throw new Error( + `[evjs] Page "${page.id}" references missing Application "${page.applicationId}".`, + ); + } + const pageRoutes = routesByPageId.get(page.id) ?? []; + const pageDocument = pageDocumentByPageId.get(page.id); + const applicationDocument = applicationDocumentById.get(application.id); + if ( + pageRoutes.length > 1 && + (page.render !== "csr" || pageDocument !== undefined) + ) { + throw new Error( + `[evjs] Page "${page.id}" is targeted by multiple Routes (${pageRoutes + .map((route) => `"${route.id}"`) + .join( + ", ", + )}), but independently rendered Pages must map to exactly one URL.`, + ); + } + const route = pageRoutes[0]; + const document = pageDocument ?? applicationDocument; + const composition = route + ? collectPageComposition(application, routeById, route, page.id) + : []; + pages[page.id] = { + id: page.id, + applicationId: page.applicationId, + routingMode: application.routingMode, + scope: page.source.scope, + ...(route ? { routePath: formatCoreRoutePattern(route.pattern) } : {}), + ...(route ? { routeId: route.id } : {}), + component: page.source.module, + ...(document ? { documentId: document.id } : {}), + html: document?.template ?? "", + ...(document?.output ? { documentOutput: document.output } : {}), + ...(pageDocument?.aliases + ? { documentAliases: [...pageDocument.aliases] } + : {}), + ...(pageDocument?.output ? { output: pageDocument.output } : {}), + render: page.render, + ...(page.componentModel ? { componentModel: page.componentModel } : {}), + ...(page.hydrate ? { hydrate: page.hydrate } : {}), + ...(document?.mount ? { mount: document.mount } : {}), + ...(page.prerender ? { prerender: page.prerender } : {}), + ...(page.ppr ? { ppr: page.ppr } : {}), + ...(page.metadata ? { metadata: clonePageMetadata(page.metadata) } : {}), + ...(composition.length > 0 ? { layers: composition } : {}), + }; + } + + for (const route of clientRoutes) { + const path = formatCoreRoutePattern(route.pattern); + const page = + route.target.kind === "page" + ? graph.pages[route.target.pageId] + : undefined; + const target: AppRouteTarget = + route.target.kind === "page" + ? { kind: "page", pageId: route.target.pageId } + : route.target.kind === "group" + ? { kind: "group" } + : { + kind: "redirect", + to: + route.target.to.kind === "url" + ? { kind: "url", href: route.target.to.href } + : { + kind: "path", + path: formatCoreRoutePattern(route.target.to.pattern), + }, + }; + const layoutModule = + typeof route.facets.layout === "string" ? route.facets.layout : undefined; + if (layoutModule && route.target.kind === "page") { + const layoutId = `${route.id}:layout`; + routes.push({ + id: layoutId, + path, + ...(route.parentId ? { parentId: route.parentId } : {}), + kind: "layout", + appId: route.applicationId, + module: layoutModule, + ...(route.facets.error ? { errorModule: route.facets.error } : {}), + ...(route.facets.notFound + ? { notFoundModule: route.facets.notFound } + : {}), + }); + routes.push({ + id: route.id, + path, + parentId: layoutId, + appId: route.applicationId, + pageId: route.target.pageId, + module: page?.source.module, + target, + wrappers: [...route.facets.wrappers], + }); + continue; + } + routes.push({ + id: route.id, + path, + ...(route.parentId ? { parentId: route.parentId } : {}), + ...(layoutModule ? { kind: "layout" as const } : {}), + appId: route.applicationId, + ...(route.target.kind === "page" + ? { + pageId: route.target.pageId, + module: page?.source.module, + } + : layoutModule + ? { module: layoutModule } + : {}), + target, + wrappers: [...route.facets.wrappers], + ...(route.facets.layout === false ? { layout: false as const } : {}), + ...(route.facets.error ? { errorModule: route.facets.error } : {}), + ...(route.facets.notFound + ? { notFoundModule: route.facets.notFound } + : {}), + }); + } + + return { + apps, + pages, + routes, + devClientRoutes: createDevClientRoutes(graph), + serverRenderedRoutePaths: createServerRenderedRoutePaths(graph), + serverFunctions: graph.serverFunctions, + serverRoutes: graph.serverRoutes, + ...(graph.clientReferences + ? { clientReferences: graph.clientReferences } + : {}), + }; +} + +/** + * Resolve Page composition from the owning Route's parent chain, ordered from + * the Application layout through outer-to-inner Route layouts and wrappers. + * `layout: false` suppresses only the Application layout. + */ +function collectPageComposition( + application: CoreApplicationNode, + routesById: ReadonlyMap, + route: CoreClientRouteNode, + pageId: string, +): ReactPageLayer[] { + const innerToOuter: CoreClientRouteNode[] = []; + const visited = new Set(); + let current: CoreClientRouteNode | undefined = route; + + while (current) { + if (visited.has(current.id)) { + throw new Error( + `[evjs] Page "${pageId}" has a circular layout parent chain at Route "${current.id}".`, + ); + } + visited.add(current.id); + innerToOuter.push(current); + if (!current.parentId) break; + const parentId: string = current.parentId; + current = routesById.get(parentId); + if (!current) { + throw new Error( + `[evjs] Page "${pageId}" references missing layout Route "${parentId}".`, + ); + } + } + + const routeChain = innerToOuter.reverse(); + const bypassApplicationLayout = routeChain.some( + (candidate) => candidate.facets.layout === false, + ); + const layers: ReactPageLayer[] = []; + if (application.layout && !bypassApplicationLayout) { + layers.push({ kind: "layout", module: application.layout }); + } + for (const candidate of routeChain) { + if (typeof candidate.facets.layout === "string") { + layers.push({ kind: "layout", module: candidate.facets.layout }); + } + layers.push( + ...candidate.facets.wrappers.map((module) => ({ + kind: "wrapper" as const, + module, + })), + ); + } + return layers; +} + +function createDevClientRoutes( + graph: CoreGraph, +): BuildPlan["dev"]["clientRoutes"] { + const routes: BuildPlan["dev"]["clientRoutes"] = []; + const seen = new Set(); + + for (const route of graph.routes) { + const application = graph.applications[route.applicationId]; + if (!application) continue; + + let target: BuildPlan["dev"]["clientRoutes"][number]["target"]; + if (route.target.kind === "page") { + const page = graph.pages[route.target.pageId]; + if (!page || page.render !== "csr") continue; + target = + application.routingMode === "mpa" + ? { kind: "page", pageId: page.id } + : { kind: "app", appId: application.id }; + } else { + if (application.routingMode !== "spa") continue; + target = { kind: "app", appId: application.id }; + } + + const pathname = formatCoreRoutePattern(route.pattern); + const key = + target.kind === "page" + ? `${pathname}:page:${target.pageId}` + : `${pathname}:app:${target.appId}`; + if (seen.has(key)) continue; + seen.add(key); + routes.push({ path: pathname, target }); + } + + return routes; +} + +function createServerRenderedRoutePaths(graph: CoreGraph): string[] { + const paths = graph.routes.flatMap((route) => { + if (route.target.kind !== "page") return []; + const page = graph.pages[route.target.pageId]; + // Full SSG renderers run only while emitting HTML. Their canonical route + // must stay on the static dev host instead of being proxied to the server. + return page?.render === "ssr" + ? [formatCoreRoutePattern(route.pattern)] + : []; + }); + return [...new Set(paths)]; +} + +/** + * Derive the complete bundler-independent compilation plan from CoreGraph. + * The plan owns concrete entries, HTML outputs, renderer units, runtime + * endpoints, and dev routing; adapters consume it without rediscovering source + * conventions or semantic ownership. + */ +export function createBuildPlan( + config: BuildPlanConfig, + coreGraph: CoreGraph, + options: CreateBuildPlanOptions = {}, +): BuildPlan { + const distDir = options.distDir ?? "dist"; + assertFrameworkOutputOwnership(config.output, distDir); + const graph = deriveBuildPlanFacts(coreGraph); + const mode = options.mode ?? readBuildMode(); + const hasPpr = hasPprPages(graph); + const hasRsc = hasRscPages(graph); + const runtimeServer = createRuntimeServerPlan(config.server, { + hasPpr, + hasRsc, + }); + validateClientServerRouteConflicts(coreGraph); + validateRuntimeEndpointConflicts(coreGraph, runtimeServer); + validatePageBuildContracts(graph); + const serverRenderers = createServerRenderers(graph); + const entries = createEntries(config, graph, serverRenderers); + const html = createHtmlPlans(graph); + validateBuildOutputNames(entries, html); + const server = createServerPlan(config, graph, serverRenderers); + + return { + version: 1, + buildId: options.buildId ?? createBuildGenerationId(mode), + mode, + distDir, + output: { + clientDir: config.output.client, + serverDir: config.output.server, + }, + resolve: { + alias: { + ...DEFAULT_RESOLVE_ALIAS, + }, + }, + entries, + html, + server, + runtime: { + publicPath: options.publicPath ?? DEFAULT_PUBLIC_PATH, + server: runtimeServer, + transport: config.transport, + }, + dev: { + clientRoutes: graph.devClientRoutes, + serverRequestRoutePaths: [ + ...new Set(graph.serverRoutes.map((route) => route.path)), + ], + serverRenderedPagePaths: graph.serverRenderedRoutePaths, + hasPpr, + }, + ...((graph.clientReferences?.length ?? 0) > 0 + ? { + rsc: { + clientReferenceModules: [ + ...new Set( + graph.clientReferences?.map((reference) => reference.module), + ), + ], + }, + } + : {}), + }; +} + +/** Create one identity for a framework output generation. */ +export function createBuildGenerationId( + mode: "development" | "production", +): string { + return mode === "production" ? `build-${randomUUID()}` : "development"; +} + +/** + * Classify a plan transition for incremental adapters. Identity-based entry and + * Document diffs are reported separately from generated IR, resolution, + * runtime, reason-driven delivery, server compilation, server Document, and + * dev-routing changes. + */ +export function diffBuildPlan( + previous: BuildPlan, + next: BuildPlan, + reason: BuildPlanUpdate["reason"], +): BuildPlanUpdate { + const runtimeChanged = + stableStringify(previous.runtime) !== stableStringify(next.runtime); + const previousServerCompilation = { + entry: previous.server.entry, + renderers: previous.server.renderers, + }; + const nextServerCompilation = { + entry: next.server.entry, + renderers: next.server.renderers, + }; + return { + reason, + previous, + next, + entries: diffByKey(previous.entries, next.entries, buildEntryKey), + html: diffByKey(previous.html, next.html, (html) => html.id), + generatedChanged: + stableStringify(previous.generated) !== stableStringify(next.generated), + resolveChanged: + stableStringify(previous.resolve) !== stableStringify(next.resolve), + runtimeChanged, + deliveryChanged: reason === "config", + serverCompilationChanged: + previous.output.serverDir !== next.output.serverDir || + stableStringify(previousServerCompilation) !== + stableStringify(nextServerCompilation) || + stableStringify(previous.rsc) !== stableStringify(next.rsc), + serverDocumentsChanged: + stableStringify(previous.server.documents) !== + stableStringify(next.server.documents), + devRoutingChanged: + stableStringify(previous.dev) !== stableStringify(next.dev), + }; +} + +function createEntries( + config: BuildPlanConfig, + graph: BuildPlanFacts, + serverRenderers: ServerRenderPlan[], +): BuildEntry[] { + const entries: BuildEntry[] = []; + const pages = Object.values(graph.pages); + const apps = Object.values(graph.apps); + + for (const app of apps) { + if (isClientlessStaticDocumentApp(graph, app.id)) continue; + + entries.push({ + name: createApplicationClientBuildEntryName(app.id), + import: GENERATED_PAGES_APP_BUILD_ENTRY, + environment: "client", + runtime: "browser", + kind: "app-client", + owner: { appId: app.id }, + metadata: { + type: "pages-app", + routes: createPagesAppRoutes(graph, app.id), + mount: app.mount ?? "#app", + ...(app.rootModule ? { rootModule: app.rootModule } : {}), + }, + }); + } + + for (const page of pages) { + if (page.routingMode === "mpa") { + const pageEntry = getPageClientEntry(page); + if (pageEntry) { + entries.push({ + name: createPageClientBuildEntryName(page.id), + import: pageEntry.import, + environment: "client", + runtime: "browser", + kind: "page-client", + owner: createPageBuildOwner(page), + ...(pageEntry.metadata ? { metadata: pageEntry.metadata } : {}), + }); + } + } + + entries.push( + ...serverRenderers + .filter((renderer) => renderer.owner?.pageId === page.id) + .map((renderer) => ({ + name: renderer.name, + import: renderer.import, + environment: "server" as const, + runtime: "node" as const, + kind: renderer.kind, + ...(renderer.phase ? { phase: renderer.phase } : {}), + owner: renderer.owner, + ...(renderer.metadata ? { metadata: renderer.metadata } : {}), + })), + ); + } + + if (hasRscPages(graph)) { + entries.push({ + name: "evjs-rsc-client", + import: "@evjs/ev/_internal/client/rsc-runtime", + environment: "client", + runtime: "browser", + kind: "runtime", + }); + } + + const serverEntry = createServerRuntimeEntry(config, graph, serverRenderers); + if (serverEntry) { + entries.push({ + name: SERVER_RUNTIME_BUILD_ENTRY_NAME, + import: serverEntry.import, + environment: "server", + runtime: "node", + kind: "server-runtime", + ...(serverEntry.metadata ? { metadata: serverEntry.metadata } : {}), + }); + } + + return entries; +} + +function createPagesAppRoutes( + graph: BuildPlanFacts, + appId: string, +): PagesAppRouteNode[] { + return graph.routes.flatMap((route) => { + if (route.appId !== appId) return []; + if (!route.module && !route.target) return []; + if (route.target?.kind === "page" && !route.module) { + throw new Error( + `[evjs] Application-route Page target "${route.id}" has no component module for the pages-app entry.`, + ); + } + const metadata = + route.target?.kind === "page" + ? clonePageMetadata(graph.pages[route.target.pageId]?.metadata) + : undefined; + return [ + { + id: route.id, + path: route.path, + ...(route.module ? { module: route.module } : {}), + ...(route.parentId ? { parentId: route.parentId } : {}), + ...(route.kind ? { kind: route.kind } : {}), + ...(route.target ? { target: route.target } : {}), + ...(route.wrappers ? { wrappers: [...route.wrappers] } : {}), + ...(route.layout === false ? { layout: false as const } : {}), + ...(route.errorModule ? { errorModule: route.errorModule } : {}), + ...(route.notFoundModule + ? { notFoundModule: route.notFoundModule } + : {}), + ...(metadata ? { metadata } : {}), + }, + ]; + }); +} + +function validatePageBuildContracts(graph: BuildPlanFacts): void { + for (const page of Object.values(graph.pages)) { + validatePageBuildContract(`Page "${page.id}"`, page); + if ( + page.render === "ssg" && + page.routePath && + !isStaticPagePath(page.routePath) + ) { + throw new Error( + `[evjs] Page "${page.id}" uses render "ssg" on dynamic Route "${page.routePath}", which does not identify one build-time HTML output. Use a static Route or render "ssr".`, + ); + } + } +} + +function validateBuildOutputNames( + entries: BuildEntry[], + html: HtmlPlan[], +): void { + const entriesByName = new Map(); + for (const entry of entries) { + assertPortableArtifactFileName( + entry.name, + `Build entry name "${entry.name}"`, + ); + const entryNameKey = canonicalPortableArtifactPathKey(entry.name); + const existing = entriesByName.get(entryNameKey); + if (existing) { + throw new Error( + `[evjs] Duplicate build entry name "${entry.name}" from ${describeBuildEntryOwner( + existing, + )} and ${describeBuildEntryOwner(entry)}. Build entry names are manifest asset keys and physical bundler entry names, so they must be globally unique across platforms.`, + ); + } + entriesByName.set(entryNameKey, entry); + } + + const htmlByFileName = new Map(); + for (const document of html) { + for (const fileName of [document.fileName, ...(document.aliases ?? [])]) { + assertPortableRelativeArtifactPath( + fileName, + `HTML Document "${document.id}" output "${fileName}"`, + ); + const fileNameKey = canonicalPortableArtifactPathKey(fileName); + const existing = [...htmlByFileName.entries()].find(([existingKey]) => + portableArtifactPathsConflict(existingKey, fileNameKey), + )?.[1]; + if (existing) { + throw new Error( + `[evjs] Duplicate HTML output file "${fileName}" from ${describeHtmlOwner( + existing, + )} and ${describeHtmlOwner(document)}. Canonical HTML outputs and aliases must be globally unique.`, + ); + } + htmlByFileName.set(fileNameKey, document); + } + } +} + +function describeBuildEntryOwner(entry: BuildEntry): string { + if (entry.owner?.pageId && entry.owner.regionId) { + return `page "${entry.owner.pageId}" PPR region "${entry.owner.regionId}"`; + } + if (entry.owner?.pageId) return `page "${entry.owner.pageId}"`; + if (entry.owner?.appId) return `app "${entry.owner.appId}"`; + return `${entry.kind} entry`; +} + +function describeHtmlOwner(document: HtmlPlan): string { + if (document.owner.appId) return `app "${document.owner.appId}"`; + return `page "${document.owner.pageId}"`; +} + +function createServerRenderers(graph: BuildPlanFacts): ServerRenderPlan[] { + const renderers: ServerRenderPlan[] = []; + for (const page of Object.values(graph.pages)) { + if (page.render === "csr") continue; + + if (isRscPage(page)) { + renderers.push({ + name: createPageServerBuildEntryName(page.id), + import: page.component, + kind: "page-server", + owner: pageOwner(page), + metadata: createServerPageMetadata(page), + }); + renderers.push({ + name: createRscPageBuildEntryName(page.id), + import: page.component, + kind: "rsc-page", + owner: pageOwner(page), + metadata: createServerPageMetadata(page), + }); + } else if (isPartialPrerenderPage(page)) { + renderers.push({ + name: createPprShellBuildEntryName(page.id), + import: page.component, + kind: "ppr-shell", + owner: pageOwner(page), + metadata: createServerPageMetadata(page), + }); + } else { + renderers.push({ + name: createPageServerBuildEntryName(page.id), + import: page.component, + kind: "page-server", + ...(isBuildOnlySsgPage(page) ? { phase: "build" as const } : {}), + owner: pageOwner(page), + metadata: createServerPageMetadata(page), + }); + } + + for (const [regionId, region] of Object.entries(page.ppr?.regions ?? {})) { + renderers.push({ + name: createPprRegionBuildEntryName(page.id, regionId), + import: region.component, + kind: "ppr-region", + owner: pageOwner(page, { regionId }), + }); + } + } + + return renderers; +} + +function createServerPageMetadata( + page: BuildPageFacts, +): NonNullable { + return { + type: "react-server-page", + component: page.component, + ...(page.layers?.length + ? { + layers: page.layers.map((layer) => ({ ...layer })), + } + : {}), + }; +} + +function pageOwner( + page: { id: string; routeId?: string }, + extra: { regionId?: string } = {}, +): BuildEntry["owner"] { + return { + pageId: page.id, + ...(page.routeId ? { routeId: page.routeId } : {}), + ...extra, + }; +} + +function isBuildOnlySsgPage(page: BuildPageFacts): boolean { + return ( + page.render === "ssg" && !isRscPage(page) && !isPartialPrerenderPage(page) + ); +} + +function getPageClientEntry( + page: BuildPageFacts, +): + | { import: string; metadata?: NonNullable } + | undefined { + if (isPartialPrerenderPage(page)) return undefined; + if (isRscPage(page)) return undefined; + const hydrate = page.hydrate ?? defaultHydrate(page.render); + if (hydrate === "none" && page.render !== "csr") { + return undefined; + } + return { + import: page.component, + metadata: { + type: "react-component-page", + component: page.component, + ...(page.layers?.length + ? { layers: page.layers.map((layer) => ({ ...layer })) } + : {}), + mount: page.mount ?? "#app", + hydrate, + render: page.render, + ...(page.routePath + ? { route: { id: page.routeId ?? page.id, path: page.routePath } } + : {}), + }, + }; +} + +function createHtmlPlans(graph: BuildPlanFacts): HtmlPlan[] { + const apps = Object.values(graph.apps); + const pages = Object.values(graph.pages); + const pageDocuments: HtmlPlan[] = pages + .filter(shouldEmitDocumentForPage) + .map((page) => ({ + id: requirePageDocumentId(page), + template: page.html, + fileName: resolvePageDocumentOutput(page), + ...(page.documentAliases ? { aliases: [...page.documentAliases] } : {}), + owner: createPageBuildOwner(page), + ...(page.metadata ? { metadata: clonePageMetadata(page.metadata) } : {}), + })); + const applicationDocuments: HtmlPlan[] = apps + .filter((app) => !isStaticDocumentApp(graph, app.id)) + .map((app) => { + const preferredOutput = + app.documentOutput ?? + (app.id === "default" ? "index.html" : `${app.id}.html`); + const conflictsWithPage = pageDocuments.some((document) => { + if (document.fileName !== preferredOutput || !document.owner.pageId) { + return false; + } + return graph.pages[document.owner.pageId]?.applicationId === app.id; + }); + return { + id: app.documentId, + template: app.html, + fileName: conflictsWithPage + ? `${FRAMEWORK_SPA_FALLBACK_OUTPUT_DIR}/${sanitizePageId(app.id)}.html` + : preferredOutput, + ...(app.documentAliases ? { aliases: [...app.documentAliases] } : {}), + owner: { appId: app.id }, + }; + }); + + return [...applicationDocuments, ...pageDocuments]; +} + +function requirePageDocumentId(page: BuildPageFacts): string { + if (page.documentId) return page.documentId; + throw new Error( + `[evjs] Page "${page.id}" cannot emit HTML without a Core Document.`, + ); +} + +function resolvePageDocumentOutput(page: BuildPageFacts): string { + if (page.output) return page.output; + const output = page.routePath + ? createStaticPageDocumentOutput(page.routePath) + : undefined; + if (page.render === "ssg" && output) return output; + throw new Error( + `[evjs] Page "${page.id}" does not resolve to a static semantic Route output.`, + ); +} + +function createPageBuildOwner( + page: Pick, +): { appId?: string; pageId: string } { + if (page.routingMode === "mpa") { + return { appId: page.applicationId, pageId: page.id }; + } + return { pageId: page.id }; +} + +function isStaticDocumentApp(graph: BuildPlanFacts, appId: string): boolean { + const routes = getMaterializedAppRoutes(graph, appId); + if (routes.length === 0) return false; + + return routes.every((route) => isStaticSsgRoute(graph, route)); +} + +function isClientlessStaticDocumentApp( + graph: BuildPlanFacts, + appId: string, +): boolean { + const routes = getMaterializedAppRoutes(graph, appId); + if (routes.length === 0) return false; + + return routes.every((route) => { + if (!isStaticSsgRoute(graph, route) || !route.pageId) return false; + const page = graph.pages[route.pageId]; + return ( + page !== undefined && + (page.hydrate ?? defaultHydrate(page.render)) === "none" + ); + }); +} + +function getMaterializedAppRoutes( + graph: BuildPlanFacts, + appId: string, +): BuildRouteFacts[] { + return graph.routes.filter( + (route) => route.appId === appId && route.kind !== "layout", + ); +} + +function isStaticSsgRoute( + graph: BuildPlanFacts, + route: BuildPlanFacts["routes"][number], +) { + if (route.kind === "layout" || !route.pageId) return false; + if (!isStaticPagePath(route.path)) return false; + + const page = graph.pages[route.pageId]; + return page ? isBuildOnlySsgPage(page) : false; +} + +function shouldEmitDocumentForPage(page: BuildPageFacts): boolean { + if ( + page.render === "ssg" && + page.routePath && + isStaticPagePath(page.routePath) + ) { + return true; + } + + // Static SSG Pages emit Page-owned Documents in either mode. CSR Page + // Documents are emitted only by canonical MPA materialization. + return page.routingMode === "mpa" && page.render === "csr"; +} + +function isStaticPagePath(pathname: string): boolean { + return createStaticPageDocumentOutput(pathname) !== undefined; +} + +function createServerPlan( + config: BuildPlanConfig, + graph: BuildPlanFacts, + renderers: ServerRenderPlan[], +): ServerBuildPlan { + const entry = createServerRuntimeEntry(config, graph, renderers)?.import; + const documents = createServerDocumentPlans(graph); + return { + ...(entry ? { entry } : {}), + ...(renderers.length > 0 ? { renderers } : {}), + ...(documents.length > 0 ? { documents } : {}), + }; +} + +function createServerDocumentPlans( + graph: BuildPlanFacts, +): ServerDocumentPlan[] { + return Object.values(graph.pages) + .filter((page) => page.render === "ssr") + .sort((left, right) => left.id.localeCompare(right.id)) + .map((page) => { + if (!page.documentId || !page.html || !page.documentOutput) { + throw new Error( + `[evjs] Server-rendered Page "${page.id}" must resolve to one HTML Document template.`, + ); + } + + return { + pageId: page.id, + documentId: page.documentId, + applicationId: page.applicationId, + template: page.html, + fileName: page.documentOutput, + mount: page.mount ?? "#app", + ...(page.metadata + ? { metadata: clonePageMetadata(page.metadata) } + : {}), + }; + }); +} + +/** + * Create the single deployed server entry only when request-time capabilities + * need it. Build-only SSG renderers do not by themselves create a server + * runtime. + */ +function createServerRuntimeEntry( + config: BuildPlanConfig, + graph: BuildPlanFacts, + renderers: ServerRenderPlan[], +): Pick | undefined { + const routes = getConfiguredServerRoutes(config, graph); + const middlewares = config.server.conventions?.globalMiddlewares ?? []; + const serverFunctions = graph.serverFunctions; + const runtimeRenderers = renderers.filter( + (renderer) => renderer.phase !== "build", + ); + if ( + routes.length > 0 || + middlewares.length > 0 || + serverFunctions.length > 0 + ) { + return { + import: FRAMEWORK_SERVER_FETCH_ENTRY, + metadata: { + type: "server-app", + routes, + ...(middlewares.length > 0 ? { middlewares } : {}), + ...(serverFunctions.length > 0 ? { serverFunctions } : {}), + }, + }; + } + if (runtimeRenderers.length > 0) { + return { import: FRAMEWORK_SERVER_FETCH_ENTRY }; + } + return undefined; +} + +function getConfiguredServerRoutes( + config: BuildPlanConfig, + graph: BuildPlanFacts, +): DiscoveredServerRouteNode[] { + const configured = config.server.routes ?? []; + if (configured.length === 0) return []; + const graphIds = new Set(graph.serverRoutes.map((route) => route.id)); + return configured.filter((route) => graphIds.has(route.id)); +} + +function readBuildMode(): "development" | "production" { + return process.env.NODE_ENV === "production" ? "production" : "development"; +} + +function defaultHydrate( + render: BuildPageFacts["render"], +): NonNullable { + if (render === "ssg") return "none"; + return "load"; +} + +function hasPprPages(graph: BuildPlanFacts): boolean { + return Object.values(graph.pages).some(isPartialPrerenderPage); +} + +function hasRscPages(graph: BuildPlanFacts): boolean { + return Object.values(graph.pages).some(isRscPage); +} + +function buildEntryKey(entry: BuildEntry): string { + return `${entry.environment}:${entry.name}`; +} + +function diffByKey( + previous: T[], + next: T[], + keyOf: (value: T) => string, +): { + added: T[]; + removed: T[]; + changed: T[]; +} { + const previousByKey = new Map(previous.map((value) => [keyOf(value), value])); + const nextByKey = new Map(next.map((value) => [keyOf(value), value])); + const added: T[] = []; + const removed: T[] = []; + const changed: T[] = []; + + for (const [key, value] of nextByKey) { + const oldValue = previousByKey.get(key); + if (!oldValue) { + added.push(value); + } else if (stableStringify(oldValue) !== stableStringify(value)) { + changed.push(value); + } + } + + for (const [key, value] of previousByKey) { + if (!nextByKey.has(key)) { + removed.push(value); + } + } + + return { added, removed, changed }; +} + +function stableStringify(value: unknown): string { + return JSON.stringify(sortObject(value)); +} + +function sortObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortObject); + } + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, sortObject(nested)]), + ); +} diff --git a/packages/ev/src/_internal/build/plan/index.ts b/packages/ev/src/_internal/build/plan/index.ts index 13c598b5..9b8ac507 100644 --- a/packages/ev/src/_internal/build/plan/index.ts +++ b/packages/ev/src/_internal/build/plan/index.ts @@ -1,1181 +1,6 @@ -import { randomUUID } from "node:crypto"; -import type { - AppRouteTarget, - BuildEntry, - BuildPlan, - BuildPlanUpdate, - CoreApplicationNode, - CoreClientRouteNode, - CoreGraph, - CorePageNode, - HtmlPlan, - PagesAppRouteNode, - ReactPageLayer, - RuntimePlan, - ServerBuildPlan, - ServerDocumentPlan, - ServerMiddlewareNode, - ServerRenderPlan, -} from "@evjs/shared/manifest"; -import { clonePageMetadata } from "@evjs/shared/manifest"; -import { - createApplicationClientBuildEntryName, - createPageClientBuildEntryName, - createPageServerBuildEntryName, - createPprRegionBuildEntryName, - createPprShellBuildEntryName, - createRscPageBuildEntryName, - GENERATED_PAGES_APP_BUILD_ENTRY, - SERVER_RUNTIME_BUILD_ENTRY_NAME, -} from "../build-entry-conventions.js"; -import { assertFrameworkOutputOwnership } from "../output-path-conventions.js"; -import { createStaticPageDocumentOutput } from "../page-document-output.js"; -import { - isPartialPrerenderPage, - isRscPage, - validatePageBuildContract, -} from "../page-rendering-contract.js"; -import { - assertPortableArtifactFileName, - assertPortableRelativeArtifactPath, - canonicalPortableArtifactPathKey, - portableArtifactPathsConflict, -} from "../portable-artifact-path.js"; -import type { DiscoveredServerRouteNode } from "../server-routes.js"; -import { sanitizePageId } from "../utils.js"; -import { formatCoreRoutePattern } from "./route-pattern.js"; -import { - createRuntimeServerPlan, - validateClientServerRouteConflicts, - validateRuntimeEndpointConflicts, -} from "./runtime-server.js"; - -const DEFAULT_PUBLIC_PATH: RuntimePlan["publicPath"] = "auto"; -const FRAMEWORK_SERVER_FETCH_ENTRY = "@evjs/ev/_internal/server/fetch"; -const FRAMEWORK_SPA_FALLBACK_OUTPUT_DIR = "__evjs"; -const DEFAULT_RESOLVE_ALIAS = { - "@": "./src", -} as const satisfies NonNullable["alias"]; - -interface BuildApplicationFacts { - id: string; - documentId: string; - html: string; - documentOutput: string; - documentAliases?: string[]; - mount?: string; - rootModule?: string; -} - -interface BuildPageFacts - extends Pick< - CorePageNode, - | "id" - | "render" - | "componentModel" - | "hydrate" - | "prerender" - | "ppr" - | "metadata" - > { - applicationId: string; - routingMode: CoreApplicationNode["routingMode"]; - scope: CorePageNode["source"]["scope"]; - routePath?: string; - routeId?: string; - component: string; - documentId?: string; - /** Source template of the selected Page or Application Document. */ - html: string; - documentOutput?: string; - documentAliases?: string[]; - /** Concrete Page-owned static output; Application fallback is not copied. */ - output?: string; - mount?: string; - layers?: ReactPageLayer[]; -} - -interface BuildRouteFacts extends PagesAppRouteNode { - appId: string; - pageId?: string; -} - -interface BuildPlanFacts { - apps: Record; - pages: Record; - routes: BuildRouteFacts[]; - devClientRoutes: BuildPlan["dev"]["clientRoutes"]; - serverRenderedRoutePaths: string[]; - serverFunctions: CoreGraph["serverFunctions"]; - serverRoutes: CoreGraph["serverRoutes"]; - clientReferences?: CoreGraph["clientReferences"]; -} - -export interface BuildPlanConfig { - transport?: { - baseUrl?: string; - }; - output: { - client: string; - server: string; - }; - server: { - routes?: DiscoveredServerRouteNode[]; - conventions?: { - globalMiddlewares: ServerMiddlewareNode[]; - routeMiddlewares: ServerMiddlewareNode[]; - }; - basePath: string; - runtime: { - fn: string; - ppr?: string; - rsc?: string; - }; - }; -} - -export interface CreateBuildPlanOptions { - mode?: "development" | "production"; - buildId?: string; - distDir?: string; - publicPath?: RuntimePlan["publicPath"]; -} - -/** - * Project semantic graph nodes into the build-only facts needed to create - * concrete entries and Documents. This projection enforces materialization - * constraints, but CoreGraph remains the source of semantic identity. - */ -function deriveBuildPlanFacts(graph: CoreGraph): BuildPlanFacts { - const apps: Record = {}; - const pages: Record = {}; - const routes: BuildRouteFacts[] = []; - const clientRoutes = graph.routes; - const routeById = new Map(clientRoutes.map((route) => [route.id, route])); - const routesByPageId = new Map(); - const pageDocumentByPageId = new Map< - string, - CoreGraph["documents"][string] - >(); - const applicationDocumentById = new Map< - string, - CoreGraph["documents"][string] - >(); - - for (const route of clientRoutes) { - if (route.target.kind === "page") { - const routes = routesByPageId.get(route.target.pageId) ?? []; - routes.push(route); - routesByPageId.set(route.target.pageId, routes); - } - } - for (const document of Object.values(graph.documents)) { - if (document.owner.kind === "page") { - const previous = pageDocumentByPageId.get(document.owner.pageId); - if (previous) { - throw new Error( - `[evjs] Page "${document.owner.pageId}" owns more than one Document: "${previous.id}" and "${document.id}".`, - ); - } - pageDocumentByPageId.set(document.owner.pageId, document); - } else if (document.owner.kind === "application") { - const previous = applicationDocumentById.get(document.applicationId); - if (previous) { - throw new Error( - `[evjs] Application "${document.applicationId}" owns more than one Application Document: "${previous.id}" and "${document.id}".`, - ); - } - applicationDocumentById.set(document.applicationId, document); - } - } - - for (const application of Object.values(graph.applications)) { - const hasClientRoutes = clientRoutes.some( - (route) => route.applicationId === application.id, - ); - if (application.routingMode !== "spa" || !hasClientRoutes) continue; - const document = applicationDocumentById.get(application.id); - if (!document) { - throw new Error( - `[evjs] Application "${application.id}" with client entry "${GENERATED_PAGES_APP_BUILD_ENTRY}" must own one Document.`, - ); - } - apps[application.id] = { - id: application.id, - documentId: document.id, - html: document.template, - documentOutput: document.output, - ...(document.aliases ? { documentAliases: [...document.aliases] } : {}), - ...(document.mount ? { mount: document.mount } : {}), - ...(application.layout ? { rootModule: application.layout } : {}), - }; - } - - for (const page of Object.values(graph.pages)) { - const application = graph.applications[page.applicationId]; - if (!application) { - throw new Error( - `[evjs] Page "${page.id}" references missing Application "${page.applicationId}".`, - ); - } - const pageRoutes = routesByPageId.get(page.id) ?? []; - const pageDocument = pageDocumentByPageId.get(page.id); - const applicationDocument = applicationDocumentById.get(application.id); - if ( - pageRoutes.length > 1 && - (page.render !== "csr" || pageDocument !== undefined) - ) { - throw new Error( - `[evjs] Page "${page.id}" is targeted by multiple Routes (${pageRoutes - .map((route) => `"${route.id}"`) - .join( - ", ", - )}), but independently rendered Pages must map to exactly one URL.`, - ); - } - const route = pageRoutes[0]; - const document = pageDocument ?? applicationDocument; - const composition = route - ? collectPageComposition(application, routeById, route, page.id) - : []; - pages[page.id] = { - id: page.id, - applicationId: page.applicationId, - routingMode: application.routingMode, - scope: page.source.scope, - ...(route ? { routePath: formatCoreRoutePattern(route.pattern) } : {}), - ...(route ? { routeId: route.id } : {}), - component: page.source.module, - ...(document ? { documentId: document.id } : {}), - html: document?.template ?? "", - ...(document?.output ? { documentOutput: document.output } : {}), - ...(pageDocument?.aliases - ? { documentAliases: [...pageDocument.aliases] } - : {}), - ...(pageDocument?.output ? { output: pageDocument.output } : {}), - render: page.render, - ...(page.componentModel ? { componentModel: page.componentModel } : {}), - ...(page.hydrate ? { hydrate: page.hydrate } : {}), - ...(document?.mount ? { mount: document.mount } : {}), - ...(page.prerender ? { prerender: page.prerender } : {}), - ...(page.ppr ? { ppr: page.ppr } : {}), - ...(page.metadata ? { metadata: clonePageMetadata(page.metadata) } : {}), - ...(composition.length > 0 ? { layers: composition } : {}), - }; - } - - for (const route of clientRoutes) { - const path = formatCoreRoutePattern(route.pattern); - const page = - route.target.kind === "page" - ? graph.pages[route.target.pageId] - : undefined; - const target: AppRouteTarget = - route.target.kind === "page" - ? { kind: "page", pageId: route.target.pageId } - : route.target.kind === "group" - ? { kind: "group" } - : { - kind: "redirect", - to: - route.target.to.kind === "url" - ? { kind: "url", href: route.target.to.href } - : { - kind: "path", - path: formatCoreRoutePattern(route.target.to.pattern), - }, - }; - const layoutModule = - typeof route.facets.layout === "string" ? route.facets.layout : undefined; - if (layoutModule && route.target.kind === "page") { - const layoutId = `${route.id}:layout`; - routes.push({ - id: layoutId, - path, - ...(route.parentId ? { parentId: route.parentId } : {}), - kind: "layout", - appId: route.applicationId, - module: layoutModule, - ...(route.facets.error ? { errorModule: route.facets.error } : {}), - ...(route.facets.notFound - ? { notFoundModule: route.facets.notFound } - : {}), - }); - routes.push({ - id: route.id, - path, - parentId: layoutId, - appId: route.applicationId, - pageId: route.target.pageId, - module: page?.source.module, - target, - wrappers: [...route.facets.wrappers], - }); - continue; - } - routes.push({ - id: route.id, - path, - ...(route.parentId ? { parentId: route.parentId } : {}), - ...(layoutModule ? { kind: "layout" as const } : {}), - appId: route.applicationId, - ...(route.target.kind === "page" - ? { - pageId: route.target.pageId, - module: page?.source.module, - } - : layoutModule - ? { module: layoutModule } - : {}), - target, - wrappers: [...route.facets.wrappers], - ...(route.facets.layout === false ? { layout: false as const } : {}), - ...(route.facets.error ? { errorModule: route.facets.error } : {}), - ...(route.facets.notFound - ? { notFoundModule: route.facets.notFound } - : {}), - }); - } - - return { - apps, - pages, - routes, - devClientRoutes: createDevClientRoutes(graph), - serverRenderedRoutePaths: createServerRenderedRoutePaths(graph), - serverFunctions: graph.serverFunctions, - serverRoutes: graph.serverRoutes, - ...(graph.clientReferences - ? { clientReferences: graph.clientReferences } - : {}), - }; -} - -/** - * Resolve Page composition from the owning Route's parent chain, ordered from - * the Application layout through outer-to-inner Route layouts and wrappers. - * `layout: false` suppresses only the Application layout. - */ -function collectPageComposition( - application: CoreApplicationNode, - routesById: ReadonlyMap, - route: CoreClientRouteNode, - pageId: string, -): ReactPageLayer[] { - const innerToOuter: CoreClientRouteNode[] = []; - const visited = new Set(); - let current: CoreClientRouteNode | undefined = route; - - while (current) { - if (visited.has(current.id)) { - throw new Error( - `[evjs] Page "${pageId}" has a circular layout parent chain at Route "${current.id}".`, - ); - } - visited.add(current.id); - innerToOuter.push(current); - if (!current.parentId) break; - const parentId: string = current.parentId; - current = routesById.get(parentId); - if (!current) { - throw new Error( - `[evjs] Page "${pageId}" references missing layout Route "${parentId}".`, - ); - } - } - - const routeChain = innerToOuter.reverse(); - const bypassApplicationLayout = routeChain.some( - (candidate) => candidate.facets.layout === false, - ); - const layers: ReactPageLayer[] = []; - if (application.layout && !bypassApplicationLayout) { - layers.push({ kind: "layout", module: application.layout }); - } - for (const candidate of routeChain) { - if (typeof candidate.facets.layout === "string") { - layers.push({ kind: "layout", module: candidate.facets.layout }); - } - layers.push( - ...candidate.facets.wrappers.map((module) => ({ - kind: "wrapper" as const, - module, - })), - ); - } - return layers; -} - -function createDevClientRoutes( - graph: CoreGraph, -): BuildPlan["dev"]["clientRoutes"] { - const routes: BuildPlan["dev"]["clientRoutes"] = []; - const seen = new Set(); - - for (const route of graph.routes) { - const application = graph.applications[route.applicationId]; - if (!application) continue; - - let target: BuildPlan["dev"]["clientRoutes"][number]["target"]; - if (route.target.kind === "page") { - const page = graph.pages[route.target.pageId]; - if (!page || page.render !== "csr") continue; - target = - application.routingMode === "mpa" - ? { kind: "page", pageId: page.id } - : { kind: "app", appId: application.id }; - } else { - if (application.routingMode !== "spa") continue; - target = { kind: "app", appId: application.id }; - } - - const pathname = formatCoreRoutePattern(route.pattern); - const key = - target.kind === "page" - ? `${pathname}:page:${target.pageId}` - : `${pathname}:app:${target.appId}`; - if (seen.has(key)) continue; - seen.add(key); - routes.push({ path: pathname, target }); - } - - return routes; -} - -function createServerRenderedRoutePaths(graph: CoreGraph): string[] { - const paths = graph.routes.flatMap((route) => { - if (route.target.kind !== "page") return []; - const page = graph.pages[route.target.pageId]; - // Full SSG renderers run only while emitting HTML. Their canonical route - // must stay on the static dev host instead of being proxied to the server. - return page?.render === "ssr" - ? [formatCoreRoutePattern(route.pattern)] - : []; - }); - return [...new Set(paths)]; -} - -/** - * Derive the complete bundler-independent compilation plan from CoreGraph. - * The plan owns concrete entries, HTML outputs, renderer units, runtime - * endpoints, and dev routing; adapters consume it without rediscovering source - * conventions or semantic ownership. - */ -export function createBuildPlan( - config: BuildPlanConfig, - coreGraph: CoreGraph, - options: CreateBuildPlanOptions = {}, -): BuildPlan { - const distDir = options.distDir ?? "dist"; - assertFrameworkOutputOwnership(config.output, distDir); - const graph = deriveBuildPlanFacts(coreGraph); - const mode = options.mode ?? readBuildMode(); - const hasPpr = hasPprPages(graph); - const hasRsc = hasRscPages(graph); - const runtimeServer = createRuntimeServerPlan(config.server, { - hasPpr, - hasRsc, - }); - validateClientServerRouteConflicts(coreGraph); - validateRuntimeEndpointConflicts(coreGraph, runtimeServer); - validatePageBuildContracts(graph); - const serverRenderers = createServerRenderers(graph); - const entries = createEntries(config, graph, serverRenderers); - const html = createHtmlPlans(graph); - validateBuildOutputNames(entries, html); - const server = createServerPlan(config, graph, serverRenderers); - - return { - version: 1, - buildId: options.buildId ?? createBuildGenerationId(mode), - mode, - distDir, - output: { - clientDir: config.output.client, - serverDir: config.output.server, - }, - resolve: { - alias: { - ...DEFAULT_RESOLVE_ALIAS, - }, - }, - entries, - html, - server, - runtime: { - publicPath: options.publicPath ?? DEFAULT_PUBLIC_PATH, - server: runtimeServer, - transport: config.transport, - }, - dev: { - clientRoutes: graph.devClientRoutes, - serverRequestRoutePaths: [ - ...new Set(graph.serverRoutes.map((route) => route.path)), - ], - serverRenderedPagePaths: graph.serverRenderedRoutePaths, - hasPpr, - }, - ...((graph.clientReferences?.length ?? 0) > 0 - ? { - rsc: { - clientReferenceModules: [ - ...new Set( - graph.clientReferences?.map((reference) => reference.module), - ), - ], - }, - } - : {}), - }; -} - -/** Create one identity for a framework output generation. */ -export function createBuildGenerationId( - mode: "development" | "production", -): string { - return mode === "production" ? `build-${randomUUID()}` : "development"; -} - -/** - * Classify a plan transition for incremental adapters. Identity-based entry and - * Document diffs are reported separately from generated IR, resolution, - * runtime, reason-driven delivery, server compilation, server Document, and - * dev-routing changes. - */ -export function diffBuildPlan( - previous: BuildPlan, - next: BuildPlan, - reason: BuildPlanUpdate["reason"], -): BuildPlanUpdate { - const runtimeChanged = - stableStringify(previous.runtime) !== stableStringify(next.runtime); - const previousServerCompilation = { - entry: previous.server.entry, - renderers: previous.server.renderers, - }; - const nextServerCompilation = { - entry: next.server.entry, - renderers: next.server.renderers, - }; - return { - reason, - previous, - next, - entries: diffByKey(previous.entries, next.entries, buildEntryKey), - html: diffByKey(previous.html, next.html, (html) => html.id), - generatedChanged: - stableStringify(previous.generated) !== stableStringify(next.generated), - resolveChanged: - stableStringify(previous.resolve) !== stableStringify(next.resolve), - runtimeChanged, - deliveryChanged: reason === "config", - serverCompilationChanged: - previous.output.serverDir !== next.output.serverDir || - stableStringify(previousServerCompilation) !== - stableStringify(nextServerCompilation) || - stableStringify(previous.rsc) !== stableStringify(next.rsc), - serverDocumentsChanged: - stableStringify(previous.server.documents) !== - stableStringify(next.server.documents), - devRoutingChanged: - stableStringify(previous.dev) !== stableStringify(next.dev), - }; -} - -function createEntries( - config: BuildPlanConfig, - graph: BuildPlanFacts, - serverRenderers: ServerRenderPlan[], -): BuildEntry[] { - const entries: BuildEntry[] = []; - const pages = Object.values(graph.pages); - const apps = Object.values(graph.apps); - - for (const app of apps) { - if (isClientlessStaticDocumentApp(graph, app.id)) continue; - - entries.push({ - name: createApplicationClientBuildEntryName(app.id), - import: GENERATED_PAGES_APP_BUILD_ENTRY, - environment: "client", - runtime: "browser", - kind: "app-client", - owner: { appId: app.id }, - metadata: { - type: "pages-app", - routes: createPagesAppRoutes(graph, app.id), - mount: app.mount ?? "#app", - ...(app.rootModule ? { rootModule: app.rootModule } : {}), - }, - }); - } - - for (const page of pages) { - if (page.routingMode === "mpa") { - const pageEntry = getPageClientEntry(page); - if (pageEntry) { - entries.push({ - name: createPageClientBuildEntryName(page.id), - import: pageEntry.import, - environment: "client", - runtime: "browser", - kind: "page-client", - owner: createPageBuildOwner(page), - ...(pageEntry.metadata ? { metadata: pageEntry.metadata } : {}), - }); - } - } - - entries.push( - ...serverRenderers - .filter((renderer) => renderer.owner?.pageId === page.id) - .map((renderer) => ({ - name: renderer.name, - import: renderer.import, - environment: "server" as const, - runtime: "node" as const, - kind: renderer.kind, - ...(renderer.phase ? { phase: renderer.phase } : {}), - owner: renderer.owner, - ...(renderer.metadata ? { metadata: renderer.metadata } : {}), - })), - ); - } - - if (hasRscPages(graph)) { - entries.push({ - name: "evjs-rsc-client", - import: "@evjs/ev/_internal/client/rsc-runtime", - environment: "client", - runtime: "browser", - kind: "runtime", - }); - } - - const serverEntry = createServerRuntimeEntry(config, graph, serverRenderers); - if (serverEntry) { - entries.push({ - name: SERVER_RUNTIME_BUILD_ENTRY_NAME, - import: serverEntry.import, - environment: "server", - runtime: "node", - kind: "server-runtime", - ...(serverEntry.metadata ? { metadata: serverEntry.metadata } : {}), - }); - } - - return entries; -} - -function createPagesAppRoutes( - graph: BuildPlanFacts, - appId: string, -): PagesAppRouteNode[] { - return graph.routes.flatMap((route) => { - if (route.appId !== appId) return []; - if (!route.module && !route.target) return []; - if (route.target?.kind === "page" && !route.module) { - throw new Error( - `[evjs] Application-route Page target "${route.id}" has no component module for the pages-app entry.`, - ); - } - const metadata = - route.target?.kind === "page" - ? clonePageMetadata(graph.pages[route.target.pageId]?.metadata) - : undefined; - return [ - { - id: route.id, - path: route.path, - ...(route.module ? { module: route.module } : {}), - ...(route.parentId ? { parentId: route.parentId } : {}), - ...(route.kind ? { kind: route.kind } : {}), - ...(route.target ? { target: route.target } : {}), - ...(route.wrappers ? { wrappers: [...route.wrappers] } : {}), - ...(route.layout === false ? { layout: false as const } : {}), - ...(route.errorModule ? { errorModule: route.errorModule } : {}), - ...(route.notFoundModule - ? { notFoundModule: route.notFoundModule } - : {}), - ...(metadata ? { metadata } : {}), - }, - ]; - }); -} - -function validatePageBuildContracts(graph: BuildPlanFacts): void { - for (const page of Object.values(graph.pages)) { - validatePageBuildContract(`Page "${page.id}"`, page); - if ( - page.render === "ssg" && - page.routePath && - !isStaticPagePath(page.routePath) - ) { - throw new Error( - `[evjs] Page "${page.id}" uses render "ssg" on dynamic Route "${page.routePath}", which does not identify one build-time HTML output. Use a static Route or render "ssr".`, - ); - } - } -} - -function validateBuildOutputNames( - entries: BuildEntry[], - html: HtmlPlan[], -): void { - const entriesByName = new Map(); - for (const entry of entries) { - assertPortableArtifactFileName( - entry.name, - `Build entry name "${entry.name}"`, - ); - const entryNameKey = canonicalPortableArtifactPathKey(entry.name); - const existing = entriesByName.get(entryNameKey); - if (existing) { - throw new Error( - `[evjs] Duplicate build entry name "${entry.name}" from ${describeBuildEntryOwner( - existing, - )} and ${describeBuildEntryOwner(entry)}. Build entry names are manifest asset keys and physical bundler entry names, so they must be globally unique across platforms.`, - ); - } - entriesByName.set(entryNameKey, entry); - } - - const htmlByFileName = new Map(); - for (const document of html) { - for (const fileName of [document.fileName, ...(document.aliases ?? [])]) { - assertPortableRelativeArtifactPath( - fileName, - `HTML Document "${document.id}" output "${fileName}"`, - ); - const fileNameKey = canonicalPortableArtifactPathKey(fileName); - const existing = [...htmlByFileName.entries()].find(([existingKey]) => - portableArtifactPathsConflict(existingKey, fileNameKey), - )?.[1]; - if (existing) { - throw new Error( - `[evjs] Duplicate HTML output file "${fileName}" from ${describeHtmlOwner( - existing, - )} and ${describeHtmlOwner(document)}. Canonical HTML outputs and aliases must be globally unique.`, - ); - } - htmlByFileName.set(fileNameKey, document); - } - } -} - -function describeBuildEntryOwner(entry: BuildEntry): string { - if (entry.owner?.pageId && entry.owner.regionId) { - return `page "${entry.owner.pageId}" PPR region "${entry.owner.regionId}"`; - } - if (entry.owner?.pageId) return `page "${entry.owner.pageId}"`; - if (entry.owner?.appId) return `app "${entry.owner.appId}"`; - return `${entry.kind} entry`; -} - -function describeHtmlOwner(document: HtmlPlan): string { - if (document.owner.appId) return `app "${document.owner.appId}"`; - return `page "${document.owner.pageId}"`; -} - -function createServerRenderers(graph: BuildPlanFacts): ServerRenderPlan[] { - const renderers: ServerRenderPlan[] = []; - for (const page of Object.values(graph.pages)) { - if (page.render === "csr") continue; - - if (isRscPage(page)) { - renderers.push({ - name: createPageServerBuildEntryName(page.id), - import: page.component, - kind: "page-server", - owner: pageOwner(page), - metadata: createServerPageMetadata(page), - }); - renderers.push({ - name: createRscPageBuildEntryName(page.id), - import: page.component, - kind: "rsc-page", - owner: pageOwner(page), - metadata: createServerPageMetadata(page), - }); - } else if (isPartialPrerenderPage(page)) { - renderers.push({ - name: createPprShellBuildEntryName(page.id), - import: page.component, - kind: "ppr-shell", - owner: pageOwner(page), - metadata: createServerPageMetadata(page), - }); - } else { - renderers.push({ - name: createPageServerBuildEntryName(page.id), - import: page.component, - kind: "page-server", - ...(isBuildOnlySsgPage(page) ? { phase: "build" as const } : {}), - owner: pageOwner(page), - metadata: createServerPageMetadata(page), - }); - } - - for (const [regionId, region] of Object.entries(page.ppr?.regions ?? {})) { - renderers.push({ - name: createPprRegionBuildEntryName(page.id, regionId), - import: region.component, - kind: "ppr-region", - owner: pageOwner(page, { regionId }), - }); - } - } - - return renderers; -} - -function createServerPageMetadata( - page: BuildPageFacts, -): NonNullable { - return { - type: "react-server-page", - component: page.component, - ...(page.layers?.length - ? { - layers: page.layers.map((layer) => ({ ...layer })), - } - : {}), - }; -} - -function pageOwner( - page: { id: string; routeId?: string }, - extra: { regionId?: string } = {}, -): BuildEntry["owner"] { - return { - pageId: page.id, - ...(page.routeId ? { routeId: page.routeId } : {}), - ...extra, - }; -} - -function isBuildOnlySsgPage(page: BuildPageFacts): boolean { - return ( - page.render === "ssg" && !isRscPage(page) && !isPartialPrerenderPage(page) - ); -} - -function getPageClientEntry( - page: BuildPageFacts, -): - | { import: string; metadata?: NonNullable } - | undefined { - if (isPartialPrerenderPage(page)) return undefined; - if (isRscPage(page)) return undefined; - const hydrate = page.hydrate ?? defaultHydrate(page.render); - if (hydrate === "none" && page.render !== "csr") { - return undefined; - } - return { - import: page.component, - metadata: { - type: "react-component-page", - component: page.component, - ...(page.layers?.length - ? { layers: page.layers.map((layer) => ({ ...layer })) } - : {}), - mount: page.mount ?? "#app", - hydrate, - render: page.render, - ...(page.routePath - ? { route: { id: page.routeId ?? page.id, path: page.routePath } } - : {}), - }, - }; -} - -function createHtmlPlans(graph: BuildPlanFacts): HtmlPlan[] { - const apps = Object.values(graph.apps); - const pages = Object.values(graph.pages); - const pageDocuments: HtmlPlan[] = pages - .filter(shouldEmitDocumentForPage) - .map((page) => ({ - id: requirePageDocumentId(page), - template: page.html, - fileName: resolvePageDocumentOutput(page), - ...(page.documentAliases ? { aliases: [...page.documentAliases] } : {}), - owner: createPageBuildOwner(page), - ...(page.metadata ? { metadata: clonePageMetadata(page.metadata) } : {}), - })); - const applicationDocuments: HtmlPlan[] = apps - .filter((app) => !isStaticDocumentApp(graph, app.id)) - .map((app) => { - const preferredOutput = - app.documentOutput ?? - (app.id === "default" ? "index.html" : `${app.id}.html`); - const conflictsWithPage = pageDocuments.some((document) => { - if (document.fileName !== preferredOutput || !document.owner.pageId) { - return false; - } - return graph.pages[document.owner.pageId]?.applicationId === app.id; - }); - return { - id: app.documentId, - template: app.html, - fileName: conflictsWithPage - ? `${FRAMEWORK_SPA_FALLBACK_OUTPUT_DIR}/${sanitizePageId(app.id)}.html` - : preferredOutput, - ...(app.documentAliases ? { aliases: [...app.documentAliases] } : {}), - owner: { appId: app.id }, - }; - }); - - return [...applicationDocuments, ...pageDocuments]; -} - -function requirePageDocumentId(page: BuildPageFacts): string { - if (page.documentId) return page.documentId; - throw new Error( - `[evjs] Page "${page.id}" cannot emit HTML without a Core Document.`, - ); -} - -function resolvePageDocumentOutput(page: BuildPageFacts): string { - if (page.output) return page.output; - const output = page.routePath - ? createStaticPageDocumentOutput(page.routePath) - : undefined; - if (page.render === "ssg" && output) return output; - throw new Error( - `[evjs] Page "${page.id}" does not resolve to a static semantic Route output.`, - ); -} - -function createPageBuildOwner( - page: Pick, -): { appId?: string; pageId: string } { - if (page.routingMode === "mpa") { - return { appId: page.applicationId, pageId: page.id }; - } - return { pageId: page.id }; -} - -function isStaticDocumentApp(graph: BuildPlanFacts, appId: string): boolean { - const routes = getMaterializedAppRoutes(graph, appId); - if (routes.length === 0) return false; - - return routes.every((route) => isStaticSsgRoute(graph, route)); -} - -function isClientlessStaticDocumentApp( - graph: BuildPlanFacts, - appId: string, -): boolean { - const routes = getMaterializedAppRoutes(graph, appId); - if (routes.length === 0) return false; - - return routes.every((route) => { - if (!isStaticSsgRoute(graph, route) || !route.pageId) return false; - const page = graph.pages[route.pageId]; - return ( - page !== undefined && - (page.hydrate ?? defaultHydrate(page.render)) === "none" - ); - }); -} - -function getMaterializedAppRoutes( - graph: BuildPlanFacts, - appId: string, -): BuildRouteFacts[] { - return graph.routes.filter( - (route) => route.appId === appId && route.kind !== "layout", - ); -} - -function isStaticSsgRoute( - graph: BuildPlanFacts, - route: BuildPlanFacts["routes"][number], -) { - if (route.kind === "layout" || !route.pageId) return false; - if (!isStaticPagePath(route.path)) return false; - - const page = graph.pages[route.pageId]; - return page ? isBuildOnlySsgPage(page) : false; -} - -function shouldEmitDocumentForPage(page: BuildPageFacts): boolean { - if ( - page.render === "ssg" && - page.routePath && - isStaticPagePath(page.routePath) - ) { - return true; - } - - // Static SSG Pages emit Page-owned Documents in either mode. CSR Page - // Documents are emitted only by canonical MPA materialization. - return page.routingMode === "mpa" && page.render === "csr"; -} - -function isStaticPagePath(pathname: string): boolean { - return createStaticPageDocumentOutput(pathname) !== undefined; -} - -function createServerPlan( - config: BuildPlanConfig, - graph: BuildPlanFacts, - renderers: ServerRenderPlan[], -): ServerBuildPlan { - const entry = createServerRuntimeEntry(config, graph, renderers)?.import; - const documents = createServerDocumentPlans(graph); - return { - ...(entry ? { entry } : {}), - ...(renderers.length > 0 ? { renderers } : {}), - ...(documents.length > 0 ? { documents } : {}), - }; -} - -function createServerDocumentPlans( - graph: BuildPlanFacts, -): ServerDocumentPlan[] { - return Object.values(graph.pages) - .filter((page) => page.render === "ssr") - .sort((left, right) => left.id.localeCompare(right.id)) - .map((page) => { - if (!page.documentId || !page.html || !page.documentOutput) { - throw new Error( - `[evjs] Server-rendered Page "${page.id}" must resolve to one HTML Document template.`, - ); - } - - return { - pageId: page.id, - documentId: page.documentId, - applicationId: page.applicationId, - template: page.html, - fileName: page.documentOutput, - mount: page.mount ?? "#app", - ...(page.metadata - ? { metadata: clonePageMetadata(page.metadata) } - : {}), - }; - }); -} - -/** - * Create the single deployed server entry only when request-time capabilities - * need it. Build-only SSG renderers do not by themselves create a server - * runtime. - */ -function createServerRuntimeEntry( - config: BuildPlanConfig, - graph: BuildPlanFacts, - renderers: ServerRenderPlan[], -): Pick | undefined { - const routes = getConfiguredServerRoutes(config, graph); - const middlewares = config.server.conventions?.globalMiddlewares ?? []; - const serverFunctions = graph.serverFunctions; - const runtimeRenderers = renderers.filter( - (renderer) => renderer.phase !== "build", - ); - if ( - routes.length > 0 || - middlewares.length > 0 || - serverFunctions.length > 0 - ) { - return { - import: FRAMEWORK_SERVER_FETCH_ENTRY, - metadata: { - type: "server-app", - routes, - ...(middlewares.length > 0 ? { middlewares } : {}), - ...(serverFunctions.length > 0 ? { serverFunctions } : {}), - }, - }; - } - if (runtimeRenderers.length > 0) { - return { import: FRAMEWORK_SERVER_FETCH_ENTRY }; - } - return undefined; -} - -function getConfiguredServerRoutes( - config: BuildPlanConfig, - graph: BuildPlanFacts, -): DiscoveredServerRouteNode[] { - const configured = config.server.routes ?? []; - if (configured.length === 0) return []; - const graphIds = new Set(graph.serverRoutes.map((route) => route.id)); - return configured.filter((route) => graphIds.has(route.id)); -} - -function readBuildMode(): "development" | "production" { - return process.env.NODE_ENV === "production" ? "production" : "development"; -} - -function defaultHydrate( - render: BuildPageFacts["render"], -): NonNullable { - if (render === "ssg") return "none"; - return "load"; -} - -function hasPprPages(graph: BuildPlanFacts): boolean { - return Object.values(graph.pages).some(isPartialPrerenderPage); -} - -function hasRscPages(graph: BuildPlanFacts): boolean { - return Object.values(graph.pages).some(isRscPage); -} - -function buildEntryKey(entry: BuildEntry): string { - return `${entry.environment}:${entry.name}`; -} - -function diffByKey( - previous: T[], - next: T[], - keyOf: (value: T) => string, -): { - added: T[]; - removed: T[]; - changed: T[]; -} { - const previousByKey = new Map(previous.map((value) => [keyOf(value), value])); - const nextByKey = new Map(next.map((value) => [keyOf(value), value])); - const added: T[] = []; - const removed: T[] = []; - const changed: T[] = []; - - for (const [key, value] of nextByKey) { - const oldValue = previousByKey.get(key); - if (!oldValue) { - added.push(value); - } else if (stableStringify(oldValue) !== stableStringify(value)) { - changed.push(value); - } - } - - for (const [key, value] of previousByKey) { - if (!nextByKey.has(key)) { - removed.push(value); - } - } - - return { added, removed, changed }; -} - -function stableStringify(value: unknown): string { - return JSON.stringify(sortObject(value)); -} - -function sortObject(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(sortObject); - } - if (!value || typeof value !== "object") { - return value; - } - - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, nested]) => [key, sortObject(nested)]), - ); -} +export { + createBuildGenerationId, + createBuildPlan, + diffBuildPlan, +} from "./create-build-plan.js"; +export type { BuildPlanConfig, CreateBuildPlanOptions } from "./types.js"; diff --git a/packages/ev/src/_internal/build/plan/types.ts b/packages/ev/src/_internal/build/plan/types.ts new file mode 100644 index 00000000..21ae492d --- /dev/null +++ b/packages/ev/src/_internal/build/plan/types.ts @@ -0,0 +1,32 @@ +import type { RuntimePlan, ServerMiddlewareNode } from "@evjs/shared/manifest"; +import type { DiscoveredServerRouteNode } from "../discovery/server-routes.js"; + +export interface BuildPlanConfig { + transport?: { + baseUrl?: string; + }; + output: { + client: string; + server: string; + }; + server: { + routes?: DiscoveredServerRouteNode[]; + conventions?: { + globalMiddlewares: ServerMiddlewareNode[]; + routeMiddlewares: ServerMiddlewareNode[]; + }; + basePath: string; + runtime: { + fn: string; + ppr?: string; + rsc?: string; + }; + }; +} + +export interface CreateBuildPlanOptions { + mode?: "development" | "production"; + buildId?: string; + distDir?: string; + publicPath?: RuntimePlan["publicPath"]; +} diff --git a/packages/ev/src/_internal/build/deployment-output-reservations.ts b/packages/ev/src/_internal/build/plugins/deployment-output-reservations.ts similarity index 93% rename from packages/ev/src/_internal/build/deployment-output-reservations.ts rename to packages/ev/src/_internal/build/plugins/deployment-output-reservations.ts index 45afad4a..7a42845f 100644 --- a/packages/ev/src/_internal/build/deployment-output-reservations.ts +++ b/packages/ev/src/_internal/build/plugins/deployment-output-reservations.ts @@ -1,12 +1,12 @@ import path from "node:path"; -import type { BuildResult, PluginHooks } from "../../plugin/index.js"; -import type { BundlerEmittedFiles } from "./bundler.js"; -import { assertBundlerEmittedFiles } from "./bundler-output-files.js"; +import type { BuildResult, PluginHooks } from "../../../plugin/index.js"; +import type { BundlerEmittedFiles } from "../bundler/contracts.js"; +import { assertBundlerEmittedFiles } from "../bundler/output-files.js"; import { assertPortableArtifactFileName, canonicalPortableArtifactPathKey, portableArtifactPathsConflict, -} from "./portable-artifact-path.js"; +} from "../output/portable-artifact-path.js"; export interface DeploymentOutputReservation { cwd: string; diff --git a/packages/ev/src/_internal/build/plugin-lifecycle.ts b/packages/ev/src/_internal/build/plugins/lifecycle.ts similarity index 98% rename from packages/ev/src/_internal/build/plugin-lifecycle.ts rename to packages/ev/src/_internal/build/plugins/lifecycle.ts index c91d917c..0e2bb72f 100644 --- a/packages/ev/src/_internal/build/plugin-lifecycle.ts +++ b/packages/ev/src/_internal/build/plugins/lifecycle.ts @@ -1,5 +1,5 @@ import type { BuildOutput } from "@evjs/shared/manifest"; -import { type Config, resolvePluginsConfig } from "../../config/index.js"; +import { type Config, resolvePluginsConfig } from "../../../config/index.js"; import { copyDefinedPluginRuntime, createPluginApplicationSettingContext, @@ -7,8 +7,8 @@ import { forkDefinedPluginRuntime, prepareDefinedPluginApplicationSetting, shareDefinedPluginApplicationBinding, -} from "../../plugin/defined.js"; -import { PLUGIN_HOOK_NAMES } from "../../plugin/hook-names.js"; +} from "../../../plugin/defined.js"; +import { PLUGIN_HOOK_NAMES } from "../../../plugin/hook-names.js"; import type { BeforeBuildContext, BuildResult, @@ -19,8 +19,8 @@ import type { PluginHooks, PluginSetupContext, TransformOutputContext, -} from "../../plugin/index.js"; -import type { BundlerEmittedFiles } from "./bundler.js"; +} from "../../../plugin/index.js"; +import type { BundlerEmittedFiles } from "../bundler/contracts.js"; import { assertAfterBuildDeploymentOutputsAvailable } from "./deployment-output-reservations.js"; const typedPluginHookNames: readonly (keyof PluginHooks)[] = PLUGIN_HOOK_NAMES; diff --git a/packages/ev/src/_internal/build/plugin-settings.ts b/packages/ev/src/_internal/build/plugins/settings.ts similarity index 96% rename from packages/ev/src/_internal/build/plugin-settings.ts rename to packages/ev/src/_internal/build/plugins/settings.ts index 303ebc5a..d37ec126 100644 --- a/packages/ev/src/_internal/build/plugin-settings.ts +++ b/packages/ev/src/_internal/build/plugins/settings.ts @@ -7,8 +7,8 @@ import type { CorePluginCatalogSnapshot, } from "@evjs/shared/manifest"; import { assertCoreGraph } from "@evjs/shared/manifest"; -import type { ResolvedConfig } from "../../config/index.js"; -import type { ResolvedPagePluginOptionsInput } from "../../config/plugins.js"; +import type { ResolvedConfig } from "../../../config/index.js"; +import type { ResolvedPagePluginOptionsInput } from "../../../config/plugins.js"; import { createPluginApplicationSettingContext, type DefinedPluginDeclaration, @@ -16,10 +16,10 @@ import { type PluginOptionsContext, resolveDefinedPluginApplicationSetting, resolveDefinedPluginPageSetting, -} from "../../plugin/defined.js"; -import type { Plugin } from "../../plugin/index.js"; -import type { ResolvedPageFileConfig } from "./page-config-module.js"; -import { orderPluginsByDependencies } from "./plugin-lifecycle.js"; +} from "../../../plugin/defined.js"; +import type { Plugin } from "../../../plugin/index.js"; +import type { ResolvedPageFileConfig } from "../config-loading/page-config-module.js"; +import { orderPluginsByDependencies } from "./lifecycle.js"; export interface RegisteredPluginSettings { readonly id: string; diff --git a/packages/ev/src/_internal/build/transforms/client/index.ts b/packages/ev/src/_internal/build/transforms/client/index.ts index 54b70db5..62a63c2e 100644 --- a/packages/ev/src/_internal/build/transforms/client/index.ts +++ b/packages/ev/src/_internal/build/transforms/client/index.ts @@ -1,6 +1,6 @@ import { type Module, parseSync } from "@swc/core"; -import { formatModuleExportName } from "../../module-exports.js"; -import type { ServerFunctionExport } from "../../server-fns.js"; +import { formatModuleExportName } from "../../analysis/module-exports.js"; +import type { ServerFunctionExport } from "../../analysis/server-fns.js"; import { SERVER_FUNCTION_TRANSFORM_RUNTIME, type TransformOptions, diff --git a/packages/ev/src/_internal/build/transforms/index.ts b/packages/ev/src/_internal/build/transforms/index.ts index b05ab329..94005f4a 100644 --- a/packages/ev/src/_internal/build/transforms/index.ts +++ b/packages/ev/src/_internal/build/transforms/index.ts @@ -1,80 +1,2 @@ -import { printSync } from "@swc/core"; -import { - analyzeServerFunctionExportsFromAst, - formatServerFunctionParseDiagnostic, - parseServerFunctionModule, -} from "../server-fns.js"; -import type { TransformOptions } from "../types.js"; -import { - CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, - detectConflictingFrameworkDirectives, - detectUseServer, -} from "../utils.js"; -import { buildClientOutput } from "./client/index.js"; -import { buildServerOutput } from "./server/index.js"; - -export interface TransformResult { - code: string; - map?: string; -} - -/** - * Transform a "use server" file for either client or server builds. - * This is a pure function with no bundler dependency. - * - * - **Server**: keeps implementations and reports them to the build manifest - * - **Client**: replaces function bodies with generated client reference stubs - */ -export async function transformServerFile( - source: string, - options: TransformOptions, -): Promise { - if (!detectUseServer(source)) { - return { code: source }; - } - if (detectConflictingFrameworkDirectives(source)) { - throw new Error( - [ - '[evjs] Invalid "use server" module.', - CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, - ].join("\n"), - ); - } - - const { ast: program, error } = parseServerFunctionModule(source); - if (!program) { - throw new Error( - [ - '[evjs] Invalid "use server" module.', - formatServerFunctionParseDiagnostic(error), - ].join("\n"), - ); - } - - const serverFunctions = analyzeServerFunctionExportsFromAst(program.body); - if (serverFunctions.diagnostics.length > 0) { - throw new Error( - [ - '[evjs] Invalid "use server" module.', - ...serverFunctions.diagnostics.map((diagnostic) => diagnostic.message), - ].join("\n"), - ); - } - if (serverFunctions.exports.length === 0) { - return { code: source }; - } - - const modifiedAst = options.isServer - ? buildServerOutput(program, serverFunctions.exports, options) - : buildClientOutput(program, serverFunctions.exports, options); - - const { code, map } = printSync(modifiedAst, { - sourceMaps: true, - inlineSourcesContent: true, - filename: options.resourcePath, - sourceFileName: options.resourcePath, - jsc: { target: "esnext" }, - }); - - return { code, map }; -} +export { transformServerFile } from "./server-file.js"; +export type { TransformResult } from "./types.js"; diff --git a/packages/ev/src/_internal/build/transforms/server-file.ts b/packages/ev/src/_internal/build/transforms/server-file.ts new file mode 100644 index 00000000..a451684a --- /dev/null +++ b/packages/ev/src/_internal/build/transforms/server-file.ts @@ -0,0 +1,76 @@ +import { printSync } from "@swc/core"; +import { + analyzeServerFunctionExportsFromAst, + formatServerFunctionParseDiagnostic, + parseServerFunctionModule, +} from "../analysis/server-fns.js"; +import type { TransformOptions } from "../types.js"; +import { + CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, + detectConflictingFrameworkDirectives, + detectUseServer, +} from "../utils.js"; +import { buildClientOutput } from "./client/index.js"; +import { buildServerOutput } from "./server/index.js"; +import type { TransformResult } from "./types.js"; + +/** + * Transform a "use server" file for either client or server builds. + * This is a pure function with no bundler dependency. + * + * - **Server**: keeps implementations and reports them to the build manifest + * - **Client**: replaces function bodies with generated client reference stubs + */ +export async function transformServerFile( + source: string, + options: TransformOptions, +): Promise { + if (!detectUseServer(source)) { + return { code: source }; + } + if (detectConflictingFrameworkDirectives(source)) { + throw new Error( + [ + '[evjs] Invalid "use server" module.', + CONFLICTING_FRAMEWORK_DIRECTIVES_MESSAGE, + ].join("\n"), + ); + } + + const { ast: program, error } = parseServerFunctionModule(source); + if (!program) { + throw new Error( + [ + '[evjs] Invalid "use server" module.', + formatServerFunctionParseDiagnostic(error), + ].join("\n"), + ); + } + + const serverFunctions = analyzeServerFunctionExportsFromAst(program.body); + if (serverFunctions.diagnostics.length > 0) { + throw new Error( + [ + '[evjs] Invalid "use server" module.', + ...serverFunctions.diagnostics.map((diagnostic) => diagnostic.message), + ].join("\n"), + ); + } + if (serverFunctions.exports.length === 0) { + return { code: source }; + } + + const modifiedAst = options.isServer + ? buildServerOutput(program, serverFunctions.exports, options) + : buildClientOutput(program, serverFunctions.exports, options); + + const { code, map } = printSync(modifiedAst, { + sourceMaps: true, + inlineSourcesContent: true, + filename: options.resourcePath, + sourceFileName: options.resourcePath, + jsc: { target: "esnext" }, + }); + + return { code, map }; +} diff --git a/packages/ev/src/_internal/build/transforms/server/index.ts b/packages/ev/src/_internal/build/transforms/server/index.ts index be13a45f..e4b986f4 100644 --- a/packages/ev/src/_internal/build/transforms/server/index.ts +++ b/packages/ev/src/_internal/build/transforms/server/index.ts @@ -1,5 +1,5 @@ import type { Module } from "@swc/core"; -import type { ServerFunctionExport } from "../../server-fns.js"; +import type { ServerFunctionExport } from "../../analysis/server-fns.js"; import type { TransformOptions } from "../../types.js"; import { makeFnId } from "../../utils.js"; diff --git a/packages/ev/src/_internal/build/transforms/types.ts b/packages/ev/src/_internal/build/transforms/types.ts new file mode 100644 index 00000000..33c215b2 --- /dev/null +++ b/packages/ev/src/_internal/build/transforms/types.ts @@ -0,0 +1,4 @@ +export interface TransformResult { + code: string; + map?: string; +} diff --git a/packages/ev/src/_internal/build/page-route-types.ts b/packages/ev/src/_internal/build/typegen/page-route-types.ts similarity index 96% rename from packages/ev/src/_internal/build/page-route-types.ts rename to packages/ev/src/_internal/build/typegen/page-route-types.ts index 2393f701..3a1a6fc6 100644 --- a/packages/ev/src/_internal/build/page-route-types.ts +++ b/packages/ev/src/_internal/build/typegen/page-route-types.ts @@ -1,14 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { CoreGraph, PageRouteNode } from "@evjs/shared/manifest"; -import { writeOwnedOutputFile } from "./owned-file-output.js"; import { CANONICAL_PAGE_ROUTE_ROOT, PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY, PAGE_ROUTE_CONVENTION_DOCS_URL, -} from "./page-route-conventions.js"; -import { sortRoutesBySpecificity } from "./route-order.js"; -import { toProjectPath } from "./utils.js"; +} from "../conventions/page-route-conventions.js"; +import { sortRoutesBySpecificity } from "../conventions/route-order.js"; +import { writeOwnedOutputFile } from "../output/owned-file-output.js"; +import { toProjectPath } from "../utils.js"; export const PAGE_ROUTE_TYPES_FILE = "route-types.d.ts"; export const PAGE_ROUTE_TYPES_MARKER = diff --git a/packages/ev/src/_internal/build/plugin-types.ts b/packages/ev/src/_internal/build/typegen/plugin-types.ts similarity index 99% rename from packages/ev/src/_internal/build/plugin-types.ts rename to packages/ev/src/_internal/build/typegen/plugin-types.ts index 420a3dcf..7088c0b6 100644 --- a/packages/ev/src/_internal/build/plugin-types.ts +++ b/packages/ev/src/_internal/build/typegen/plugin-types.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { removeOwnedOutputFile, writeOwnedOutputFile, -} from "./owned-file-output.js"; +} from "../output/owned-file-output.js"; export const PLUGIN_TYPES_FILE = "plugin-types.d.ts"; export const PLUGIN_TYPES_MARKER = diff --git a/packages/ev/src/build-tools/index.ts b/packages/ev/src/build-tools/index.ts index 23157f6e..0096dc2a 100644 --- a/packages/ev/src/build-tools/index.ts +++ b/packages/ev/src/build-tools/index.ts @@ -1,2 +1,2 @@ -export type { LoadConfigFileOptions } from "../_internal/build/config-module.js"; -export { loadConfigFile } from "../_internal/build/config-module.js"; +export type { LoadConfigFileOptions } from "../_internal/build/config-loading/config-module.js"; +export { loadConfigFile } from "../_internal/build/config-loading/config-module.js"; diff --git a/packages/ev/src/config/index.ts b/packages/ev/src/config/index.ts index 6cb29485..b052e5d9 100644 --- a/packages/ev/src/config/index.ts +++ b/packages/ev/src/config/index.ts @@ -19,12 +19,12 @@ import { type ServerMiddlewareNode, type ServerRouteNode, } from "@evjs/shared/manifest"; -import type { BundlerAdapter } from "../_internal/build/bundler.js"; +import type { BundlerAdapter } from "../_internal/build/bundler/contracts.js"; import { assertFrameworkOutputDirectory, assertSeparateFrameworkOutputDirectories, -} from "../_internal/build/output-path-conventions.js"; -import { CANONICAL_PAGE_ROUTE_ROOT } from "../_internal/build/page-route-conventions.js"; +} from "../_internal/build/conventions/output-path-conventions.js"; +import { CANONICAL_PAGE_ROUTE_ROOT } from "../_internal/build/conventions/page-route-conventions.js"; import { copyDefinedPluginRuntime, definedPluginRuntimeMetadata, diff --git a/packages/ev/src/deployment/index.ts b/packages/ev/src/deployment/index.ts index a415147a..09ae48e7 100644 --- a/packages/ev/src/deployment/index.ts +++ b/packages/ev/src/deployment/index.ts @@ -20,18 +20,18 @@ import { collectBuildOutputServerJavaScriptArtifacts, createDeploymentMetadata, } from "@evjs/shared/manifest"; -import { - type DeploymentOutputReservation, - declareDeploymentOutputReservations, -} from "../_internal/build/deployment-output-reservations.js"; import { createFrameworkRuntime, serializeFrameworkRuntimeExpression, -} from "../_internal/build/framework-runtime.js"; +} from "../_internal/build/output/framework-runtime.js"; import { assertPortableRelativeArtifactPath, FRAMEWORK_DEPLOYMENT_METADATA_FILE_NAME, -} from "../_internal/build/portable-artifact-path.js"; +} from "../_internal/build/output/portable-artifact-path.js"; +import { + type DeploymentOutputReservation, + declareDeploymentOutputReservations, +} from "../_internal/build/plugins/deployment-output-reservations.js"; import type { Plugin } from "../plugin/index.js"; import { assertDeploymentFileNamesAvailable, diff --git a/packages/ev/src/deployment/output-files.ts b/packages/ev/src/deployment/output-files.ts index 4c7c35ab..b4e77a27 100644 --- a/packages/ev/src/deployment/output-files.ts +++ b/packages/ev/src/deployment/output-files.ts @@ -1,11 +1,11 @@ import path from "node:path"; -import { writeOwnedOutputFile } from "../_internal/build/owned-file-output.js"; +import { writeOwnedOutputFile } from "../_internal/build/output/owned-file-output.js"; import { assertPortableArtifactFileName, assertPortableRelativeArtifactPath, canonicalPortableArtifactPathKey, portableArtifactPathsConflict, -} from "../_internal/build/portable-artifact-path.js"; +} from "../_internal/build/output/portable-artifact-path.js"; export interface NamedDeploymentFile { field: string; diff --git a/packages/ev/tests/build-tools-graph-plan.test.ts b/packages/ev/tests/build-tools-graph-plan.test.ts index e8c028cf..c7636151 100644 --- a/packages/ev/tests/build-tools-graph-plan.test.ts +++ b/packages/ev/tests/build-tools-graph-plan.test.ts @@ -15,7 +15,7 @@ import { createRscPageBuildEntryName, GENERATED_PAGES_APP_BUILD_ENTRY, SERVER_RUNTIME_BUILD_ENTRY_NAME, -} from "../src/_internal/build/build-entry-conventions.js"; +} from "../src/_internal/build/conventions/build-entry-conventions.js"; import type { BuildPlanConfig, GraphConfig, diff --git a/packages/ev/tests/build-tools-html.test.ts b/packages/ev/tests/build-tools-html.test.ts index cbdb1403..00c29e13 100644 --- a/packages/ev/tests/build-tools-html.test.ts +++ b/packages/ev/tests/build-tools-html.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { generateHtml } from "../src/_internal/build/html.js"; -import { applyPageMetadataToHtmlDocument } from "../src/_internal/build/page-metadata-html.js"; +import { generateHtml } from "../src/_internal/build/output/html/html.js"; +import { applyPageMetadataToHtmlDocument } from "../src/_internal/build/output/html/page-metadata-html.js"; const FIXTURES_DIR = path.join(import.meta.dirname, "__fixtures__"); const TEMPLATE_PATH = path.join(FIXTURES_DIR, "template.html"); diff --git a/packages/ev/tests/build-tools-output-path-safety.test.ts b/packages/ev/tests/build-tools-output-path-safety.test.ts index 6ff989d8..a9accc79 100644 --- a/packages/ev/tests/build-tools-output-path-safety.test.ts +++ b/packages/ev/tests/build-tools-output-path-safety.test.ts @@ -9,7 +9,7 @@ import { removeOwnedOutputFile, writeOwnedOutputFile, } from "../src/_internal/build/index.js"; -import { removeOwnedOutputFileSync } from "../src/_internal/build/owned-file-output.js"; +import { removeOwnedOutputFileSync } from "../src/_internal/build/output/owned-file-output.js"; const tempDirs: string[] = []; diff --git a/packages/ev/tests/build-tools-page-route-types.test.ts b/packages/ev/tests/build-tools-page-route-types.test.ts index 9d13afe9..b27b4367 100644 --- a/packages/ev/tests/build-tools-page-route-types.test.ts +++ b/packages/ev/tests/build-tools-page-route-types.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY } from "../src/_internal/build/page-route-conventions.js"; +import { PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY } from "../src/_internal/build/conventions/page-route-conventions.js"; import { collectGeneratedPageRouteTypeFiles, generatePageRouteTypes, @@ -15,7 +15,7 @@ import { PAGE_ROUTE_TYPES_REGISTER_MODULE, PAGE_ROUTE_TYPES_USAGE_HINT, writePageRouteTypesIfChanged, -} from "../src/_internal/build/page-route-types.js"; +} from "../src/_internal/build/typegen/page-route-types.js"; const tempDirs: string[] = []; diff --git a/packages/ev/tests/bundler-capabilities.test.ts b/packages/ev/tests/bundler-capabilities.test.ts index ef114042..ce7892fb 100644 --- a/packages/ev/tests/bundler-capabilities.test.ts +++ b/packages/ev/tests/bundler-capabilities.test.ts @@ -9,7 +9,7 @@ import { preflightBundlerDevUpdate, resolveBundlerClientEntryAssets, resolveBundlerServerEntryAssets, -} from "../src/_internal/build/bundler.js"; +} from "../src/_internal/build/bundler/contracts.js"; const noCapabilities: BundlerCapabilities = { build: { server: false, rsc: false, ppr: false }, diff --git a/packages/ev/tests/commands-api-restart.test.ts b/packages/ev/tests/commands-api-restart.test.ts index ede147ed..1d6411c9 100644 --- a/packages/ev/tests/commands-api-restart.test.ts +++ b/packages/ev/tests/commands-api-restart.test.ts @@ -10,7 +10,7 @@ import type { BundlerBuildFactsDisposition, BundlerDevController, BundlerDevUpdateTransition, -} from "../src/_internal/build/bundler.js"; +} from "../src/_internal/build/bundler/contracts.js"; import { dev } from "../src/_internal/build/commands.js"; import type { Config } from "../src/config/index.js"; diff --git a/packages/ev/tests/commands.test.ts b/packages/ev/tests/commands.test.ts index 03e8d357..f1b929fb 100644 --- a/packages/ev/tests/commands.test.ts +++ b/packages/ev/tests/commands.test.ts @@ -13,33 +13,33 @@ import { import { configureSync, resetSync } from "@logtape/logtape"; import { execa } from "execa"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - createPageClientBuildEntryName, - createPageServerBuildEntryName, - createPprShellBuildEntryName, - createRscPageBuildEntryName, -} from "../src/_internal/build/build-entry-conventions.js"; import type { BundlerAdapter, BundlerBuildFacts, BundlerBuildFactsDisposition, BundlerDevController, BundlerDevUpdateTransition, -} from "../src/_internal/build/bundler.js"; -import { isEmptyBuildPlanUpdate } from "../src/_internal/build/bundler.js"; +} from "../src/_internal/build/bundler/contracts.js"; +import { isEmptyBuildPlanUpdate } from "../src/_internal/build/bundler/contracts.js"; import { build, dev, prepareFrameworkBuild, recordDevChangeSnapshot, } from "../src/_internal/build/commands.js"; -import { loadConfigFile } from "../src/_internal/build/config-module.js"; -import { materializeFrameworkIR } from "../src/_internal/build/generated-contributions.js"; -import { PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY } from "../src/_internal/build/page-route-conventions.js"; +import { loadConfigFile } from "../src/_internal/build/config-loading/config-module.js"; +import { + createPageClientBuildEntryName, + createPageServerBuildEntryName, + createPprShellBuildEntryName, + createRscPageBuildEntryName, +} from "../src/_internal/build/conventions/build-entry-conventions.js"; +import { PAGE_ANCHOR_ROUTE_CONVENTION_SUMMARY } from "../src/_internal/build/conventions/page-route-conventions.js"; +import { materializeFrameworkIR } from "../src/_internal/build/generated-ir/generated-contributions.js"; import { PAGE_ROUTE_TYPES_MARKER, PAGE_ROUTE_TYPES_USAGE_HINT, -} from "../src/_internal/build/page-route-types.js"; +} from "../src/_internal/build/typegen/page-route-types.js"; import type { Config } from "../src/config/index.js"; import { staticDeploymentAdapter } from "../src/deployment/index.js"; import type { diff --git a/packages/ev/tests/config-route.test.ts b/packages/ev/tests/config-route.test.ts index 4d21f3d5..df51e5e8 100644 --- a/packages/ev/tests/config-route.test.ts +++ b/packages/ev/tests/config-route.test.ts @@ -7,12 +7,12 @@ import { type PagesAppEntryMetadata, } from "@evjs/shared/manifest"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { GENERATED_PAGES_APP_BUILD_ENTRY } from "../src/_internal/build/build-entry-conventions.js"; import { prepareFrameworkBuild } from "../src/_internal/build/commands.js"; -import { createFrameworkHtmlDocument } from "../src/_internal/build/framework-html-document.js"; -import { createClientRuntime } from "../src/_internal/build/framework-runtime.js"; +import { GENERATED_PAGES_APP_BUILD_ENTRY } from "../src/_internal/build/conventions/build-entry-conventions.js"; import { createConfigRouteGraph } from "../src/_internal/build/graph/config-route.js"; import { createCoreGraph } from "../src/_internal/build/graph/index.js"; +import { createClientRuntime } from "../src/_internal/build/output/framework-runtime.js"; +import { createFrameworkHtmlDocument } from "../src/_internal/build/output/html/framework-html-document.js"; import { createBuildPlan } from "../src/_internal/build/plan/index.js"; import { resolveConfig } from "../src/config/index.js"; diff --git a/packages/ev/tests/config.test.ts b/packages/ev/tests/config.test.ts index 560b6d24..0f7b5eec 100644 --- a/packages/ev/tests/config.test.ts +++ b/packages/ev/tests/config.test.ts @@ -1,9 +1,9 @@ import { describe, expect, expectTypeOf, it } from "vitest"; -import type { BundlerAdapter } from "../src/_internal/build/bundler.js"; +import type { BundlerAdapter } from "../src/_internal/build/bundler/contracts.js"; import { createNoPageRoutesFoundMessage, withPageRoutingDefaults, -} from "../src/_internal/build/convention-config.js"; +} from "../src/_internal/build/discovery/convention-config.js"; import type { Config, ExtractInstalledPlugin, diff --git a/packages/ev/tests/deployment.test.ts b/packages/ev/tests/deployment.test.ts index 13e2ad59..93fd34c8 100644 --- a/packages/ev/tests/deployment.test.ts +++ b/packages/ev/tests/deployment.test.ts @@ -5,13 +5,13 @@ import vm from "node:vm"; import type { BuildOutput } from "@evjs/shared/manifest"; import { assertFrameworkManifestShape } from "@evjs/shared/manifest"; import { afterEach, describe, expect, it } from "vitest"; -import { createBuildResult } from "../src/_internal/build/build-result.js"; -import { createStaticPageDocumentOutput } from "../src/_internal/build/page-document-output.js"; +import { createStaticPageDocumentOutput } from "../src/_internal/build/conventions/page-document-output.js"; +import { createBuildResult } from "../src/_internal/build/output/build-result.js"; import { createLatePluginContext, runAfterBuildHooks, runTransformOutputHooks, -} from "../src/_internal/build/plugin-lifecycle.js"; +} from "../src/_internal/build/plugins/lifecycle.js"; import { createDeploymentArtifact, createEdgeDeploymentFiles, diff --git a/packages/ev/tests/dev-api-process.test.ts b/packages/ev/tests/dev-api-process.test.ts index 13422ff8..7e864d26 100644 --- a/packages/ev/tests/dev-api-process.test.ts +++ b/packages/ev/tests/dev-api-process.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DevApiProcessController } from "../src/_internal/build/dev-api-process.js"; +import { DevApiProcessController } from "../src/_internal/build/dev/api-process.js"; interface FakeApiProcess { id: string; diff --git a/packages/ev/tests/dev-runtime.test.ts b/packages/ev/tests/dev-runtime.test.ts index e0d6586a..1b714547 100644 --- a/packages/ev/tests/dev-runtime.test.ts +++ b/packages/ev/tests/dev-runtime.test.ts @@ -15,7 +15,7 @@ import { reserveDevPorts, stopApiProcess, writeDevDistLock, -} from "../src/_internal/build/dev-runtime.js"; +} from "../src/_internal/build/dev/runtime.js"; const cleanups: Array<() => void | Promise> = []; diff --git a/packages/ev/tests/dev-watch.test.ts b/packages/ev/tests/dev-watch.test.ts index 1726768b..50af626d 100644 --- a/packages/ev/tests/dev-watch.test.ts +++ b/packages/ev/tests/dev-watch.test.ts @@ -11,7 +11,7 @@ import { prepareWatchFilesPlan, resolveInitialDevWatchMode, watchFiles, -} from "../src/_internal/build/dev-watch.js"; +} from "../src/_internal/build/dev/watch.js"; import { resolveConfig } from "../src/config/index.js"; type WatchCallback = ( diff --git a/packages/ev/tests/framework-runtime.test.ts b/packages/ev/tests/framework-runtime.test.ts index f46cbf3f..c04f3321 100644 --- a/packages/ev/tests/framework-runtime.test.ts +++ b/packages/ev/tests/framework-runtime.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { createFrameworkRuntime, serializeFrameworkRuntimeExpression, -} from "../src/_internal/build/framework-runtime.js"; +} from "../src/_internal/build/output/framework-runtime.js"; describe("createFrameworkRuntime", () => { it("keeps SPA Pages inside the routing union", () => { diff --git a/packages/ev/tests/package-surface.test.ts b/packages/ev/tests/package-surface.test.ts index 00c4ce26..2cc042db 100644 --- a/packages/ev/tests/package-surface.test.ts +++ b/packages/ev/tests/package-surface.test.ts @@ -713,6 +713,60 @@ describe("workspace package surface", () => { ); }); + it("keeps internal build implementations grouped by capability", async () => { + const buildRoot = path.join(repoRoot, "packages/ev/src/_internal/build"); + const entries = await fs.readdir(buildRoot, { withFileTypes: true }); + const rootFiles = entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + const domainDirectories = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + expect(rootFiles).toEqual([ + "commands.ts", + "index.ts", + "types.ts", + "utils.ts", + ]); + expect(domainDirectories).toEqual([ + "analysis", + "bundler", + "config-loading", + "conventions", + "dev", + "discovery", + "generated-ir", + "graph", + "operations", + "output", + "plan", + "plugins", + "transforms", + "typegen", + ]); + + const commandFacade = await fs.readFile( + path.join(buildRoot, "commands.ts"), + "utf-8", + ); + expect(commandFacade).toContain('from "./operations/commands.js"'); + expect(commandFacade).not.toContain("function "); + + for (const domain of ["graph", "plan", "transforms"]) { + const files = await fs.readdir(path.join(buildRoot, domain)); + expect(files).toContain("index.ts"); + expect(files).toContain("types.ts"); + const facade = await fs.readFile( + path.join(buildRoot, domain, "index.ts"), + "utf-8", + ); + expect(facade).not.toContain("function "); + } + }); + it("keeps @evjs/ev/build-tools narrowed to config loading", () => { expect(Object.keys(publicBuildTools).sort()).toEqual(["loadConfigFile"]); }); diff --git a/packages/ev/tests/page-config-module.test.ts b/packages/ev/tests/page-config-module.test.ts index 62b4d140..3600f4a0 100644 --- a/packages/ev/tests/page-config-module.test.ts +++ b/packages/ev/tests/page-config-module.test.ts @@ -3,10 +3,10 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { analyzePageModuleExports } from "../src/_internal/build/analysis/page-module-exports.js"; +import { resolvePageConfigModules } from "../src/_internal/build/config-loading/page-config-module.js"; import type { GraphConfig } from "../src/_internal/build/graph/index.js"; import { createCoreGraph } from "../src/_internal/build/graph/index.js"; -import { resolvePageConfigModules } from "../src/_internal/build/page-config-module.js"; -import { analyzePageModuleExports } from "../src/_internal/build/page-module-exports.js"; import type { PageAnchorMetadata, PageRouteDiscoveryMetadata, diff --git a/packages/ev/tests/plugin-lifecycle.test.ts b/packages/ev/tests/plugin-lifecycle.test.ts index ad6a7566..885e00e8 100644 --- a/packages/ev/tests/plugin-lifecycle.test.ts +++ b/packages/ev/tests/plugin-lifecycle.test.ts @@ -5,7 +5,7 @@ import { createPluginConfigView, runAfterBuildHooks, runBeforeBuildHooks, -} from "../src/_internal/build/plugin-lifecycle.js"; +} from "../src/_internal/build/plugins/lifecycle.js"; import { resolveConfig } from "../src/config/index.js"; import { createPluginApplicationSettingContext, diff --git a/packages/ev/tests/plugin-settings.test.ts b/packages/ev/tests/plugin-settings.test.ts index 08e03531..2fcfd59b 100644 --- a/packages/ev/tests/plugin-settings.test.ts +++ b/packages/ev/tests/plugin-settings.test.ts @@ -2,14 +2,14 @@ import type { CoreGraph, CorePagePluginSetting } from "@evjs/shared/manifest"; import { PAGE_ANCHOR_PROVIDER_ID } from "@evjs/shared/manifest"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import { describe, expect, it } from "vitest"; -import type { ResolvedPageFileConfig } from "../src/_internal/build/page-config-module.js"; -import { runConfigureHooks } from "../src/_internal/build/plugin-lifecycle.js"; +import type { ResolvedPageFileConfig } from "../src/_internal/build/config-loading/page-config-module.js"; +import { runConfigureHooks } from "../src/_internal/build/plugins/lifecycle.js"; import { applyPluginSettings, collectPluginSettingsRegistry, createPluginSettingsResolutionSession, resolvePluginSettingsState, -} from "../src/_internal/build/plugin-settings.js"; +} from "../src/_internal/build/plugins/settings.js"; import { type Config, resolveConfig } from "../src/config/index.js"; import { type ResolvedPagePluginOptionsInput, diff --git a/packages/ev/tests/plugin-types.test.ts b/packages/ev/tests/plugin-types.test.ts index dfe6a362..2f54c687 100644 --- a/packages/ev/tests/plugin-types.test.ts +++ b/packages/ev/tests/plugin-types.test.ts @@ -16,7 +16,7 @@ import { removeGeneratedPluginTypes, syncPluginTypes, writePluginTypesIfChanged, -} from "../src/_internal/build/plugin-types.js"; +} from "../src/_internal/build/typegen/plugin-types.js"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url);