Skip to main content

Architecture

evjs is a React framework built around file-based page routes, explicit source declarations, a framework graph, a bundler-independent build plan, and one runtime manifest.

src/pages + ev.config.ts + server declarations
-> AppGraph
-> BuildPlan
-> bundler build
-> BuildOutput
-> runtime / shell / deployment adapters

Public Packages

Application code imports config, plugin, build, and deployment APIs through @evjs/ev. Runtime APIs come from @evjs/client and @evjs/server, and apps that use those capabilities should declare the runtime packages directly. Other packages are tooling, bundler adapters, or shared contracts for framework packages. When a new capability needs a boundary, prefer adding a subpath export to the package that owns the behavior before creating another distributed package.

@evjs/ev
config, plugin lifecycle, dev/build orchestration, framework build types,
and deployment helpers

@evjs/client
browser runtime, server-function transport, page hooks, navigation helpers,
and remote host helpers

@evjs/server
Hono/fetch app, server functions, server routes, SSR/PPR/RSC request boundary

@evjs/cli and @evjs/create-app are distribution tooling. Bundler adapters stay in @evjs/bundler-utoopack and @evjs/bundler-webpack, and shared runtime/manifest contracts stay in @evjs/shared.

RolePackagesImport guidance
Framework surface@evjs/evUse @evjs/ev for config/build/plugin/deployment APIs.
Runtime APIs@evjs/client, @evjs/serverUse these packages for page hooks, navigation, server functions, server routes, rendering, and deployment runtimes.
Tooling@evjs/cli, @evjs/create-appInstall or execute them; application modules should not import them.
Bundler adapters@evjs/bundler-utoopack, @evjs/bundler-webpack@evjs/cli owns the default Utoopack adapter. Import an adapter directly only when authoring custom tooling.
Shared contracts@evjs/sharedPublished so framework packages share manifest/runtime types; app code should not import it directly.

Published package manifests stay ESM-only and intentionally narrow. Every distributed package sets "type": "module", publishes with public access and the MIT license, and whitelists generated output only: esm for framework, runtime, adapter, and contract packages; dist/bin for @evjs/cli; and dist/templates for @evjs/create-app.

Subpath exports stay explicit and documented; adding a new package export is a public API decision, not a convenience alias.

