Skip to content

Commit

Permalink
Support service bindings from Pages projects to workers in a single `…
Browse files Browse the repository at this point in the history
…workerd` instance (#7715)

* Support single-instance multiworker for Pages

* Create gentle-sloths-eat.md

* Throw an error if a pages project is used as a service binding target

* Add clearer messaging

* address comments
  • Loading branch information
penalosa authored Jan 20, 2025
1 parent 806cee8 commit 26fa9e8
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 64 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-sloths-eat.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 85 additions & 0 deletions packages/wrangler/e2e/multiworker-dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": `<h1>hello pages assets</h1>`,
});
});

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(
"<h1>hello pages assets</h1>"
),
{ 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/
);
});
});
});
8 changes: 8 additions & 0 deletions packages/wrangler/src/api/startDevWorker/ConfigController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ async function resolveConfig(
config: Config,
input: StartDevWorkerInput
): Promise<StartDevWorkerOptions> {
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);
Expand Down
14 changes: 13 additions & 1 deletion packages/wrangler/src/api/startDevWorker/ProxyController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,24 @@ export class ProxyController extends Controller<ProxyControllerEventMap> {
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
Expand Down
7 changes: 6 additions & 1 deletion packages/wrangler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
172 changes: 110 additions & 62 deletions packages/wrangler/src/pages/dev.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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);
};
Expand Down

0 comments on commit 26fa9e8

Please sign in to comment.