diff --git a/src/rendering/prepared-module-loader.test.ts b/src/rendering/prepared-module-loader.test.ts new file mode 100644 index 0000000000..e3e3b6f7ad --- /dev/null +++ b/src/rendering/prepared-module-loader.test.ts @@ -0,0 +1,285 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { + IMPORT_RESOLUTION_ERROR, + INVALID_ARGUMENT, + MODULE_NOT_FOUND, + VeryfrontError, +} from "#veryfront/errors"; +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { join, resolve } from "#veryfront/compat/path"; +import { getRuntimeModuleLoader } from "#veryfront/platform/adapters/module-loader.ts"; +import type { RuntimeAdapter, RuntimeModuleReference } from "#veryfront/platform/adapters/base.ts"; +import { resolveRenderGenerationIdentity } from "./render-generation-binding.ts"; +import { + createPreparedRenderModuleLoader, + type PreparedRenderModuleLoaderOptions, +} from "./prepared-module-loader.ts"; + +const projectDir = resolve("/prepared-project"); +const binding = () => ({ + projectId: "project", + environmentId: "preview", + sourceSnapshotId: "source-one", + configurationId: "configuration-one", + dependencySnapshotId: "dependencies-one", + artifactId: "artifacts-one", + frameworkId: "framework-one", + runtimeId: "runtime-one", + executionPolicyId: "policy-one", +}); +const options = (): PreparedRenderModuleLoaderOptions => ({ + binding: binding(), + projectDir, + maxEntries: 8, + sources: {}, + packages: {}, +}); + +describe("prepared render module loader", () => { + it("uses the existing adapter capability and generation identity without eager imports", async () => { + let imports = 0; + const module = { default: "prepared page" }; + const loader = await createPreparedRenderModuleLoader({ + ...options(), + sources: { + "app/page.tsx": async () => { + imports++; + return module; + }, + }, + }); + assertEquals(loader.identity, await resolveRenderGenerationIdentity(binding())); + assertEquals(Object.isFrozen(loader), true); + assertEquals(imports, 0); + const captured = getRuntimeModuleLoader({ moduleLoader: loader } as unknown as RuntimeAdapter)!; + assertStrictEquals( + await captured.importModule({ kind: "source", path: join(projectDir, "app/page.tsx") }), + module, + ); + assertEquals(imports, 1); + }); + + it("keeps source and package names separate", async () => { + const loader = await createPreparedRenderModuleLoader({ + ...options(), + sources: { react: async () => ({ kind: "source" }) }, + packages: { react: async () => ({ kind: "package" }) }, + }); + assertEquals(await loader.importModule({ kind: "source", path: join(projectDir, "react") }), { + kind: "source", + }); + assertEquals(await loader.importModule({ kind: "package", specifier: "react" }), { + kind: "package", + }); + }); + + it("captures binding, root and callback tables before yielding", async () => { + const input = { + ...options(), + sources: { "app/page.tsx": async () => ({ value: "original" }) }, + }; + const pending = createPreparedRenderModuleLoader(input); + input.sources["app/page.tsx"] = async () => ({ value: "replaced" }); + input.projectDir = resolve("/other-project"); + Object.assign(input.binding, { artifactId: "mutated" }); + input.binding = { ...binding(), artifactId: "other" }; + const loader = await pending; + assertEquals(loader.identity, await resolveRenderGenerationIdentity(binding())); + assertEquals( + await loader.importModule({ kind: "source", path: join(projectDir, "app/page.tsx") }), + { value: "original" }, + ); + }); + + it("rejects absent imports without invoking another callback", async () => { + let imports = 0; + const loader = await createPreparedRenderModuleLoader({ + ...options(), + packages: { + react: async () => { + imports++; + return {}; + }, + }, + }); + for ( + const reference of [ + { kind: "package", specifier: "missing" }, + { kind: "package", specifier: "toString" }, + { kind: "source", path: join(projectDir, "missing.ts") }, + ] as const + ) { + const error = await assertRejects(() => loader.importModule(reference), VeryfrontError); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, MODULE_NOT_FOUND.slug); + } + assertEquals(imports, 0); + }); + + it("rejects escaped and malformed references", async () => { + const loader = await createPreparedRenderModuleLoader(options()); + for ( + const reference of [ + { kind: "source", path: join(projectDir, "../other/page.tsx") }, + { kind: "source", path: "../page.tsx" }, + { kind: "source", path: "app//page.tsx" }, + { kind: "source", path: "app/page.tsx\u0000" }, + { kind: "package", specifier: "" }, + { kind: "package", specifier: "x".repeat(4097) }, + { kind: "unknown", path: "app/page.tsx" }, + null, + ] + ) { + const error = await assertRejects( + () => loader.importModule(reference as RuntimeModuleReference), + VeryfrontError, + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, INVALID_ARGUMENT.slug); + } + }); + + it("bounds the combined table size and rejects noncanonical source names", async () => { + const invalidOptions: PreparedRenderModuleLoaderOptions[] = [ + { ...options(), maxEntries: 0 }, + { ...options(), maxEntries: 1.5 }, + { + ...options(), + maxEntries: 1, + sources: { page: async () => ({}) }, + packages: { react: async () => ({}) }, + }, + { ...options(), projectDir: "relative" }, + { ...options(), sources: { "../page.tsx": async () => ({}) } }, + { ...options(), sources: { "app//page.tsx": async () => ({}) } }, + ]; + for (const input of invalidOptions) { + await assertRejects(() => createPreparedRenderModuleLoader(input), VeryfrontError); + } + await createPreparedRenderModuleLoader({ + ...options(), + maxEntries: 1, + sources: { page: async () => ({}) }, + }); + }); + + it("rejects option, table, callback and reference hooks without running them", async () => { + let hooks = 0; + const trap = () => { + hooks++; + throw new Error("Unexpected hook"); + }; + const sources = Object.defineProperty({}, "page.ts", { enumerable: true, get: trap }); + for ( + const input of [ + new Proxy(options(), { get: trap, getOwnPropertyDescriptor: trap }), + { + ...options(), + get sources() { + return trap(); + }, + }, + { ...options(), sources }, + { ...options(), sources: new Proxy({}, { ownKeys: trap }) }, + { ...options(), sources: { page: new Proxy(async () => ({}), { apply: trap }) } }, + ] + ) await assertRejects(() => createPreparedRenderModuleLoader(input), VeryfrontError); + const loader = await createPreparedRenderModuleLoader(options()); + await assertRejects( + () => + loader.importModule( + new Proxy({ kind: "package", specifier: "react" }, { + get: trap, + }) as RuntimeModuleReference, + ), + VeryfrontError, + ); + assertEquals(hooks, 0); + }); + + it("retains the exact importer result and failure without a second resolver", async () => { + const failure = new Error("Synthetic import failure"); + let calls = 0; + const loader = await createPreparedRenderModuleLoader({ + ...options(), + packages: { + broken: async () => { + calls++; + throw failure; + }, + }, + }); + assertStrictEquals( + await assertRejects(() => loader.importModule({ kind: "package", specifier: "broken" })), + failure, + ); + assertEquals(calls, 1); + }); + + it("rejects missing options and invalid table entries before any import", async () => { + // A malformed inferred function name crashes Deno's coverage serializer. + const preparedImport = async () => ({}); + const invalidOptions = [ + Object.create(options()), + { ...options(), sources: null }, + { ...options(), packages: { react: undefined } }, + { ...options(), packages: { "": async () => ({}) } }, + { ...options(), sources: { [Symbol("unexpected")]: async () => ({}) } }, + { ...options(), sources: { "\ud800": preparedImport } }, + { ...options(), binding: { ...binding(), artifactId: "" } }, + ]; + for (const input of invalidOptions) { + await assertRejects( + () => createPreparedRenderModuleLoader(input as PreparedRenderModuleLoaderOptions), + VeryfrontError, + ); + } + }); + + it("rejects a prepared callback that returns no namespace", async () => { + const loader = await createPreparedRenderModuleLoader({ + ...options(), + packages: { empty: async () => null as unknown as Record }, + }); + const error = await assertRejects( + () => loader.importModule({ kind: "package", specifier: "empty" }), + VeryfrontError, + ); + assert(error instanceof VeryfrontError); + assertEquals(error.slug, IMPORT_RESOLUTION_ERROR.slug); + }); + + it("uses replica-local roots with the same logical binding and separate imported modules", async () => { + const firstModule = { value: "page" }; + const secondModule = { value: "page" }; + const first = await createPreparedRenderModuleLoader({ + ...options(), + sources: { page: async () => firstModule }, + }); + const otherRoot = resolve("/other-replica"); + const second = await createPreparedRenderModuleLoader({ + ...options(), + projectDir: otherRoot, + sources: { page: async () => secondModule }, + }); + assertEquals(first.identity, second.identity); + assertStrictEquals( + await first.importModule({ kind: "source", path: join(projectDir, "page") }), + firstModule, + ); + assertStrictEquals( + await second.importModule({ kind: "source", path: join(otherRoot, "page") }), + secondModule, + ); + await assertRejects( + () => second.importModule({ kind: "source", path: join(projectDir, "page") }), + VeryfrontError, + ); + }); +}); diff --git a/src/rendering/prepared-module-loader.ts b/src/rendering/prepared-module-loader.ts new file mode 100644 index 0000000000..c55a082b31 --- /dev/null +++ b/src/rendering/prepared-module-loader.ts @@ -0,0 +1,164 @@ +import { IMPORT_RESOLUTION_ERROR, INVALID_ARGUMENT, MODULE_NOT_FOUND } from "#veryfront/errors"; +import type { + RuntimeModuleLoader, + RuntimeModuleReference, +} from "#veryfront/platform/adapters/base.ts"; +import { + canIdentifyProxyWithoutHooks, + isProxyWithoutHooks, +} from "#veryfront/platform/compat/error-introspection.ts"; +import { isAbsolute, resolve } from "#veryfront/compat/path"; +import { + assertBoundedPathString, + assertCanonicalProjectRelativePath, + toCanonicalProjectRelativePath, +} from "#veryfront/utils/project-relative-path.ts"; +import { isWellFormedString } from "#veryfront/utils/is-well-formed-string.ts"; +import type { WorkerGenerationIdentity } from "#veryfront/security/sandbox/worker-generation.ts"; +import { + type RenderGenerationBinding, + resolveRenderGenerationIdentity, +} from "#veryfront/rendering/render-generation-binding.ts"; + +type PreparedImport = () => Promise>; +type DataRecord = Record; + +export interface PreparedRenderModuleLoaderOptions { + readonly binding: RenderGenerationBinding; + /** Replica-local root of the matching immutable source filesystem view. */ + readonly projectDir: string; + /** Compiler-generated imports indexed by canonical project-relative source paths. */ + readonly sources: Readonly>; + /** Compiler-generated imports indexed by exact package specifiers. */ + readonly packages: Readonly>; + /** Combined source/package entry budget. The caller must bound source and artifact bytes. */ + readonly maxEntries: number; +} + +/** One executor-owned import capability, never rebound to a different generation. */ +export interface PreparedRenderModuleLoader extends RuntimeModuleLoader { + readonly identity: Readonly; +} + +const apply = Reflect.apply; +const freeze = Object.freeze; +const create = Object.create; +const defineProperty = Object.defineProperty; +const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ownKeys = Reflect.ownKeys; +const hasOwn = Object.hasOwn; +const isSafeInteger = Number.isSafeInteger; + +function invalidInput() { + return INVALID_ARGUMENT.create({ detail: "Prepared render imports require valid bounded data" }); +} + +function requireRecord(value: unknown): DataRecord { + if ( + !canIdentifyProxyWithoutHooks || value === null || typeof value !== "object" || + isProxyWithoutHooks(value) + ) throw invalidInput(); + return value as DataRecord; +} + +function dataProperty(value: DataRecord, key: string): unknown { + const descriptor = getOwnPropertyDescriptor(value, key); + if (!descriptor || !hasOwn(descriptor, "value")) throw invalidInput(); + return descriptor.value; +} + +function specifier(value: unknown): string { + try { + const text = assertBoundedPathString(value); + if (!isWellFormedString(text)) throw invalidInput(); + return text; + } catch { + throw invalidInput(); + } +} + +/** + * Capture a prepared source/package import table before serving one generation. + * All inputs are captured before hashing yields; creation never imports a module. + * Unknown references fail without filesystem, cache, package, or network fallback. + * Native imports own module caching; this capability adds no request queue or cache. + * + * The trusted bootstrap must verify the compiler-generated table against the + * binding's artifact identity and retain its matching immutable filesystem view. + * This function captures callbacks, not source bytes, and does not establish + * authority, verify artifact contents, or sandbox their execution. Install it once + * in a dedicated execution realm; stop that executor before releasing artifacts. + */ +export async function createPreparedRenderModuleLoader( + options: PreparedRenderModuleLoaderOptions, +): Promise { + const input = requireRecord(options); + const binding = dataProperty(input, "binding") as RenderGenerationBinding; + const root = specifier(dataProperty(input, "projectDir")); + if (!isAbsolute(root)) throw invalidInput(); + const projectDir = resolve(root); + const maxEntries = dataProperty(input, "maxEntries"); + if (typeof maxEntries !== "number" || !isSafeInteger(maxEntries) || maxEntries < 1) { + throw invalidInput(); + } + const entryLimit = maxEntries; + let entryCount = 0; + function capture(value: unknown, source: boolean): Readonly> { + const table = requireRecord(value); + const keys = ownKeys(table); + entryCount += keys.length; + if (entryCount > entryLimit) throw invalidInput(); + const captured = create(null) as Record; + for (let index = 0; index < keys.length; index++) { + const key = specifier(keys[index]); + if (source) { + try { + assertCanonicalProjectRelativePath(key); + } catch { + throw invalidInput(); + } + } + const load = dataProperty(table, key); + if (typeof load !== "function" || isProxyWithoutHooks(load)) throw invalidInput(); + const descriptor = create(null) as PropertyDescriptor; + descriptor.value = load; + descriptor.enumerable = true; + defineProperty(captured, key, descriptor); + } + return freeze(captured); + } + const sources = capture(dataProperty(input, "sources"), true); + const packages = capture(dataProperty(input, "packages"), false); + const identity = await resolveRenderGenerationIdentity(binding); + return freeze({ + identity, + async importModule(reference: RuntimeModuleReference): Promise> { + const value = requireRecord(reference); + const kind = dataProperty(value, "kind"); + let key: string; + let table: Readonly>; + if (kind === "source") { + try { + key = toCanonicalProjectRelativePath(projectDir, specifier(dataProperty(value, "path"))); + } catch { + throw invalidInput(); + } + table = sources; + } else if (kind === "package") { + key = specifier(dataProperty(value, "specifier")); + table = packages; + } else throw invalidInput(); + const load = table[key]; + if (!load) { + throw MODULE_NOT_FOUND.create({ detail: "Module was not prepared for this generation" }); + } + const module: unknown = await apply(load, undefined, []); + if (module === null || typeof module !== "object") { + throw IMPORT_RESOLUTION_ERROR.create({ + detail: "Prepared import did not return a module namespace", + }); + } + return module as Record; + }, + }); +} diff --git a/tests/integration/renderer/fixtures/generation-page-executor.ts b/tests/integration/renderer/fixtures/generation-page-executor.ts index d040e58d43..b2bcb14b8b 100644 --- a/tests/integration/renderer/fixtures/generation-page-executor.ts +++ b/tests/integration/renderer/fixtures/generation-page-executor.ts @@ -12,27 +12,37 @@ import { EsbuildBundler, EsModuleLexer } from "@veryfront/ext-bundler-esbuild"; import { TailwindCSSProcessor } from "@veryfront/ext-css-tailwind"; import { register } from "#veryfront/extensions/contracts.ts"; import { installMockFetch } from "#veryfront/testing/mock-fetch.ts"; +import { createPreparedRenderModuleLoader } from "#veryfront/rendering/prepared-module-loader.ts"; -const [moduleUrl, coordinator, projectDir] = process.argv.slice(2); -if (!moduleUrl || !coordinator || !projectDir) throw new Error("Missing fixture arguments"); +const [moduleUrl, coordinator, projectDir, bindingData] = process.argv.slice(2); +if (!moduleUrl || !coordinator || !projectDir || !bindingData) { + throw new Error("Missing fixture arguments"); +} +const expected = JSON.parse(bindingData); const modules = await import(moduleUrl); const adapter = await runtime.get(); const imports = new AsyncLocalStorage(); +const loader = await createPreparedRenderModuleLoader({ + binding: expected.binding, + projectDir, + sources: modules.sources, + packages: modules.packages, + maxEntries: 64, +}); +if ( + loader.identity.scopeId !== expected.identity.scopeId || + loader.identity.generationId !== expected.identity.generationId +) throw new Error("Prepared loader generation did not match its host binding"); Object.defineProperty(adapter, "moduleLoader", { value: Object.freeze({ importModule: async (reference: RuntimeModuleReference) => { const key = reference.kind === "package" ? reference.specifier : relative(projectDir, reference.path).replaceAll("\\", "/"); - const table = reference.kind === "package" ? modules.packages : modules.sources; - const load = Object.getOwnPropertyDescriptor(table, key)?.value; - if (typeof load !== "function") { - throw new Error("Module was not prepared for this generation"); - } const requestImports = imports.getStore(); if (!requestImports) throw new Error("Prepared imports require a request scope"); requestImports.push(key); - return await load(); + return await loader.importModule(reference); }, }), }); diff --git a/tests/integration/renderer/render-generation.test.ts b/tests/integration/renderer/render-generation.test.ts index 43e8bccc96..20cde61def 100644 --- a/tests/integration/renderer/render-generation.test.ts +++ b/tests/integration/renderer/render-generation.test.ts @@ -21,6 +21,12 @@ import { withMockFetch } from "#veryfront/testing/mock-fetch.ts"; import { runWithCacheDir } from "#veryfront/utils/cache-dir.ts"; import { __setDistributedCacheAccessorForTests } from "#veryfront/transforms/esm/http-cache-wrapper.ts"; import { createRequire } from "node:module"; +import process from "node:process"; +import React from "react"; +import { computeHash } from "#veryfront/utils/hash-utils.ts"; +import { RUNTIME_VERSION } from "#veryfront/utils/version.ts"; +import { getDenoRuntime } from "#veryfront/platform/compat/runtime.ts"; +import { resolveRenderGenerationIdentity } from "#veryfront/rendering/render-generation-binding.ts"; import { jsonForInlineScript } from "#veryfront/security/client/html-sanitizer.ts"; import { MdxContentProcessor } from "@veryfront/ext-content-mdx"; import { register, tryResolve, unregister } from "#veryfront/extensions/contracts.ts"; @@ -344,13 +350,46 @@ export async function createPage() { ? ["run", "--no-config", "--allow-read", "--allow-net=127.0.0.1"] : []; const denoDir = isDeno ? getEnv("DENO_DIR") : undefined; + let bindingData: string | undefined; + if (pipeline) { + // Only these test-owned files form this fixture's immutable source view. + const sourceFiles = await Promise.all( + ["app/page/page.mdx", "app/layout.tsx", "app/page/child.tsx", "app/page/layout.mdx"] + .map( + async (path) => [path, await fs.readTextFile(join(project, path))], + ), + ); + const binding = { + projectId: "generation-test", + environmentId: "production", + sourceSnapshotId: await computeHash(JSON.stringify(sourceFiles)), + configurationId: await computeHash( + JSON.stringify({ react: { version: React.version } }), + ), + dependencySnapshotId: "off", + artifactId: prepared.id, + frameworkId: RUNTIME_VERSION, + runtimeId: `${adapter.id}:${ + isDeno + ? getDenoRuntime()!.version.deno + : isBun + ? process.versions.bun + : process.versions.node + }`, + executionPolicyId: "fixture-dedicated-process-v1", + }; + bindingData = JSON.stringify({ + binding, + identity: await resolveRenderGenerationIdentity(binding), + }); + } processResult = runCommand(execPath(), { args: [ ...runtimeArgs, fixture, prepared.entrypointUrls[0]!, `http://127.0.0.1:${coordinator.addr.port}`, - ...(pipeline ? [project] : []), + ...(pipeline ? [project, bindingData!] : []), ], clearEnv: true, env: {