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(
/og
  • - /og?title=World + /og?title=Hello
  • +
    + +
    ); } diff --git a/packages/react-server/examples/og/e2e/basic.test.ts b/packages/react-server/examples/og/e2e/basic.test.ts new file mode 100644 index 000000000..2285032b8 --- /dev/null +++ b/packages/react-server/examples/og/e2e/basic.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert"; +import { expect, test } from "@playwright/test"; + +test("basic", async ({ page }) => { + const res = await page.goto("/og?title=Hi"); + assert(res); + expect(res.status()).toBe(200); + expect(res?.headers()).toMatchObject({ "content-type": "image/png" }); + await expect(page).toHaveScreenshot(); +}); diff --git a/packages/react-server/examples/og/e2e/basic.test.ts-snapshots/basic-1-chromium-linux.png b/packages/react-server/examples/og/e2e/basic.test.ts-snapshots/basic-1-chromium-linux.png new file mode 100644 index 000000000..a6bbab819 Binary files /dev/null and b/packages/react-server/examples/og/e2e/basic.test.ts-snapshots/basic-1-chromium-linux.png differ diff --git a/packages/react-server/examples/og/package.json b/packages/react-server/examples/og/package.json index 81a419ae1..bfcf1034a 100644 --- a/packages/react-server/examples/og/package.json +++ b/packages/react-server/examples/og/package.json @@ -7,10 +7,15 @@ "dev": "next dev", "build": "next build", "start": "next start", - "copy-assets": "cp node_modules/@vercel/og/dist/*.{ttf,wasm} .vercel/output/functions/index.func", - "vc-build": "VERCEL=1 pnpm build && pnpm copy-assets", + "cf-build": "CF_PAGES=1 pnpm build", + "cf-preview": "wrangler pages dev ./dist/cloudflare --compatibility-date=2024-01-01 --compatibility-flags=nodejs_compat", + "cf-release": "wrangler pages deploy ./dist/cloudflare --commit-dirty --branch main --project-name next-vite-example-og", + "vc-build": "VERCEL=1 pnpm build", "vc-release-staging": "vercel deploy --prebuilt", - "vc-release": "vercel deploy --prod --prebuilt" + "vc-release": "vercel deploy --prod --prebuilt", + "test-e2e": "playwright test", + "test-e2e-preview": "E2E_PREVIEW=1 playwright test", + "test-e2e-cf-preview": "E2E_PREVIEW=1 E2E_CF=1 pnpm test-e2e" }, "dependencies": { "@hiogawa/react-server": "workspace:*", @@ -28,6 +33,7 @@ "@types/react-dom": "latest", "@vercel/ncc": "^0.38.1", "@vercel/nft": "^0.27.3", + "magic-string": "^0.30.8", "rolldown": "^0.12.1", "rollup": "^4.18.1", "vite": "latest" diff --git a/packages/react-server/examples/og/playwright.config.ts b/packages/react-server/examples/og/playwright.config.ts new file mode 100644 index 000000000..1d33a5775 --- /dev/null +++ b/packages/react-server/examples/og/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.E2E_PORT || 6174); +const isPreview = Boolean(process.env.E2E_PREVIEW); +const command = isPreview + ? process.env["E2E_CF"] + ? `pnpm cf-preview --port ${port}` + : `pnpm start --port ${port} --strict-port` + : `pnpm dev --port ${port} --strict-port`; + +export default defineConfig({ + testDir: "e2e", + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: null, + deviceScaleFactor: undefined, + }, + }, + ], + webServer: { + command, + port, + }, + grepInvert: isPreview ? /@dev/ : /@build/, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 2 : 0, + reporter: "list", +}); diff --git a/packages/react-server/examples/og/vite-plugins.ts b/packages/react-server/examples/og/vite-plugins.ts new file mode 100644 index 000000000..a83e0c54c --- /dev/null +++ b/packages/react-server/examples/og/vite-plugins.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import path from "node:path"; +import MagicString from "magic-string"; +import { type ConfigEnv, type Plugin } from "vite"; + +// +// input +// fetch(new URL("./xxx", import.meta.url)) +// +// output (fs / dev) +// new Response(fs.readFileSync("/absolute-path-to/xxx") +// +// output (fs / build) +// import("node:fs").then(fs => new Response(fs.readFileSync(new URL("./relocated-xxx", import.meta.url).href))) +// +// output (import / build) +// import("./relocated-xxx.bin").then(mod => new Response(mod.default)) +// +export function fetchImportMetaUrlPlugin(options: { + mode: "fs" | "import"; +}): Plugin { + // cf. https://github.com/vitejs/vite/blob/0f56e1724162df76fffd5508148db118767ebe32/packages/vite/src/node/plugins/assetImportMetaUrl.ts#L51-L52 + const FETCH_ASSET_RE = + /\bfetch\s*\(new\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)\s*(?:,\s*)?\)/dg; + + let env: ConfigEnv; + + return { + name: fetchImportMetaUrlPlugin.name, + config(_, env_) { + env = env_; + }, + transform(code, id) { + if (code.includes("import.meta.url")) { + const output = new MagicString(code); + const matches = code.matchAll(FETCH_ASSET_RE); + for (const match of matches) { + const urlArg = match[1]!.slice(1, -1); + const absFile = path.resolve(id, "..", urlArg); + if (fs.existsSync(absFile)) { + let replacement!: string; + if (options.mode === "fs") { + if (env.command === "serve") { + replacement = `((async () => { + const fs = await import("node:fs"); + return new Response(fs.readFileSync(${JSON.stringify(absFile)})); + })())`; + } else { + const referenceId = this.emitFile({ + type: "asset", + name: path.basename(absFile), + source: fs.readFileSync(absFile), + }); + replacement = `((async () => { + const fs = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + return new Response(fs.readFileSync(fileURLToPath(import.meta.ROLLUP_FILE_URL_${referenceId}))); + })())`; + } + } + if (options.mode === "import") { + if (env.command === "serve") { + throw new Error("unsupported"); + } + const referenceId = this.emitFile({ + type: "asset", + name: path.basename(absFile) + ".bin", + source: fs.readFileSync(absFile), + }); + replacement = `"__FETCH_ASSET_IMPORT_${referenceId}".then(mod => new Response(mod.default))`; + } + const [start, end] = match.indices![0]!; + output.update(start, end, replacement); + } + } + if (output.hasChanged()) { + return { code: output.toString(), map: output.generateMap() }; + } + } + }, + renderChunk(code, chunk) { + const matches = code.matchAll(/"__FETCH_ASSET_IMPORT_(\w+)"/dg); + const output = new MagicString(code); + for (const match of matches) { + const referenceId = match[1]; + const assetFileName = this.getFileName(referenceId); + const importSource = + "./" + + path.relative( + path.resolve(chunk.fileName, ".."), + path.resolve(assetFileName), + ); + const [start, end] = match.indices![0]; + const replacement = `import(${JSON.stringify(importSource)})`; + output.update(start, end, replacement); + } + if (output.hasChanged()) { + return output.toString(); + } + }, + }; +} diff --git a/packages/react-server/examples/og/vite.config.ts b/packages/react-server/examples/og/vite.config.ts index 59f9d7907..cbb1ef7e4 100644 --- a/packages/react-server/examples/og/vite.config.ts +++ b/packages/react-server/examples/og/vite.config.ts @@ -1,6 +1,29 @@ import next from "next/vite"; import { defineConfig } from "vite"; +import { wasmModulePlugin } from "../basic/vite-plugin-wasm-module"; +import { fetchImportMetaUrlPlugin } from "./vite-plugins"; export default defineConfig({ - plugins: [next()], + plugins: [ + next({ + plugins: [ + { + name: "config", + config() { + return { + resolve: { + alias: { + "@vercel/og": "/node_modules/@vercel/og/dist/index.edge.js", + }, + }, + }; + }, + }, + wasmModulePlugin({ mode: process.env.CF_PAGES ? "import" : "fs" }), + fetchImportMetaUrlPlugin({ + mode: process.env.CF_PAGES ? "import" : "fs", + }), + ], + }), + ], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 752188223..ec285b98d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: esbuild: specifier: ^0.20.2 version: 0.20.2 + magic-string: + specifier: ^0.30.8 + version: 0.30.10 react: specifier: 19.0.0-rc-df5f2736-20240712 version: 19.0.0-rc-df5f2736-20240712 @@ -410,6 +413,9 @@ importers: '@vercel/nft': specifier: ^0.27.3 version: 0.27.3 + magic-string: + specifier: ^0.30.8 + version: 0.30.10 rolldown: specifier: ^0.12.1 version: 0.12.1