Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support service bindings from Pages projects to workers in a single workerd instance #7715

Merged
merged 5 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/
);
});
});
});
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 @@ -301,7 +301,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) => {
penalosa marked this conversation as resolved.
Show resolved Hide resolved
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) => {
penalosa marked this conversation as resolved.
Show resolved Hide resolved
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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any risk to moving pages dev off unstable_dev? as far as i can tell its just a wrapper around startDev but not sure if there's some context I'm missing around why it's been unstable_dev until now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unstable_dev used to be a different code path, and was refactored to be startDev() based when --x-dev-env was stabilised. As it stands today, unstable_dev is just a wrapper around startDev

{
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
Loading