workers-sdk icon indicating copy to clipboard operation
workers-sdk copied to clipboard

RFC: wrangler as an API

Open threepointone opened this issue 2 years ago • 9 comments

Premise: wrangler should be usable in "code", enabling folks to build their own tooling and abstractions on top of it, and potentially preventing feature creep in wrangler itself. Some usecases that have come up -

  • The first usecase that's come up is writing integration tests (tracking at https://github.com/cloudflare/wrangler2/issues/1183). At it's core, the usecase here is to program wrangler itself, and build one's own scaffoling on top of it.

  • Something similar, is the Pages+Wrangler convergence. We would like Pages to have every feature that wrangler has, and it shouldn't require a rewrite to do. A major portion of Pages functionality should able to be built on top of wrangler (and newer features that are still generic should be built into wrangler). So if wrangler had a first class api, the pages cli/commands/configuration would simply be an abstraction on top of it. This not only makes Pages better, but it proves that anyone wanting to build a platform on top of Workers should be able to just reuse the tooling that wrangler provides.

  • A simpler/smaller usecase, is to enable alternate configuration formats. The JS community doesn't commonly use toml, and would prefer to use json/js/typescript/whatever. We probably shouldn't be adding newer configuration formats, but having a first class api means we can get, at minimum, static type checking for free. (related: https://github.com/cloudflare/wrangler2/issues/1165)

  • Internal to Cloudflare, folks write Workers to control Cloudflare itself, but the configuration is a bit different. Specifically, account ids aren't necessary (a lot of these workers are run for every worker on the control plane, for example), some bindings are different, and the way they're published is dramatically different. That said, a lot of things are similar; they'd like to use typescript/wasm, third party dependencies from npm, as well as the bindings we already enable, and so on. Having an api will let them use functionality that's already built in wrangler, and run it next to their own tooling/infrastructure.


I think a first step here is to enable integrations tests as described in https://github.com/cloudflare/wrangler2/issues/1183, that should flesh out some of the bsaic things we need to move forward on the other usecases.

threepointone avatar Jun 05 '22 15:06 threepointone

I agree that integration tests are a great User 0 case for this.

But I'm curious, there are community dev tools out there that use the CF API endpoints directly (https://denoflare.dev/) or libraries that do the same (https://github.com/fab-spec/fab/tree/main/packages/deployer-cf-workers), and I'm guessing a bunch of internal tools at workplaces that do this. I'm wondering what other ones are people aware of? Would be good to know if they have any specific needs a Wrangler API still wouldn't support.

geelen avatar Jun 06 '22 16:06 geelen

I bet @jplhomer would know

threepointone avatar Jun 06 '22 17:06 threepointone

Super cool idea ❤️

RE: other tooling, I don't know if there are specific tools. I had considered adding tooling to Flareact that made it super easy to create things like Durable Objects + add them to wrangler.toml for you automatically, but I never got around to it. If wrangler were usable as a JS API, that would have made it even easier!

Over at Shopify, we're embarking on a couple related projects:

  1. We've been investigating a new CLI experience that has the same spirit of plugability and extensibility. @pepicrft has been leading the charge on that and might have opinions — I'm sure we can share more when it's public 😄
  2. We built a small oxygen-preview CLI which is essentially a stripped-down version of Miniflare. However, we still have to manually deal with a custom configuration file, build management, reloading and change detection, etc. We could probably use a wrangler API instead for the Oxygen platform when that's available!

jplhomer avatar Jun 08 '22 12:06 jplhomer

For the Pages local dev server, we need the following from a dev server API:

  • pass it a script as an in-memory string
  • pass a script filepath with watch mode
  • set the running port
  • customize the logger (we override the prefix with pages: rather than mf:
  • logUnhandledRejections instead of crashing on failure
  • sourcemaps
  • KV namespace bindings
  • Durable Object bindings
  • Service bindings to other Workers Services
  • .dev.vars and explicitly passed env var bindings too
  • A custom Service binding which uses wrangler'y fetch, Request, Response objects to let us serve static assets from either the filesystem, or from some other locally running process (e.g. npx react-scripts start)
  • data persistence for KV, Durable Objects and Cache
  • This
  • ability to reload arbitrarily
  • ability to dispose of the server

The following are nice-to-haves:

  • conditionally disable cfFetch
  • initialize with an empty script/nonexistent filepath and not crash
  • live reload
  • option to automatically open in browser
  • auto-cleanup on SIGTERM/SIGINT

This is all we use today in our local-only dev server. We haven't yet figured out how/if we'll make remote work.

GregBrimble avatar Jun 22 '22 16:06 GregBrimble

Some thoughts about a design for "wrangler as an API"

After some internal discussion and exploration, we've tentatively settled on a design for worker testing that looks like treating workers as an EventTarget, specifically the dispatchEvent method.

// index.ts

export default {
  scheduled: (event, env, ctx) => {
    ctx.waitUntil(async () => {
      await env.MY_KV_STORE.put("last-trigger": new Date())
    })
  },

  fetch: async (request) => {
    const name = await request.headers.get("NAME") ?? "world";
    return new Response(`Hello, ${name}!`);
  }
}

// index.spec.ts

import { makeWorker } from "wrangler/test";
import type { Worker } from "wrangler/test";
import defaultExports from "./index.ts";

const worker: Worker = makeWorker("./index.ts");

it("says hello world", async () => {
  const response = await worker.fetch("some-url.com");
  await expect(response.text()).resolves.toEqual("Hello, world!");

  // this is functionally equivalent to `worker.fetch`
  const response2 = await worker.dispatchEvent("fetch",
    new Request("some-url.com", { headers: { NAME: "Xanathar" } })
  );
  await expect(response.text()).resolves.toEqual("Hello, Xanathar!");
});

it("writes to KV when triggered", async () => {
  await worker.cron("* * * * *"); // or `worker.dispatchEvent("cron", "* * * * *")`;
  expect(worker.MY_KV_STORE).hasProperty<number>("last-trigger");
})

This implies that "wrangler as a library" should also have this sort of design, e.g. to allow wrangler/test or wrangler-environment-jest or whatever to simply delegate:

export const makeWorker = (file: string, options?: MakeWorkerOptions): DevSession => {
  return wrangler.dev({ file, ...options })
}

Additionally, it makes sense to have commands be EventEmitters, since (especially for long-running commands like dev) there will be multiple lifecycle hooks clients want to listen to (see @GregBrimble's comment above).

import { dev } from 'wrangler';

const devSession = dev({ 
  source: "path/to/index.ts",
  sourceMaps: true,
  // ...
  });

devSession.on("reload", (reloadEvent) => handleReloadEvent(reloadEvent));

Combined with the EventTarget implementation, this provides an idiomatic two-way channel for communication.

import { dev } from 'wrangler';
import process from 'node:process';

const devSession = dev({ 
  source: "path/to/index.ts",
  sourceMaps: true,
  // ...
  });

devSession.on("reload", (reloadEvent) => handleReloadEvent(reloadEvent));

process.on("SIGKILL", () => { devSession.dispatchEvent("kill"); });

Internally, this would be useful for decoupling our own code such that every command looks essentially like:

  1. Parse input into a command
  2. Attach hooks to the command that displays output to the user

This separation of user-facing output from core wrangler APIs will be really good dogfooding of our own API, and will allow us to spot issues with the design before we stabilize.

Lastly, taking some inspiration from ExecaChildProcess, we could also have commands implement the Promise API, so that for shorter-lived commands (e.g. wrangler secret put) a user could simply await the command without needing to tap into the various lifecycle hooks.

This also provides an idiomatic way to "wait for completion" of longer-running commands after setting up various lifecycle hooks:

import { dev } from 'wrangler';
import process from 'node:process';

const devSession = dev({ 
  source: "path/to/index.ts",
  sourceMaps: true,
  // ...
  });

devSession.on("reload", (reloadEvent) => handleReloadEvent(reloadEvent));

process.on("SIGKILL", () => { devSession.dispatchEvent("kill"); });

await devSession;

I've done a little experimenting and it seems like this should work fairly well. I'll start with the dev command, since that's the highest priority, but I think once this design has been proved out it would be worthwhile to migrate over all the wrangler commands.

I'm not convinced we need to implement the entire spec for, say, EventEmitter (in particular I'm a little hung up on exposing the emit function to the user, which really should only be used internally by the command), but I think it's a good starting point.

Thoughts?

caass avatar Aug 03 '22 14:08 caass

devSession.on("reload", (reloadEvent) => handleReloadEvent(reloadEvent)); Having something like this would be really useful for implementing live reloads, https://github.com/cloudflare/wrangler2/issues/1167. The current implementation in miniflare does this in a similar way - live reload event listener

cameron-robey avatar Aug 03 '22 16:08 cameron-robey

Following a discussion with @IgorMinar, we've decided to focus solely on implementing an EventTarget-based interface, since 1. that's a browser standard, rather than a Node API, and 2. it has a smaller API surface (i.e. addEventListener, removeEventListener, and dispatchEvent).

We also considered the Worker API, but found it didn't quite fit our needs as well.

Lastly, for an MVP we'll hold off on implementing Promise, and instead focus solely on implementing EventTarget.

I'll have a draft PR up shortly that more explicitly demonstrates what I have in mind.

caass avatar Aug 03 '22 23:08 caass

I have a use case where I would love to programmatically publish a worker where the files are never actually written to disk. I create them in memory and would love to be able to pass that to some sort of "wrangler api" to publish it for me.

nicksrandall avatar Sep 02 '22 03:09 nicksrandall

I have a use case where I would love to programmatically publish a worker where the files are never actually written to disk. I create them in memory and would love to be able to pass that to some sort of "wrangler api" to publish it for me.

This might be something you would be interested to track https://github.com/cloudflare/wrangler2/pull/1538

JacobMGEvans avatar Sep 02 '22 15:09 JacobMGEvans