Internal @evjs/* runtime dependencies are kept explicit. @evjs/ev consumes shared contracts but does not publish runtime subpaths. @evjs/server also consumes @evjs/client for shared runtime types. @evjs/cli owns the default Utoopack adapter dependency, and bundler adapters depend on @evjs/ev instead of depending on each other. Internal runtime dependency versions stay "*" so release automation treats the distributed packages as one framework version.

Generated-only @evjs/client/internal/* subpaths let framework-emitted route declarations, page bootstraps, server-function stubs, and RSC runtime entries type-check. Application code should keep importing navigation/runtime APIs from @evjs/client and should not import generated-only internal helpers. Examples include @evjs/client/internal/route-types for generated SPA route declarations and @evjs/client/internal/rsc-runtime for RSC page bootstraps.

Do not reintroduce legacy split packages such as @evjs/build-tools, @evjs/manifest, or @evjs/router-*. Build helpers are exported from @evjs/ev/build-tools, and manifest contracts are exported from @evjs/shared/manifest.

Documentation code examples follow the same package boundary: application examples import from @evjs/ev, @evjs/client, or @evjs/server; adapter examples may import @evjs/bundler-utoopack when demonstrating custom tooling.

Internal Modules

@evjs/ev/build-tools
source analysis, route/server-function extraction, graph/plan helpers,
framework transforms, HTML helpers

@evjs/shared/manifest
AppGraph, BuildPlan, BuildOutput, and manifest schemas

@evjs/client generated-only internals
framework-managed runtime, shell, router-free react-page runtime, transport,
RSC client runtime, SPA router integration, and generated bootstrap behind
@evjs/client/internal/* subpaths backed by @evjs/client internals

@evjs/bundler-utoopack
default bundler adapter used by @evjs/cli

@evjs/bundler-webpack
validation/fallback adapter for SSR/PPR/RSC and dynamic entry/server
dev plan updates while Utoopack lower-layer APIs catch up

@evjs/ev/build-tools does not import bundler adapters. Bundler adapters consume BuildPlan; they do not rediscover framework semantics from source files after bundling. The @evjs/ev/build-tools subpath is intentionally limited to CLI and bundler adapter tooling APIs. Low-level module export parsing, server-function ID hashing, and module-ref helpers stay private to @evjs/ev.

Build Flow

The manifest is dist/manifest.json. Legacy dist/client/manifest.json and dist/server/manifest.json are not the new framework contract.

TanStack Router is an SPA implementation detail owned by the framework. Page code uses src/pages, page hooks, and navigation helpers instead of constructing router bootstraps directly.

Runtime Flow

PPR does not require the browser to fetch region endpoints during initial page load. The framework server can use either merge or stream delivery for the page route. merge is the default non-streaming mode and returns the final server-composed HTML after shell and regions resolve. stream sends shell HTML first, then sends region patches in the same document response. The derived runtime.server.ppr endpoint remains available for direct/debug access and cache validation.

In a single server process, region resolution is an internal framework call. In an edge deployment, the same contract can split across layers: the edge can serve a cached shell and resolve dynamic regions by server-to-server calls to an internal origin/FaaS endpoint. The browser still sees only the page route:

That split means GET /__evjs/ppr/... may appear in edge-to-origin logs but not in browser network logs. The long-term runtime boundary is a replaceable region resolver: local Node/dev can call the renderer in-process, while edge adapters can fetch an internal FaaS endpoint without changing the public page protocol.

The preferred PPR authoring model is React Suspense with a lazy(() => import(...)) child. The page component declares export const render = "ssr" plus export const prerender = { partial: true, delivery }. Dynamic regions can declare export const cache and export const hydrate in their own modules. PPR is a prerendering strategy on top of SSR, not a separate document render mode.

PPR page hydration is page-level none in the public manifest. Client interactivity should be introduced through explicit client islands or region-level hydration metadata, not by hydrating the whole PPR shell.

RSC uses the same @evjs/server boundary for Flight requests. The Flight endpoint accepts page=<id> and an optional url=<pathname+search> value; that page id must be a manifest page id using the build-identifier rule. The URL context must be an absolute same-origin path or HTTP(S) URL and must not include a hash. The Webpack validation path uses React Flight client consumption and React client/server reference manifests; Utoopack still needs equivalent lower-layer metadata before it can run the same path.

Remote shared dependencies use an explicit host-provided share scope. The internal remote runtime checks remote shared requirements before loading the remote entry, supports shareKey, singleton checks, eager metadata, and semver-style ranges including compound comparators and ||, and exposes provided entries to remote React components through remote context. Host applications can observe negotiation results with onRemoteSharedNegotiated() for diagnostics, telemetry, or policy UI; ordinary remote components should not render framework dependency versions. React host pages should use useRemoteHost() / RemoteApp; lower-level startRemoteAppRuntime() accepts advanced runtime hooks for custom shared scope, manifest loading, module loading, and error handling. Remote host activeWhen options use the same pathname-pattern validation as remotes config. RemoteApp target remote uses the same build-identifier rule as remotes config, and object-shaped activation requests inherit that target unless they explicitly set remoteId. Use request.remoteEntryId to select a specific remote entry without repeating the host remote id, or use request.url to pick an entry through activeWhen; do not provide both in one request. Object-shaped activation requests validate remoteId, remoteEntryId, pageId, and buildId as build identifiers; appId and url must be non-empty strings without leading or trailing whitespace, with url limited to HTTP(S) URLs or pathnames that start with /. mountPoint must be an Element object when provided. hydrate must be boolean when provided. manifest and manifest values read through optional manifestQueryParam must be non-empty HTTP(S) URLs or paths without leading or trailing whitespace. Fetched remote manifest names and entry ids are validated as build identifiers before activation. A fetched remote manifest name must match the host remotes.<id> that loaded it. Manifest string fields such as module and asset hrefs are rejected when they contain leading or trailing whitespace or do not resolve to http: or https: URLs from the remote manifest base URL. Default-exported React remote modules are automatically adapted to internal lifecycle modules. A remote module must not mix a default React component with explicit mount(), hydrate(), or unmount() exports; init() may accompany a default component for setup. Explicit lifecycle modules remain available only as an advanced escape hatch; they must export mount() or hydrate() because init() / unmount() alone cannot render a remote entry. Custom runtime.loadModule() hooks use the same module shape, so they may return either lifecycle functions or { default: RemoteComponent } for a react-component remote entry. Automatic package loading/version selection remains outside this implementation.

Configuration Ownership

routing
page route source of truth: spa or mpa mode, dir, html, mount point

entry/html
manual single app shorthand

pages.*
explicit independent page output: path, entry/component/app, mount point

server.basePath
derives framework server runtime paths: fn, ppr, rsc

transport.baseUrl
browser-to-framework-server origin override

plugins
framework and bundler extension points

routing points to src/pages by default. In SPA mode, graph creation turns the discovered files into one internal TanStack Router app entry. In MPA mode, the same files become independent page outputs without a client router.

Page modules own path-to-component wiring by filename and rendering metadata through static exports such as render, hydrate, rsc, and prerender. When graph creation sees SSR, RSC, or partial prerender metadata, it derives the required server renderers, PPR regions, assets, and manifest output from that page module. Full server manifests keep those renderer relationships explicit: SSR, SSG, and RSC document pages resolve through a page-server renderer owned by the page id or by one of that page's route ids. PPR pages resolve through ppr-shell and ppr-region entries instead.

pages.* remains the explicit lower-level page API. It is useful when a page does not map cleanly to the src/pages file tree. Rendering metadata still belongs in the referenced page module, not in ev.config.ts.

Server Function Pipeline

"use server" module
-> build-tools extraction
-> client transform creates internal client references
-> server transform/register path
-> BuildOutput.server.functions
-> framework server dispatches POST runtime.server.fn

The public config exposes server.basePath; the function endpoint is derived from that base path.

RSC use client reference extraction preserves default exports, identifier exports, class exports, same-module aliases, namespace re-export names such as export * as Widgets from "./widgets", and re-exported names including string-literal aliases in BuildOutput.rsc. Type-only exports are ignored. The client reference transform emits internal bindings with export specifiers so reserved words and string-literal aliases stay valid JavaScript. BuildOutput.rsc.clientReferences and BuildOutput.rsc.serverReferences use the extracted reference id as a trimmed string key. Those ids may contain file paths, URL syntax, #, or :; the value object carries the trimmed module and optional trimmed exportName. Reference-only RSC output can omit BuildOutput.rsc.endpoint; RSC page output cannot, because Flight requests need a concrete endpoint. The manifest linker rejects RSC page output when runtime.server.rsc is missing. For full server manifests, each RSC page renderer reference must resolve to an rsc-page renderer whose owner.pageId matches the RSC page id; public manifests may omit that server-only renderer metadata. After ignoring type-only and ambient declarations, a "use client" module must still expose at least one runtime client reference. Bare runtime export * from "./widgets" is rejected because the framework manifest must know every client reference export name; use explicit named re-exports or a namespace re-export instead. Malformed "use client" modules are reported during graph analysis with the file path and parser message before the bundler transform runs.

Deployment

Deployment adapters consume BuildOutput. @evjs/ev provides:

  • createDeploymentArtifact(output) for platform-neutral routing/assets/server metadata;
  • nodeDeploymentAdapter() for a concrete Node production target that emits dist/deployment.node.json and dist/server.mjs;
  • staticDeploymentAdapter() for static-host routing metadata and _redirects;
  • edgeDeploymentAdapter() for edge-worker style runtime bootstraps that call the framework server bundle and an asset binding.

Platform-specific adapters should derive their routing, framework endpoint, SSR, PPR, RSC, remote, shared dependency, and asset metadata from BuildOutput instead of reading bundler stats. Full server manifests retain source modules and server renderer references; public/browser manifests keep the same routing and asset shape but redact those server-only fields, so client-side validation treats them as optional.

The deployment model is capability-driven:

static-only
CSR / MPA client entries / SSG / remote manifests / assets

unified node
static assets + framework endpoints + SSR/PPR/RSC + server functions/routes

unified edge worker
asset binding + edge-compatible framework server bundle

edge + origin/FaaS split
edge caches assets/shells
origin/FaaS resolves functions, routes, SSR/RSC, and PPR regions

Adapters should classify BuildOutput first, then emit platform routes. Static hosting must not claim support for SSR, PPR, RSC, server functions, or server routes unless a server-capable runtime is attached.

Dev Updates

Framework-level declaration changes are handled separately from normal HMR:

config / page route / server declaration change
-> recreate AppGraph
-> recreate BuildPlan
-> diff BuildPlan
-> if BuildPlan changes:
bundlerDevController.updatePlan(update, nextGraph)
-> if graph-only:
refresh active graph + dependency watchers

The default Utoopack adapter applies HTML-only plan updates by relinking framework output from existing build stats. It still reports a clear unsupported error for dynamic entry and server renderer updates until Utoopack exposes the lower-layer API. The webpack adapter can apply those broader updates in-process for architecture validation. Style and asset edits remain on the bundler HMR path. Server-function and server-route implementation edits usually keep the same BuildPlan; in that case the framework refreshes graph metadata and watch inputs, while the bundler's normal server watch emits the updated code.

Graph analysis reads page route modules plus static import closures to discover server functions, server routes, page metadata, and RSC references. Static import closure discovery parses modules, so it follows ordinary imports, re-exports, and valid string-literal import aliases. Literal dynamic imports are also tracked when they point at project-relative modules. Dev watches the page route directory, explicit graph roots, and files that already contain framework markers. Configured page components are explicit graph roots because their static render, hydrate, rsc, and prerender exports affect framework planning. Ordinary component, app entry, and style edits stay on the bundler HMR path unless those modules declare framework markers.