diff --git a/CHANGELOG.md b/CHANGELOG.md index a96a74a7..0bb7ad58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ All notable changes to evjs are documented here. Releases follow [Semantic Versi ## [Unreleased] +### Features + +- Let plugins declare browser module references through `emit.moduleReference()`. + Core validates and watches the references, and Utoopack replaces the original + browser modules with runtime factory results while retaining server source. +- Expose the generated application's frozen `apiRuntimeScope` so integration + runtimes can use the same application and build identity as the API client. + +### Dependencies + +- Require `@utoo/pack` 1.6.0 or newer in the default bundler adapter and use its + published module-reference configuration types. Browser module references + continue to validate the capability of the loaded native binding. + ### Bug Fixes - Refresh Utoopack development HTML and asset metadata after client rebuilds diff --git a/docs/docs/generated-contributions.md b/docs/docs/generated-contributions.md index e034cb40..217bdcea 100644 --- a/docs/docs/generated-contributions.md +++ b/docs/docs/generated-contributions.md @@ -53,6 +53,63 @@ Contribution ids are local to the plugin. In `emitPageIR()`, ids are also local to the current page, so the same id can be reused safely for every enabled page. The `@evjs/` prefix is reserved by the framework. +## Replace a browser module with runtime references + +`emit.moduleReference()` declares a browser-only replacement for one exact source +file. It returns nothing and does not attach to a slot. The plugin supplies the +export mapping and browser-safe data; the runtime factory decides what each +reference does. evjs does not parse decorators or interpret protocols. + +```ts +import { definePlugin } from "@evjs/ev/plugin"; + +export const references = definePlugin({ + id: "greeting-references", + emitIR({ emit }) { + emit.moduleReference({ + id: "greeting", + resource: "./src/greeting.ts", + references: [{ + id: "greeting", + factory: { + module: "./src/greeting-client.ts", + export: "createGreetingReference", + }, + data: { endpoint: "/greetings" }, + }], + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }); + }, +}); +``` + +The browser receives the result of `createGreetingReference(data)` for both +exports. If the factory returns a constructor, application code can keep +`import { Greeting } from "./greeting"; new Greeting()`. Original module +initialization and dependencies are excluded from this browser replacement. +Server compilation continues to use the original module. The factory and its +dependencies must themselves be browser-safe; static types are not rewritten. + +Source files must exist inside the application root. Relative source and runtime +filenames resolve from that root; runtime package specifiers are also supported. +Export names and factory exports currently use ASCII JavaScript identifiers, +including `default`. Several exports can share one reference identity. A source +file may have only one contribution, including across symlink aliases and +plugins. Invalid or conflicting contributions fail during planning. + +Payloads must be strict JSON and should contain only information meant for the +browser. The complete build plan stays in `.ev`; it is not a public deployment +manifest. Source changes are watched automatically. Plugins reading additional +metadata files must register those files with `ctx.addWatchFile()`. + +This capability requires `@utoo/pack` 1.6.0 or newer with a matching native binding +reporting `moduleReferencesVersion = 1`. Older Utoopack builds and the Webpack +adapter fail explicitly instead of compiling the original module into the browser. +Bundler hooks cannot replace the framework-owned reference list. + ## Add code to the client entry Generate an installer and import it after the main application entry: diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/generated-contributions.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/generated-contributions.md index bf2583b6..37ed517c 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/generated-contributions.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/generated-contributions.md @@ -45,6 +45,56 @@ scope: { kind: "server" } 生成项的 id 只需在插件内部唯一。`emitPageIR()` 中的 id 还限定在当前页面,因此每个启用页面都可以安全复用相同 id。`@evjs/` 前缀由框架保留。 +## 将浏览器模块替换为运行时引用 + +`emit.moduleReference()` 为一个精确的源码文件声明仅用于浏览器的替换。它没有返回值, +也不需要挂到槽位。插件提供导出映射和允许进入浏览器的数据,运行时工厂决定每个引用的行为。 +evjs 不解析装饰器,也不解释协议。 + +```ts +import { definePlugin } from "@evjs/ev/plugin"; + +export const references = definePlugin({ + id: "greeting-references", + emitIR({ emit }) { + emit.moduleReference({ + id: "greeting", + resource: "./src/greeting.ts", + references: [{ + id: "greeting", + factory: { + module: "./src/greeting-client.ts", + export: "createGreetingReference", + }, + data: { endpoint: "/greetings" }, + }], + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }); + }, +}); +``` + +浏览器中的两个导出共享 `createGreetingReference(data)` 的结果。如果工厂返回构造函数, +业务可以继续写 `import { Greeting } from "./greeting"; new Greeting()`。 +该浏览器替换不包含原模块的初始化逻辑和依赖,服务端编译继续使用原模块。 +工厂及其依赖必须适用于浏览器;这项能力不会改写静态类型。 + +源码必须是应用根目录内已存在的文件。相对源码和运行时路径均从应用根目录解析, +运行时也可以使用包说明符。导出名和工厂导出当前支持 ASCII JavaScript 标识符, +包括 `default`。多个导出可以共享同一引用。一个源码文件只能对应一项贡献, +该限制也覆盖符号链接别名和不同插件。无效或冲突的贡献会在规划阶段报错。 + +数据必须是严格 JSON,只能包含准备向浏览器公开的信息。完整构建计划保留在 `.ev`, +不是公开部署 manifest。源码变化会自动被监听;插件读取其他元数据文件时, +必须通过 `ctx.addWatchFile()` 注册这些文件。 + +该能力需要 `@utoo/pack` 1.6.0 或更新版本,并加载报告 `moduleReferencesVersion = 1` +的配套原生绑定。旧版 Utoopack 和 Webpack 适配器 +会明确报错,避免将原模块编译进浏览器。bundler 钩子不能替换框架拥有的引用列表。 + ## 向客户端入口添加代码 生成安装器,并在应用主入口后导入: diff --git a/package-lock.json b/package-lock.json index 9c0479a8..1dc9cf06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9161,9 +9161,9 @@ } }, "node_modules/@utoo/pack-darwin-arm64": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.5.36.tgz", - "integrity": "sha512-Be3OZ87rYzEFxF7H8WnaFleHQV/sXTtutnUXdV4lHXwlKNKcvikoy5tTqAGEwUvt+lf22YqKf/gYZX6G+/4X4Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-arm64/-/pack-darwin-arm64-1.6.0.tgz", + "integrity": "sha512-CmY9BS7mbVlwv/p53Ts8bcb4xWN3bz7ZmHOUWNfZFSXek7N30TG6KW+TEBg/+sOE3X44uq5+nvSyTH7GKXaajw==", "cpu": [ "arm64" ], @@ -9177,9 +9177,9 @@ } }, "node_modules/@utoo/pack-darwin-x64": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.5.36.tgz", - "integrity": "sha512-WSCVqQajW0wasinAFHQCvXzC3XxtJE6UkJ5dxwQ/clh9OriEZz3yG2Qs1VbnPH/t/p9YCuHMb8MgT3y44zoCyA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-darwin-x64/-/pack-darwin-x64-1.6.0.tgz", + "integrity": "sha512-rxaWbnwhXFwV0ylCtEhF/yGYP/k4k6yqV3bfdBi4/GwpCpab3b0AlH6DDhi2U+kjDhFkNpQFbgxD5IekmWTT6w==", "cpu": [ "x64" ], @@ -9193,9 +9193,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-gnu": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.5.36.tgz", - "integrity": "sha512-vMrS6zd8hh7L+UnKgbBGclMcYahlzf1bKcSOgZBqLrlnYQ6KLwRNq0X0th6ClGsPMSTwX05MwBINdOYLKDqj5Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-gnu/-/pack-linux-arm64-gnu-1.6.0.tgz", + "integrity": "sha512-Mh3MK+1qcEa/kuqAQcEsS+XwE5QPUsA47nEG8AMN/iRjKEbIy8z/qG9+7gimesXEa2Xodtcxp5F2U8RHmHl0RA==", "cpu": [ "arm64" ], @@ -9209,9 +9209,9 @@ } }, "node_modules/@utoo/pack-linux-arm64-musl": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.5.36.tgz", - "integrity": "sha512-s7J1CkmzdWD3D1DfojWTZKG8G79nh0OZxUlEs6bIIk5DoG2OkIa2eQ19HCYi/dXfcZR7yBPIYDT+DuYBmyzgtA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-arm64-musl/-/pack-linux-arm64-musl-1.6.0.tgz", + "integrity": "sha512-Fb+VK3wYPdM4MHYVZR1LnGcKzgVBDZlynPyUzCRvnSIphbYiEiDQJzRNYXJxovXux8NY0kZuvVgfjffM8eotlg==", "cpu": [ "arm64" ], @@ -9225,9 +9225,9 @@ } }, "node_modules/@utoo/pack-linux-x64-gnu": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.5.36.tgz", - "integrity": "sha512-LKc2BTDnn/IGqE7tTllfx7QGhLiSr+FMMmnLdM6qa1HBdfgmuE/v8qr6+Y9A1+dcfdkWf3EeLlN/NJykb/k05w==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-gnu/-/pack-linux-x64-gnu-1.6.0.tgz", + "integrity": "sha512-1hZ6XxlqVhzfRYA7P3el7p4XDXPnkMV04MhoNx6ojUWPybWqIC9JMCdnbIsr6Xh/iAXljE82MGLDCpE/IEozUA==", "cpu": [ "x64" ], @@ -9241,9 +9241,9 @@ } }, "node_modules/@utoo/pack-linux-x64-musl": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.5.36.tgz", - "integrity": "sha512-9WntMbc9F1vokvuSfUcAoSlUnJXtYYulieWo1bxqaHjEyJxAwzd26DeRKKRoAV2jufbMPEmiwTMdQfoeaJnjZw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-linux-x64-musl/-/pack-linux-x64-musl-1.6.0.tgz", + "integrity": "sha512-Nb2CvNUbdGV+1c//TlJguVKKK87KqZ8hplSmXPb0qYDtIfZvXlFoFmls5HkvMZwrMzNdNc5aPhXfcqx9m7ADOw==", "cpu": [ "x64" ], @@ -9257,9 +9257,9 @@ } }, "node_modules/@utoo/pack-shared": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.5.36.tgz", - "integrity": "sha512-7ISpVi9JzTdwlejbx168j0CJ3ybgkObGWF9YZgRuschsM0C9LFR2AhZJQ5JMXkJD3H+Qjac2T/aEocJ0859g4g==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-shared/-/pack-shared-1.6.0.tgz", + "integrity": "sha512-H/OQ8OPkifxGG8klZhXDhJPZ9QseXQqMpIAnIpRy8vVcFxbtIEF05OB/7lkcHmno9Qp/0zQ+pwgiRBHv2AWUhA==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", @@ -9282,9 +9282,9 @@ } }, "node_modules/@utoo/pack-win32-x64-msvc": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.5.36.tgz", - "integrity": "sha512-z40ExNM1OvC5NR9JBnsIgNEBOBIrTWXcjg2rYE+Gb5kvBungyKmYgGl6TpMIeo8EQdiINgHewhW2VmTj8nJIIQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack-win32-x64-msvc/-/pack-win32-x64-msvc-1.6.0.tgz", + "integrity": "sha512-gUgXF33/+jFYiPtnCo9FNiAGU9LwMXcNpHM5mWzW7KT7g6tAKvEx9WRDw1ZHt1MNZOeAdiooEiLSnkrHIoEtGw==", "cpu": [ "x64" ], @@ -24880,7 +24880,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.36", + "@utoo/pack": "^1.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" @@ -24903,9 +24903,9 @@ } }, "packages/bundler-utoopack/node_modules/@utoo/pack": { - "version": "1.5.36", - "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.5.36.tgz", - "integrity": "sha512-WuN0uqpnp/M5IyAzQFkBbSSCSzddlXdAfAo2UyNK906C0TjBFC5DL9VR9zFuiyMZTV3QI4PAMzjJ3u5bokyshQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@utoo/pack/-/pack-1.6.0.tgz", + "integrity": "sha512-q2uJS1hAc/51M5+nP5T0OLSP/5wfJAY2nDGO8MkJn06zEW0PkLpkF5cH5pgREwleqNHv161iDMBITRpsjfTNWQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.22.5", @@ -24913,7 +24913,7 @@ "@hono/node-ws": "^1.3.0", "@jridgewell/trace-mapping": "^0.3.31", "@swc/helpers": "0.5.23", - "@utoo/pack-shared": "1.5.36", + "@utoo/pack-shared": "1.6.0", "browserslist": "^4.28.5", "domparser-rs": "^0.0.7", "find-up": "4.1.0", @@ -24928,13 +24928,13 @@ "node": ">= 20" }, "optionalDependencies": { - "@utoo/pack-darwin-arm64": "1.5.36", - "@utoo/pack-darwin-x64": "1.5.36", - "@utoo/pack-linux-arm64-gnu": "1.5.36", - "@utoo/pack-linux-arm64-musl": "1.5.36", - "@utoo/pack-linux-x64-gnu": "1.5.36", - "@utoo/pack-linux-x64-musl": "1.5.36", - "@utoo/pack-win32-x64-msvc": "1.5.36" + "@utoo/pack-darwin-arm64": "1.6.0", + "@utoo/pack-darwin-x64": "1.6.0", + "@utoo/pack-linux-arm64-gnu": "1.6.0", + "@utoo/pack-linux-arm64-musl": "1.6.0", + "@utoo/pack-linux-x64-gnu": "1.6.0", + "@utoo/pack-linux-x64-musl": "1.6.0", + "@utoo/pack-win32-x64-msvc": "1.6.0" }, "peerDependencies": { "less": "^4.0.0", diff --git a/packages/bundler-utoopack/README.md b/packages/bundler-utoopack/README.md index 345bb103..430d89a8 100644 --- a/packages/bundler-utoopack/README.md +++ b/packages/bundler-utoopack/README.md @@ -5,6 +5,9 @@ The default utoopack (`@utoo/pack`) bundler adapter for the evjs framework. This package exports `utoopackAdapter`, which implements the `BundlerAdapter` interface for `ev build` and `ev dev`. +The adapter requires `@utoo/pack` 1.6.0 or newer. Plugin-contributed browser +module references also check the loaded native binding's capability at runtime. + The adapter integrates with Utoopack's programmatic API, compiles the concrete entries in `BuildPlan`, and returns build facts for framework output linking. Its declared capabilities are the authoritative boundary: conventional diff --git a/packages/bundler-utoopack/package.json b/packages/bundler-utoopack/package.json index a47cbebb..132215c6 100644 --- a/packages/bundler-utoopack/package.json +++ b/packages/bundler-utoopack/package.json @@ -35,7 +35,7 @@ "@evjs/ev": "*", "@evjs/shared": "*", "@logtape/logtape": "^2.0.4", - "@utoo/pack": "^1.5.36", + "@utoo/pack": "^1.6.0", "fast-glob": "^3.3.3", "less": "4.1.3", "less-loader": "12.3.2" diff --git a/packages/bundler-utoopack/src/adapter/config/create-config.ts b/packages/bundler-utoopack/src/adapter/config/create-config.ts index eb550c76..a44e0f75 100644 --- a/packages/bundler-utoopack/src/adapter/config/create-config.ts +++ b/packages/bundler-utoopack/src/adapter/config/create-config.ts @@ -36,6 +36,11 @@ import { assertSafeUtoopackCleanOutput, assertUtoopackOutputPathsMatchPlan, } from "../output/output-paths.js"; +import { + assertUtoopackModuleReferencesMatchPlan, + createUtoopackModuleReferences, + type UtoopackModuleReferenceConfig, +} from "./module-references.js"; const logger = getLogger(["evjs", "bundler-utoopack", "config"]); const lessImplementation = require.resolve("less"); @@ -152,8 +157,12 @@ export async function createUtoopackConfig( : {}), }; - const utoopackConfig: ConfigComplete = { + const moduleReferences = createUtoopackModuleReferences(plan); + const utoopackConfig: UtoopackModuleReferenceConfig = { mode, + ...(moduleReferences + ? { module: { references: structuredClone(moduleReferences) } } + : {}), // Keep concurrent dev and production Projects on separate databases and locks. cacheDirectory: `.turbopack/evjs/${mode}`, ...(isProduction && config.target !== undefined && clientEntries.length > 0 @@ -271,6 +280,7 @@ export async function createUtoopackConfig( for (const h of hooks) { if (h.configureBundler) { await h.configureBundler(utoopackConfig, ctx); + assertUtoopackModuleReferencesMatchPlan(utoopackConfig, moduleReferences); assertUtoopackServerEntryMatchesPlan(utoopackConfig, expectedServerEntry); assertUtoopackOutputPathsMatchPlan(cwd, utoopackConfig, outputPaths, { requireServerOutput: finalServerEntry !== undefined, @@ -282,6 +292,7 @@ export async function createUtoopackConfig( } assertUtoopackServerEntryMatchesPlan(utoopackConfig, expectedServerEntry); + assertUtoopackModuleReferencesMatchPlan(utoopackConfig, moduleReferences); assertUtoopackOutputPathsMatchPlan(cwd, utoopackConfig, outputPaths, { requireServerOutput: finalServerEntry !== undefined, }); diff --git a/packages/bundler-utoopack/src/adapter/config/module-references.ts b/packages/bundler-utoopack/src/adapter/config/module-references.ts new file mode 100644 index 00000000..50a76d0a --- /dev/null +++ b/packages/bundler-utoopack/src/adapter/config/module-references.ts @@ -0,0 +1,46 @@ +import { isDeepStrictEqual } from "node:util"; +import type { BuildPlan, ClientModuleReference } from "@evjs/shared/manifest"; +import type { ConfigComplete, ModuleReferenceConfig } from "@utoo/pack"; + +export type UtoopackModuleReferenceConfig = ConfigComplete; + +export function createUtoopackModuleReferences( + plan: BuildPlan, +): ModuleReferenceConfig[] | undefined { + if (!plan.clientModuleReferences?.length) return undefined; + return plan.clientModuleReferences.map( + ({ resource, references, exports }) => + // structuredClone creates mutable arrays and objects even when the plan + // snapshot is deeply readonly. Native package types describe that copy. + structuredClone({ + resource, + references, + exports, + }) as ModuleReferenceConfig, + ); +} + +export function assertUtoopackModuleReferencesMatchPlan( + config: UtoopackModuleReferenceConfig, + expected: ClientModuleReference[] | undefined, +): void { + if (!isDeepStrictEqual(config.module?.references, expected)) { + throw new Error( + "[evjs] Utoopack module.references must remain the exact framework-owned BuildPlan references. configureBundler hooks cannot change browser module boundaries.", + ); + } +} + +export function assertUtoopackModuleReferenceSupport( + runtime: { moduleReferencesVersion?: number }, + config: UtoopackModuleReferenceConfig, +): void { + if ( + config.module?.references?.length && + runtime.moduleReferencesVersion !== 1 + ) { + throw new Error( + "[evjs] This build requires @utoo/pack moduleReferencesVersion=1. Install @utoo/pack 1.6.0 or newer with its matching native binding before using emit.moduleReference(). Refusing to bundle the original source modules.", + ); + } +} diff --git a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts index 6fd7fc84..96e96fe2 100644 --- a/packages/bundler-utoopack/src/adapter/development/dev-worker.ts +++ b/packages/bundler-utoopack/src/adapter/development/dev-worker.ts @@ -1,6 +1,7 @@ import { createRequire } from "node:module"; import { parentPort } from "node:worker_threads"; import type { DevServerReadyContext } from "@utoo/pack"; +import { assertUtoopackModuleReferenceSupport } from "../config/module-references.js"; import { runUtoopackDevServer, type UtoopackClientBuildContext, @@ -38,7 +39,9 @@ interface ActiveSession { publications: Map; } -type UtoopackDevRuntime = Pick; +type UtoopackDevRuntime = Pick & { + moduleReferencesVersion?: number; +}; const require = createRequire(import.meta.url); const port = requireParentPort(); @@ -127,6 +130,7 @@ async function startSession( ); } + assertUtoopackModuleReferenceSupport(runtime, options.config); const lifecycle = createUtoopackReusableSessionLifecycle(); const session: ActiveSession = { id: sessionId, diff --git a/packages/bundler-utoopack/src/adapter/execution/utoopack.ts b/packages/bundler-utoopack/src/adapter/execution/utoopack.ts index 1bda1857..8941c71e 100644 --- a/packages/bundler-utoopack/src/adapter/execution/utoopack.ts +++ b/packages/bundler-utoopack/src/adapter/execution/utoopack.ts @@ -3,16 +3,20 @@ * Utoopack to discover the enclosing workspace root for hoisted dependencies. */ import type { ConfigComplete } from "@utoo/pack"; +import { assertUtoopackModuleReferenceSupport } from "../config/module-references.js"; export interface UtoopackClientBuildContext { clientPaths: string[]; isRebuild: boolean; } -type UtoopackBuildRuntime = Pick; +type UtoopackBuildRuntime = Pick & { + moduleReferencesVersion?: number; +}; // This bridge uses the void-returning serve overload; Session teardown is // owned by the native-owner Worker rather than the optional dev server handle. type UtoopackDevRuntime = { + moduleReferencesVersion?: number; serve( ...args: Parameters ): Promise; @@ -28,6 +32,7 @@ export function runUtoopackBuild( config: ConfigComplete, projectPath: string, ): Promise { + assertUtoopackModuleReferenceSupport(runtime, config); return runtime.build({ config }, projectPath); } @@ -37,5 +42,6 @@ export function runUtoopackDevServer( projectPath: string, serverOptions: UtoopackServerOptions, ): Promise { + assertUtoopackModuleReferenceSupport(runtime, config); return runtime.serve({ config }, projectPath, undefined, serverOptions); } diff --git a/packages/bundler-utoopack/tests/create-config.test.ts b/packages/bundler-utoopack/tests/create-config.test.ts index 0018f7d1..e9378d62 100644 --- a/packages/bundler-utoopack/tests/create-config.test.ts +++ b/packages/bundler-utoopack/tests/create-config.test.ts @@ -69,6 +69,73 @@ describe("createUtoopackConfig", () => { return config; } + it("forwards planned browser references and rejects bundler hook changes", async () => { + const config = createResolvedConfig(); + const plan = await createPlan(config); + const reference = { + resource: "/workspace/src/greeting.ts", + references: [ + { + id: "greeting", + factory: { + module: "/workspace/src/runtime.ts", + export: "createReference", + }, + data: { endpoint: "/greetings" }, + }, + ], + exports: [{ name: "Greeting", reference: "greeting" }], + }; + plan.clientModuleReferences = [ + { ...reference, id: "greeting", pluginId: "fixture" }, + ]; + const result = await createUtoopackConfig(config, plan, process.cwd(), []); + expect(result.module).toEqual({ references: [reference] }); + expect(result.server).toBeUndefined(); + await expect( + createUtoopackConfig(config, plan, process.cwd(), [ + { + configureBundler(config) { + config.module = {}; + }, + }, + ]), + ).rejects.toThrow("module.references must remain"); + }); + + it("copies readonly reference arrays into mutable native configuration without changing the plan", async () => { + const config = createResolvedConfig(); + const plan = await createPlan(config); + const data = Object.freeze(["/greetings"]); + plan.clientModuleReferences = [ + { + id: "greeting", + pluginId: "fixture", + resource: "/workspace/src/greeting.ts", + references: [ + { + id: "greeting", + factory: { + module: "/workspace/src/runtime.ts", + export: "createReference", + }, + data, + }, + ], + exports: [{ name: "Greeting", reference: "greeting" }], + }, + ]; + const result = await createUtoopackConfig(config, plan, process.cwd(), []); + const copy = result.module?.references?.[0].references[0].data; + expect(copy).not.toBe(data); + expect(Object.isFrozen(copy)).toBe(false); + if (!Array.isArray(copy)) + throw new Error("Expected a native array payload"); + copy.push("/second"); + expect(copy).toEqual(["/greetings", "/second"]); + expect(data).toEqual(["/greetings"]); + }); + it("passes resolved dev server options and SPA fallback to Utoopack", async () => { const config = createResolvedConfig(); const plan = await createPlan(config); diff --git a/packages/bundler-utoopack/tests/fixtures/module-references-native-harness.mjs b/packages/bundler-utoopack/tests/fixtures/module-references-native-harness.mjs new file mode 100644 index 00000000..bb383369 --- /dev/null +++ b/packages/bundler-utoopack/tests/fixtures/module-references-native-harness.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import vm from "node:vm"; +import { prepareFrameworkBuild } from "@evjs/ev/_internal/build"; +import { build, moduleReferencesVersion } from "@utoo/pack"; +import { createUtoopackConfig } from "../../esm/adapter/config/create-config.js"; +import { assertUtoopackModuleReferenceSupport } from "../../esm/adapter/config/module-references.js"; + +const [cwd] = process.argv.slice(2); +assert(cwd, "Native reference harness requires a project directory."); +// This test must fail on an older package or a mismatched native addon. +assert.equal( + moduleReferencesVersion, + 1, + "Requires @utoo/pack 1.6.0 native references", +); + +const implementationMarker = "ORIGINAL_GREETING_IMPLEMENTATION"; +const sideEffectMarker = "ORIGINAL_GREETING_SIDE_EFFECT"; +const sourceFiles = { + "package.json": '{ "private": true }', + "index.html": '
', + "src/pages/page.tsx": "export default function Page() { return null; }", + "src/greeting.js": ` + globalThis.${sideEffectMarker} = 1; + export class Greeting { + greet() { return "${implementationMarker}"; } + } + export default Greeting; + `, + "src/ordinary.js": ` + globalThis.ordinaryEvaluations = (globalThis.ordinaryEvaluations || 0) + 1; + export function greet() { return "ordinary greeting"; } + `, + "src/runtime.js": ` + export let creations = 0; + export function createReference(data) { + creations++; + return class GreetingReference { + greet(name) { return data.prefix + " " + name; } + }; + } + `, + "src/entry.js": ` + import DefaultGreeting, { Greeting } from "./greeting.js"; + import { creations } from "./runtime.js"; + import { greet } from "./ordinary.js"; + globalThis.GreetingFixture = { + sameIdentity: DefaultGreeting === Greeting, + greeting: new Greeting().greet("Ada"), + creations, + ordinaryGreeting: greet(), + }; + `, +}; +for (const [filename, source] of Object.entries(sourceFiles)) { + const target = path.join(cwd, filename); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, source); +} + +const prepared = await prepareFrameworkBuild( + { + routing: { mode: "spa" }, + plugins: [ + { + id: "greeting-reference", + emitIR({ emit, slot }) { + emit.moduleReference({ + id: "greeting", + resource: "./src/greeting.js", + references: [ + { + id: "greeting", + factory: { + module: "./src/runtime.js", + export: "createReference", + }, + data: { prefix: "Hello" }, + }, + ], + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }); + // Keep the real application emitter while avoiding a React renderer + // dependency in this focused compiler-boundary fixture. + slot("client.entry").add({ + id: "entry-slot", + module: "./src/entry.js", + position: "before-main", + mode: "replace", + }); + }, + }, + ], + }, + { cwd, mode: "production" }, +); + +try { + const { plan } = JSON.parse( + await fs.readFile(path.join(cwd, ".ev/framework/build-plan.json"), "utf8"), + ); + const config = await createUtoopackConfig(prepared.config, plan, cwd, [ + { + configureBundler(config) { + config.sourceMaps = true; + config.persistentCaching = false; + config.optimization.minify = false; + }, + }, + ]); + assertUtoopackModuleReferenceSupport({ moduleReferencesVersion }, config); + await build( + { config, tracing: false, buildId: "evjs-module-reference" }, + cwd, + cwd, + ); + + const output = path.join(cwd, "dist/client"); + const files = (await fs.readdir(output, { recursive: true })).map( + (filename) => path.join(output, filename), + ); + const javascript = files.filter((filename) => filename.endsWith(".js")); + const maps = files.filter((filename) => filename.endsWith(".map")); + const source = await Promise.all( + [...javascript, ...maps].map((filename) => fs.readFile(filename, "utf8")), + ); + const stats = JSON.parse( + await fs.readFile(path.join(output, "stats.json"), "utf8"), + ); + const entryName = plan.entries.find( + (entry) => entry.environment === "client", + ).name; + const entryAssets = (stats.entrypoints[entryName]?.assets ?? []) + .map((asset) => (typeof asset === "string" ? asset : asset.name)) + .filter((filename) => filename?.endsWith(".js")) + .map((filename) => filename.replace(/^\.\//, "")); + assert(entryAssets.length, "Expected emitted application entry assets."); + const context = vm.createContext({ console, URL }); + context.self = context; + context.location = "https://example.test/"; + context.TURBOPACK_NEXT_CHUNK_URLS = []; + context.importScripts = (url) => { + const filename = decodeURIComponent( + new URL(url, context.location).pathname, + ).slice(1); + // Browser loading is synchronous in this worker-shaped VM host. + vm.runInContext(readOutput(filename), context, { filename }); + }; + const outputSources = new Map( + await Promise.all( + javascript.map(async (filename) => [ + path.relative(output, filename).split(path.sep).join("/"), + await fs.readFile(filename, "utf8"), + ]), + ), + ); + function readOutput(filename) { + assert(outputSources.has(filename), `Missing emitted chunk ${filename}`); + return outputSources.get(filename); + } + // Use the native stats' script order, just as an HTML page does. Runtime + // filenames follow output templates and may be inlined into the entry. + for (const filename of entryAssets) { + context.TURBOPACK_NEXT_CHUNK_URLS.push( + new URL(filename, context.location).href, + ); + vm.runInContext(readOutput(filename), context, { filename }); + } + await new Promise((resolve) => setImmediate(resolve)); + assert(context.GreetingFixture, "Expected the browser entry to execute."); + process.stdout.write( + `EVJS_NATIVE_REFERENCE_RESULT=${JSON.stringify({ + capabilityVersion: moduleReferencesVersion, + plannedPluginId: plan.clientModuleReferences[0].pluginId, + canonicalResource: + plan.clientModuleReferences[0].resource === + (await fs.realpath(path.join(cwd, "src/greeting.js"))), + javascriptCount: javascript.length, + sourceMapCount: maps.length, + originalSourceLeaked: source.some( + (text) => + text.includes(implementationMarker) || + text.includes(sideEffectMarker), + ), + ...context.GreetingFixture, + originalEvaluations: context[sideEffectMarker] ?? 0, + ordinaryEvaluations: context.ordinaryEvaluations ?? 0, + })}\n`, + ); +} finally { + await prepared.dispose(); +} diff --git a/packages/bundler-utoopack/tests/module-references-native.test.ts b/packages/bundler-utoopack/tests/module-references-native.test.ts new file mode 100644 index 00000000..cd535b93 --- /dev/null +++ b/packages/bundler-utoopack/tests/module-references-native.test.ts @@ -0,0 +1,72 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const harnessPath = fileURLToPath( + new URL("./fixtures/module-references-native-harness.mjs", import.meta.url), +); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all( + roots.splice(0).map((cwd) => fs.rm(cwd, { recursive: true, force: true })), + ); +}); + +describe("planned module references with published native Utoopack", () => { + it("builds shared reference exports without exposing the original module in browser JS or maps", async () => { + const cwd = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "evjs-native-reference-")), + ); + roots.push(cwd); + const { stdout } = await execFileAsync( + process.execPath, + [harnessPath, cwd], + { + env: { ...process.env, UTOO_ROOT_PATH: cwd }, + maxBuffer: 4 * 1024 * 1024, + timeout: 80_000, + killSignal: "SIGKILL", + }, + ); + const resultLine = stdout + .split("\n") + .find((line) => line.startsWith("EVJS_NATIVE_REFERENCE_RESULT=")); + expect(resultLine).toBeDefined(); + const result = JSON.parse( + resultLine?.slice("EVJS_NATIVE_REFERENCE_RESULT=".length) ?? "null", + ) as { + capabilityVersion: number; + plannedPluginId: string; + canonicalResource: boolean; + javascriptCount: number; + sourceMapCount: number; + originalSourceLeaked: boolean; + sameIdentity: boolean; + creations: number; + greeting: string; + originalEvaluations: number; + ordinaryGreeting: string; + ordinaryEvaluations: number; + }; + expect(result).toMatchObject({ + capabilityVersion: 1, + plannedPluginId: "greeting-reference", + canonicalResource: true, + originalSourceLeaked: false, + sameIdentity: true, + creations: 1, + greeting: "Hello Ada", + originalEvaluations: 0, + ordinaryGreeting: "ordinary greeting", + ordinaryEvaluations: 1, + }); + expect(result.javascriptCount).toBeGreaterThan(0); + expect(result.sourceMapCount).toBeGreaterThan(0); + }, 90_000); +}); diff --git a/packages/bundler-utoopack/tests/runtime.test.ts b/packages/bundler-utoopack/tests/runtime.test.ts index 42a17da4..e45a3ba9 100644 --- a/packages/bundler-utoopack/tests/runtime.test.ts +++ b/packages/bundler-utoopack/tests/runtime.test.ts @@ -1,5 +1,6 @@ import type { ConfigComplete } from "@utoo/pack"; import { describe, expect, it, vi } from "vitest"; +import type { UtoopackModuleReferenceConfig } from "../src/adapter/config/module-references.js"; import { runUtoopackBuild, runUtoopackDevServer, @@ -9,6 +10,56 @@ const PROJECT_PATH = "/workspace/apps/example"; const config = {} as ConfigComplete; describe("Utoopack runtime invocation", () => { + const referenceConfig: UtoopackModuleReferenceConfig = { + entry: [], + module: { + references: [ + { + resource: "/workspace/greeting.ts", + references: [ + { + id: "greeting", + factory: { + module: "/workspace/runtime.ts", + export: "createReference", + }, + data: null, + }, + ], + exports: [{ name: "Greeting", reference: "greeting" }], + }, + ], + }, + }; + + it("rejects an unsupported runtime before it can build original source", () => { + const build = vi.fn(async () => {}); + const serve = vi.fn(async () => {}); + expect(() => + runUtoopackBuild({ build }, referenceConfig, PROJECT_PATH), + ).toThrow("moduleReferencesVersion=1"); + expect(() => + runUtoopackDevServer({ serve }, referenceConfig, PROJECT_PATH, { + port: 3000, + }), + ).toThrow("moduleReferencesVersion=1"); + expect(build).not.toHaveBeenCalled(); + expect(serve).not.toHaveBeenCalled(); + }); + + it("forwards references only to a compatible runtime", async () => { + const build = vi.fn(async () => {}); + await runUtoopackBuild( + { build, moduleReferencesVersion: 1 }, + referenceConfig, + PROJECT_PATH, + ); + expect(build).toHaveBeenCalledWith( + { config: referenceConfig }, + PROJECT_PATH, + ); + }); + it("anchors builds to the project and lets Utoopack discover the workspace root", async () => { const build = vi.fn( async (..._args: Parameters) => {}, diff --git a/packages/bundler-webpack/src/adapter/config/create-config.ts b/packages/bundler-webpack/src/adapter/config/create-config.ts index 0a7c6848..32ef684f 100644 --- a/packages/bundler-webpack/src/adapter/config/create-config.ts +++ b/packages/bundler-webpack/src/adapter/config/create-config.ts @@ -65,6 +65,11 @@ export async function createWebpackConfigs( addWatchFile?: (file: string) => void; } = {}, ): Promise { + if (plan.clientModuleReferences?.length) { + throw new Error( + "[evjs] The Webpack adapter does not support browser module references. Select a Utoopack build with native module reference support. Refusing to bundle the original source modules.", + ); + } const outputPaths = resolveBuildOutputPaths(cwd, plan); await assertSafeBuildOutputPaths(cwd, outputPaths); const configs: WebpackConfigs = []; diff --git a/packages/bundler-webpack/tests/create-config.test.ts b/packages/bundler-webpack/tests/create-config.test.ts index 315a2108..92ec39d5 100644 --- a/packages/bundler-webpack/tests/create-config.test.ts +++ b/packages/bundler-webpack/tests/create-config.test.ts @@ -20,6 +20,33 @@ import { } from "../src/adapter/config/create-config.js"; describe("createWebpackConfigs", () => { + it("rejects module references instead of bundling the original source", async () => { + const config = createResolvedConfig(); + const plan = await createGeneratedPlan( + config, + createGraph(config), + "development", + ); + plan.clientModuleReferences = [ + { + id: "greeting", + pluginId: "fixture", + resource: "/workspace/greeting.ts", + references: [ + { + id: "greeting", + factory: { module: "runtime", export: "createReference" }, + data: null, + }, + ], + exports: [{ name: "Greeting", reference: "greeting" }], + }, + ]; + await expect( + createWebpackConfigs(config, plan, process.cwd(), []), + ).rejects.toThrow("does not support browser module references"); + }); + it("uses a generated pages app entry for framework-managed pages", async () => { const config = createResolvedConfig(); const graph = createGraph(config); diff --git a/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts b/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts index 292a9181..46dd2b7b 100644 --- a/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts +++ b/packages/ev/src/_internal/build/generated-ir/generated-contributions.ts @@ -57,6 +57,7 @@ import { } from "../output/portable-artifact-path.js"; import { createPluginConfigView } from "../plugins/lifecycle.js"; import { toPosixPath } from "../utils.js"; +import { normalizeClientModuleReference } from "./module-references.js"; import { API_CLIENT_FILE, API_CLIENT_MODULE, @@ -556,6 +557,24 @@ class ContributionCollector { private createEmitApi(pluginId: string, namespace?: string): EmitApi { const emit: EmitApi = { + moduleReference: (input) => { + const id = validateContributionId(input.id, pluginId); + this.reserveKey(pluginId, id, "module reference", namespace); + const reference = normalizeClientModuleReference( + this.options.cwd, + pluginId, + input, + ); + this.options.plan.clientModuleReferences ??= []; + const references = this.options.plan.clientModuleReferences; + if (references.some((item) => item.resource === reference.resource)) { + throw new Error( + `[evjs] Source module "${reference.resource}" has multiple module reference contributions.`, + ); + } + references.push(reference); + this.options.pluginContext.addWatchFile(reference.resource); + }, module: (input) => { const id = validateContributionId(input.id, pluginId); return this.emitGeneratedModule( diff --git a/packages/ev/src/_internal/build/generated-ir/module-references.ts b/packages/ev/src/_internal/build/generated-ir/module-references.ts new file mode 100644 index 00000000..a2af5825 --- /dev/null +++ b/packages/ev/src/_internal/build/generated-ir/module-references.ts @@ -0,0 +1,108 @@ +import { realpathSync, statSync } from "node:fs"; +import path from "node:path"; +import { assertStaticJsonValue } from "@evjs/shared/_internal/static-json"; +import type { + ClientModuleReference, + ClientModuleReferencePlan, +} from "@evjs/shared/manifest"; + +const exportName = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** Normalize without evaluating source code or interpreting payload semantics. */ +export function normalizeClientModuleReference( + cwd: string, + pluginId: string, + input: ClientModuleReference & { id: string }, +): ClientModuleReferencePlan { + const label = `Plugin "${pluginId}" module reference "${input.id}"`; + assertStaticJsonValue(input, label); + if (typeof input.resource !== "string" || !input.resource.trim()) { + throw new Error(`[evjs] ${label} requires a source filename.`); + } + const root = realpathSync(cwd); + const resource = realpathSync(path.resolve(root, input.resource)); + const relative = path.relative(root, resource); + if ( + !statSync(resource).isFile() || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error( + `[evjs] ${label} source must be a file inside the application root.`, + ); + } + if (!Array.isArray(input.references) || input.references.length === 0) { + throw new Error(`[evjs] ${label} requires at least one reference.`); + } + const ids = new Set(); + const references = input.references.map((reference) => { + if ( + !reference || + typeof reference !== "object" || + Array.isArray(reference) + ) { + throw new Error(`[evjs] ${label} requires reference objects.`); + } + if ( + typeof reference.id !== "string" || + !reference.id || + ids.has(reference.id) + ) { + throw new Error( + `[evjs] ${label} has a missing or duplicate reference id.`, + ); + } + ids.add(reference.id); + if ( + !reference.factory || + typeof reference.factory.module !== "string" || + !reference.factory.module.trim() || + typeof reference.factory.export !== "string" || + !exportName.test(reference.factory.export) + ) { + throw new Error( + `[evjs] ${label} requires a runtime module and factory export.`, + ); + } + assertStaticJsonValue( + reference.data, + `${label} reference "${reference.id}" data`, + ); + const runtime = reference.factory.module; + return { + id: reference.id, + factory: { + module: runtime.startsWith(".") ? path.resolve(root, runtime) : runtime, + export: reference.factory.export, + }, + data: structuredClone(reference.data), + }; + }); + if (!Array.isArray(input.exports) || input.exports.length === 0) { + throw new Error(`[evjs] ${label} requires at least one export.`); + } + const names = new Set(); + const exports = input.exports.map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error(`[evjs] ${label} requires export objects.`); + } + if ( + typeof item.name !== "string" || + !exportName.test(item.name) || + names.has(item.name) + ) { + throw new Error( + `[evjs] ${label} has an invalid or duplicate export name.`, + ); + } + names.add(item.name); + if (!ids.has(item.reference)) { + throw new Error( + `[evjs] ${label} export "${item.name}" points to an unknown reference.`, + ); + } + return { name: item.name, reference: item.reference }; + }); + return { id: input.id, pluginId, resource, references, exports }; +} diff --git a/packages/ev/src/_internal/build/generated-ir/sources/api-client-source.ts b/packages/ev/src/_internal/build/generated-ir/sources/api-client-source.ts index af2fb97b..f8c4b48f 100644 --- a/packages/ev/src/_internal/build/generated-ir/sources/api-client-source.ts +++ b/packages/ev/src/_internal/build/generated-ir/sources/api-client-source.ts @@ -12,7 +12,8 @@ export function createApiClientSource( return [ 'import { createFrameworkApiClient } from "@evjs/client/internal/http-api";', 'import { resolveApiTransport } from "@evjs/ev/_internal/client/api-transport";', - `export const api = createFrameworkApiClient(${JSON.stringify({ buildId: plan.buildId, applicationId: applications[0] ?? "app" })}, ${JSON.stringify(plan.runtime.transport ?? {})}, resolveApiTransport);`, + `export const apiRuntimeScope = Object.freeze(${JSON.stringify({ buildId: plan.buildId, applicationId: applications[0] ?? "app" })});`, + `export const api = createFrameworkApiClient(apiRuntimeScope, ${JSON.stringify(plan.runtime.transport ?? {})}, resolveApiTransport);`, "", ].join("\n"); } diff --git a/packages/ev/src/_internal/generated/client/api.ts b/packages/ev/src/_internal/generated/client/api.ts index c2282551..e4d71501 100644 --- a/packages/ev/src/_internal/generated/client/api.ts +++ b/packages/ev/src/_internal/generated/client/api.ts @@ -1,12 +1,13 @@ import { createFrameworkApiClient } from "@evjs/client/internal/http-api"; /** Replaced by the application-owned module for framework browser builds. */ -export const api = createFrameworkApiClient( - { buildId: "unbound", applicationId: "unbound" }, - {}, - () => { - throw new Error( - "[evjs] api is available in framework browser builds. Use createApiClient() from @evjs/client/http-api for standalone HTTP requests.", - ); - }, -); +export const apiRuntimeScope = Object.freeze({ + buildId: "unbound", + applicationId: "unbound", +}); + +export const api = createFrameworkApiClient(apiRuntimeScope, {}, () => { + throw new Error( + "[evjs] api is available in framework browser builds. Use createApiClient() from @evjs/client/http-api for standalone HTTP requests.", + ); +}); diff --git a/packages/ev/src/plugin/contracts.ts b/packages/ev/src/plugin/contracts.ts index f1ab4b92..ad4a2a66 100644 --- a/packages/ev/src/plugin/contracts.ts +++ b/packages/ev/src/plugin/contracts.ts @@ -7,6 +7,7 @@ import type { BuildEnvironment, BuildOutput, ClientContributionRuntime, + ClientModuleReference, ComponentModel, ContributionRuntime, ContributionTarget, @@ -36,6 +37,7 @@ import type { Config, ResolvedFrameworkConfig } from "../config/resolution.js"; export type { ClientContributionRuntime, + ClientModuleReference, ContributionRuntime, ContributionTarget, CorePageScope, @@ -581,6 +583,12 @@ export interface PluginEmitIRContext } export interface EmitApi { + /** Replace this exact module in browser builds with runtime-created references. + * The plugin owns export discovery and the browser-safe payload. This does not + * execute or compile the original module. Relative filenames use the app root. + */ + moduleReference(input: ClientModuleReference & { id: string }): void; + module(input: { id: string; scope: GeneratedScope; diff --git a/packages/ev/src/plugin/index.ts b/packages/ev/src/plugin/index.ts index acb20c1d..e4f7f1dc 100644 --- a/packages/ev/src/plugin/index.ts +++ b/packages/ev/src/plugin/index.ts @@ -8,6 +8,7 @@ export { type ClientDevMiddlewareSetupContext, type ClientDevRequestContext, type ClientEntryContribution, + type ClientModuleReference, type CliFlags, type CliFlagValue, type ConfigureBundlerContext, diff --git a/packages/ev/tests/api-client-runtime.test.ts b/packages/ev/tests/api-client-runtime.test.ts index b7c0f212..1ea09ebc 100644 --- a/packages/ev/tests/api-client-runtime.test.ts +++ b/packages/ev/tests/api-client-runtime.test.ts @@ -1,11 +1,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import type { BuildPlan } from "@evjs/shared/manifest"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { ApiRuntimeScope } from "@evjs/client/internal/http-api"; +import type { BuildPlan, CoreGraph } from "@evjs/shared/manifest"; import { createJiti } from "jiti"; -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, expect, expectTypeOf, it, vi } from "vitest"; import { prepareFrameworkBuild } from "../src/_internal/build/commands.js"; +import { createApiClientSource } from "../src/_internal/build/generated-ir/sources/api-client-source.js"; +import { apiRuntimeScope as unboundScope } from "../src/_internal/generated/client/api.js"; const directories: string[] = []; afterEach(async () => { @@ -27,7 +30,7 @@ it("binds top-level calls to the application before evaluating consumer modules" "src/apis/health/api.ts": "throw new Error('server implementation must not be imported'); export const GET = () => Response.json({ ok: true });", "consumer.ts": - 'import { api } from "@evjs/ev"; export const request = api.get("/health");', + 'import { api } from "@evjs/ev"; export { apiRuntimeScope } from "@evjs/ev/_internal/client/api"; export const request = api.get("/health");', })) { await fs.mkdir(path.dirname(path.join(cwd, name)), { recursive: true }); await fs.writeFile(path.join(cwd, name), content); @@ -79,9 +82,15 @@ it("binds top-level calls to the application before evaluating consumer modules" "@evjs/ev": fileURLToPath(new URL("../src/browser.ts", import.meta.url)), }, }); - const consumer = await jiti.import<{ request: Promise }>( - path.join(cwd, "consumer.ts"), - ); + const consumer = await jiti.import<{ + request: Promise; + apiRuntimeScope: Readonly; + }>(path.join(cwd, "consumer.ts")); + expect(consumer.apiRuntimeScope).toEqual({ + buildId: plan.buildId, + applicationId: "default", + }); + expect(Object.isFrozen(consumer.apiRuntimeScope)).toBe(true); await expect((await consumer.request).json()).resolves.toEqual({ ok: true }); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch.mock.calls[0][0]).toBe( @@ -91,3 +100,116 @@ it("binds top-level calls to the application before evaluating consumer modules" Object.fromEntries(new Headers(fetch.mock.calls[0][1]?.headers)), ).toEqual({ "x-app": "application", "x-gateway": "deployment" }); }); + +it("exports the same immutable scope used by the client and isolates build and application identities", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "evjs-api-scope-")); + directories.push(cwd); + await fs.mkdir(path.join(cwd, "src/pages"), { recursive: true }); + await fs.writeFile( + path.join(cwd, "src/pages/page.tsx"), + "export default function Page() { return null; }", + ); + await fs.writeFile(path.join(cwd, "index.html"), '
'); + const prepared = await prepareFrameworkBuild( + { routing: { mode: "spa" } }, + { cwd }, + ); + const { graph } = JSON.parse( + await fs.readFile(path.join(cwd, ".ev/framework/core-graph.json"), "utf8"), + ) as { graph: CoreGraph }; + const { plan } = JSON.parse( + await fs.readFile(path.join(cwd, ".ev/framework/build-plan.json"), "utf8"), + ) as { plan: BuildPlan }; + const fetch = vi + .fn() + .mockImplementation(async () => Response.json({ ok: true })); + const resolver = vi.fn((scope: ApiRuntimeScope) => ({ + baseUrl: `https://${scope.buildId}.${scope.applicationId}.test`, + fetch, + })); + vi.stubGlobal("__EVJS_TEST_SCOPE_RESOLVER__", resolver); + const resolverFile = path.join(cwd, "transport.mjs"); + await fs.writeFile( + resolverFile, + "export const resolveApiTransport = globalThis.__EVJS_TEST_SCOPE_RESOLVER__;", + ); + const clientRuntime = new URL( + "../../client/esm/http-api/framework-runtime.js", + import.meta.url, + ).href; + const expectedScopes = [ + { buildId: "build-a", applicationId: "application-a" }, + { buildId: "build-b", applicationId: "application-a" }, + { buildId: "build-a", applicationId: "application-b" }, + ]; + const scopes: Readonly[] = []; + try { + const application = Object.values(graph.applications)[0]; + for (const [index, scope] of expectedScopes.entries()) { + const filename = path.join(cwd, `client-${index}.mjs`); + await fs.writeFile( + filename, + createApiClientSource( + { + ...graph, + applications: { + [scope.applicationId]: { + ...application, + id: scope.applicationId, + }, + }, + }, + { ...plan, buildId: scope.buildId }, + ) + .replace( + '"@evjs/client/internal/http-api"', + JSON.stringify(clientRuntime), + ) + .replace( + '"@evjs/ev/_internal/client/api-transport"', + JSON.stringify(pathToFileURL(resolverFile).href), + ), + ); + const generated = (await import(pathToFileURL(filename).href)) as { + apiRuntimeScope: Readonly; + api: { get(path: string): Promise }; + }; + expect(generated.apiRuntimeScope).toEqual(scope); + expect(Object.isFrozen(generated.apiRuntimeScope)).toBe(true); + expect(resolver.mock.calls[index][0]).toBe(generated.apiRuntimeScope); + expect(Reflect.set(generated.apiRuntimeScope, "buildId", "changed")).toBe( + false, + ); + scopes.push(generated.apiRuntimeScope); + await expect( + (await generated.api.get("/health")).json(), + ).resolves.toEqual({ ok: true }); + } + expect(resolver).toHaveBeenCalledTimes(3); + expect(new Set(scopes).size).toBe(3); + expect(fetch.mock.calls.map(([url]) => url)).toEqual( + expectedScopes.map( + ({ buildId, applicationId }) => + `https://${buildId}.${applicationId}.test/health`, + ), + ); + } finally { + await prepared.dispose(); + } +}); + +it("exposes a readonly frozen scope from the unbound fallback module", () => { + expect(unboundScope).toEqual({ + buildId: "unbound", + applicationId: "unbound", + }); + expect(Object.isFrozen(unboundScope)).toBe(true); + expectTypeOf(unboundScope).toMatchTypeOf>(); + const checkReadonly = (source: typeof unboundScope) => { + // @ts-expect-error generated runtime scope fields are readonly. + unboundScope.buildId = source.buildId; + // @ts-expect-error generated runtime scope fields are readonly. + unboundScope.applicationId = source.applicationId; + }; + void checkReadonly; +}); diff --git a/packages/ev/tests/commands.test.ts b/packages/ev/tests/commands.test.ts index e67fc731..9ad04a7f 100644 --- a/packages/ev/tests/commands.test.ts +++ b/packages/ev/tests/commands.test.ts @@ -1107,6 +1107,71 @@ describe("prepareFrameworkBuild", () => { expect(events).toEqual(["dispose:second", "dispose:first"]); }); + it("plans plugin module references, watches their source, and rejects duplicate ownership", async () => { + const cwd = await createProject(); + const resource = path.join(cwd, "src/greeting.ts"); + await writeFile( + resource, + 'throw new Error("source must not execute");', + "utf-8", + ); + const plugin: Plugin = { + id: "reference-fixture", + emitIR({ emit }) { + emit.moduleReference({ + id: "greeting", + resource, + references: [ + { + id: "greeting", + factory: { + module: "./src/runtime.ts", + export: "createReference", + }, + data: { endpoint: "/greetings" }, + }, + ], + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }); + }, + }; + const prepared = await prepareFrameworkBuild( + { plugins: [plugin] }, + { cwd }, + ); + try { + const manifest = JSON.parse( + await fs.promises.readFile( + path.join(cwd, ".ev/framework/build-plan.json"), + "utf-8", + ), + ) as { plan: BuildPlan }; + expect(manifest.plan.clientModuleReferences).toMatchObject([ + { + pluginId: plugin.id, + resource: fs.realpathSync(resource), + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }, + ]); + expect(prepared.pluginWatchFiles).toContain(fs.realpathSync(resource)); + expect(JSON.stringify(manifest)).not.toContain("source must not execute"); + } finally { + await prepared.dispose(); + } + await expect( + prepareFrameworkBuild( + { plugins: [plugin, { ...plugin, id: "other-reference-fixture" }] }, + { cwd }, + ), + ).rejects.toThrow("multiple module reference contributions"); + }); + it("generates .ev framework IR and plugin contributions", async () => { const cwd = await createProject(); await fs.promises.mkdir(path.join(cwd, "src/pages"), { recursive: true }); diff --git a/packages/ev/tests/dev-watch.test.ts b/packages/ev/tests/dev-watch.test.ts index 133dbae4..6b17d471 100644 --- a/packages/ev/tests/dev-watch.test.ts +++ b/packages/ev/tests/dev-watch.test.ts @@ -656,11 +656,19 @@ describe("watchFiles", () => { expect(changes).toEqual([]); }); - it("deduplicates polling writes with unchanged file content", async () => { + it("deduplicates polling replacements with unchanged file content", async () => { const root = await createTemporaryDirectory(); const file = path.join(root, "stable.ts"); await writeFile(file); + const replaceContents = async (contents: string) => { + const temporaryFile = path.join(root, "replacement.ts"); + await fs.promises.writeFile(temporaryFile, contents, "utf-8"); + // Publish complete contents: an in-place write can briefly expose an + // empty file, which is a different snapshot and a valid invalidation. + await fs.promises.rename(temporaryFile, file); + }; + const changes: string[] = []; const stop = watchFiles( [file], @@ -672,13 +680,13 @@ describe("watchFiles", () => { ); try { - await fs.promises.writeFile(file, "test", "utf-8"); + await replaceContents("test"); await new Promise((resolve) => setTimeout(resolve, 250)); expect(changes).toEqual([]); - await fs.promises.writeFile(file, "changed", "utf-8"); + await replaceContents("changed"); await waitForChange(changes, file); - await fs.promises.writeFile(file, "changed", "utf-8"); + await replaceContents("changed"); await new Promise((resolve) => setTimeout(resolve, 250)); expect(changes).toEqual([file]); } finally { diff --git a/packages/ev/tests/module-references.test.ts b/packages/ev/tests/module-references.test.ts new file mode 100644 index 00000000..f81fadde --- /dev/null +++ b/packages/ev/tests/module-references.test.ts @@ -0,0 +1,141 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ClientModuleReference } from "@evjs/shared/manifest"; +import { afterEach, describe, expect, it } from "vitest"; +import { normalizeClientModuleReference } from "../src/_internal/build/generated-ir/module-references.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const cwd = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), "ev-module-reference-")), + ); + roots.push(cwd); + await fs.mkdir(path.join(cwd, "src")); + await fs.writeFile( + path.join(cwd, "src/greeting.ts"), + 'throw new Error("must not execute during planning");', + ); + const input: ClientModuleReference & { id: string } = { + id: "greeting-module", + resource: "./src/greeting.ts", + references: [ + { + id: "greeting", + factory: { module: "./src/runtime.ts", export: "createReference" }, + data: { endpoint: "/greetings" }, + }, + ], + exports: [ + { name: "Greeting", reference: "greeting" }, + { name: "default", reference: "greeting" }, + ], + }; + return { cwd, input }; +} + +describe("client module reference normalization", () => { + it("anchors source and runtime paths without evaluating source; snapshots opaque data and export aliases", async () => { + const { cwd, input } = await fixture(); + const plan = normalizeClientModuleReference( + cwd, + "reference-fixture", + input, + ); + expect(plan.resource).toBe(path.join(cwd, "src/greeting.ts")); + expect(plan.references[0].factory.module).toBe( + path.join(cwd, "src/runtime.ts"), + ); + expect(plan.exports).toEqual(input.exports); + input.references[0].data = { endpoint: "/changed" }; + input.exports[0].name = "Changed"; + expect(plan.references[0].data).toEqual({ endpoint: "/greetings" }); + expect(plan.exports[0].name).toBe("Greeting"); + }); + + it("resolves source symlinks to the same canonical resource", async () => { + const { cwd, input } = await fixture(); + await fs.symlink("greeting.ts", path.join(cwd, "src/alias.ts")); + const original = normalizeClientModuleReference(cwd, "fixture", input); + input.resource = "src/alias.ts"; + expect(normalizeClientModuleReference(cwd, "fixture", input).resource).toBe( + original.resource, + ); + }); + + it("rejects a symlink that escapes the application", async () => { + const { cwd, input } = await fixture(); + const other = await fixture(); + await fs.symlink( + path.join(other.cwd, "src/greeting.ts"), + path.join(cwd, "src/outside.ts"), + ); + input.resource = "src/outside.ts"; + expect(() => normalizeClientModuleReference(cwd, "fixture", input)).toThrow( + "inside the application root", + ); + }); + + it("reports malformed reference and export records during planning", async () => { + const { cwd, input } = await fixture(); + const nullReference = JSON.parse(JSON.stringify(input)); + nullReference.references = [null]; + expect(() => + normalizeClientModuleReference(cwd, "fixture", nullReference), + ).toThrow("requires reference objects"); + const nullExport = JSON.parse(JSON.stringify(input)); + nullExport.exports = [null]; + expect(() => + normalizeClientModuleReference(cwd, "fixture", nullExport), + ).toThrow("requires export objects"); + }); + + it.each([ + [ + "duplicate reference", + (input: ClientModuleReference) => + input.references.push(input.references[0]), + "duplicate reference", + ], + [ + "duplicate export", + (input: ClientModuleReference) => input.exports.push(input.exports[0]), + "duplicate export", + ], + [ + "unknown reference", + (input: ClientModuleReference) => { + input.exports[0].reference = "missing"; + }, + "unknown reference", + ], + [ + "invalid factory", + (input: ClientModuleReference) => { + input.references[0].factory.export = "not an identifier"; + }, + "factory export", + ], + [ + "non-JSON data", + (input: ClientModuleReference) => { + input.references[0].data = Number.NaN; + }, + "finite numbers", + ], + ] as const)("rejects %s", async (_name, mutate, message) => { + const { cwd, input } = await fixture(); + mutate(input); + expect(() => normalizeClientModuleReference(cwd, "fixture", input)).toThrow( + message, + ); + }); +}); diff --git a/packages/plugin-qiankun/tests/plugin.test.ts b/packages/plugin-qiankun/tests/plugin.test.ts index d60f41e5..923db201 100644 --- a/packages/plugin-qiankun/tests/plugin.test.ts +++ b/packages/plugin-qiankun/tests/plugin.test.ts @@ -571,6 +571,9 @@ function createContributionCapture( const entryFacades: Parameters[0][] = []; const refs = new Map(); const emit: EmitApi = { + moduleReference() { + throw new Error("Unexpected module reference in qiankun test"); + }, module(input) { const ref = { id: input.id } as unknown as GeneratedModuleRef; refs.set(ref, input.id); diff --git a/packages/shared/src/manifest/contracts.ts b/packages/shared/src/manifest/contracts.ts index c82de16f..164ec44f 100644 --- a/packages/shared/src/manifest/contracts.ts +++ b/packages/shared/src/manifest/contracts.ts @@ -43,6 +43,7 @@ import { getUrlStringValidationError, type UrlStringValidationError, } from "../urls/validation.js"; +import type { ClientModuleReferencePlan } from "./module-references.js"; import { assertBuildOutputServerArtifacts } from "./output/server-artifacts.js"; import { assertPageMetadata, type PageMetadata } from "./page/metadata.js"; @@ -140,6 +141,8 @@ export interface BuildPlan { }; resolve?: ResolvePlan; generated?: GeneratedFrameworkPlan; + /** Browser-only source replacements. Server compilation retains the source. */ + clientModuleReferences?: ClientModuleReferencePlan[]; entries: BuildEntry[]; html: HtmlPlan[]; server: ServerBuildPlan; diff --git a/packages/shared/src/manifest/index.ts b/packages/shared/src/manifest/index.ts index 7ae9dffe..5851cf79 100644 --- a/packages/shared/src/manifest/index.ts +++ b/packages/shared/src/manifest/index.ts @@ -147,6 +147,10 @@ export { coreRoutePatternsEqual, isCoreRoutePatternPrefix, } from "./graph/route-pattern.js"; +export type { + ClientModuleReference, + ClientModuleReferencePlan, +} from "./module-references.js"; export { assertPortableRelativeArtifactPath, assertPortableRelativeBrowserArtifactPath, diff --git a/packages/shared/src/manifest/module-references.ts b/packages/shared/src/manifest/module-references.ts new file mode 100644 index 00000000..3830b475 --- /dev/null +++ b/packages/shared/src/manifest/module-references.ts @@ -0,0 +1,25 @@ +import type { StaticJsonValue } from "../serialization/static-json.js"; + +/** A browser replacement for one exact source module, supplied by a plugin. */ +export interface ClientModuleReference { + /** Source filename, resolved from the application root. No glob matching. */ + resource: string; + references: Array<{ + /** Module-local identity. Multiple exports may share one reference. */ + id: string; + factory: { + /** Browser runtime: app-root-relative or absolute filename, or package specifier. */ + module: string; + export: string; + }; + /** Only data intended for the browser may be supplied here. */ + data: StaticJsonValue; + }>; + exports: Array<{ name: string; reference: string }>; +} + +/** Normalized build input; never a deployment or browser manifest. */ +export interface ClientModuleReferencePlan extends ClientModuleReference { + id: string; + pluginId: string; +}