Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions packages/bundler-utoopack/src/adapter/config/module-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientModuleReference, "references"> & {
references: Array<
Omit<ClientModuleReference["references"][number], "data"> & {
data: MutableReferenceData;
}
>;
};

// Keep framework ownership independent of the installed native package version.
// Execution must also verify moduleReferencesVersion.
export type UtoopackModuleReferenceConfig = ConfigComplete & {
module?: NonNullable<ConfigComplete["module"]> & {
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,
);
}

Expand Down
33 changes: 33 additions & 0 deletions packages/bundler-utoopack/tests/create-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
19 changes: 10 additions & 9 deletions packages/ev/src/_internal/generated/client/api.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
});
136 changes: 129 additions & 7 deletions packages/ev/tests/api-client-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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<Response> }>(
path.join(cwd, "consumer.ts"),
);
const consumer = await jiti.import<{
request: Promise<Response>;
apiRuntimeScope: Readonly<ApiRuntimeScope>;
}>(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(
Expand All @@ -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"), '<main id="app"></main>');
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<typeof globalThis.fetch>()
.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<ApiRuntimeScope>[] = [];
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<ApiRuntimeScope>;
api: { get(path: string): Promise<Response> };
};
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<Readonly<ApiRuntimeScope>>();
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;
});