/**
* Build the browser Wasm target and serve the example with HMR.
*
* Bun's frontend dev server bundles `examples/web.html`, watches its module
* graph, and runs the React Fast Refresh transform. An edit to
* `examples/chat.tsx` keeps `useState`, so the GPUI canvas, the wasm module,
* and the scroll position all survive the update.
*
* The wasm half must never re-evaluate. `WebGpuixRenderer::init` fails with
* "GPUIX web is already running" once `WEB_APP` is set, and `gpui_web` appends
* its own canvas to `
`. What protects it is not that it lives in
* `node_modules` — Bun bundles it into the same client registry as the app.
* It is that Bun re-runs only the changed module and then walks *upward*
* through its importers, so an unchanged dependency stays evaluated and cached.
* Keep Wasm init in a module that can never become an HMR boundary and is never
* explicitly accepted. Only a full page reload re-creates it, which is fine.
*
* `/` serves the chat example and `/infinite` serves the infinite history one.
*
* bun scripts/web.ts # build the Wasm if it is missing, then serve
* bun scripts/web.ts --rebuild # force the cargo + wasm-bindgen step first
* bun scripts/web.ts --build-only # only cargo + wasm-bindgen, do not serve
* bun scripts/web.ts --production
*/
import { spawn } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import chatPage from "../examples/web.html"
import infinitePage from "../examples/web-infinite-chat.html"
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const NATIVE = path.join(ROOT, "packages", "native")
const PACKAGE_OUTPUT = path.join(NATIVE, "wasm")
const WASM = path.join(NATIVE, "target", "wasm32-unknown-unknown", "release", "gpuix_native.wasm")
const PRODUCTION_OUTPUT = path.join(ROOT, "website", "public", "chat-example")
/**
* `packages/native/.cargo/config.toml` links the Wasm with `--shared-memory`,
* so its `WebAssembly.Memory` is `shared: true`. That needs SharedArrayBuffer,
* which only exists in a cross-origin isolated document.
*/
const ISOLATION_HEADERS = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
}
/**
* `Bun.serve` has no way to add headers to an HTML route, so each bundled
* document is registered on a private path and re-sent from its public path with
* the two isolation headers. Only the top-level document needs them:
* `require-corp` constrains cross-origin subresources, and every asset the dev
* server emits is same-origin. Tracking: https://github.com/oven-sh/bun/issues/16873
*/
const EXAMPLES = [
{ path: "/", document: "/__gpuix-document-chat", page: chatPage },
{ path: "/infinite", document: "/__gpuix-document-infinite", page: infinitePage },
]
function run({ command, args, cwd }: { command: string; args: string[]; cwd: string }): Promise {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, stdio: "inherit" })
child.on("error", reject)
child.on("exit", (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} exited with code ${code ?? 1}`))
})
})
}
async function buildWasm(): Promise {
console.log("web: building gpuix-native for wasm32-unknown-unknown")
await run({
command: "cargo",
args: ["+nightly", "build", "--target", "wasm32-unknown-unknown", "--no-default-features", "--release", "--lib"],
cwd: NATIVE,
})
fs.mkdirSync(PACKAGE_OUTPUT, { recursive: true })
console.log("web: generating the @gpuix/native browser loader")
await run({
command: "wasm-bindgen",
args: [WASM, "--target", "web", "--out-dir", PACKAGE_OUTPUT, "--out-name", "gpuix-web"],
cwd: NATIVE,
})
}
async function buildProduction(): Promise {
fs.rmSync(PRODUCTION_OUTPUT, { recursive: true, force: true })
console.log(`web: bundling the chat example into ${path.relative(ROOT, PRODUCTION_OUTPUT)}`)
const bundle = await Bun.build({
entrypoints: [path.join(ROOT, "examples", "web-chat.tsx")],
outdir: PRODUCTION_OUTPUT,
target: "browser",
format: "esm",
minify: true,
naming: { entry: "chat.js", asset: "[name].[ext]" },
})
if (!bundle.success) {
for (const message of bundle.logs) console.error(message)
throw new Error("browser bundle failed")
}
fs.copyFileSync(path.join(ROOT, "examples", "web.css"), path.join(PRODUCTION_OUTPUT, "web.css"))
}
async function main() {
const buildOnly = process.argv.includes("--build-only")
const production = process.argv.includes("--production")
// `browser.mjs` needs both halves of the wasm-bindgen output. Checking only
// the `.wasm` lets a half-finished run skip the build and then fail at import.
const missing = ["gpuix-web.js", "gpuix-web_bg.wasm"].some(
(file) => !fs.existsSync(path.join(PACKAGE_OUTPUT, file)),
)
if (buildOnly || missing || process.argv.includes("--rebuild")) {
await buildWasm()
}
if (buildOnly) {
console.log(`web: generated ${path.relative(ROOT, PACKAGE_OUTPUT)}`)
return
}
// `examples/` imports `@gpuix/react` through its `main`, so `dist` has to
// exist. Run `bun run dev` in `packages/react` to keep it fresh while editing
// the library itself.
console.log("web: building @gpuix/react")
await run({ command: "bun", args: ["run", "build"], cwd: path.join(ROOT, "packages", "react") })
if (production) {
await buildProduction()
return
}
const routes: Record = {}
for (const example of EXAMPLES) {
routes[example.document] = example.page
routes[example.path] = async (_request: Request, self: { url: string }) => {
const bundled = await fetch(new URL(example.document, self.url))
const headers = new Headers(bundled.headers)
for (const [key, value] of Object.entries(ISOLATION_HEADERS)) {
headers.set(key, value)
}
return new Response(bundled.body, { status: bundled.status, headers })
}
}
const server = Bun.serve({
port: Number(process.env.PORT || 4173),
routes: routes as Parameters[0]["routes"],
development: { hmr: true, console: true },
})
for (const example of EXAMPLES) {
console.log(`web: ${new URL(example.path, server.url)}`)
}
}
main().catch((error) => {
console.error(`web: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})