diff --git a/.changeset/gentle-sloths-eat.md b/.changeset/gentle-sloths-eat.md new file mode 100644 index 000000000000..047e0bc00f0b --- /dev/null +++ b/.changeset/gentle-sloths-eat.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Support service bindings from Pages projects to Workers in a single `workerd` instance. To try it out, pass multiple `-c` flags to Wrangler: i.e. `wrangler pages dev -c wrangler.toml -c ../other-worker/wrangler.toml`. The first `-c` flag must point to your Pages config file, and the rest should point to Workers that are bound to your Pages project. diff --git a/packages/wrangler/e2e/multiworker-dev.test.ts b/packages/wrangler/e2e/multiworker-dev.test.ts index 0298f9918887..d584242b085b 100644 --- a/packages/wrangler/e2e/multiworker-dev.test.ts +++ b/packages/wrangler/e2e/multiworker-dev.test.ts @@ -361,4 +361,89 @@ describe("multiworker", () => { ); }); }); + + describe("pages", () => { + beforeEach(async () => { + await baseSeed(a, { + "wrangler.toml": dedent` + name = "${workerName}" + pages_build_output_dir = "./public" + compatibility_date = "2024-11-01" + + [[services]] + binding = "CEE" + service = '${workerName3}' + + [[services]] + binding = "BEE" + service = '${workerName2}' + `, + "functions/cee.ts": dedent/* javascript */ ` + export async function onRequest(context) { + return context.env.CEE.fetch("https://example.com"); + }`, + "functions/bee.ts": dedent/* javascript */ ` + export async function onRequest(context) { + return context.env.BEE.fetch("https://example.com"); + }`, + "public/index.html": `

hello pages assets

`, + }); + }); + + it("pages project assets", async () => { + const pages = helper.runLongLived( + `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, + { cwd: a } + ); + const { url } = await pages.waitForReady(5_000); + + await vi.waitFor( + async () => + await expect(fetchText(`${url}`)).resolves.toBe( + "

hello pages assets

" + ), + { interval: 1000, timeout: 10_000 } + ); + }); + + it("pages project fetching service worker", async () => { + const pages = helper.runLongLived( + `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, + { cwd: a } + ); + const { url } = await pages.waitForReady(5_000); + + await vi.waitFor( + async () => + await expect(fetchText(`${url}/cee`)).resolves.toBe( + "Hello from service worker" + ), + { interval: 1000, timeout: 10_000 } + ); + }); + + it("pages project fetching module worker", async () => { + const pages = helper.runLongLived( + `wrangler pages dev -c wrangler.toml -c ${b}/wrangler.toml -c ${c}/wrangler.toml`, + { cwd: a } + ); + const { url } = await pages.waitForReady(5_000); + + await vi.waitFor( + async () => + await expect(fetchText(`${url}/bee`)).resolves.toBe("hello world"), + { interval: 1000, timeout: 10_000 } + ); + }); + + it("should error if multiple pages configs are provided", async () => { + const pages = helper.runLongLived( + `wrangler pages dev -c wrangler.toml -c wrangler.toml`, + { cwd: a } + ); + await pages.readUntil( + /You cannot use a Pages project as a service binding target/ + ); + }); + }); }); diff --git a/packages/wrangler/src/api/startDevWorker/ConfigController.ts b/packages/wrangler/src/api/startDevWorker/ConfigController.ts index 561b5eac952e..c95f422a3c05 100644 --- a/packages/wrangler/src/api/startDevWorker/ConfigController.ts +++ b/packages/wrangler/src/api/startDevWorker/ConfigController.ts @@ -227,6 +227,14 @@ async function resolveConfig( config: Config, input: StartDevWorkerInput ): Promise { + if ( + config.pages_build_output_dir && + input.dev?.multiworkerPrimary === false + ) { + throw new UserError( + `You cannot use a Pages project as a service binding target.\nIf you are trying to develop Pages and Workers together, please use \`wrangler pages dev\`. Note the first config file specified must be for the Pages project` + ); + } const legacySite = unwrapHook(input.legacy?.site, config); const legacyAssets = unwrapHook(input.legacy?.legacyAssets, config); diff --git a/packages/wrangler/src/api/startDevWorker/ProxyController.ts b/packages/wrangler/src/api/startDevWorker/ProxyController.ts index 036d9219eed7..4ffec334df49 100644 --- a/packages/wrangler/src/api/startDevWorker/ProxyController.ts +++ b/packages/wrangler/src/api/startDevWorker/ProxyController.ts @@ -193,12 +193,24 @@ export class ProxyController extends Controller { void Promise.all([ proxyWorker.ready, proxyWorker.unsafeGetDirectURL("InspectorProxyWorker"), - this.reconnectInspectorProxyWorker(), ]) + .then(([url, inspectorUrl]) => { + // Don't connect the inspector proxy worker until we have a valid ready Miniflare instance. + // Otherwise, tearing down the ProxyController immediately after setting it up + // will result in proxyWorker.ready throwing, but reconnectInspectorProxyWorker hanging for ever, + // preventing teardown + return this.reconnectInspectorProxyWorker().then(() => [ + url, + inspectorUrl, + ]); + }) .then(([url, inspectorUrl]) => { this.emitReadyEvent(proxyWorker, url, inspectorUrl); }) .catch((error) => { + if (this._torndown) { + return; + } this.emitErrorEvent( "Failed to start ProxyWorker or InspectorProxyWorker", error diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 5b6afce6db28..ac6697642f29 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -220,7 +220,12 @@ export function createCLIParser(argv: string[]) { requiresArg: true, }) .check( - demandSingleValue("config", (configArgv) => configArgv["_"][0] === "dev") + demandSingleValue( + "config", + (configArgv) => + configArgv["_"][0] === "dev" || + (configArgv["_"][0] === "pages" && configArgv["_"][1] === "dev") + ) ) .option("env", { alias: "e", diff --git a/packages/wrangler/src/pages/dev.ts b/packages/wrangler/src/pages/dev.ts index a547e089bb36..0b224531f30a 100644 --- a/packages/wrangler/src/pages/dev.ts +++ b/packages/wrangler/src/pages/dev.ts @@ -1,15 +1,17 @@ import { execSync, spawn } from "node:child_process"; +import events from "node:events"; import { existsSync, lstatSync, readFileSync } from "node:fs"; -import { dirname, join, normalize, resolve } from "node:path"; +import path, { dirname, join, normalize, resolve } from "node:path"; import { watch } from "chokidar"; import * as esbuild from "esbuild"; -import { unstable_dev } from "../api"; import { configFileName, readConfig } from "../config"; import { isBuildFailure } from "../deployment-bundle/build-failures"; import { shouldCheckFetch } from "../deployment-bundle/bundle"; import { esbuildAliasExternalPlugin } from "../deployment-bundle/esbuild-plugins/alias-external"; import { validateNodeCompatMode } from "../deployment-bundle/node-compat"; +import { startDev } from "../dev"; import { FatalError } from "../errors"; +import { run } from "../experimental-flags"; import { logger } from "../logger"; import * as metrics from "../metrics"; import { isNavigatorDefined } from "../navigator-user-agent"; @@ -223,12 +225,6 @@ export function Options(yargs: CommonYargsArgv) { deprecated: true, hidden: true, }, - config: { - describe: - "Pages does not support custom paths for the Wrangler configuration file", - type: "string", - hidden: true, - }, "log-level": { choices: ["debug", "info", "log", "warn", "error", "none"] as const, describe: "Specify logging level", @@ -262,7 +258,7 @@ export const Handler = async (args: PagesDevArguments) => { ); } - if (args.config) { + if (args.config && !Array.isArray(args.config)) { throw new FatalError( "Pages does not support custom paths for the Wrangler configuration file", 1 @@ -285,9 +281,21 @@ export const Handler = async (args: PagesDevArguments) => { // for `dev` we always use the top-level config, which means we need // to read the config file with `env` set to `undefined` const config = readConfig( - { ...args, env: undefined }, + { ...args, env: undefined, config: undefined }, { useRedirectIfAvailable: true } ); + + if ( + args.config && + Array.isArray(args.config) && + config.configPath && + path.resolve(process.cwd(), args.config[0]) !== config.configPath + ) { + throw new FatalError( + "The first `--config` argument must point to your Pages configuration file: " + + path.relative(process.cwd(), config.configPath) + ); + } const resolvedDirectory = args.directory ?? config.pages_build_output_dir; const [_pages, _dev, ...remaining] = args._; const command = remaining; @@ -525,8 +533,8 @@ export const Handler = async (args: PagesDevArguments) => { try { await runBuild(); - watcher.on("all", async (eventName, path) => { - logger.debug(`🌀 "${eventName}" event detected at ${path}.`); + watcher.on("all", async (eventName, p) => { + logger.debug(`🌀 "${eventName}" event detected at ${p}.`); // Skip re-building the Worker if "_worker.js" was deleted. // This is necessary for Pages projects + Frameworks, where @@ -708,8 +716,8 @@ export const Handler = async (args: PagesDevArguments) => { await buildFn(); // If Functions found routes, continue using Functions - watcher.on("all", async (eventName, path) => { - logger.debug(`🌀 "${eventName}" event detected at ${path}.`); + watcher.on("all", async (eventName, p) => { + logger.debug(`🌀 "${eventName}" event detected at ${p}.`); debouncedBuildFn(); }); @@ -862,61 +870,101 @@ export const Handler = async (args: PagesDevArguments) => { } } - const { stop, waitUntilExit } = await unstable_dev(scriptEntrypoint, { - env: undefined, - ip, - port, - inspectorPort, - localProtocol, - httpsKeyPath: args.httpsKeyPath, - httpsCertPath: args.httpsCertPath, - compatibilityDate, - compatibilityFlags, - nodeCompat: nodejsCompatMode === "legacy", - vars, - kv: kv_namespaces, - durableObjects: do_bindings, - r2: r2_buckets, - services, - ai, - rules: usingWorkerDirectory - ? [ - { - type: "ESModule", - globs: ["**/*.js", "**/*.mjs"], - }, - ] - : undefined, - bundle: enableBundling, - persistTo: args.persistTo, - inspect: undefined, - logLevel: args.logLevel, - experimental: { - processEntrypoint: true, - additionalModules: modules, - d1Databases: d1_databases, - disableExperimentalWarning: true, - enablePagesAssetsServiceBinding: { - proxyPort, - directory, - }, - liveReload: args.liveReload, - forceLocal: true, - showInteractiveDevSession: args.showInteractiveDevSession, - testMode: false, - watch: true, - enableIpc: true, + const devServer = await run( + { + MULTIWORKER: Array.isArray(args.config), + RESOURCES_PROVISION: false, }, - }); - metrics.sendMetricsEvent("run pages dev"); + () => + startDev({ + script: scriptEntrypoint, + _: [], + $0: "", + remote: false, + local: true, + experimentalLocal: undefined, + d1Databases: d1_databases, + testScheduled: false, + enablePagesAssetsServiceBinding: { + proxyPort, + directory, + }, + forceLocal: true, + liveReload: args.liveReload, + showInteractiveDevSession: args.showInteractiveDevSession, + processEntrypoint: true, + additionalModules: modules, + v: undefined, + assets: undefined, + name: undefined, + noBundle: false, + format: undefined, + latest: false, + routes: undefined, + host: undefined, + localUpstream: undefined, + experimentalPublic: undefined, + upstreamProtocol: undefined, + var: undefined, + define: undefined, + alias: undefined, + jsxFactory: undefined, + jsxFragment: undefined, + tsconfig: undefined, + minify: undefined, + experimentalEnableLocalPersistence: undefined, + legacyEnv: undefined, + public: undefined, + env: undefined, + ip, + port, + inspectorPort, + localProtocol, + httpsKeyPath: args.httpsKeyPath, + httpsCertPath: args.httpsCertPath, + compatibilityDate, + compatibilityFlags, + nodeCompat: nodejsCompatMode === "legacy", + vars, + kv: kv_namespaces, + durableObjects: do_bindings, + r2: r2_buckets, + services, + ai, + rules: usingWorkerDirectory + ? [ + { + type: "ESModule", + globs: ["**/*.js", "**/*.mjs"], + }, + ] + : undefined, + bundle: enableBundling, + persistTo: args.persistTo, + logLevel: args.logLevel ?? "log", + experimentalProvision: undefined, + experimentalVectorizeBindToProd: false, + enableIpc: true, + config: Array.isArray(args.config) ? args.config : undefined, + legacyAssets: undefined, + site: undefined, + siteInclude: undefined, + siteExclude: undefined, + inspect: undefined, + }) + ); - CLEANUP_CALLBACKS.push(stop); + metrics.sendMetricsEvent("run pages dev"); process.on("exit", CLEANUP); process.on("SIGINT", CLEANUP); process.on("SIGTERM", CLEANUP); - await waitUntilExit(); + await events.once(devServer.devEnv, "teardown"); + const teardownRegistry = await devServer.teardownRegistryPromise; + await teardownRegistry?.(devServer.devEnv.config.latestConfig?.name); + + devServer.unregisterHotKeys?.(); CLEANUP(); process.exit(0); };