Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support edge wasm module and asset similar to Next.js #618

Merged
merged 18 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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