diff --git a/.changeset/swift-pears-carry.md b/.changeset/swift-pears-carry.md new file mode 100644 index 000000000000..84f49fd38511 --- /dev/null +++ b/.changeset/swift-pears-carry.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +feat: Updates `wrangler pages functions build` to support using configuration from `wrangler.toml` in the generated output. diff --git a/packages/wrangler/src/__tests__/pages/functions-build.test.ts b/packages/wrangler/src/__tests__/pages/functions-build.test.ts index 3e3693189e1c..225d3603fadb 100644 --- a/packages/wrangler/src/__tests__/pages/functions-build.test.ts +++ b/packages/wrangler/src/__tests__/pages/functions-build.test.ts @@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, } from "node:fs"; +import dedent from "ts-dedent"; import { endEventLoop } from "../helpers/end-event-loop"; import { mockConsoleMethods } from "../helpers/mock-console"; import { runInTempDir } from "../helpers/run-in-tmp"; @@ -532,3 +533,236 @@ export const cat = "dog";` expect(std.err).toMatchInlineSnapshot(`""`); }); }); + +describe("functions build w/ config", () => { + const std = mockConsoleMethods(); + + runInTempDir(); + const originalEnv = process.env; + + afterEach(async () => { + process.env = originalEnv; + // Force a tick to ensure that all promises resolve + await endEventLoop(); + }); + + beforeEach(() => { + // Write an example wrangler.toml file with a _lot_ of config + writeFileSync( + "wrangler.toml", + dedent` + name = "project-name" + pages_build_output_dir = "dist-test" + compatibility_date = "2023-02-14" + placement = { mode = "smart" } + limits = { cpu_ms = 50 } + + [vars] + TEST_JSON_PREVIEW = """ + { + json: "value" + }""" + TEST_PLAINTEXT_PREVIEW = "PLAINTEXT" + + [[kv_namespaces]] + id = "kv-id" + binding = "KV_PREVIEW" + + [[kv_namespaces]] + id = "kv-id" + binding = "KV_PREVIEW2" + + [[durable_objects.bindings]] + name = "DO_PREVIEW" + class_name = "some-class-do-id" + script_name = "some-script-do-id" + environment = "some-environment-do-id" + + [[durable_objects.bindings]] + name = "DO_PREVIEW2" + class_name = "some-class-do-id" + script_name = "some-script-do-id" + environment = "some-environment-do-id" + + [[durable_objects.bindings]] + name = "DO_PREVIEW3" + class_name = "do-class" + script_name = "do-s" + environment = "do-e" + + [[d1_databases]] + database_id = "d1-id" + binding = "D1_PREVIEW" + database_name = "D1_PREVIEW" + + [[d1_databases]] + database_id = "d1-id" + binding = "D1_PREVIEW2" + database_name = "D1_PREVIEW2" + + [[r2_buckets]] + bucket_name = "r2-name" + binding = "R2_PREVIEW" + + [[r2_buckets]] + bucket_name = "r2-name" + binding = "R2_PREVIEW2" + + [[services]] + binding = "SERVICE_PREVIEW" + service = "service" + environment = "production" + + [[services]] + binding = "SERVICE_PREVIEW2" + service = "service" + environment = "production" + + [[queues.producers]] + binding = "QUEUE_PREVIEW" + queue = "q-id" + + [[queues.producers]] + binding = "QUEUE_PREVIEW2" + queue = "q-id" + + [[analytics_engine_datasets]] + binding = "AE_PREVIEW" + dataset = "data" + + [[analytics_engine_datasets]] + binding = "AE_PREVIEW2" + dataset = "data" + + [ai] + binding = "AI_PREVIEW" + + [env.production] + compatibility_date = "2024-02-14" + + [env.production.vars] + TEST_JSON = """ + { + json: "value" + }""" + TEST_PLAINTEXT = "PLAINTEXT" + + [[env.production.kv_namespaces]] + id = "kv-id" + binding = "KV" + + [[env.production.durable_objects.bindings]] + name = "DO" + class_name = "some-class-do-id" + script_name = "some-script-do-id" + environment = "some-environment-do-id" + + [[env.production.d1_databases]] + database_id = "d1-id" + binding = "D1" + database_name = "D1" + + [[env.production.r2_buckets]] + bucket_name = "r2-name" + binding = "R2" + + [[env.production.services]] + binding = "SERVICE" + service = "service" + environment = "production" + + [[env.production.queues.producers]] + binding = "QUEUE" + queue = "q-id" + + [[env.production.analytics_engine_datasets]] + binding = "AE" + dataset = "data" + + [env.production.ai] + binding = "AI"` + ); + }); + + it("should include all config in the _worker.bundle metadata", async () => { + /* ---------------------------- */ + /* Set up js files */ + /* ---------------------------- */ + mkdirSync("utils"); + writeFileSync( + "utils/meaning-of-life.js", + ` +export const MEANING_OF_LIFE = 21; +` + ); + + /* ---------------------------- */ + /* Set up _worker.js */ + /* ---------------------------- */ + mkdirSync("dist-test"); + writeFileSync( + "dist-test/_worker.js", + ` +import { MEANING_OF_LIFE } from "./../utils/meaning-of-life.js"; + +export default { + async fetch(request, env) { + return new Response("Hello from _worker.js. The meaning of life is " + MEANING_OF_LIFE); + }, +};` + ); + + /* --------------------------------- */ + /* Run cmd & make assertions */ + /* --------------------------------- */ + // --build-output-directory is included here to validate that it's value is ignored + await runWrangler( + `pages functions build --build-output-directory public --outfile=_worker.bundle --build-metadata-path build-metadata.json --project-directory .` + ); + expect(existsSync("_worker.bundle")).toBe(true); + expect(std.out).toMatchInlineSnapshot(`"✨ Compiled Worker successfully"`); + + // some values in workerBundleContents, such as the undici form boundary + // or the file hashes, are randomly generated. Let's replace them + // with static values so we can test the file contents + const workerBundleContents = readFileSync("_worker.bundle", "utf-8"); + const workerBundleWithConstantData = replaceRandomWithConstantData( + workerBundleContents, + [ + [/------formdata-undici-0.[0-9]*/g, "------formdata-undici-0.test"], + [/functionsWorker-0.[0-9]*.js/g, "functionsWorker-0.test.js"], + ] + ); + + expect(workerBundleWithConstantData).toMatchInlineSnapshot(` + "------formdata-undici-0.test + Content-Disposition: form-data; name=\\"metadata\\" + + {\\"main_module\\":\\"functionsWorker-0.test.js\\",\\"bindings\\":[{\\"name\\":\\"TEST_JSON_PREVIEW\\",\\"type\\":\\"plain_text\\",\\"text\\":\\"{\\\\njson: \\\\\\"value\\\\\\"\\\\n}\\"},{\\"name\\":\\"TEST_PLAINTEXT_PREVIEW\\",\\"type\\":\\"plain_text\\",\\"text\\":\\"PLAINTEXT\\"},{\\"name\\":\\"KV_PREVIEW\\",\\"type\\":\\"kv_namespace\\",\\"namespace_id\\":\\"kv-id\\"},{\\"name\\":\\"KV_PREVIEW2\\",\\"type\\":\\"kv_namespace\\",\\"namespace_id\\":\\"kv-id\\"},{\\"name\\":\\"DO_PREVIEW\\",\\"type\\":\\"durable_object_namespace\\",\\"class_name\\":\\"some-class-do-id\\",\\"script_name\\":\\"some-script-do-id\\",\\"environment\\":\\"some-environment-do-id\\"},{\\"name\\":\\"DO_PREVIEW2\\",\\"type\\":\\"durable_object_namespace\\",\\"class_name\\":\\"some-class-do-id\\",\\"script_name\\":\\"some-script-do-id\\",\\"environment\\":\\"some-environment-do-id\\"},{\\"name\\":\\"DO_PREVIEW3\\",\\"type\\":\\"durable_object_namespace\\",\\"class_name\\":\\"do-class\\",\\"script_name\\":\\"do-s\\",\\"environment\\":\\"do-e\\"},{\\"type\\":\\"queue\\",\\"name\\":\\"QUEUE_PREVIEW\\",\\"queue_name\\":\\"q-id\\"},{\\"type\\":\\"queue\\",\\"name\\":\\"QUEUE_PREVIEW2\\",\\"queue_name\\":\\"q-id\\"},{\\"name\\":\\"R2_PREVIEW\\",\\"type\\":\\"r2_bucket\\",\\"bucket_name\\":\\"r2-name\\"},{\\"name\\":\\"R2_PREVIEW2\\",\\"type\\":\\"r2_bucket\\",\\"bucket_name\\":\\"r2-name\\"},{\\"name\\":\\"D1_PREVIEW\\",\\"type\\":\\"d1\\",\\"id\\":\\"d1-id\\"},{\\"name\\":\\"D1_PREVIEW2\\",\\"type\\":\\"d1\\",\\"id\\":\\"d1-id\\"},{\\"name\\":\\"SERVICE_PREVIEW\\",\\"type\\":\\"service\\",\\"service\\":\\"service\\",\\"environment\\":\\"production\\"},{\\"name\\":\\"SERVICE_PREVIEW2\\",\\"type\\":\\"service\\",\\"service\\":\\"service\\",\\"environment\\":\\"production\\"},{\\"name\\":\\"AE_PREVIEW\\",\\"type\\":\\"analytics_engine\\",\\"dataset\\":\\"data\\"},{\\"name\\":\\"AE_PREVIEW2\\",\\"type\\":\\"analytics_engine\\",\\"dataset\\":\\"data\\"},{\\"name\\":\\"AI_PREVIEW\\",\\"type\\":\\"ai\\"}],\\"compatibility_date\\":\\"2023-02-14\\",\\"compatibility_flags\\":[],\\"placement\\":{\\"mode\\":\\"smart\\"},\\"limits\\":{\\"cpu_ms\\":50}} + ------formdata-undici-0.test + Content-Disposition: form-data; name=\\"functionsWorker-0.test.js\\"; filename=\\"functionsWorker-0.test.js\\" + Content-Type: application/javascript+module + + // ../utils/meaning-of-life.js + var MEANING_OF_LIFE = 21; + + // _worker.js + var worker_default = { + async fetch(request, env) { + return new Response(\\"Hello from _worker.js. The meaning of life is \\" + MEANING_OF_LIFE); + } + }; + export { + worker_default as default + }; + + ------formdata-undici-0.test--" + `); + const buildMetadataContents = readFileSync("build-metadata.json", "utf-8"); + expect(buildMetadataContents).toMatchInlineSnapshot( + `"{\\"wrangler_config_hash\\":\\"49290f05177579eac4442a3cfd403a84429c189fc57e75697605eca07eb49d26\\",\\"build_output_directory\\":\\"dist-test\\"}"` + ); + + expect(std.err).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts b/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts index d9cf2426d3ff..d0a357efb270 100644 --- a/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts +++ b/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts @@ -2,8 +2,9 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { Response } from "undici"; import { createWorkerUploadForm } from "../../deployment-bundle/create-worker-upload-form"; +import type { Config } from "../../config"; import type { BundleResult } from "../../deployment-bundle/bundle"; -import type { CfWorkerInit } from "../../deployment-bundle/worker"; +import type { CfPlacement, CfWorkerInit } from "../../deployment-bundle/worker"; import type { Blob } from "node:buffer"; import type { FormData } from "undici"; @@ -12,20 +13,16 @@ import type { FormData } from "undici"; * contents */ export async function createUploadWorkerBundleContents( - workerBundle: BundleResult + workerBundle: BundleResult, + config: Config | undefined ): Promise { - const workerBundleFormData = createWorkerBundleFormData(workerBundle); + const workerBundleFormData = createWorkerBundleFormData(workerBundle, config); const metadata = JSON.parse(workerBundleFormData.get("metadata") as string); - - /** - * Pages doesn't need the metadata bindings returned by - * `createWorkerBundleFormData`. Let's strip them out and return only - * the contents we need - */ - workerBundleFormData.set( - "metadata", - JSON.stringify({ main_module: metadata.main_module }) - ); + // Remove the empty bindings array if no Pages config has been found + if (config === undefined) { + delete metadata.bindings; + } + workerBundleFormData.set("metadata", JSON.stringify(metadata)); return await new Response(workerBundleFormData).blob(); } @@ -33,7 +30,10 @@ export async function createUploadWorkerBundleContents( /** * Creates a `FormData` upload from a `BundleResult` */ -function createWorkerBundleFormData(workerBundle: BundleResult): FormData { +function createWorkerBundleFormData( + workerBundle: BundleResult, + config: Config | undefined +): FormData { const mainModule = { name: path.basename(workerBundle.resolvedEntryPointPath), filePath: workerBundle.resolvedEntryPointPath, @@ -43,43 +43,51 @@ function createWorkerBundleFormData(workerBundle: BundleResult): FormData { type: workerBundle.bundleType || "esm", }; + const bindings: CfWorkerInit["bindings"] = { + kv_namespaces: config?.kv_namespaces, + vars: config?.vars, + browser: config?.browser, + ai: config?.ai, + durable_objects: config?.durable_objects, + queues: config?.queues.producers?.map((producer) => { + return { binding: producer.binding, queue_name: producer.queue }; + }), + r2_buckets: config?.r2_buckets, + d1_databases: config?.d1_databases, + vectorize: config?.vectorize, + hyperdrive: config?.hyperdrive, + services: config?.services, + analytics_engine_datasets: config?.analytics_engine_datasets, + mtls_certificates: config?.mtls_certificates, + send_email: undefined, + wasm_modules: undefined, + text_blobs: undefined, + data_blobs: undefined, + constellation: undefined, + dispatch_namespaces: undefined, + logfwdr: undefined, + unsafe: undefined, + }; + + // The upload API only accepts an empty string or no specified placement for the "off" mode. + const placement: CfPlacement | undefined = + config?.placement?.mode === "smart" ? { mode: "smart" } : undefined; + const worker: CfWorkerInit = { name: mainModule.name, main: mainModule, modules: workerBundle.modules, - bindings: { - vars: undefined, - kv_namespaces: undefined, - send_email: undefined, - wasm_modules: undefined, - text_blobs: undefined, - browser: undefined, - ai: undefined, - data_blobs: undefined, - durable_objects: undefined, - queues: undefined, - r2_buckets: undefined, - d1_databases: undefined, - vectorize: undefined, - constellation: undefined, - hyperdrive: undefined, - services: undefined, - analytics_engine_datasets: undefined, - dispatch_namespaces: undefined, - mtls_certificates: undefined, - logfwdr: undefined, - unsafe: undefined, - }, + bindings, migrations: undefined, - compatibility_date: undefined, - compatibility_flags: undefined, + compatibility_date: config?.compatibility_date, + compatibility_flags: config?.compatibility_flags, usage_model: undefined, keepVars: undefined, keepSecrets: undefined, logpush: undefined, - placement: undefined, + placement: placement, tail_consumers: undefined, - limits: undefined, + limits: config?.limits, }; return createWorkerUploadForm(worker); diff --git a/packages/wrangler/src/api/pages/deploy.tsx b/packages/wrangler/src/api/pages/deploy.tsx index 9d4af0354f5d..e894029d2622 100644 --- a/packages/wrangler/src/api/pages/deploy.tsx +++ b/packages/wrangler/src/api/pages/deploy.tsx @@ -295,7 +295,8 @@ export async function deploy({ if (_workerJS || _workerJSIsDirectory) { const workerBundleContents = await createUploadWorkerBundleContents( - workerBundle as BundleResult + workerBundle as BundleResult, + undefined ); formData.append( @@ -329,7 +330,8 @@ export async function deploy({ */ if (builtFunctions && !_workerJS && !_workerJSIsDirectory) { const workerBundleContents = await createUploadWorkerBundleContents( - workerBundle as BundleResult + workerBundle as BundleResult, + undefined ); formData.append( diff --git a/packages/wrangler/src/pages/build.ts b/packages/wrangler/src/pages/build.ts index f691a48cd7b6..8610199f7281 100644 --- a/packages/wrangler/src/pages/build.ts +++ b/packages/wrangler/src/pages/build.ts @@ -1,6 +1,15 @@ +import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, writeFileSync } from "node:fs"; -import { basename, dirname, relative, resolve as resolvePath } from "node:path"; +import { readFile } from "node:fs/promises"; +import path, { + basename, + dirname, + relative, + resolve as resolvePath, +} from "node:path"; import { createUploadWorkerBundleContents } from "../api/pages/create-worker-bundle-contents"; +import { readConfig } from "../config"; +import { isPagesConfig } from "../config/validation"; import { writeAdditionalModules } from "../deployment-bundle/find-additional-modules"; import { FatalError, UserError } from "../errors"; import { logger } from "../logger"; @@ -17,6 +26,7 @@ import { buildRawWorker, produceWorkerBundleForWorkerJSDirectory, } from "./functions/buildWorker"; +import type { Config } from "../config"; import type { BundleResult } from "../deployment-bundle/bundle"; import type { CommonYargsArgv, @@ -49,6 +59,10 @@ export function Options(yargs: CommonYargsArgv) { type: "string", description: "The location for the build metadata file", }, + "project-directory": { + type: "string", + description: "The location of the Pages project", + }, "output-routes-path": { type: "string", description: "The location for the output _routes.json file", @@ -113,7 +127,7 @@ export function Options(yargs: CommonYargsArgv) { } export const Handler = async (args: PagesBuildArgs) => { - const validatedArgs = validateArgs(args); + const validatedArgs = await validateArgs(args); let bundle: BundleResult | undefined = undefined; @@ -181,6 +195,9 @@ export const Handler = async (args: PagesBuildArgs) => { } } else { const { + config, + buildMetadataPath, + buildMetadata, directory, outfile, outdir, @@ -271,7 +288,8 @@ export const Handler = async (args: PagesBuildArgs) => { if (outfile) { const workerBundleContents = await createUploadWorkerBundleContents( - bundle as BundleResult + bundle as BundleResult, + config ); mkdirSync(dirname(outfile), { recursive: true }); @@ -280,6 +298,9 @@ export const Handler = async (args: PagesBuildArgs) => { Buffer.from(await workerBundleContents.arrayBuffer()) ); } + if (buildMetadataPath && buildMetadata) { + writeFileSync(buildMetadataPath, JSON.stringify(buildMetadata)); + } } await metrics.sendMetricsEvent("build pages functions"); @@ -292,6 +313,13 @@ type WorkerBundleArgs = Omit & { nodejsCompat: boolean; defineNavigatorUserAgent: boolean; workerScriptPath: string; + config: Config | undefined; + buildMetadata: + | { + wrangler_config_hash: string; + build_output_directory: string; + } + | undefined; }; type PluginArgs = Omit< PagesBuildArgs, @@ -303,10 +331,40 @@ type PluginArgs = Omit< nodejsCompat: boolean; defineNavigatorUserAgent: boolean; }; +async function maybeReadPagesConfig( + args: PagesBuildArgs +): Promise<(Config & { hash: string }) | undefined> { + if (!args.projectDirectory || !args.buildMetadataPath) { + return; + } + const configPath = path.resolve(args.projectDirectory, "wrangler.toml"); + // Fail early if the config file doesn't exist + if (!existsSync(configPath)) { + return undefined; + } + const config = readConfig(configPath, { + ...args, + // eslint-disable-next-line turbo/no-undeclared-env-vars + env: process.env.PAGES_ENVIRONMENT, + }); + // Fail if the config file exists but isn't valid for Pages + if (!isPagesConfig(config)) { + logger.warn("Your wrangler.toml is not a valid Pages config file"); + return undefined; + } + return { + ...config, + hash: createHash("sha256") + .update(await readFile(configPath)) + .digest("hex"), + }; +} type ValidatedArgs = WorkerBundleArgs | PluginArgs; -const validateArgs = (args: PagesBuildArgs): ValidatedArgs => { +const validateArgs = async (args: PagesBuildArgs): Promise => { + const config = await maybeReadPagesConfig(args); + if (args.outdir && args.outfile) { throw new FatalError( "Cannot specify both an `--outdir` and an `--outfile`.", @@ -353,9 +411,12 @@ const validateArgs = (args: PagesBuildArgs): ValidatedArgs => { args.buildOutputDirectory ??= args.outfile ? dirname(args.outfile) : "."; } - if (args.buildOutputDirectory) { - args.buildOutputDirectory = resolvePath(args.buildOutputDirectory); - } + args.buildOutputDirectory = + config?.pages_build_output_dir ?? + (args.buildOutputDirectory + ? resolvePath(args.buildOutputDirectory) + : undefined); + if (args.outdir) { args.outdir = resolvePath(args.outdir); } @@ -422,5 +483,16 @@ We looked for the Functions directory (${basename( nodejsCompat, legacyNodeCompat, defineNavigatorUserAgent, + config, + buildMetadata: + config && args.projectDirectory && config.pages_build_output_dir + ? { + wrangler_config_hash: config.hash, + build_output_directory: path.relative( + args.projectDirectory, + config.pages_build_output_dir + ), + } + : undefined, } as ValidatedArgs; };