From 7b2fb70a67ac114414941ea93a1351871bfe46ea Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Wed, 2 Mar 2022 17:09:36 +0200 Subject: [PATCH] Expose WASM bindings in Middleware (#34437) This PR introduces a way to use WASM in middlewares. Next.js will find all `.wasm` imports in middlewares and load them as `WebAssembly.Module` objects, which then can be later instantiated. The metadata will be stored in `middleware-manifest.json` ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint` --- packages/next/build/webpack-config.ts | 9 ++ .../loaders/next-middleware-wasm-loader.ts | 21 ++++ .../plugins/functions-manifest-plugin.ts | 6 +- .../webpack/plugins/middleware-plugin.ts | 33 +++++- packages/next/server/next-server.ts | 1 + packages/next/server/require.ts | 12 +- packages/next/server/web/sandbox/context.ts | 32 +++++- packages/next/server/web/sandbox/sandbox.ts | 5 +- .../middleware-can-use-wasm-files/add.wasm | Bin 0 -> 126 bytes .../index.test.ts | 103 ++++++++++++++++++ 10 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts create mode 100755 test/e2e/middleware-can-use-wasm-files/add.wasm create mode 100644 test/e2e/middleware-can-use-wasm-files/index.test.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 3b280aba9737..82d9cb2d9e60 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1146,6 +1146,7 @@ export default async function getBaseWebpackConfig( 'noop-loader', 'next-middleware-loader', 'next-middleware-ssr-loader', + 'next-middleware-wasm-loader', ].reduce((alias, loader) => { // using multiple aliases to replace `resolveLoader.modules` alias[loader] = path.join(__dirname, 'webpack', 'loaders', loader) @@ -1529,6 +1530,14 @@ export default async function getBaseWebpackConfig( const webpack5Config = webpackConfig as webpack5.Configuration + webpack5Config.module?.rules?.unshift({ + test: /\.wasm$/, + issuerLayer: 'middleware', + loader: 'next-middleware-wasm-loader', + type: 'javascript/auto', + resourceQuery: /module/i, + }) + webpack5Config.experiments = { layers: true, cacheUnaffected: true, diff --git a/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts new file mode 100644 index 000000000000..58cf76c9b5e4 --- /dev/null +++ b/packages/next/build/webpack/loaders/next-middleware-wasm-loader.ts @@ -0,0 +1,21 @@ +import crypto from 'crypto' + +export type WasmBinding = { + filePath: string + name: string +} + +export default function MiddlewareWasmLoader(this: any, source: Buffer) { + const name = `wasm_${sha1(source)}` + const filePath = `server/middleware-chunks/${name}.wasm` + const binding: WasmBinding = { filePath, name } + this._module.buildInfo.nextWasmMiddlewareBinding = binding + this.emitFile(`/${filePath}`, source, null) + return `module.exports = ${name};` +} + +export const raw = true + +function sha1(source: string | Buffer) { + return crypto.createHash('sha1').update(source).digest('hex') +} diff --git a/packages/next/build/webpack/plugins/functions-manifest-plugin.ts b/packages/next/build/webpack/plugins/functions-manifest-plugin.ts index 4a5cf366e147..c8a04a2f34b9 100644 --- a/packages/next/build/webpack/plugins/functions-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/functions-manifest-plugin.ts @@ -3,7 +3,7 @@ import { sources, webpack5 } from 'next/dist/compiled/webpack/webpack' import { normalizePagePath } from '../../../server/normalize-page-path' import { FUNCTIONS_MANIFEST } from '../../../shared/lib/constants' import { getPageFromPath } from '../../entries' -import { collectAssets, getEntrypointInfo } from './middleware-plugin' +import { collectAssets, getEntrypointInfo, PerRoute } from './middleware-plugin' const PLUGIN_NAME = 'FunctionsManifestPlugin' export interface FunctionsManifest { @@ -52,7 +52,7 @@ export default class FunctionsManifestPlugin { createAssets( compilation: webpack5.Compilation, assets: any, - envPerRoute: Map, + perRoute: PerRoute, isEdgeRuntime: boolean ) { const functionsManifest: FunctionsManifest = { @@ -60,7 +60,7 @@ export default class FunctionsManifestPlugin { pages: {}, } - const infos = getEntrypointInfo(compilation, envPerRoute, isEdgeRuntime) + const infos = getEntrypointInfo(compilation, perRoute, isEdgeRuntime) infos.forEach((info) => { const { page } = info // TODO: use global default runtime instead of 'web' diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 7ada45ac32d4..02981076035c 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -10,6 +10,7 @@ import { MIDDLEWARE_SSR_RUNTIME_WEBPACK, } from '../../../shared/lib/constants' import { nonNullable } from '../../../lib/non-nullable' +import type { WasmBinding } from '../loaders/next-middleware-wasm-loader' const PLUGIN_NAME = 'MiddlewarePlugin' const MIDDLEWARE_FULL_ROUTE_REGEX = /^pages[/\\]?(.*)\/_middleware$/ @@ -27,6 +28,7 @@ export interface MiddlewareManifest { name: string page: string regexp: string + wasm?: WasmBinding[] } } } @@ -49,9 +51,14 @@ function getPageFromEntrypointName(pagePath: string) { return page } +export type PerRoute = { + envPerRoute: Map + wasmPerRoute: Map +} + export function getEntrypointInfo( compilation: webpack5.Compilation, - envPerRoute: Map, + { envPerRoute, wasmPerRoute }: PerRoute, isEdgeRuntime: boolean ) { const entrypoints = compilation.entrypoints @@ -87,6 +94,7 @@ export function getEntrypointInfo( infos.push({ env: envPerRoute.get(entrypoint.name) || [], + wasm: wasmPerRoute.get(entrypoint.name) || [], files, name: entrypoint.name, page, @@ -114,10 +122,14 @@ export default class MiddlewarePlugin { createAssets( compilation: webpack5.Compilation, assets: any, - envPerRoute: Map, + { envPerRoute, wasmPerRoute }: PerRoute, isEdgeRuntime: boolean ) { - const infos = getEntrypointInfo(compilation, envPerRoute, isEdgeRuntime) + const infos = getEntrypointInfo( + compilation, + { envPerRoute, wasmPerRoute }, + isEdgeRuntime + ) infos.forEach((info) => { middlewareManifest.middleware[info.page] = info }) @@ -152,7 +164,7 @@ export function collectAssets( createAssets: ( compilation: webpack5.Compilation, assets: any, - envPerRoute: Map, + { envPerRoute, wasmPerRoute }: PerRoute, isEdgeRuntime: boolean ) => void, options: { @@ -175,6 +187,7 @@ export function collectAssets( }) const envPerRoute = new Map() + const wasmPerRoute = new Map() compilation.hooks.afterOptimizeModules.tap(PLUGIN_NAME, () => { const { moduleGraph } = compilation as any @@ -187,6 +200,7 @@ export function collectAssets( ) { const middlewareEntries = new Set() const env = new Set() + const wasm = new Set() const addEntriesFromDependency = (dep: any) => { const module = moduleGraph.getModule(dep) @@ -203,6 +217,9 @@ export function collectAssets( const queue = new Set(middlewareEntries) for (const module of queue) { const { buildInfo } = module + if (buildInfo.nextWasmMiddlewareBinding) { + wasm.add(buildInfo.nextWasmMiddlewareBinding) + } if ( !options.dev && buildInfo && @@ -247,6 +264,7 @@ export function collectAssets( } envPerRoute.set(name, Array.from(env)) + wasmPerRoute.set(name, Array.from(wasm)) } } }) @@ -375,7 +393,12 @@ export function collectAssets( stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, (assets: any) => { - createAssets(compilation, assets, envPerRoute, options.isEdgeRuntime) + createAssets( + compilation, + assets, + { envPerRoute, wasmPerRoute }, + options.isEdgeRuntime + ) } ) } diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 69d30e7357d4..d31e33bb5ce1 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -1269,6 +1269,7 @@ export default class NextNodeServer extends BaseServer { name: middlewareInfo.name, paths: middlewareInfo.paths, env: middlewareInfo.env, + wasm: middlewareInfo.wasm, request: { headers: params.request.headers, method, diff --git a/packages/next/server/require.ts b/packages/next/server/require.ts index 9c34b8538300..a5cb61665bb0 100644 --- a/packages/next/server/require.ts +++ b/packages/next/server/require.ts @@ -11,6 +11,7 @@ import { normalizePagePath, denormalizePagePath } from './normalize-page-path' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' +import type { WasmBinding } from '../build/webpack/loaders/next-middleware-wasm-loader' export function pageNotFoundError(page: string): Error { const err: any = new Error(`Cannot find module for page: ${page}`) @@ -85,7 +86,12 @@ export function getMiddlewareInfo(params: { distDir: string page: string serverless: boolean -}): { name: string; paths: string[]; env: string[] } { +}): { + name: string + paths: string[] + env: string[] + wasm: WasmBinding[] +} { const serverBuildPath = join( params.distDir, params.serverless && !params.dev ? SERVERLESS_DIRECTORY : SERVER_DIRECTORY @@ -113,5 +119,9 @@ export function getMiddlewareInfo(params: { name: pageInfo.name, paths: pageInfo.files.map((file) => join(params.distDir, file)), env: pageInfo.env ?? [], + wasm: (pageInfo.wasm ?? []).map((binding) => ({ + ...binding, + filePath: join(params.distDir, binding.filePath), + })), } } diff --git a/packages/next/server/web/sandbox/context.ts b/packages/next/server/web/sandbox/context.ts index 42f480bb6a31..6685b4f6255e 100644 --- a/packages/next/server/web/sandbox/context.ts +++ b/packages/next/server/web/sandbox/context.ts @@ -1,6 +1,6 @@ import type { Context } from 'vm' import { Blob, File, FormData } from 'next/dist/compiled/formdata-node' -import { readFileSync } from 'fs' +import { readFileSync, promises as fs } from 'fs' import { requireDependencies } from './require' import { TransformStream } from 'next/dist/compiled/web-streams-polyfill' import cookie from 'next/dist/compiled/cookie' @@ -10,6 +10,7 @@ import { AbortSignal, } from 'next/dist/compiled/abort-controller' import vm from 'vm' +import type { WasmBinding } from '../../../build/webpack/loaders/next-middleware-wasm-loader' const WEBPACK_HASH_REGEX = /__webpack_require__\.h = function\(\) \{ return "[0-9a-f]+"; \}/g @@ -52,18 +53,19 @@ const caches = new Map< * run in within the context. It may or may not use a cache depending on * the parameters. */ -export function getModuleContext(options: { +export async function getModuleContext(options: { module: string onWarning: (warn: Error) => void useCache: boolean env: string[] + wasm: WasmBinding[] }) { let moduleCache = options.useCache ? caches.get(options.module) - : createModuleContext(options) + : await createModuleContext(options) if (!moduleCache) { - moduleCache = createModuleContext(options) + moduleCache = await createModuleContext(options) caches.set(options.module, moduleCache) } @@ -95,10 +97,11 @@ export function getModuleContext(options: { * 2. Dependencies that require runtime globals such as Blob. * 3. Dependencies that are scoped for the provided parameters. */ -function createModuleContext(options: { +async function createModuleContext(options: { onWarning: (warn: Error) => void module: string env: string[] + wasm: WasmBinding[] }) { const requireCache = new Map([ [require.resolve('next/dist/compiled/cookie'), { exports: cookie }], @@ -166,6 +169,8 @@ function createModuleContext(options: { return fetch(String(input), init) } + Object.assign(context, await loadWasm(options.wasm)) + return moduleCache } @@ -260,3 +265,20 @@ function buildEnvironmentVariablesFrom( const pairs = keys.map((key) => [key, process.env[key]]) return Object.fromEntries(pairs) } + +async function loadWasm( + wasm: WasmBinding[] +): Promise> { + const modules: Record = {} + + await Promise.all( + wasm.map(async (binding) => { + const module = await WebAssembly.compile( + await fs.readFile(binding.filePath) + ) + modules[binding.name] = module + }) + ) + + return modules +} diff --git a/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 576e8be7580c..b24451abcb26 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -1,3 +1,4 @@ +import type { WasmBinding } from '../../../build/webpack/loaders/next-middleware-wasm-loader' import type { RequestData, FetchEventResult } from '../types' import { getModuleContext } from './context' @@ -8,12 +9,14 @@ export async function run(params: { paths: string[] request: RequestData useCache: boolean + wasm: WasmBinding[] }): Promise { - const { runInContext, context } = getModuleContext({ + const { runInContext, context } = await getModuleContext({ module: params.name, onWarning: params.onWarning, useCache: params.useCache !== false, env: params.env, + wasm: params.wasm, }) for (const paramPath of params.paths) { diff --git a/test/e2e/middleware-can-use-wasm-files/add.wasm b/test/e2e/middleware-can-use-wasm-files/add.wasm new file mode 100755 index 0000000000000000000000000000000000000000..f22496d0b6c87704a3844134af2a9fad47fa344c GIT binary patch literal 126 zcmY+)F%E)25CzcxcYuwog{_@8@C=+}7_*ZYlLaC+R?E>mnzU4}d9bw*08e2AMpjml z05&ZblC2Pz?kbhTw*8PQj>db_6)*Gq8<13=Zi_x_bz!fX?PKawmJlsxohJwTbJ%CZ I4Fg~44-%gmga7~l literal 0 HcmV?d00001 diff --git a/test/e2e/middleware-can-use-wasm-files/index.test.ts b/test/e2e/middleware-can-use-wasm-files/index.test.ts new file mode 100644 index 000000000000..9e99b896601c --- /dev/null +++ b/test/e2e/middleware-can-use-wasm-files/index.test.ts @@ -0,0 +1,103 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP } from 'next-test-utils' +import path from 'path' +import fs from 'fs-extra' + +function baseNextConfig(): Parameters[0] { + return { + files: { + 'src/add.wasm': new FileRef(path.join(__dirname, './add.wasm')), + 'src/add.js': ` + import wasm from './add.wasm?module' + const instance$ = WebAssembly.instantiate(wasm); + + export async function increment(a) { + const { exports } = await instance$; + return exports.add_one(a); + } + `, + 'pages/_middleware.js': ` + import { increment } from '../src/add.js' + export default async function middleware(request) { + const input = Number(request.nextUrl.searchParams.get('input')) || 1; + const value = await increment(input); + return new Response(JSON.stringify({ input, value })); + } + `, + }, + } +} + +describe('middleware can use wasm files', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(await response.json()).toEqual({ + input: 1, + value: 2, + }) + }) + + it('can be called twice', async () => { + const response = await fetchViaHTTP(next.url, '/', { input: 2 }) + expect(await response.json()).toEqual({ + input: 2, + value: 3, + }) + }) + + it('lists the necessary wasm bindings in the manifest', async () => { + const manifestPath = path.join( + next.testDir, + '.next/server/middleware-manifest.json' + ) + const manifest = await fs.readJSON(manifestPath) + expect(manifest.middleware['/']).toMatchObject({ + wasm: [ + { + filePath: + 'server/middleware-chunks/wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d.wasm', + name: 'wasm_58ccff8b2b94b5dac6ef8957082ecd8f6d34186d', + }, + ], + }) + }) +}) + +describe('middleware can use wasm files with the experimental modes on', () => { + let next: NextInstance + + beforeAll(async () => { + const config = baseNextConfig() + config.files['next.config.js'] = ` + module.exports = { + webpack(config) { + config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' + + // Since Webpack 5 doesn't enable WebAssembly by default, we should do it manually + config.experiments = { ...config.experiments, asyncWebAssembly: true } + + return config + }, + } + ` + next = await createNext(config) + }) + afterAll(() => next.destroy()) + + it('uses the wasm file', async () => { + const response = await fetchViaHTTP(next.url, '/') + expect(await response.json()).toEqual({ + input: 1, + value: 2, + }) + }) +})