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

feat: vite module runner on browser #33

Merged
merged 24 commits into from
Apr 16, 2024
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
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