From dabd84669a5ca394b76397945f5c12cfaffae130 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Mon, 17 Apr 2023 12:05:51 +0200 Subject: [PATCH] Add client loader to enable importing server actions Made easier by https://github.com/facebook/react/pull/26632 --- apps/cloudflare-app/webpack.config.js | 13 +- apps/vercel-app/webpack.config.js | 11 +- packages/webpack-rsc/README.md | 59 +++++-- packages/webpack-rsc/package.json | 4 +- packages/webpack-rsc/src/index.ts | 13 +- .../src/webpack-rsc-client-loader.cts | 114 +++++++++++++ .../src/webpack-rsc-client-loader.test.ts | 151 ++++++++++++++++++ .../src/webpack-rsc-server-plugin.test.ts | 36 ++++- .../src/webpack-rsc-server-plugin.ts | 13 +- 9 files changed, 389 insertions(+), 25 deletions(-) create mode 100644 packages/webpack-rsc/src/webpack-rsc-client-loader.cts create mode 100644 packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts diff --git a/apps/cloudflare-app/webpack.config.js b/apps/cloudflare-app/webpack.config.js index 658ade0..2e88bad 100644 --- a/apps/cloudflare-app/webpack.config.js +++ b/apps/cloudflare-app/webpack.config.js @@ -3,6 +3,7 @@ import path from 'path'; import { WebpackRscClientPlugin, WebpackRscServerPlugin, + createWebpackRscClientLoader, createWebpackRscServerLoader, webpackRscLayerName, } from '@mfng/webpack-rsc'; @@ -73,6 +74,7 @@ export default function createConfigs(_env, argv) { * @type {import('@mfng/webpack-rsc').ClientReferencesMap} */ const clientReferencesMap = new Map(); + const serverReferencesMap = new Map(); const rscServerLoader = createWebpackRscServerLoader({clientReferencesMap}); /** @@ -121,7 +123,7 @@ export default function createConfigs(_env, argv) { }, plugins: [ new MiniCssExtractPlugin({filename: `server-main.css`, runtime: false}), - new WebpackRscServerPlugin({clientReferencesMap}), + new WebpackRscServerPlugin({clientReferencesMap, serverReferencesMap}), ], experiments: {outputModule: true, layers: true}, performance: {maxAssetSize: 1_000_000, maxEntrypointSize: 1_000_000}, @@ -131,6 +133,8 @@ export default function createConfigs(_env, argv) { stats, }; + const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); + /** * @type {import('webpack').Configuration} */ @@ -149,7 +153,12 @@ export default function createConfigs(_env, argv) { }, module: { rules: [ - {test: /\.tsx?$/, loader: `swc-loader`, exclude: [/node_modules/]}, + {test: /\.js$/, use: rscClientLoader}, + { + test: /\.tsx?$/, + use: [rscClientLoader, `swc-loader`], + exclude: [/node_modules/], + }, cssRule, ], }, diff --git a/apps/vercel-app/webpack.config.js b/apps/vercel-app/webpack.config.js index 9455034..4dbbec6 100644 --- a/apps/vercel-app/webpack.config.js +++ b/apps/vercel-app/webpack.config.js @@ -4,6 +4,7 @@ import url from 'url'; import { WebpackRscClientPlugin, WebpackRscServerPlugin, + createWebpackRscClientLoader, createWebpackRscServerLoader, webpackRscLayerName, } from '@mfng/webpack-rsc'; @@ -100,6 +101,7 @@ export default function createConfigs(_env, argv) { * @type {import('@mfng/webpack-rsc').ClientReferencesMap} */ const clientReferencesMap = new Map(); + const serverReferencesMap = new Map(); const rscServerLoader = createWebpackRscServerLoader({clientReferencesMap}); /** @@ -151,6 +153,7 @@ export default function createConfigs(_env, argv) { new MiniCssExtractPlugin({filename: `server-main.css`, runtime: false}), new WebpackRscServerPlugin({ clientReferencesMap, + serverReferencesMap, serverManifestFilename: path.relative( outputFunctionDirname, reactServerManifestFilename, @@ -168,6 +171,7 @@ export default function createConfigs(_env, argv) { }; const clientOutputDirname = path.join(outputDirname, `static/client`); + const rscClientLoader = createWebpackRscClientLoader({serverReferencesMap}); /** * @type {import('webpack').Configuration} @@ -187,7 +191,12 @@ export default function createConfigs(_env, argv) { }, module: { rules: [ - {test: /\.tsx?$/, loader: `swc-loader`, exclude: [/node_modules/]}, + {test: /\.js$/, use: rscClientLoader}, + { + test: /\.tsx?$/, + use: [rscClientLoader, `swc-loader`], + exclude: [/node_modules/], + }, cssRule, ], }, diff --git a/packages/webpack-rsc/README.md b/packages/webpack-rsc/README.md index a8030f5..623a4f4 100644 --- a/packages/webpack-rsc/README.md +++ b/packages/webpack-rsc/README.md @@ -2,9 +2,9 @@ ⚠️ **Experimental** -This library provides a Webpack loader and a pair of Webpack plugins for -integrating React Server Components (RSC) and Server-Side Rendering (SSR) in a -React application that can be deployed to the edge. +This library provides a set of Webpack loaders and plugins for integrating React +Server Components (RSC) and Server-Side Rendering (SSR) in a React application +that can be deployed to the edge. > Disclaimer: There are many moving parts involved in creating an RSC app that > also handles SSR, without using a framework like Next.js. This library only @@ -21,23 +21,25 @@ To use this library in your React Server Components project, follow these steps: npm install --save-dev @mfng/webpack-rsc ``` -2. Update your `webpack.config.js` to include the loader and plugins provided by - this library. See the example configuration below for reference. +2. Update your `webpack.config.js` to include the loaders and plugins provided + by this library. See the example configuration below for reference. ## Example Webpack Configuration -The following example demonstrates how to use the loader and plugins in a +The following example demonstrates how to use the loaders and plugins in a Webpack configuration: ```js import { WebpackRscClientPlugin, WebpackRscServerPlugin, + createWebpackRscClientLoader, createWebpackRscServerLoader, webpackRscLayerName, } from '@mfng/webpack-rsc'; const clientReferencesMap = new Map(); +const serverReferencesMap = new Map(); const serverConfig = { name: 'server', @@ -68,7 +70,9 @@ const serverConfig = { }, ], }, - plugins: [new WebpackRscServerPlugin({clientReferencesMap})], + plugins: [ + new WebpackRscServerPlugin({clientReferencesMap, serverReferencesMap}), + ], experiments: {layers: true}, // ... }; @@ -77,7 +81,17 @@ const clientConfig = { name: 'client', dependencies: ['server'], // ... - module: {rules: [{test: /\.tsx?$/, loader: 'swc-loader'}]}, + module: { + rules: [ + { + test: /\.tsx?$/, + use: [ + createWebpackRscClientLoader({serverReferencesMap}), + 'swc-loader', + ], + }, + ], + }, plugins: [new WebpackRscClientPlugin({clientReferencesMap})], // ... }; @@ -88,17 +102,17 @@ export default [serverConfig, clientConfig]; **Note:** It's important to specify the names and dependencies of the configs as shown above, so that the plugins work in the correct order, even in watch mode. -## Webpack Loader and Plugins +## Webpack Loaders and Plugins -This library provides the following Webpack loader and plugins: +This library provides the following Webpack loaders and plugins: ### `createWebpackRscServerLoader` -A function to create the RSC server loader `use` item. This loader is -responsible for replacing client components in a `use client` module with client -references (objects that contain meta data about the client components), and -removing all other parts of the client module. It also populates the -`clientReferencesMap`. +A function to create the RSC server loader `use` item for the server entry +webpack config. This loader is responsible for replacing client components in a +`use client` module with client references (objects that contain meta data about +the client components), and removing all other parts of the client module. It +also populates the given `clientReferencesMap`. ### `WebpackRscServerPlugin` @@ -110,7 +124,20 @@ The plugin also handles server references for React server actions by adding meta data to all exported functions of a `use server` module. Based on this, it generates the server manifest that is needed for validating the server references for server actions (also known as mutations) that are sent back from -the client. +the client. It also populates the given `serverReferencesMap`. + +### `createWebpackRscClientLoader` + +A function to create the RSC client loader `use` item for the client entry +webpack config. This loader is responsible for replacing server actions in a +`use server` module with server references (based on the given +`serverReferencesMap`), and removing all other parts of the server module, so +that the server module can be imported from a client module. + +**Note:** Importing server actions from a client module requires that +`callServer` can be imported from a module. Per default `@mfng/core/client` is +used as import source, but this can be customized with the +`callServerImportSource` option. ### `WebpackRscClientPlugin` diff --git a/packages/webpack-rsc/package.json b/packages/webpack-rsc/package.json index f68c421..13b3faa 100644 --- a/packages/webpack-rsc/package.json +++ b/packages/webpack-rsc/package.json @@ -1,7 +1,7 @@ { "name": "@mfng/webpack-rsc", - "version": "2.0.2", - "description": "A Webpack loader and a pair of plugins for React Server Components", + "version": "2.1.0", + "description": "A set of Webpack loaders and plugins for React Server Components", "repository": { "type": "git", "url": "https://github.com/unstubbable/mfng.git", diff --git a/packages/webpack-rsc/src/index.ts b/packages/webpack-rsc/src/index.ts index 15f7065..3abe0a1 100644 --- a/packages/webpack-rsc/src/index.ts +++ b/packages/webpack-rsc/src/index.ts @@ -1,16 +1,25 @@ import {createRequire} from 'module'; import type {RuleSetUseItem} from 'webpack'; +import type {WebpackRscClientLoaderOptions} from './webpack-rsc-client-loader.cjs'; import type {WebpackRscServerLoaderOptions} from './webpack-rsc-server-loader.cjs'; +export * from './webpack-rsc-client-loader.cjs'; export * from './webpack-rsc-client-plugin.js'; export * from './webpack-rsc-server-loader.cjs'; export * from './webpack-rsc-server-plugin.js'; const require = createRequire(import.meta.url); -const loader = require.resolve(`./webpack-rsc-server-loader.cjs`); +const serverLoader = require.resolve(`./webpack-rsc-server-loader.cjs`); +const clientLoader = require.resolve(`./webpack-rsc-client-loader.cjs`); export function createWebpackRscServerLoader( options: WebpackRscServerLoaderOptions, ): RuleSetUseItem { - return {loader, options}; + return {loader: serverLoader, options}; +} + +export function createWebpackRscClientLoader( + options: WebpackRscClientLoaderOptions, +): RuleSetUseItem { + return {loader: clientLoader, options}; } diff --git a/packages/webpack-rsc/src/webpack-rsc-client-loader.cts b/packages/webpack-rsc/src/webpack-rsc-client-loader.cts new file mode 100644 index 0000000..bedcf94 --- /dev/null +++ b/packages/webpack-rsc/src/webpack-rsc-client-loader.cts @@ -0,0 +1,114 @@ +import generate from '@babel/generator'; +import {parse} from '@babel/parser'; +import traverse from '@babel/traverse'; +import * as t from '@babel/types'; +import type {LoaderContext} from 'webpack'; + +export interface WebpackRscClientLoaderOptions { + readonly serverReferencesMap: ServerReferencesMap; + readonly callServerImportSource?: string; +} + +export type ServerReferencesMap = Map; + +export interface ServerReferencesModuleInfo { + readonly moduleId: string | number; + readonly exportNames: string[]; +} + +export default function webpackRscClientLoader( + this: LoaderContext, + source: string, +): void { + this.cacheable(true); + + const {serverReferencesMap, callServerImportSource = `@mfng/core/client`} = + this.getOptions(); + + const loaderContext = this; + const resourcePath = this.resourcePath; + + const ast = parse(source, { + sourceType: `module`, + sourceFilename: resourcePath, + }); + + traverse(ast, { + enter(path) { + const {node} = path; + + if (!t.isProgram(node)) { + return; + } + + if (!node.directives.some(isUseServerDirective)) { + return; + } + + const moduleInfo = serverReferencesMap.get(resourcePath); + + if (!moduleInfo) { + loaderContext.emitError( + new Error( + `Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`, + ), + ); + + path.replaceWith(t.program([])); + + return; + } + + const {moduleId, exportNames} = moduleInfo; + + path.replaceWith( + t.program([ + t.importDeclaration( + [ + t.importSpecifier( + t.identifier(`createServerReference`), + t.identifier(`createServerReference`), + ), + ], + t.stringLiteral(`react-server-dom-webpack/client`), + ), + t.importDeclaration( + [ + t.importSpecifier( + t.identifier(`callServer`), + t.identifier(`callServer`), + ), + ], + t.stringLiteral(callServerImportSource), + ), + ...exportNames.map((exportName) => + t.exportNamedDeclaration( + t.variableDeclaration(`const`, [ + t.variableDeclarator( + t.identifier(exportName), + t.callExpression(t.identifier(`createServerReference`), [ + t.stringLiteral(`${moduleId}#${exportName}`), + t.identifier(`callServer`), + ]), + ), + ]), + ), + ), + ]), + ); + }, + }); + + const {code} = generate(ast, {sourceFileName: this.resourcePath}); + + // TODO: Handle source maps. + + this.callback(null, code); +} + +function isUseServerDirective(directive: t.Directive): boolean { + return ( + t.isDirectiveLiteral(directive.value) && + directive.value.value === `use server` + ); +} diff --git a/packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts b/packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts new file mode 100644 index 0000000..ca8b908 --- /dev/null +++ b/packages/webpack-rsc/src/webpack-rsc-client-loader.test.ts @@ -0,0 +1,151 @@ +import fs from 'fs/promises'; +import path from 'path'; +import url from 'url'; +import {jest} from '@jest/globals'; +import type webpack from 'webpack'; +import type { + ServerReferencesMap, + WebpackRscClientLoaderOptions, +} from './webpack-rsc-client-loader.cjs'; +import webpackRscClientLoader from './webpack-rsc-client-loader.cjs'; + +const currentDirname = path.dirname(url.fileURLToPath(import.meta.url)); + +async function callLoader( + resourcePath: string, + options: WebpackRscClientLoaderOptions, + emitError?: jest.Mock<(error: Error) => void>, +): Promise { + const input = await fs.readFile(resourcePath); + + return new Promise((resolve, reject) => { + const context: Partial< + webpack.LoaderContext + > = { + getOptions: () => options, + resourcePath, + cacheable: jest.fn(), + emitError, + callback: (error, content) => { + if (error) { + reject(error); + } else if (content !== undefined) { + resolve(content); + } else { + reject( + new Error( + `Did not receive any content from webpackRscClientLoader.`, + ), + ); + } + }, + }; + + void webpackRscClientLoader.default.call( + context as webpack.LoaderContext, + input.toString(`utf-8`), + ); + }); +} + +describe(`webpackRscClientLoader`, () => { + test(`generates a server reference module based on given serverReferencesMap`, async () => { + const resourcePath = path.resolve( + currentDirname, + `__fixtures__/server-function.js`, + ); + + const serverReferencesMap: ServerReferencesMap = new Map([ + [resourcePath, {moduleId: `test`, exportNames: [`foo`, `bar`]}], + ]); + + const output = await callLoader(resourcePath, {serverReferencesMap}); + + expect(output).toEqual( + ` +import { createServerReference } from "react-server-dom-webpack/client"; +import { callServer } from "@mfng/core/client"; +export const foo = createServerReference("test#foo", callServer); +export const bar = createServerReference("test#bar", callServer); +`.trim(), + ); + }); + + test(`accepts a custom callServer import source`, async () => { + const resourcePath = path.resolve( + currentDirname, + `__fixtures__/server-function.js`, + ); + + const serverReferencesMap: ServerReferencesMap = new Map([ + [resourcePath, {moduleId: `test`, exportNames: [`foo`]}], + ]); + + const callServerImportSource = `some-router/call-server`; + + const output = await callLoader(resourcePath, { + serverReferencesMap, + callServerImportSource, + }); + + expect(output).toEqual( + ` +import { createServerReference } from "react-server-dom-webpack/client"; +import { callServer } from "some-router/call-server"; +export const foo = createServerReference("test#foo", callServer); +`.trim(), + ); + }); + + test(`emits an error if module info is missing in serverReferencesMap`, async () => { + const resourcePath = path.resolve( + currentDirname, + `__fixtures__/server-function.js`, + ); + + const serverReferencesMap: ServerReferencesMap = new Map(); + const emitError = jest.fn(); + + const output = await callLoader( + resourcePath, + {serverReferencesMap}, + emitError, + ); + + expect(emitError.mock.calls).toEqual([ + [ + new Error( + `Could not find server references module info in \`serverReferencesMap\` for ${resourcePath}.`, + ), + ], + ]); + + expect(output).toEqual(``); + }); + + test(`does not change modules without a 'use server' directive`, async () => { + const resourcePath = path.resolve( + currentDirname, + `__fixtures__/client-component.js`, + ); + + const serverReferencesMap = new Map(); + const output = await callLoader(resourcePath, {serverReferencesMap}); + + expect(output).toEqual( + ` +// @ts-nocheck +'use client'; + +import * as React from 'react'; +export function ClientComponent({ + action +}) { + React.useEffect(() => { + action().then(console.log); + }, []); + return null; +}`.trim(), + ); + }); +}); diff --git a/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts b/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts index 472d6bd..a075dee 100644 --- a/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts +++ b/packages/webpack-rsc/src/webpack-rsc-server-plugin.test.ts @@ -4,6 +4,7 @@ import url from 'url'; import MemoryFS from 'memory-fs'; import prettier from 'prettier'; import webpack from 'webpack'; +import type {ServerReferencesMap} from './webpack-rsc-client-loader.cjs'; import {WebpackRscServerPlugin} from './webpack-rsc-server-plugin.js'; const fs = new MemoryFS(); @@ -46,8 +47,11 @@ async function runWebpack(config: webpack.Configuration): Promise { describe(`WebpackRscServerPlugin`, () => { let buildConfig: webpack.Configuration; + let serverReferencesMap: ServerReferencesMap; beforeEach(() => { + serverReferencesMap = new Map(); + buildConfig = { entry: path.resolve(currentDirname, `__fixtures__/main.js`), output: { @@ -65,7 +69,12 @@ describe(`WebpackRscServerPlugin`, () => { }, ], }, - plugins: [new WebpackRscServerPlugin({clientReferencesMap: new Map()})], + plugins: [ + new WebpackRscServerPlugin({ + clientReferencesMap: new Map(), + serverReferencesMap, + }), + ], resolve: { conditionNames: [`react-server`, `node`, `import`, `require`], }, @@ -126,6 +135,20 @@ Object.defineProperties(serverFunction, { ], }); }); + + test(`populates the given serverReferencesMap`, async () => { + await runWebpack(buildConfig); + + expect([...serverReferencesMap.entries()]).toEqual([ + [ + path.resolve(currentDirname, `./__fixtures__/server-function.js`), + { + moduleId: `./packages/webpack-rsc/src/__fixtures__/server-function.js`, + exportNames: [`serverFunction`], + }, + ], + ]); + }); }); describe(`in production mode`, () => { @@ -172,5 +195,16 @@ Object.defineProperties(serverFunction, { [expectedModuleId]: [`serverFunction`], }); }); + + test(`populates the given serverReferencesMap`, async () => { + await runWebpack(buildConfig); + + expect([...serverReferencesMap.entries()]).toEqual([ + [ + path.resolve(currentDirname, `./__fixtures__/server-function.js`), + {moduleId: expectedModuleId, exportNames: [`serverFunction`]}, + ], + ]); + }); }); }); diff --git a/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts b/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts index 0140f99..39c178d 100644 --- a/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts +++ b/packages/webpack-rsc/src/webpack-rsc-server-plugin.ts @@ -1,9 +1,11 @@ import type {Directive, ModuleDeclaration, Statement} from 'estree'; import type Webpack from 'webpack'; +import type {ServerReferencesMap} from './webpack-rsc-client-loader.cjs'; import type {ClientReferencesMap} from './webpack-rsc-server-loader.cjs'; export interface WebpackRscServerPluginOptions { readonly clientReferencesMap: ClientReferencesMap; + readonly serverReferencesMap?: ServerReferencesMap; readonly serverManifestFilename?: string; } @@ -16,12 +18,14 @@ export const webpackRscLayerName = `react-server`; export class WebpackRscServerPlugin { private clientReferencesMap: ClientReferencesMap; + private serverReferencesMap: ServerReferencesMap | undefined; private serverManifest: Record = {}; private serverManifestFilename: string; private clientModuleResources = new Set(); constructor(options: WebpackRscServerPluginOptions) { this.clientReferencesMap = options.clientReferencesMap; + this.serverReferencesMap = options.serverReferencesMap; this.serverManifestFilename = options?.serverManifestFilename || `react-server-manifest.json`; @@ -233,10 +237,17 @@ export class WebpackRscServerPlugin { } } } else if (hasServerReference(module, resource)) { - this.serverManifest[moduleId] = getExportNames( + const exportNames = getExportNames( compilation.moduleGraph, module, ); + + this.serverReferencesMap?.set(resource, { + moduleId, + exportNames, + }); + + this.serverManifest[moduleId] = exportNames; } } },