Skip to content

Commit

Permalink
Service bindings in Pages (#2162)
Browse files Browse the repository at this point in the history
* Service bindings in Pages

* Add env support + test

* run prettify

* update workers-types dependency in external service bindings fixture

* update pnpm-lock file

* fix copy-pasta in external-service-bindings fixture

* fix and improve external-service-bindings-app fixture

* add test for service environments

* fix types in external-service-bindings-app fixture

* add environment to service flag description

* add changeset

* Update fixtures/external-service-bindings-app/package.json

Co-authored-by: Greg Brimble <gbrimble@cloudflare.com>

* add missing newlines

* tweak flag description

* improve changeset

* add warning about service binding env being experimental

* only print the env warning once

* fix bad wording

* add comment for SERVICE_BINDING_REGEXP

* remove unneeded jest section in package.json

---------

Co-authored-by: Daniel Walsh <walshydev@gmail.com>
Co-authored-by: Dario Piotrowicz <dario@cloudflare.com>
Co-authored-by: Greg Brimble <gbrimble@cloudflare.com>
  • Loading branch information
3 people committed Oct 19, 2023
1 parent 980df04 commit a1f212e
Show file tree
Hide file tree
Showing 22 changed files with 376 additions and 46 deletions.
19 changes: 19 additions & 0 deletions .changeset/three-dolphins-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"wrangler": minor
---

add support for service bindings in `wrangler pages dev` by providing the
new `--service`|`-s` flag which accepts an array of `BINDING_NAME=SCRIPT_NAME`
where `BINDING_NAME` is the name of the binding and `SCRIPT_NAME` is the name
of the worker (as defined in its `wrangler.toml`), such workers need to be
running locally with with `wrangler dev`.

For example if a user has a worker named `worker-a`, in order to locally bind
to that they'll need to open two different terminals, in each navigate to the
respective worker/pages application and then run respectively `wrangler dev` and
`wrangler pages ./publicDir --service MY_SERVICE=worker-a` this will add the
`MY_SERVICE` binding to pages' worker `env` object.

Note: additionally after the `SCRIPT_NAME` the name of an environment can be specified,
prefixed by an `@` (as in: `MY_SERVICE=SCRIPT_NAME@PRODUCTION`), this behavior is however
experimental and not fully properly defined.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fetch() {
return new Response("Hello from module worker a");
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
name = "module-worker-a"
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default {
async fetch(request: Request, env: { SERVICE: Fetcher }) {
const serviceResp = await env.SERVICE.fetch(request);
const serviceRespTxt = await serviceResp.text();
return new Response(
`Hello from module worker b and also: ${serviceRespTxt}`
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name = "module-worker-b"

services = [
{ binding = "SERVICE", service = "module-worker-a" }
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fetch(req, env) {
return new Response(`Hello from module worker c (${env.MY_ENV})`);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name = "module-worker-c"

[env.staging.vars]
MY_ENV = "staging"

[env.production.vars]
MY_ENV = "prod"
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fetch(req, env) {
return new Response(`Hello from module worker d (${env.MY_ENV})`);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name = "module-worker-d"

[env.staging.vars]
MY_ENV = "staging"

[env.production.vars]
MY_ENV = "prod"
25 changes: 25 additions & 0 deletions fixtures/external-service-bindings-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "external-service-bindings-app",
"private": true,
"description": "A test for external service bindings",
"scripts": {
"mod-a-dev": "wrangler dev module-worker-a/index.ts --local --port 8500",
"mod-b-dev": "wrangler dev module-worker-b/index.ts --local --port 8501",
"ser-a-dev": "wrangler dev service-worker-a/index.ts --local --port 8502",
"mod-c-dev": "wrangler dev module-worker-c/index.ts --local --port 8503 --env staging",
"mod-d-dev": "wrangler dev module-worker-d/index.ts --local --port 8504 --env production",
"pages-dev": "cd pages-functions-app && npx wrangler pages dev public --port 8505 --service MODULE_A_SERVICE=module-worker-a MODULE_B_SERVICE=module-worker-b SERVICE_A_SERVICE=service-worker-a STAGING_MODULE_C_SERVICE=module-worker-c@staging STAGING_MODULE_D_SERVICE=module-worker-d@staging",
"dev": "npx concurrently -s first -k 'npm run mod-a-dev' 'npm run mod-b-dev' 'npm run ser-a-dev' 'npm run mod-c-dev' 'npm run mod-d-dev' 'npm run pages-dev'",
"test": "npx vitest run",
"test:ci": "npx vitest run",
"test:watch": "npx vitest",
"type:tests": "tsc --noEmit"
},
"devDependencies": {
"undici": "^5.23.0",
"concurrently": "^8.2.1",
"@cloudflare/workers-tsconfig": "workspace:*",
"@cloudflare/workers-types": "^4.20221111.1",
"wrangler": "workspace:*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const onRequest = async ({ env, request }) => {
const getTextFrom = (fetcher: Fetcher) =>
fetcher.fetch(request).then((resp) => resp.text());

return Response.json({
moduleWorkerCResponse: await getTextFrom(env.STAGING_MODULE_C_SERVICE),
moduleWorkerDResponse: await getTextFrom(env.STAGING_MODULE_D_SERVICE),
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const onRequest = async ({ env, request }) => {
const getTextFrom = (fetcher: Fetcher) =>
fetcher.fetch(request).then((resp) => resp.text());

return Response.json({
moduleWorkerAResponse: await getTextFrom(env.MODULE_A_SERVICE),
moduleWorkerBResponse: await getTextFrom(env.MODULE_B_SERVICE),
serviceWorkerAResponse: await getTextFrom(env.SERVICE_A_SERVICE),
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "pages-functions-app",
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>This will never be served.</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
addEventListener("fetch", (event) => {
event.respondWith(new Response("Hello from service worker a"));
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
name = "service-worker-a"
main = "src/index.ts"
75 changes: 75 additions & 0 deletions fixtures/external-service-bindings-app/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { spawn } from "child_process";
import * as path from "path";
import type { ChildProcess } from "child_process";
import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { fetch, type Response } from "undici";

const waitUntilReady = async (url: string): Promise<Response> => {
let response: Response | undefined = undefined;

while (response === undefined) {
await new Promise((resolvePromise) => setTimeout(resolvePromise, 500));

try {
response = await fetch(url);
} catch {}
}

return response as Response;
};

const isWindows = process.platform === "win32";

describe("Pages Functions", () => {
let wranglerProcess: ChildProcess;

beforeAll(() => {
wranglerProcess = spawn("npm", ["run", "dev"], {
shell: isWindows,
cwd: path.resolve(__dirname, "../"),
env: { BROWSER: "none", ...process.env },
});
wranglerProcess.stdout?.on("data", (chunk) => {
console.log(chunk.toString());
});
wranglerProcess.stderr?.on("data", (chunk) => {
console.log(chunk.toString());
});
});

afterAll(async () => {
await new Promise((resolve, reject) => {
wranglerProcess.once("exit", (code) => {
if (!code) {
resolve(code);
} else {
reject(code);
}
});
wranglerProcess.kill("SIGTERM");
});
});

it("connects up Workers (both module and service ones) and fetches from them", async () => {
const combinedResponse = await waitUntilReady("http://localhost:8505/");
const json = await combinedResponse.json();
expect(json).toMatchInlineSnapshot(`
{
"moduleWorkerAResponse": "Hello from module worker a",
"moduleWorkerBResponse": "Hello from module worker b and also: Hello from module worker a",
"serviceWorkerAResponse": "Hello from service worker a",
}
`);
});

it("respects the environments specified for the service bindings (and doesn't connect if the env doesn't match)", async () => {
const combinedResponse = await waitUntilReady("http://localhost:8505/env");
const json = await combinedResponse.json();
expect(json).toMatchInlineSnapshot(`
{
"moduleWorkerCResponse": "Hello from module worker c (staging)",
"moduleWorkerDResponse": "You should start up wrangler dev --local on the STAGING_MODULE_D_SERVICE worker",
}
`);
});
});
16 changes: 16 additions & 0 deletions fixtures/external-service-bindings-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"include": [
"module-worker-a",
"module-worker-b",
"service-worker-a",
"module-worker-c",
"module-worker-d",
"pages-functions-app"
],
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"]
}
}
5 changes: 5 additions & 0 deletions packages/wrangler/src/api/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export interface UnstableDevOptions {
script_name?: string | undefined;
environment?: string | undefined;
}[];
services?: {
binding: string;
service: string;
environment?: string | undefined;
}[];
r2?: {
binding: string;
bucket_name: string;
Expand Down
8 changes: 7 additions & 1 deletion packages/wrangler/src/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ export type AdditionalDevProps = {
script_name?: string | undefined;
environment?: string | undefined;
}[];
services?: {
binding: string;
service: string;
environment?: string;
}[];
r2?: {
binding: string;
bucket_name: string;
Expand Down Expand Up @@ -819,6 +824,7 @@ function getBindingsAndAssetPaths(args: StartDevOptions, configParam: Config) {
vars: { ...args.vars, ...cliVars },
durableObjects: args.durableObjects,
r2: args.r2,
services: args.services,
d1Databases: args.d1Databases,
});

Expand Down Expand Up @@ -915,7 +921,7 @@ function getBindings(
],
dispatch_namespaces: configParam.dispatch_namespaces,
mtls_certificates: configParam.mtls_certificates,
services: configParam.services,
services: [...(configParam.services || []), ...(args.services || [])],
analytics_engine_datasets: configParam.analytics_engine_datasets,
unsafe: {
bindings: configParam.unsafe.bindings,
Expand Down
51 changes: 51 additions & 0 deletions packages/wrangler/src/pages/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ const DURABLE_OBJECTS_BINDING_REGEXP = new RegExp(
*/
const BINDING_REGEXP = new RegExp(/^(?<binding>[^=]+)(?:=(?<ref>[^\s]+))?$/);

/* SERVICE_BINDING_REGEXP matches strings like:
* - "binding=service"
* - "binding=service@environment"
* This is used to capture both the binding name (how the binding is used in JS) alongside the name of the service it needs to bind to.
* Additionally it can also accept an environment which indicates what environment the service has to be running for.
*/
const SERVICE_BINDING_REGEXP = new RegExp(
/^(?<binding>[^=]+)=(?<service>[^@\s]+)(@(?<environment>.*)$)?$/
);

export function Options(yargs: CommonYargsArgv) {
return yargs
.positional("directory", {
Expand Down Expand Up @@ -144,6 +154,11 @@ export function Options(yargs: CommonYargsArgv) {
type: "array",
description: "R2 bucket to bind (--r2 R2_BINDING)",
},
service: {
type: "array",
description: "Service to bind (--service SERVICE=SCRIPT_NAME)",
alia: "s",
},
"live-reload": {
type: "boolean",
default: false,
Expand Down Expand Up @@ -199,6 +214,7 @@ export const Handler = async ({
do: durableObjects = [],
d1: d1s = [],
r2: r2s = [],
service: requestedServices = [],
liveReload,
localProtocol,
persistTo,
Expand Down Expand Up @@ -544,6 +560,40 @@ export const Handler = async ({
}
}

const services = requestedServices
.map((serviceBinding) => {
const { binding, service, environment } =
SERVICE_BINDING_REGEXP.exec(serviceBinding.toString())?.groups || {};

if (!binding || !service) {
logger.warn(
"Could not parse Service binding:",
serviceBinding.toString()
);
return;
}

// Envs get appended to the end of the name
let serviceName = service;
if (environment) {
serviceName = `${service}-${environment}`;
}

return {
binding,
service: serviceName,
environment,
};
})
.filter(Boolean) as NonNullable<AdditionalDevProps["services"]>;

if (services.find(({ environment }) => !!environment)) {
// We haven't yet properly defined how environments of service bindings should
// work, so if the user is using an environment for any of their service
// bindings we warn them that they are experimental
logger.warn("Support for service binding environments is experimental.");
}

const { stop, waitUntilExit } = await unstable_dev(entrypoint, {
ip,
port,
Expand All @@ -557,6 +607,7 @@ export const Handler = async ({
.map((binding) => binding.toString().split("="))
.map(([key, ...values]) => [key, values.join("=")])
),
services,
kv: kvs
.map((kv) => {
const { binding, ref } =
Expand Down
Loading

0 comments on commit a1f212e

Please sign in to comment.