diff --git a/packages/vite-plugin-cloudflare/playground/build-output/__tests__/build-output.spec.ts b/packages/vite-plugin-cloudflare/playground/build-output/__tests__/build-output.spec.ts index 6ffa6fe1cae..ac9443331fc 100644 --- a/packages/vite-plugin-cloudflare/playground/build-output/__tests__/build-output.spec.ts +++ b/packages/vite-plugin-cloudflare/playground/build-output/__tests__/build-output.spec.ts @@ -22,6 +22,11 @@ describe("Build Output Specification", () => { expect(response).toBe("hello from text binding"); }); + test("serves the additional module", async ({ expect }) => { + const response = await getTextResponse("/additional-module"); + expect(response).toBe("hello from additional module\n"); + }); + test("serves static assets", async ({ expect }) => { const response = await getTextResponse("/static.txt"); expect(response.trim()).toBe("static asset"); @@ -44,6 +49,7 @@ describe.runIf(isBuild)("Build Output Specification files", () => { "bundle", config.manifest.mainModule ); + expect(config.manifest.mainModule).toMatch(/^chunks\/index-[\w-]+\.mjs$/); expect(fs.existsSync(entryPath)).toBe(true); }); @@ -63,7 +69,7 @@ describe.runIf(isBuild)("Build Output Specification files", () => { expect(config).not.toHaveProperty("entrypoint"); expect(typeof config.manifest).toBe("object"); const manifest = config.manifest as Record; - expect(manifest.type).toBe("complete"); + expect(manifest.type).toBe("partial"); expect(typeof manifest.mainModule).toBe("string"); expect(typeof manifest.modules).toBe("object"); }); @@ -83,7 +89,7 @@ describe.runIf(isBuild)("Build Output Specification files", () => { } }); - test("includes source maps in `manifest.modules` with type `sourcemap`", ({ + test("only includes explicitly typed additional modules in the manifest", ({ expect, }) => { const configPath = path.join(getBuildOutputDir(), "config.json"); @@ -93,10 +99,19 @@ describe.runIf(isBuild)("Build Output Specification files", () => { modules: Record; }; }; + const additionalModule = Object.entries(config.manifest.modules).find( + ([moduleName]) => + moduleName.includes("additional-module-") && moduleName.endsWith(".txt") + ); + expect(additionalModule).toBeDefined(); + expect(additionalModule?.[1]).toEqual({ type: "text" }); + expect(config.manifest.modules).not.toHaveProperty( + config.manifest.mainModule + ); + expect(Object.keys(config.manifest.modules)).toHaveLength(1); + const sourceMapName = `${config.manifest.mainModule}.map`; - expect(config.manifest.modules[sourceMapName]).toEqual({ - type: "sourcemap", - }); + expect(config.manifest.modules).not.toHaveProperty(sourceMapName); const sourceMapPath = path.join( getBuildOutputDir(), "bundle", diff --git a/packages/vite-plugin-cloudflare/playground/build-output/src/additional-module.txt b/packages/vite-plugin-cloudflare/playground/build-output/src/additional-module.txt new file mode 100644 index 00000000000..6521f75713c --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/build-output/src/additional-module.txt @@ -0,0 +1 @@ +hello from additional module diff --git a/packages/vite-plugin-cloudflare/playground/build-output/src/index.ts b/packages/vite-plugin-cloudflare/playground/build-output/src/index.ts index e2489650d7c..1d90e7f769f 100644 --- a/packages/vite-plugin-cloudflare/playground/build-output/src/index.ts +++ b/packages/vite-plugin-cloudflare/playground/build-output/src/index.ts @@ -1,4 +1,5 @@ import { env } from "cloudflare:workers"; +import additionalModule from "./additional-module.txt"; export default { fetch(request) { @@ -6,6 +7,9 @@ export default { if (url.pathname === "/text-binding") { return new Response(env.MY_TEXT); } + if (url.pathname === "/additional-module") { + return new Response(additionalModule); + } return new Response("hello from worker"); }, } satisfies ExportedHandler; diff --git a/packages/vite-plugin-cloudflare/playground/build-output/src/types.d.ts b/packages/vite-plugin-cloudflare/playground/build-output/src/types.d.ts new file mode 100644 index 00000000000..138c23e59c3 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/build-output/src/types.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const text: string; + export default text; +} diff --git a/packages/vite-plugin-cloudflare/playground/build-output/vite.config.ts b/packages/vite-plugin-cloudflare/playground/build-output/vite.config.ts index 499040d5d8f..d6a0f30ebe2 100644 --- a/packages/vite-plugin-cloudflare/playground/build-output/vite.config.ts +++ b/packages/vite-plugin-cloudflare/playground/build-output/vite.config.ts @@ -5,6 +5,11 @@ export default defineConfig({ environments: { ssr: { build: { + rollupOptions: { + output: { + entryFileNames: "chunks/[name]-[hash].mjs", + }, + }, sourcemap: true, }, }, diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts b/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts index 6037ad75d9a..038b91f83b9 100644 --- a/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts +++ b/packages/vite-plugin-cloudflare/playground/child-environment/__tests__/child-environment.spec.ts @@ -1,10 +1,14 @@ import { test } from "vitest"; -import { getTextResponse, isBuild } from "../../__test-utils__"; +import { getTextResponse } from "../../__test-utils__"; -test.runIf(!isBuild)( - "can import module from child environment", - async ({ expect }) => { - const response = await getTextResponse(); - expect(response).toBe("Hello from the child environment"); - } -); +test("can import module from child environment", async ({ expect }) => { + const response = await getTextResponse(); + expect(response).toBe("Hello from the child environment"); +}); + +test("can import additional module from child environment", async ({ + expect, +}) => { + const response = await getTextResponse("/additional-module"); + expect(response).toBe("Hello from a child environment additional module\n"); +}); diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/additional-module.txt b/packages/vite-plugin-cloudflare/playground/child-environment/src/additional-module.txt new file mode 100644 index 00000000000..e73a343fc90 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/additional-module.txt @@ -0,0 +1 @@ +Hello from a child environment additional module diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts b/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts index 8d520baac4e..228a682a363 100644 --- a/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/child-environment-module.ts @@ -1,5 +1,8 @@ // @ts-expect-error - no types import { getEnvironmentName } from "virtual:environment-name"; +import additionalModule from "./additional-module.txt"; + +export { additionalModule }; export function getMessage() { return `Hello from the ${getEnvironmentName()} environment`; diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts b/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts index 8d53086b3e7..733a7fb975a 100644 --- a/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/index.ts @@ -7,12 +7,23 @@ declare global { } export default { - async fetch() { - const childEnvironmentModule = (await __VITE_ENVIRONMENT_RUNNER_IMPORT__( - "child", - "./src/child-environment-module" - )) as { getMessage: () => string }; + async fetch(request) { + const childEnvironmentEntry = "./child/child-environment-module.js"; + const childEnvironmentModule = + (await (typeof __VITE_ENVIRONMENT_RUNNER_IMPORT__ === "function" + ? __VITE_ENVIRONMENT_RUNNER_IMPORT__( + "child", + "./src/child-environment-module" + ) + : import(/* @vite-ignore */ childEnvironmentEntry))) as { + additionalModule: string; + getMessage: () => string; + }; - return new Response(childEnvironmentModule.getMessage()); + return new Response( + new URL(request.url).pathname === "/additional-module" + ? childEnvironmentModule.additionalModule + : childEnvironmentModule.getMessage() + ); }, } satisfies ExportedHandler; diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/src/types.d.ts b/packages/vite-plugin-cloudflare/playground/child-environment/src/types.d.ts new file mode 100644 index 00000000000..138c23e59c3 --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/child-environment/src/types.d.ts @@ -0,0 +1,4 @@ +declare module "*.txt" { + const text: string; + export default text; +} diff --git a/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts b/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts index 29c4c58cc15..188861547ea 100644 --- a/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts +++ b/packages/vite-plugin-cloudflare/playground/child-environment/vite.config.ts @@ -1,7 +1,30 @@ +import assert from "node:assert"; +import * as path from "node:path"; import { cloudflare } from "@cloudflare/vite-plugin"; import { defineConfig } from "vite"; export default defineConfig({ + environments: { + child: { + build: { + rollupOptions: { + input: path.resolve(__dirname, "src/child-environment-module.ts"), + }, + }, + }, + }, + builder: { + async buildApp(builder) { + const parentEnvironment = builder.environments.parent; + const childEnvironment = builder.environments.child; + + assert(parentEnvironment, `No "parent" environment`); + assert(childEnvironment, `No "child" environment`); + + await builder.build(parentEnvironment); + await builder.build(childEnvironment); + }, + }, plugins: [ cloudflare({ types: { includeRuntime: false }, diff --git a/packages/vite-plugin-cloudflare/src/__tests__/build-output.spec.ts b/packages/vite-plugin-cloudflare/src/__tests__/build-output.spec.ts deleted file mode 100644 index 98069bb3fb8..00000000000 --- a/packages/vite-plugin-cloudflare/src/__tests__/build-output.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it } from "vitest"; -import { detectModuleType } from "../plugins/build-output"; - -describe("detectModuleType", () => { - const cases: Array<{ filename: string; expected: string }> = [ - { filename: "entry.js", expected: "esm" }, - { filename: "entry.mjs", expected: "esm" }, - { filename: "lib.wasm", expected: "wasm" }, - { filename: "raw.bin", expected: "data" }, - { filename: "greeting.txt", expected: "text" }, - { filename: "page.html", expected: "text" }, - { filename: "query.sql", expected: "text" }, - { filename: "data.json", expected: "json" }, - { filename: "bundle.js.map", expected: "sourcemap" }, - { filename: "unknown.xyz", expected: "data" }, - // Case-insensitive on extension - { filename: "ENTRY.JS", expected: "esm" }, - { filename: "LIB.WASM", expected: "wasm" }, - // No extension → default `data` - { filename: "LICENSE", expected: "data" }, - // Nested paths — only the extension matters - { filename: "chunks/foo.js", expected: "esm" }, - { filename: "chunks/foo.wasm", expected: "wasm" }, - ]; - - it.for(cases)( - "maps $filename → $expected", - ({ filename, expected }, { expect }) => { - expect(detectModuleType(filename)).toBe(expected); - } - ); -}); diff --git a/packages/vite-plugin-cloudflare/src/build.ts b/packages/vite-plugin-cloudflare/src/build.ts index 7706da8bef4..2c56a0061e7 100644 --- a/packages/vite-plugin-cloudflare/src/build.ts +++ b/packages/vite-plugin-cloudflare/src/build.ts @@ -172,7 +172,8 @@ async function fallbackBuild( fs.unlinkSync(fallbackEntryPath); } -function loadViteManifest(directory: string) { +/** Read the Vite manifest emitted for a completed environment build. */ +export function loadViteManifest(directory: string): vite.Manifest { const contents = fs.readFileSync( path.resolve(directory, ".vite", "manifest.json"), "utf-8" diff --git a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts index 6922dbcce1e..fedacb2da3a 100644 --- a/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts +++ b/packages/vite-plugin-cloudflare/src/cloudflare-environment.ts @@ -220,7 +220,6 @@ export function createCloudflareEnvironmentOptions({ userConfig, mode, environmentName, - isEntryWorker, isParentEnvironment, hasNodeJsCompat, }: { @@ -228,7 +227,6 @@ export function createCloudflareEnvironmentOptions({ userConfig: vite.UserConfig; mode: vite.ConfigEnv["mode"]; environmentName: string; - isEntryWorker: boolean; isParentEnvironment: boolean; hasNodeJsCompat: boolean; }): vite.EnvironmentOptions { @@ -278,7 +276,7 @@ export function createCloudflareEnvironmentOptions({ }, target, emitAssets: true, - manifest: isEntryWorker, + manifest: isParentEnvironment, outDir: getOutputDirectory(userConfig, environmentName), copyPublicDir: false, ssr: true, diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 3f590bb0f8c..04ff39c8eee 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -13,6 +13,7 @@ import type { WorkersResolvedConfig, } from "./plugin-config"; import type { + ModuleType, ParsedInputSettingsConfig, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, @@ -33,6 +34,13 @@ export interface SharedContext { tunnelHostnames: Set; } +/** An explicitly typed additional module emitted by a Vite environment. */ +export interface AdditionalModuleMetadata { + environmentName: string; + fileName: string; + type: ModuleType; +} + /** * Used to provide context to internal plugins. * It should be reinstantiated each time the main plugin is created. @@ -41,6 +49,10 @@ export class PluginContext { #sharedContext: SharedContext; #resolvedPluginConfig?: ResolvedPluginConfig; #resolvedViteConfig?: vite.ResolvedConfig; + #environmentNameToAdditionalModules = new Map< + string, + Map + >(); constructor(sharedContext: SharedContext) { this.#sharedContext = sharedContext; @@ -206,6 +218,53 @@ export class PluginContext { return this.#getWorker(environmentName)?.config; } + /** Clear the additional modules collected for an environment before rebuilding it. */ + clearAdditionalModules(environmentName: string): void { + this.#environmentNameToAdditionalModules.delete(environmentName); + } + + /** Record an explicitly typed additional module emitted by an environment. */ + addAdditionalModule( + environmentName: string, + fileName: string, + type: ModuleType + ): void { + const additionalModules = + this.#environmentNameToAdditionalModules.get(environmentName) ?? + new Map(); + const existingType = additionalModules.get(fileName); + assert( + existingType === undefined || existingType === type, + `Additional module "${fileName}" was emitted with conflicting types "${existingType}" and "${type}".` + ); + additionalModules.set(fileName, type); + this.#environmentNameToAdditionalModules.set( + environmentName, + additionalModules + ); + } + + /** Get the additional modules emitted by a Worker environment and its children. */ + getAdditionalModules( + workerEnvironmentName: string + ): AdditionalModuleMetadata[] { + const environmentNames = [ + workerEnvironmentName, + ...(this.resolvedPluginConfig.type === "preview" + ? [] + : (this.resolvedPluginConfig.environmentNameToChildEnvironmentNamesMap.get( + workerEnvironmentName + ) ?? [])), + ]; + + return environmentNames.flatMap((environmentName) => + Array.from( + this.#environmentNameToAdditionalModules.get(environmentName) ?? [], + ([fileName, type]) => ({ environmentName, fileName, type }) + ) + ); + } + get allWorkerConfigs(): Array< ParsedInputWorkerConfig | ParsedOutputWorkerConfig > { diff --git a/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts b/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts index d5d68eb2860..77bc42d55fa 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import MagicString from "magic-string"; import * as vite from "vite"; import { cleanUrl, createPlugin } from "../utils"; +import type { ModuleType } from "@cloudflare/config"; /** * Plugin to support additional module types (`CompiledWasm`, `Data` and `Text`) @@ -17,9 +18,13 @@ export const additionalModulesPlugin = createPlugin( // We set `enforce: "pre"` so that this plugin runs before the Vite core plugins. // Otherwise the `vite:wasm-fallback` plugin prevents the `.wasm` extension being used for module imports. enforce: "pre", + perEnvironmentStartEndDuringDev: true, applyToEnvironment(environment) { return ctx.getWorkerConfig(environment.name) !== undefined; }, + buildStart() { + ctx.clearAdditionalModules(this.environment.name); + }, resolveId: { filter: { id: moduleRuleFilters }, async handler(source, importer, options) { @@ -61,7 +66,12 @@ export const additionalModulesPlugin = createPlugin( for (const match of matches) { magicString ??= new MagicString(code); - const [full, _, modulePath] = match; + const [full, additionalModuleType, modulePath] = match; + + assert( + isAdditionalModuleType(additionalModuleType), + `Unexpected error: invalid additional module type in reference ${full}.` + ); assert( modulePath, @@ -86,6 +96,11 @@ export const additionalModulesPlugin = createPlugin( }); const emittedFileName = this.getFileName(referenceId); + ctx.addAdditionalModule( + this.environment.name, + emittedFileName, + toModuleType(additionalModuleType) + ); const relativePath = vite.normalizePath( path.relative(path.dirname(chunk.fileName), emittedFileName) ); @@ -117,6 +132,23 @@ export const additionalModulesPlugin = createPlugin( const ADDITIONAL_MODULE_TYPES = ["CompiledWasm", "Data", "Text"] as const; type AdditionalModuleType = (typeof ADDITIONAL_MODULE_TYPES)[number]; +function isAdditionalModuleType( + type: string | undefined +): type is AdditionalModuleType { + return ADDITIONAL_MODULE_TYPES.some((moduleType) => moduleType === type); +} + +function toModuleType(type: AdditionalModuleType): ModuleType { + switch (type) { + case "CompiledWasm": + return "wasm"; + case "Data": + return "data"; + case "Text": + return "text"; + } +} + const ADDITIONAL_MODULE_PATTERN = `__CLOUDFLARE_MODULE__(${ADDITIONAL_MODULE_TYPES.join("|")})__(.*?)__CLOUDFLARE_MODULE__`; export const additionalModuleRE = new RegExp(ADDITIONAL_MODULE_PATTERN); const additionalModuleGlobalRE = new RegExp(ADDITIONAL_MODULE_PATTERN, "g"); diff --git a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts index 50cc85188b9..aa4649382cd 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts @@ -1,93 +1,182 @@ import assert from "node:assert"; import * as path from "node:path"; import { - writeSettingsConfig, - writeWorkerConfig, + DEFAULT_WORKER_DIRECTORY_NAME, + getWorkerBundleDir, + writeSettingsConfig as writeBuildOutputSettingsConfig, + writeWorkerConfig as writeBuildOutputWorkerConfig, } from "@cloudflare/build-output-utils"; +import * as vite from "vite"; +import { loadViteManifest } from "../build"; import { MAIN_ENTRY_NAME } from "../cloudflare-environment"; import { assertIsNotPreview } from "../context"; import { resolveDevOnly } from "../plugin-config"; import { createPlugin } from "../utils"; +import type { AdditionalModuleMetadata } from "../context"; import type { ModuleType } from "@cloudflare/config"; -/** Emits Workers using the Build Output Specification. */ +/** Emits Workers using the Build Output Specification after every environment has built. */ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { return { - async writeBundle(_, bundle) { - assertIsNotPreview(ctx); + buildApp: { + order: "post", + async handler(builder) { + assertIsNotPreview(ctx); + await buildUnbuiltWorkerEnvironments(builder); - if (ctx.isChildEnvironment(this.environment.name)) { - return; - } - if ( - ctx.resolvedPluginConfig.type === "assets-only" && - this.environment.name === "client" - ) { - await writeWorkerConfig({ - root: ctx.resolvedViteConfig.root, - config: ctx.resolvedPluginConfig.config, - }); - await writeSettings(); - return; - } + if (ctx.resolvedPluginConfig.type === "assets-only") { + await writeBuildOutputWorkerConfig({ + root: builder.config.root, + config: ctx.resolvedPluginConfig.config, + }); + } + + for (const [environmentName, worker] of ctx.resolvedPluginConfig + .environmentNameToWorkerMap) { + if ( + resolveDevOnly(worker.devOnly) && + worker.directoryName !== DEFAULT_WORKER_DIRECTORY_NAME + ) { + continue; + } + + await writeWorkerConfig( + builder, + environmentName, + worker.directoryName + ); + } - const worker = ctx.resolvedPluginConfig.environmentNameToWorkerMap.get( - this.environment.name + await writeSettingsConfig(); + }, + }, + }; + + async function buildUnbuiltWorkerEnvironments( + builder: vite.ViteBuilder + ): Promise { + if (ctx.resolvedPluginConfig.type === "preview") { + return; + } + + const workerEnvironments = [ + ...ctx.resolvedPluginConfig.environmentNameToWorkerMap.entries(), + ] + .filter(([_, worker]) => !resolveDevOnly(worker.devOnly)) + .map(([environmentName]) => { + const environment = builder.environments[environmentName]; + assert(environment, `"${environmentName}" environment not found`); + + return environment; + }); + + await Promise.all( + workerEnvironments + .filter((environment) => !environment.isBuilt) + .map((environment) => builder.build(environment)) + ); + } + + async function writeWorkerConfig( + builder: vite.ViteBuilder, + environmentName: string, + workerDirectoryName: string + ): Promise { + const workerConfig = ctx.getWorkerNewConfig(environmentName); + assert( + workerConfig, + `No config found for "${environmentName}" environment` + ); + + const environment = builder.environments[environmentName]; + assert(environment, `"${environmentName}" environment not found`); + + if (!environment.isBuilt) { + assert( + workerDirectoryName === DEFAULT_WORKER_DIRECTORY_NAME, + `Expected "${environmentName}" environment to be built` ); - if (!worker || resolveDevOnly(worker.devOnly)) { - return; + const clientEnvironment = builder.environments.client; + assert(clientEnvironment, 'No "client" environment'); + if (!clientEnvironment.isBuilt) { + throw new Error( + "If `assetsOnly` is set to `true`, the client environment must be built" + ); } + await writeBuildOutputWorkerConfig({ + root: builder.config.root, + config: workerConfig, + workerDirectoryName, + }); + return; + } - const workerNewConfig = ctx.getWorkerNewConfig(this.environment.name); + const bundleDir = getWorkerBundleDir( + builder.config.root, + workerDirectoryName + ); + const entryChunk = Object.values(loadViteManifest(bundleDir)).find( + (chunk) => chunk.isEntry && chunk.name === MAIN_ENTRY_NAME + ); + assert(entryChunk, `Expected entry chunk with name "${MAIN_ENTRY_NAME}"`); - if (!workerNewConfig) { - return; - } + await writeBuildOutputWorkerConfig({ + root: builder.config.root, + config: workerConfig, + manifest: { + type: "partial", + mainModule: entryChunk.file, + modules: collectAdditionalModules(builder, environmentName, bundleDir), + }, + workerDirectoryName, + }); + } - const entryChunk = Object.values(bundle).find( - (chunk) => - chunk.type === "chunk" && - chunk.isEntry && - chunk.name === MAIN_ENTRY_NAME + function collectAdditionalModules( + builder: vite.ViteBuilder, + workerEnvironmentName: string, + bundleDir: string + ): Record { + const modules: Record = {}; + + for (const metadata of ctx.getAdditionalModules(workerEnvironmentName)) { + const modulePath = resolveModulePath(builder, bundleDir, metadata); + const existingModule = modules[modulePath]; + assert( + existingModule === undefined || existingModule.type === metadata.type, + `Additional module "${modulePath}" was emitted with conflicting types "${existingModule?.type}" and "${metadata.type}".` ); - assert(entryChunk, `Expected entry chunk with name "${MAIN_ENTRY_NAME}"`); + modules[modulePath] = { type: metadata.type }; + } - // Collect imported asset paths across all bundle entries - const importedAssetPaths = new Set(); - for (const entry of Object.values(bundle)) { - for (const asset of entry.viteMetadata?.importedAssets ?? []) { - importedAssetPaths.add(asset); - } - } + return modules; + } - const modules: Record = {}; - for (const fileName of Object.keys(bundle)) { - // Skip Vite's own manifest emitted via `build.manifest: true`. - if (fileName === ".vite/manifest.json") { - continue; - } - // Skip Vite-imported static assets — they will be moved out of - // `bundle/` into the client `assets/` directory by the - // asset move loop in `createBuildApp`. - if (importedAssetPaths.has(fileName)) { - continue; - } - modules[fileName] = { type: detectModuleType(fileName) }; - } + function resolveModulePath( + builder: vite.ViteBuilder, + bundleDir: string, + metadata: AdditionalModuleMetadata + ): string { + const environment = builder.environments[metadata.environmentName]; + assert( + environment, + `"${metadata.environmentName}" environment not found for additional module "${metadata.fileName}"` + ); + const environmentOutDir = path.resolve( + builder.config.root, + environment.config.build.outDir + ); + const modulePath = path.resolve(environmentOutDir, metadata.fileName); + const relativePath = path.relative(bundleDir, modulePath); + assert( + relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath), + `Additional module "${metadata.fileName}" from environment "${metadata.environmentName}" was emitted outside the Worker bundle directory.` + ); - await writeWorkerConfig({ - root: ctx.resolvedViteConfig.root, - config: workerNewConfig, - manifest: { - type: "complete", - mainModule: entryChunk.fileName, - modules, - }, - workerDirectoryName: worker.directoryName, - }); - await writeSettings(); - }, - }; + return vite.normalizePath(relativePath); + } /** * Write the top-level `config.json`, recording the settings shared by every @@ -96,41 +185,16 @@ export const buildOutputPlugin = createPlugin("build-output", (ctx) => { * Written even when there is no `settings` export, so the mode is always * captured. */ - async function writeSettings(): Promise { + async function writeSettingsConfig(): Promise { if (ctx.resolvedPluginConfig.type === "preview") { return; } const settings = ctx.resolvedPluginConfig.parsedConfig.settings; - await writeSettingsConfig( + await writeBuildOutputSettingsConfig( ctx.resolvedViteConfig.root, settings, ctx.resolvedViteConfig.mode ); } }); - -/** Map a bundle filename to its native module type. */ -export function detectModuleType(filename: string): ModuleType { - const ext = path.extname(filename).toLowerCase(); - - switch (ext) { - case ".js": - case ".mjs": - return "esm"; - case ".wasm": - return "wasm"; - case ".bin": - return "data"; - case ".txt": - case ".html": - case ".sql": - return "text"; - case ".json": - return "json"; - case ".map": - return "sourcemap"; - default: - return "data"; - } -} diff --git a/packages/vite-plugin-cloudflare/src/plugins/config.ts b/packages/vite-plugin-cloudflare/src/plugins/config.ts index 83a128e573e..74f2153d3d3 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/config.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/config.ts @@ -1,9 +1,8 @@ -import assert from "node:assert"; +import * as path from "node:path"; import { cleanBuildOutputDir, getWorkerAssetsDir, getWorkerBundleDir, - writeWorkerConfig, } from "@cloudflare/build-output-utils"; import { normalizePath } from "vite"; import { hasAssetsConfigChanged } from "../asset-config"; @@ -14,7 +13,6 @@ import { } from "../cloudflare-environment"; import { assertIsNotPreview } from "../context"; import { - resolveDevOnly, type AssetsOnlyResolvedConfig, type WorkersResolvedConfig, } from "../plugin-config"; @@ -131,69 +129,6 @@ export const configPlugin = createPlugin("config", (ctx) => { viteDevServer.watcher.on("change", configChangedHandler); }, - buildApp: { - order: "post", - async handler(builder) { - if (ctx.resolvedPluginConfig.type === "preview") { - return; - } - - const workerEnvironments = [ - ...ctx.resolvedPluginConfig.environmentNameToWorkerMap.entries(), - ] - .filter(([_, worker]) => !resolveDevOnly(worker.devOnly)) - .map(([environmentName]) => { - const environment = builder.environments[environmentName]; - assert(environment, `"${environmentName}" environment not found`); - - return environment; - }); - - // Build Worker environments that have not yet been built and are not dev-only - await Promise.all( - workerEnvironments - .filter((environment) => !environment.isBuilt) - .map((environment) => builder.build(environment)) - ); - - if (ctx.resolvedPluginConfig.type === "assets-only") { - return; - } - - const { entryWorkerEnvironmentName } = ctx.resolvedPluginConfig; - const entryWorkerEnvironment = - builder.environments[entryWorkerEnvironmentName]; - assert( - entryWorkerEnvironment, - `No "${entryWorkerEnvironmentName}" environment` - ); - - if (!entryWorkerEnvironment.isBuilt) { - // The entry Worker was only used in development so we emit an assets-only config - - const clientEnvironment = builder.environments.client; - assert(clientEnvironment, 'No "client" environment'); - - if (!clientEnvironment.isBuilt) { - throw new Error( - "If `assetsOnly` is set to `true`, the client environment must be built" - ); - } - - const entryWorkerNewConfig = ctx.getWorkerNewConfig( - entryWorkerEnvironmentName - ); - assert( - entryWorkerNewConfig, - `No config found for "${entryWorkerEnvironmentName}" environment` - ); - await writeWorkerConfig({ - root: builder.config.root, - config: entryWorkerNewConfig, - }); - } - }, - }, }; }); @@ -225,19 +160,11 @@ function getEnvironmentsConfig( mode, hasNodeJsCompat: ctx.getNodeJsCompat(environmentName) !== undefined, }; - const isEntryWorker = - environmentName === - ctx.resolvedPluginConfig.prerenderWorkerEnvironmentName || - (ctx.resolvedPluginConfig.type === "workers" && - environmentName === - ctx.resolvedPluginConfig.entryWorkerEnvironmentName); - const parentConfig = [ environmentName, createCloudflareEnvironmentOptions({ ...sharedOptions, environmentName, - isEntryWorker, isParentEnvironment: true, }), ] as const; @@ -249,7 +176,6 @@ function getEnvironmentsConfig( createCloudflareEnvironmentOptions({ ...sharedOptions, environmentName: childEnvironmentName, - isEntryWorker: false, isParentEnvironment: false, }), ] as const @@ -278,7 +204,7 @@ function getEnvironmentsConfig( /** * When the Build Output Specification is enabled, * force Worker and client `build.outDir` values to their spec-mandated - * locations. + * locations. Child environments are nested within their parent environment. * * Runs after Vite's merge in `configResolved`, so it overrides any * user-supplied `build.outDir` @@ -299,6 +225,28 @@ function forceBuildOutputDirs( } } + for (const [ + parentEnvironmentName, + childEnvironmentNames, + ] of resolvedPluginConfig.environmentNameToChildEnvironmentNamesMap) { + const parentEnvironment = + resolvedViteConfig.environments[parentEnvironmentName]; + if (!parentEnvironment) { + continue; + } + + for (const childEnvironmentName of childEnvironmentNames) { + const childEnvironment = + resolvedViteConfig.environments[childEnvironmentName]; + if (childEnvironment) { + childEnvironment.build.outDir = path.join( + parentEnvironment.build.outDir, + childEnvironmentName + ); + } + } + } + const clientEnvironment = resolvedViteConfig.environments.client; if (clientEnvironment) { clientEnvironment.build.outDir = getWorkerAssetsDir(root);