Skip to content

Commit

Permalink
feat(workerd): remote eval api (#23)
Browse files Browse the repository at this point in the history
* feat(workerd): remote eval api

* chore: workerd-repl demo

* chore: lockfile

* chore: readme

* chore: rename

* chore: readme

* chore: tweak

* test: e2e
  • Loading branch information
hi-ogawa authored Apr 14, 2024
1 parent 17e41bd commit 78a58b4
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 7 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jobs:
- run: pnpm -C examples/react-ssr test-e2e-preview
- run: pnpm -C examples/react-ssr test-e2e-workerd
- run: pnpm -C examples/react-ssr-workerd test-e2e
- run: pnpm -C examples/workerd-cli test
- run: pnpm -C examples/react-server test-e2e
- run: pnpm -C examples/react-server build
- run: pnpm -C examples/react-server test-e2e-preview
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ pnpm build
pnpm -C examples/react-ssr dev
pnpm -C examples/react-ssr-workerd dev
pnpm -C examples/react-server dev
pnpm -C examples/workerd-cli cli
```
24 changes: 24 additions & 0 deletions examples/workerd-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# workerd-cli

```sh
$ pnpm cli
[mf:inf] Ready on http://127.0.0.1:44031

> env.kv.list()
{ keys: [], list_complete: true, cacheStatus: null }

> env.kv.put("hello", "world")

> env.kv.list()
{ keys: [ { name: 'hello' } ], list_complete: true, cacheStatus: null }

> env.kv.get("hello")
world

> (await import("/wrangler.toml?raw")).default
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
kv_namespaces = [
{ binding = "kv", id = "test-namespace" }
]
```
43 changes: 43 additions & 0 deletions examples/workerd-cli/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import test from "node:test";
import childProcess from "node:child_process";

test("basic", async () => {
const proc = childProcess.spawn("pnpm", ["cli"]);
using _ = proc;
const helper = createProcessHelper(proc);
await helper.waitFor((out) => out.includes("[mf:inf] Ready"));
proc.stdin.write(`env.kv.list()\n`);
await helper.waitFor((out) => out.includes("{ keys: []"));
});

function createProcessHelper(
proc: childProcess.ChildProcessWithoutNullStreams,
) {
let stdout = "";
const listeners = new Set<() => void>();
proc.stdout.on("data", (data) => {
stdout += String(data);
for (const f of listeners) {
f();
}
});

async function waitFor(predicate: (stdout: string) => boolean) {
return new Promise<void>((resolve) => {
const listener = () => {
if (predicate(stdout)) {
resolve();
listeners.delete(listener);
}
};
listeners.add(listener);
});
}

return {
get stdout() {
return stdout;
},
waitFor,
};
}
16 changes: 16 additions & 0 deletions examples/workerd-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@hiogawa/vite-environment-examples-workerd-cli",
"private": true,
"type": "module",
"scripts": {
"cli": "tsx src/cli.ts",
"test": "node --import tsx/esm --test e2e/*.test.ts"
},
"dependencies": {},
"devDependencies": {
"@hiogawa/vite-plugin-workerd": "workspace:*"
},
"volta": {
"extends": "../../package.json"
}
}
85 changes: 85 additions & 0 deletions examples/workerd-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { createServer } from "vite";
import {
createWorkerdDevEnvironment,
type WorkerdDevEnvironment,
} from "@hiogawa/vite-plugin-workerd";
import { Log } from "miniflare";
import repl from "node:repl";

async function main() {
const server = await createServer({
clearScreen: false,
plugins: [
{
name: "virtual-repl",
resolveId(source, _importer, _options) {
if (source.startsWith("virtual:repl/")) {
return "\0" + source;
}
return;
},
load(id, _options) {
if (id.startsWith("\0virtual:repl/")) {
const cmd = id.slice("\0virtual:repl/".length);
return decodeURI(cmd);
}
return;
},
},
],
environments: {
workerd: {
resolve: {
noExternal: true,
},
dev: {
createEnvironment: (server, name) =>
createWorkerdDevEnvironment(server, name, {
miniflare: {
log: new Log(),
},
wrangler: {
configPath: "./wrangler.toml",
},
}),
},
},
},
});
const devEnv = server.environments["workerd"] as WorkerdDevEnvironment;

async function evaluate(cmd: string) {
if (!cmd.includes("return")) {
cmd = `return ${cmd}`;
}
const entrySource = `export default async function({ env }) { ${cmd} }`;
const entry = "virtual:repl/" + encodeURI(entrySource);
await devEnv.api.eval(
entry,
async function (this: any, mod: any) {
const result = await mod.default({ env: this.env });
if (typeof result !== "undefined") {
console.log(result);
}
},
[],
);
}

const replServer = repl.start({
eval: async (cmd, _context, _filename, callback) => {
try {
await evaluate(cmd);
(callback as any)(null);
} catch (e) {
callback(e as Error, null);
}
},
});

replServer.on("close", () => {
server.close();
});
}

main();
15 changes: 15 additions & 0 deletions examples/workerd-cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"include": ["src", "vite.config.ts"],
"compilerOptions": {
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"types": ["vite/client"],
"jsx": "react-jsx"
}
}
3 changes: 3 additions & 0 deletions examples/workerd-cli/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from "vite";

export default defineConfig({});
5 changes: 5 additions & 0 deletions examples/workerd-cli/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
kv_namespaces = [
{ binding = "kv", id = "test-namespace" }
]
40 changes: 34 additions & 6 deletions packages/workerd/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import {
} from "miniflare";
import { fileURLToPath } from "url";
import { DefaultMap, tinyassert } from "@hiogawa/utils";
import { ANY_URL, RUNNER_INIT_PATH, setRunnerFetchOptions } from "./shared";
import {
ANY_URL,
RUNNER_INIT_PATH,
setRunnerFetchOptions,
type RunnerEvalOptions,
RUNNER_EVAL_PATH,
} from "./shared";
import {
DevEnvironment,
type CustomPayload,
Expand All @@ -20,7 +26,7 @@ import { createMiddleware } from "@hattip/adapter-node/native-fetch";
import type { SourcelessWorkerOptions } from "wrangler";

interface WorkerdPluginOptions extends WorkerdEnvironmentOptions {
entry: string;
entry?: string;
}

interface WorkerdEnvironmentOptions {
Expand All @@ -47,12 +53,14 @@ export function vitePluginWorkerd(pluginOptions: WorkerdPluginOptions): Plugin {
},

configureServer(server) {
const entry = pluginOptions.entry;
if (!entry) {
return;
}
const devEnv = server.environments["workerd"] as WorkerdDevEnvironment;
const nodeMiddleware = createMiddleware(
(ctx) => devEnv.api.dispatchFetch(pluginOptions.entry, ctx.request),
{
alwaysCallNext: false,
},
(ctx) => devEnv.api.dispatchFetch(entry, ctx.request),
{ alwaysCallNext: false },
);
return () => {
server.middlewares.use(nodeMiddleware);
Expand Down Expand Up @@ -161,6 +169,7 @@ export async function createWorkerdDevEnvironment(

// custom environment api
const api = {
// fetch proxy
async dispatchFetch(entry: string, request: Request) {
const req = new MiniflareRequest(request.url, {
method: request.method,
Expand All @@ -178,6 +187,25 @@ export async function createWorkerdDevEnvironment(
headers: res.headers as any,
});
},

// playwright-like eval interface https://playwright.dev/docs/evaluating
// (de)serialization can be customized (currently JSON.stringify/parse)
async eval<T>(
entry: string,
fn: (mod: unknown, ...args: unknown[]) => T,
...args: unknown[]
): Promise<Awaited<T>> {
const res = await runnerObject.fetch(ANY_URL + RUNNER_EVAL_PATH, {
method: "POST",
body: JSON.stringify({
entry,
fnString: fn.toString(),
args,
} satisfies RunnerEvalOptions),
});
tinyassert(res.ok);
return (await res.json()) as any;
},
};

// workaround for tsup dts?
Expand Down
9 changes: 8 additions & 1 deletion packages/workerd/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { tinyassert } from "@hiogawa/utils";

export const RUNNER_INIT_PATH = "/__viteInit";
export const RUNNER_EVAL_PATH = "/__viteEval";
export const ANY_URL = "https://any.local";

export type RunnerEnv = {
__viteRoot: string;
__viteUnsafeEval: {
eval: (code: string, filename: string) => any;
eval: (code: string, filename?: string) => any;
};
__viteFetchModule: {
fetch: (request: Request) => Promise<Response>;
Expand All @@ -32,3 +33,9 @@ export function getRunnerFetchOptions(headers: Headers): RunnerFetchOptions {
tinyassert(raw);
return JSON.parse(decodeURIComponent(raw));
}

export type RunnerEvalOptions = {
entry: string;
fnString: string;
args: unknown[];
};
11 changes: 11 additions & 0 deletions packages/workerd/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
RUNNER_INIT_PATH,
getRunnerFetchOptions,
type RunnerEnv,
RUNNER_EVAL_PATH,
type RunnerEvalOptions,
} from "./shared";
import { ModuleRunner } from "vite/module-runner";

Expand Down Expand Up @@ -39,6 +41,15 @@ export class RunnerObject implements DurableObject {
return new Response(null, { status: 101, webSocket: pair[1] });
}

if (url.pathname === RUNNER_EVAL_PATH) {
tinyassert(this.#runner);
const options = await request.json<RunnerEvalOptions>();
const fn = this.#env.__viteUnsafeEval.eval(`() => ${options.fnString}`)();
const mod = await this.#runner.import(options.entry);
const result = await fn.apply({ env: this.#env }, [mod, ...options.args]);
return new Response(JSON.stringify(result ?? null));
}

tinyassert(this.#runner);
const options = getRunnerFetchOptions(request.headers);
const mod = await this.#runner.import(options.entry);
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 78a58b4

Please sign in to comment.