Skip to content

Commit

Permalink
feat: vite module runner on browser (#33)
Browse files Browse the repository at this point in the history
* feat: vite module runner on browser

* wip: cli

* chore: tweak

* chore: handle error

* refactor: serve html

* fix: workaround createRequire banner

* chore: cleanup

* chore: readme

* wip: exposeBinding

* chore: manually transformWithEsbuild

* chore: not manually esbuild

* chore: remove debug

* chore: use `webCompatible: true`

* chore: comment

* refactor: use `page.evaluate`

* chore: readme

* feat: transform to inject return

* chore: tweak

* test: e2e

* ci: e2e

* test: cleanup

* chore: cleanup

* chore: cleanup
  • Loading branch information
hi-ogawa authored Apr 16, 2024
1 parent 597781a commit c4d707f
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 40 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
- 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/browser-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
15 changes: 15 additions & 0 deletions examples/browser-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# browser-cli

```sh
$ pnpm cli
> self.ReactDom = await import("react-dom");;
undefined
> ReactDom.render(<div>yay</div>, document.body)
ref: <Node>
> document.body.innerHTML
<div>yay</div>
```

## credits

- https://github.com/webdriverio/bx
18 changes: 18 additions & 0 deletions examples/browser-cli/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import test from "node:test";
import childProcess from "node:child_process";
import { createProcessHelper } from "../../workerd-cli/e2e/helper";

test("basic", async () => {
using proc = childProcess.spawn("pnpm", ["-s", "cli"]);
const helper = createProcessHelper(proc);
await helper.waitFor((out) => out.includes("> "));
const code = [
`const ReactDom = await import("react-dom");`,
`ReactDom.render(<div style={{ color: "red" }}>yay</div>, document.body);`,
`document.body.innerHTML;`,
].join("");
proc.stdin.write(code + "\n");
await helper.waitFor((out) =>
out.includes(`> <div style="color: red;">yay</div>`),
);
});
16 changes: 16 additions & 0 deletions examples/browser-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@hiogawa/vite-environment-examples-browser-cli",
"private": true,
"type": "module",
"scripts": {
"cli": "tsx src/cli.ts",
"test": "node --import tsx/esm --test --test-timeout 60000 e2e/*.test.ts"
},
"volta": {
"extends": "../../package.json"
},
"dependencies": {
"react": "18.3.0-canary-6c3b8dbfe-20240226",
"react-dom": "18.3.0-canary-6c3b8dbfe-20240226"
}
}
218 changes: 218 additions & 0 deletions examples/browser-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import {
createServer,
parseAstAsync,
type Plugin,
type PluginOption,
} from "vite";
import repl from "node:repl";
import { createManualPromise, tinyassert } from "@hiogawa/utils";
import nodeStream from "node:stream";
import { chromium } from "@playwright/test";
import type { ModuleRunner } from "vite/module-runner";

const headless = !process.env["CLI_HEADED"];
const extension = process.env["CLI_EXTENSION"] ?? "tsx";

async function main() {
const server = await createServer({
clearScreen: false,
appType: "custom",
plugins: [vitePluginVirtualEval({ extension }), vitePluginBrowserRunner()],
optimizeDeps: {
noDiscovery: true,
},
environments: {
custom: {
webCompatible: true,
resolve: {
noExternal: true,
},
dev: {
optimizeDeps: {
exclude: ["vite/module-runner"],
},
},
},
},
server: {
watch: null,
},
});
await server.listen();

const serverUrl = server.resolvedUrls?.local[0];
tinyassert(serverUrl);

// TODO: page.exposeFunction to handle fetchModule?
const browser = await chromium.launch({ headless });
const page = await browser.newPage();
await page.goto(serverUrl);

const isReady = createManualPromise<void>();
page.exposeFunction("__viteRunnerReady", () => isReady.resolve());
await isReady;

// evaluate repl input
async function evaluate(cmd: string) {
// TODO: invalidate virtual entry after eval
const entrySource = `export default async () => { ${cmd} }`;
const entry = "virtual:eval/" + encodeURI(entrySource);

// run ModuleRunner via page.evaluate as it supports nice serialization
const result = await page.evaluate(async (entry) => {
const runner: ModuleRunner = (globalThis as any).__runner;
try {
const mod = await runner.import(entry);
return mod.default();
} catch (e) {
return e;
}
}, entry);

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", async () => {
await browser.close();
await server.close();
});
}

