diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c26e2b640..dec3c4c84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,12 @@ jobs: - run: pnpm -C packages/react-server/examples/custom-out-dir cf-build - run: pnpm -C packages/react-server/examples/custom-out-dir test-e2e-cf-preview - run: pnpm -C packages/react-server/examples/custom-out-dir vc-build + - run: pnpm -C packages/react-server/examples/og test-e2e + - run: pnpm -C packages/react-server/examples/og build + - run: pnpm -C packages/react-server/examples/og test-e2e-preview + - run: pnpm -C packages/react-server/examples/og cf-build + - run: pnpm -C packages/react-server/examples/og test-e2e-cf-preview + - run: pnpm -C packages/react-server/examples/og vc-build test-react-server-package: runs-on: ubuntu-latest diff --git a/packages/react-server-next/package.json b/packages/react-server-next/package.json index 6ed0b5cbd..49e22180a 100644 --- a/packages/react-server-next/package.json +++ b/packages/react-server-next/package.json @@ -50,6 +50,7 @@ "@hiogawa/vite-plugin-ssr-middleware": "workspace:*", "@vitejs/plugin-react-swc": "^3.7.0", "esbuild": "^0.20.2", + "magic-string": "^0.30.8", "vite-tsconfig-paths": "^4.3.2" }, "devDependencies": { diff --git a/packages/react-server-next/src/vite/adapters/cloudflare/build.ts b/packages/react-server-next/src/vite/adapters/cloudflare/build.ts index 30ed7fad7..3594b15b5 100644 --- a/packages/react-server-next/src/vite/adapters/cloudflare/build.ts +++ b/packages/react-server-next/src/vite/adapters/cloudflare/build.ts @@ -60,6 +60,13 @@ export async function build({ outDir }: { outDir: string }) { format: "esm", platform: "browser", external: ["node:async_hooks"], + // externalize known module types + // https://developers.cloudflare.com/pages/functions/module-support + // https://github.com/cloudflare/workers-sdk/blob/a9b4f252ccbce4856ffc967e51c0aa8cf2e1bb4f/packages/workers-playground/src/QuickEditor/module-collection.ts#L78-L84 + loader: { + ".wasm": "copy", + ".bin": "copy", + }, define: { "process.env.NODE_ENV": `"production"`, }, diff --git a/packages/react-server-next/src/vite/adapters/shared.ts b/packages/react-server-next/src/vite/adapters/shared.ts new file mode 100644 index 000000000..50d1826cf --- /dev/null +++ b/packages/react-server-next/src/vite/adapters/shared.ts @@ -0,0 +1,83 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import type * as esbuild from "esbuild"; +import MagicString from "magic-string"; + +// cf. https://github.com/hi-ogawa/vite-plugins/blob/998561660c8a27e067e976d4ede9dca53984d41a/packages/pre-bundle-new-url/src/index.ts#L26 +export function esbuildPluginAssetImportMetaUrl(): esbuild.Plugin { + return { + name: esbuildPluginAssetImportMetaUrl.name, + setup(build) { + let outdir: string; + build.onStart(() => { + if (build.initialOptions.outdir) { + outdir = build.initialOptions.outdir; + } + if (build.initialOptions.outfile) { + outdir = path.dirname(build.initialOptions.outfile); + } + if (!outdir) { + throw new Error("unreachable"); + } + }); + + build.onLoad( + { filter: /\.[cm]?js$/, namespace: "file" }, + async (args) => { + const data = await fs.promises.readFile(args.path, "utf-8"); + if (data.includes("import.meta.url")) { + const output = new MagicString(data); + + // replace + // new URL("./xxx.bin", import.meta.url) + // with + // new URL("./__asset-xxx-(hash).bin", import.meta.url) + const matches = data.matchAll(assetImportMetaUrlRE); + for (const match of matches) { + const [urlStart, urlEnd] = match.indices![1]!; + const url = match[1]!.slice(1, -1); + if (url[0] !== "/") { + const absUrl = path.resolve(path.dirname(args.path), url); + if (fs.existsSync(absUrl)) { + const assetData = await fs.promises.readFile(absUrl); + const hash = crypto + .createHash("sha1") + .update(assetData) + .digest() + .toString("hex") + .slice(0, 8); + const name = path + .basename(absUrl) + .replace(/[^0-9a-zA-Z]/g, "_"); + const filename = + `__asset-${name}-${hash}` + path.extname(absUrl); + await fs.promises.writeFile( + path.join(outdir, filename), + assetData, + ); + output.update( + urlStart, + urlEnd, + JSON.stringify(`./${filename}`), + ); + } + } + } + if (output.hasChanged()) { + return { + loader: "js", + contents: output.toString(), + }; + } + } + return null; + }, + ); + }, + }; +} + +// https://github.com/vitejs/vite/blob/0f56e1724162df76fffd5508148db118767ebe32/packages/vite/src/node/plugins/assetImportMetaUrl.ts#L51-L52 +const assetImportMetaUrlRE = + /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg; diff --git a/packages/react-server-next/src/vite/adapters/vercel/build.ts b/packages/react-server-next/src/vite/adapters/vercel/build.ts index bdabeb35c..18f5f21c0 100644 --- a/packages/react-server-next/src/vite/adapters/vercel/build.ts +++ b/packages/react-server-next/src/vite/adapters/vercel/build.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { PrerenderManifest } from "@hiogawa/react-server/plugin"; +import { esbuildPluginAssetImportMetaUrl } from "../shared"; const configJson = { version: 3, @@ -103,12 +104,20 @@ export async function build({ format: "esm", platform: runtime === "node" ? "node" : "browser", external: ["node:async_hooks"], + loader: + runtime === "node" + ? undefined + : { + ".wasm": "copy", + ".bin": "copy", + }, define: { "process.env.NODE_ENV": `"production"`, }, logOverride: { "ignored-bare-import": "silent", }, + plugins: runtime === "node" ? [esbuildPluginAssetImportMetaUrl()] : [], }); await writeFile( join(buildDir, "esbuild-metafile.json"), diff --git a/packages/react-server/examples/basic/src/routes/test/wasm/page.tsx b/packages/react-server/examples/basic/src/routes/test/wasm/page.tsx index f4950a012..96f6a5dec 100644 --- a/packages/react-server/examples/basic/src/routes/test/wasm/page.tsx +++ b/packages/react-server/examples/basic/src/routes/test/wasm/page.tsx @@ -7,9 +7,7 @@ const getHighlither = once(async () => { return createHighlighterCore({ themes: [nord], langs: [js], - // non js extension file is not externalized, so we can transform this via `load` hook - // https://github.com/vitejs/vite/blob/fcf50c2e881356ea0d725cc563722712a2bf5695/packages/vite/src/node/plugins/resolve.ts#L810-L818 - loadWasm: import("shiki/onig.wasm" as string), + loadWasm: import("shiki/onig.wasm?module" as string), }); }); diff --git a/packages/react-server/examples/basic/vite-plugin-wasm-module.ts b/packages/react-server/examples/basic/vite-plugin-wasm-module.ts index ef6ed9136..846fbbd15 100644 --- a/packages/react-server/examples/basic/vite-plugin-wasm-module.ts +++ b/packages/react-server/examples/basic/vite-plugin-wasm-module.ts @@ -3,46 +3,63 @@ import path from "node:path"; import MagicString from "magic-string"; import { type ConfigEnv, type Plugin } from "vite"; -// normalize wasm import for various environments -// - CF (including Vercel Edge): keep import "xxx.wasm" -// - Others: replace it with explicit instantiation of `WebAssembly.Module` -// - inline -// - local asset + fs.readFile - -// references +// cf. // https://developers.cloudflare.com/pages/functions/module-support/#webassembly-modules // https://github.com/withastro/adapters/blob/cd4c0842aadc58defc67f4ccf6d6ef6f0401a9ac/packages/cloudflare/src/utils/cloudflare-module-loader.ts#L213-L216 // https://vercel.com/docs/functions/wasm // https://github.com/unjs/unwasm -export function wasmModulePlugin(options: { - mode: "inline" | "asset-fs" | "asset-import"; -}): Plugin { +// +// input +// import wasm from "xxx.wasm?module" +// +// output (fs / dev) +// export default new WebAssembly.Module(fs.readFileSync("/absolute-path-to/xxx.wasm")) +// +// output (fs / build) +// export default new WebAssembly.Module(fs.readFileSync(fileURLToPath(new URL("./relocated-xxx.wasm", import.meta.url).href))) +// +// output (import / build) +// import wasm from "./relocated-xxx.wasm" +// +export function wasmModulePlugin(options: { mode: "fs" | "import" }): Plugin { + const MARKER = "\0virtual:wasm-module"; let env: ConfigEnv; + return { name: wasmModulePlugin.name, - config(_config, env_) { + config(_, env_) { env = env_; }, - load(id) { - if (id.endsWith(".wasm")) { - // inline + WebAssembly.Module - if (options.mode === "inline") { - const base64 = fs.readFileSync(id).toString("base64"); - return ` - const buffer = Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)); - export default new WebAssembly.Module(buffer); - `; + resolveId: { + order: "pre", + async handler(source, importer, options) { + if (source.endsWith(".wasm?module")) { + const resolved = await this.resolve( + source.slice(0, -"?module".length), + importer, + options, + ); + if (resolved) { + return { id: MARKER + resolved.id }; + } } + }, + }, + load(id) { + if (id.startsWith(MARKER)) { + const file = id.slice(MARKER.length); - // file + WebAssembly.Module - if (options.mode === "asset-fs") { - let source = JSON.stringify(id); - if (env.command === "build") { + // readFile + new WebAssembly.Module + if (options.mode === "fs") { + let source: string; + if (env.command === "serve") { + source = JSON.stringify(file); + } else { const referenceId = this.emitFile({ type: "asset", - name: path.basename(id), - source: fs.readFileSync(id), + name: path.basename(file), + source: fs.readFileSync(file), }); source = `fileURLToPath(import.meta.ROLLUP_FILE_URL_${referenceId})`; } @@ -54,22 +71,23 @@ export function wasmModulePlugin(options: { `; } - // keep wasm import - if (options.mode === "asset-import") { - if (env.command === "build") { - const referenceId = this.emitFile({ - type: "asset", - name: path.basename(id), - source: fs.readFileSync(id), - }); - // temporary placeholder replaced during renderChunk - return `export default "__WASM_IMPORT_URL_${referenceId}"`; + // emit wasm asset + rewrite import + if (options.mode === "import") { + if (env.command === "serve") { + throw new Error("unsupported"); } + const referenceId = this.emitFile({ + type: "asset", + name: path.basename(file), + source: fs.readFileSync(file), + }); + // temporary placeholder which we replace during `renderChunk` + return `export default "__WASM_MODULE_IMPORT_${referenceId}"`; } } }, renderChunk(code, chunk) { - const matches = code.matchAll(/"__WASM_IMPORT_URL_(\w+)"/dg); + const matches = code.matchAll(/"__WASM_MODULE_IMPORT_(\w+)"/dg); const output = new MagicString(code); for (const match of matches) { const referenceId = match[1]; diff --git a/packages/react-server/examples/basic/vite.config.ts b/packages/react-server/examples/basic/vite.config.ts index 143e0ba07..3acf27fa3 100644 --- a/packages/react-server/examples/basic/vite.config.ts +++ b/packages/react-server/examples/basic/vite.config.ts @@ -31,10 +31,7 @@ export default defineConfig({ mdx(), testVitePluginVirtual(), wasmModulePlugin({ - mode: - process.env.VERCEL || process.env.CF_PAGES - ? "asset-import" - : "asset-fs", + mode: process.env.VERCEL || process.env.CF_PAGES ? "import" : "fs", }), { name: "cusotm-react-server-config", diff --git a/packages/react-server/examples/og/README.md b/packages/react-server/examples/og/README.md index e80cff6a7..255bd3661 100644 --- a/packages/react-server/examples/og/README.md +++ b/packages/react-server/examples/og/README.md @@ -1,6 +1,7 @@ # @vercel/og example -https://next-vite-example-og.vercel.app +- https://next-vite-example-og.vercel.app +- https://next-vite-example-og.pages.dev/ ```sh # local @@ -8,11 +9,17 @@ pnpm dev pnpm build pnpm preview -# deploy vercel +# deploy vercel serverless vercel projects add next-vite-example-og vercel link -p next-vite-example-og pnpm vc-build pnpm vc-release + +# deploy cloudflare pages +pnpm cf-build +pnpm cf-preview +wrangler pages project create next-vite-example-og --production-branch main --compatibility-date=2024-01-01 --compatibility-flags=nodejs_compat +pnpm cf-release ``` ## compare bundlers diff --git a/packages/react-server/examples/og/app/og/route.tsx b/packages/react-server/examples/og/app/og/route.tsx index 59def4507..83ff16547 100644 --- a/packages/react-server/examples/og/app/og/route.tsx +++ b/packages/react-server/examples/og/app/og/route.tsx @@ -6,7 +6,7 @@ export async function GET(request: Request) { const { ImageResponse } = await import("@vercel/og"); const url = new URL(request.url); - const title = url.searchParams.get("title") ?? "Hello!"; + const title = url.searchParams.get("title") ?? "Test"; return new ImageResponse(