diff --git a/packages/bundler-utoopack/src/adapter/config/module-references.ts b/packages/bundler-utoopack/src/adapter/config/module-references.ts index 526deace..2d0a59fc 100644 --- a/packages/bundler-utoopack/src/adapter/config/module-references.ts +++ b/packages/bundler-utoopack/src/adapter/config/module-references.ts @@ -2,20 +2,43 @@ import { isDeepStrictEqual } from "node:util"; import type { BuildPlan, ClientModuleReference } from "@evjs/shared/manifest"; import type { ConfigComplete } from "@utoo/pack"; -// Kept structural until the native capability is available in a published -// @utoo/pack release. Execution must also verify moduleReferencesVersion. +type MutableReferenceData = + | null + | boolean + | number + | string + | MutableReferenceData[] + | { [key: string]: MutableReferenceData }; + +type UtoopackModuleReference = Omit & { + references: Array< + Omit & { + data: MutableReferenceData; + } + >; +}; + +// Keep framework ownership independent of the installed native package version. +// Execution must also verify moduleReferencesVersion. export type UtoopackModuleReferenceConfig = ConfigComplete & { module?: NonNullable & { - references?: ClientModuleReference[]; + references?: UtoopackModuleReference[]; }; }; export function createUtoopackModuleReferences( plan: BuildPlan, -): ClientModuleReference[] | undefined { +): UtoopackModuleReference[] | undefined { if (!plan.clientModuleReferences?.length) return undefined; - return plan.clientModuleReferences.map(({ resource, references, exports }) => - structuredClone({ resource, references, exports }), + 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 UtoopackModuleReference, ); } diff --git a/packages/bundler-utoopack/tests/create-config.test.ts b/packages/bundler-utoopack/tests/create-config.test.ts index 4ed95735..e9378d62 100644 --- a/packages/bundler-utoopack/tests/create-config.test.ts +++ b/packages/bundler-utoopack/tests/create-config.test.ts @@ -103,6 +103,39 @@ describe("createUtoopackConfig", () => { ).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/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/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; +});