function vitePluginVirtualEval({
extension,
}: {
extension: string;
}): PluginOption {
// virtual module for `virtual:eval/(source code)`
const virtual: Plugin = {
name: vitePluginVirtualEval.name + ":load",
resolveId(source, _importer, _options) {
if (source.startsWith("virtual:eval/")) {
// avoid "\0" since it's skipped by `createFilter`
// including default esbuild transform
return "_" + source + "." + extension;
}
return;
},
load(id, _options) {
if (id.startsWith("_virtual:eval/")) {
const encoded = id.slice(
"_virtual:eval/".length,
-("." + extension).length,
);
return decodeURI(encoded);
}
return;
},
transform(code, id, options) {
code;
id;
options;
},
};

// inject `return` to last expression statement
// [input]
// export default async () => { 1; 2; 3 }
// [output]
// export default async () => { 1; 2; return 3 }
const transform: Plugin = {
name: vitePluginVirtualEval.name + ":transform",
enforce: "post",
async transform(code, id, _options) {
if (id.startsWith("_virtual:eval/")) {
const ast = await parseAstAsync(code);
let outCode = code;
for (const node of ast.body) {
if (
node.type === "ExportDefaultDeclaration" &&
node.declaration.type === "ArrowFunctionExpression" &&
node.declaration.body.type === "BlockStatement"
) {
const last = node.declaration.body.body.at(-1);
if (last?.type === "ExpressionStatement") {
const start = (last as any).start;
outCode = code.slice(0, start) + "return " + code.slice(start);
}
}
}
return outCode;
}
return;
},
};

return [virtual, transform];
}

function vitePluginBrowserRunner(): Plugin {
return {
name: vitePluginBrowserRunner.name,
configureServer(server) {
// use custom (or ssr) environment since
// client.fetchModule doesn't apply ssrTransform,
// which is necessary for module runner execution.
const devEnv = server.environments["custom"];
tinyassert(devEnv);

return () => {
server.middlewares.use(async (req, res, next) => {
tinyassert(req.url);
const url = new URL(req.url, "https://any.local");

// serve html which starts module runner
if (url.pathname === "/") {
res.setHeader("content-type", "text/html;charset=utf-8");
res.end(/* html */ `
<script type="module">
const { start } = await import("/src/runner");
const runner = await start({
root: ${JSON.stringify(server.config.root)}
});
globalThis.__runner = runner;
globalThis.__viteRunnerReady();
</script>
`);
return;
}

// API endpoint for fetchModule
if (url.pathname === "/__viteFetchModule") {
tinyassert(req.method === "POST");
const stream = nodeStream.Readable.toWeb(req) as ReadableStream;
const args = JSON.parse(await streamToString(stream));
const result = await devEnv.fetchModule(...(args as [any, any]));
res.end(JSON.stringify(result));
return;
}
next();
});
};
},
};
}

async function streamToString(stream: ReadableStream<Uint8Array>) {
let result = "";
await stream.pipeThrough(new TextDecoderStream()).pipeTo(
new WritableStream({
write(chunk) {
result += chunk;
},
}),
);
return result;
}

main();
26 changes: 26 additions & 0 deletions examples/browser-cli/src/runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { tinyassert } from "@hiogawa/utils";
import { ESModulesEvaluator, ModuleRunner } from "vite/module-runner";

export async function start(options: { root: string }) {
const runner = new ModuleRunner(
{
root: options.root,
sourcemapInterceptor: false,
transport: {
fetchModule: async (...args) => {
const response = await fetch("/__viteFetchModule", {
method: "POST",
body: JSON.stringify(args),
});
tinyassert(response.ok);
const result = response.json();
return result as any;
},
},
hmr: false,
},
new ESModulesEvaluator(),
);

return runner;
}
15 changes: 15 additions & 0 deletions examples/browser-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", "e2e"],
"compilerOptions": {
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"noEmit": true,
"moduleResolution": "Bundler",
"module": "ESNext",
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"types": ["vite/client"],
"jsx": "react-jsx"
}
}
20 changes: 20 additions & 0 deletions examples/browser-cli/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";

export default defineConfig((_env) => ({
clearScreen: false,
environments: {
custom: {
dev: {
optimizeDeps: {
include: [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
],
},
},
},
},
}));
41 changes: 1 addition & 40 deletions examples/workerd-cli/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from "node:test";
import childProcess from "node:child_process";
import { createProcessHelper } from "./helper";

test("basic", async () => {
using proc = childProcess.spawn("pnpm", ["-s", "cli"]);
Expand All @@ -14,43 +15,3 @@ test("basic", async () => {
out.includes("@hiogawa/vite-environment-examples-workerd-cli"),
);
});

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) {
const promise = new Promise<void>((resolve) => {
const listener = () => {
if (predicate(stdout)) {
resolve();
listeners.delete(listener);
}
};
listeners.add(listener);
});
const timeout = sleep(5000).then(() => {
throw new Error("waitFor timeout", { cause: stdout });
});
return Promise.race([promise, timeout]);
}

return {
get stdout() {
return stdout;
},
waitFor,
};
}

function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms).unref());
}
Loading

0 comments on commit c4d707f

Please sign in to comment.