-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: vite module runner on browser (#33)
* 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
Showing
11 changed files
with
380 additions
and
40 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>`), | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
], | ||
}, | ||
}, | ||
}, | ||
}, | ||
})); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.