Skip to content

Commit

Permalink
feat: support edge wasm module and asset similar to Next.js (#618)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Aug 19, 2024
1 parent 9148faf commit c49eae8
Show file tree
Hide file tree
Showing 18 changed files with 362 additions and 52 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/react-server-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"`,
},
Expand Down
83 changes: 83 additions & 0 deletions packages/react-server-next/src/vite/adapters/shared.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 9 additions & 0 deletions packages/react-server-next/src/vite/adapters/vercel/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
});

Expand Down
92 changes: 55 additions & 37 deletions packages/react-server/examples/basic/vite-plugin-wasm-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`;
}
Expand All @@ -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];
Expand Down
5 changes: 1 addition & 4 deletions packages/react-server/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/react-server/examples/og/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
# @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
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
Expand Down
2 changes: 1 addition & 1 deletion packages/react-server/examples/og/app/og/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<div
style={{
Expand Down
5 changes: 4 additions & 1 deletion packages/react-server/examples/og/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ export default function Page(props: PageProps) {
<a href="/og">/og</a>
</li>
<li>
<a href="/og?title=World">/og?title=World</a>
<a href="/og?title=Hello">/og?title=Hello</a>
</li>
</ul>
<form method="GET" action="/og">
<input name="title" placeholder="Input title..." />
</form>
</div>
);
}
10 changes: 10 additions & 0 deletions packages/react-server/examples/og/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 9 additions & 3 deletions packages/react-server/examples/og/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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"
Expand Down
Loading

0 comments on commit c49eae8

Please sign in to comment.