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: add vite plugin for RSC #15

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft

feat: add vite plugin for RSC #15

wants to merge 3 commits into from

Conversation

ascorbic
Copy link
Owner

@ascorbic ascorbic commented Apr 1, 2023

First draft of a Vite plugin to extract server component modules. Made with reference to simple-rsc and Devon Govett's notes on RSC. As it stands, this is not specific to Impala (or even React).

How it works

Background

Generally, sites that use SSR need to do two builds with Vite, one with --ssr set and one without. These builds are independent of each other, and are likely to have different config etc. As a convention, SSR builds usually expect the client build to already have been done, and they may reference the manifest.json or ssr-manifest.json, which are both generated during the client build.

Client builds

The client build happens first, and currently the only special thing that happens is that we ensure that any module that has "use client" is written to a chunk of its own. The manifest is written out, which we can read later to find that chunk filename.

SSR build

The SSR build happens after the client build. To be clear, this does not refer to the actual SSR rendering - this is building the code that is used to do SSR later.

During the build, we register a transform hook. Whenever a module is loaded, we check if it has the use client pragma. We do this in two stages. First we do a quick string check. If the string is found we do a proper parse of code, and then traverse the AST to ensure that it's relevant syntax and not just random text somewhere in the file.

If it matches, then we find the module in the manifest. That will allow us to map it to a filename. We then inspect the parsed module again to check for the names of exports.

When we have the module id, filename and names of exports, we can generate the bundle map. For export we generate an entry in the form:

{
         id: "module-name.js#default",
         chunks: [],
         name: "default",
         async: true,
}

This is persisted to JSON at the end of the build.

We then replace the module code that is returned with something like the following:

import DefaultExport from "file://path/to/client/module-name.js";
DefaultExport.$$typeof = Symbol.for("react.client.reference");
DefaultExport.$$id="module-name.js#default"; 
export default DefaultExport;

This will then be included in the SSR bundle instead of the client code.

To make it easier to find the module map later, we also register a virtual module which lets us import "virtual:client-bundle-map" in our server code and get the filename to the JSON.

@netlify
Copy link

netlify bot commented Apr 1, 2023

Deploy Preview for impala-preact-js canceled.

Name Link
🔨 Latest commit 5f9e71a
🔍 Latest deploy log https://app.netlify.com/sites/impala-preact-js/deploys/6429b8607ff8c500084ee0df

@netlify
Copy link

netlify bot commented Apr 1, 2023

Deploy Preview for impala-preact-ts canceled.

Name Link
🔨 Latest commit 5f9e71a
🔍 Latest deploy log https://app.netlify.com/sites/impala-preact-ts/deploys/6429b860ef81f20008922be3

@netlify
Copy link

netlify bot commented Apr 1, 2023

Deploy Preview for impala-react-js canceled.

Name Link
🔨 Latest commit 5f9e71a
🔍 Latest deploy log https://app.netlify.com/sites/impala-react-js/deploys/6429b8609e6ef70008ef4ece

@netlify
Copy link

netlify bot commented Apr 1, 2023

Deploy Preview for impala-react-ts canceled.

Name Link
🔨 Latest commit 5f9e71a
🔍 Latest deploy log https://app.netlify.com/sites/impala-react-ts/deploys/6429b860c2988000089dde67

Comment on lines +105 to +112
const manifestPath = path.join(
config.root || "",
clientDist,
"manifest.json"
);
if (existsSync(manifestPath)) {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manifest.json is generated when building the client code, and maps module names to chunk filenames. e.g.

{
  "index.html": {
    "file": "assets/index-3cfb730f.js",
    "isEntry": true,
    "src": "index.html"
  },
  "src/assets/impala.png": {
    "file": "assets/impala-0936a6d6.png",
    "src": "src/assets/impala.png"
  },
  "src/components/button.tsx": {
    "file": "assets/button-8f2948fa.js",
    "isEntry": true,
    "src": "src/components/button.tsx"
  }
}

During SSR we load the manifest so we can use it to find client chunk names

Comment on lines +153 to +158
if (hasPragma(ast, clientPragma)) {
if (isSsr) {
const bundlePath = pathToFileURL(
path.join(config.root, clientDist, manifest[localId].file)
);
externals.add(bundlePath.href);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we find a client component while bundling for SSR, we first find the full path to the client code. We mark it as external so that the SSR build doesn't try to re-bundle the client code when we reference it below.

Comment on lines +159 to +163
if (manifest[localId]) {
const exports = getExports(ast);
const exportProxies = exports
.map((name) => {
const symbolName = `${manifest[localId].file}#${name}`;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we've found it in the client manifest then we have enough info to replace it with a client reference. First we need to know what the exports are, so we parse it to an AST and extract the named and default exports. We generate an id based on the module id and export name.

Comment on lines +164 to +170
bundleMap.set(symbolName, {
id: symbolName,
chunks: [],
name,
async: true,
});
const localName = name === "default" ? "DefaultExport" : name;
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the reference to the bundle map. This will be used in the client (and in SSR) to map client references to filenames.

Comment on lines +172 to +183
return `
import { ${
name === "default" ? "default as DefaultExport" : name
} } from ${JSON.stringify(
bundlePath.href
)};${localName}.$$typeof = Symbol.for("react.client.reference");${localName}.$$id=${JSON.stringify(
symbolName
)}; export ${
name === "default" ? "default DefaultExport" : `{ ${name} }`
} `;
})
.join("\n");
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace the client module code with the React symbol reference.

Comment on lines +189 to +195
} else {
this.emitFile({
type: "chunk",
id,
preserveSignature: "allow-extension",
});
}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is a client build, ensure that the client component is emitted in its own chunk, with export signatures retained so it can be imported manually in the client.

Comment on lines +202 to +211
generateBundle() {
if (isBuild && isSsr) {
this.emitFile({
type: "asset",
fileName: serverBundleMapFilename,
source: JSON.stringify(Object.fromEntries(bundleMap)),
});
}
},
};
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write the bundle map out to a JSON file, so it can be loaded and sent to the browser and SSR

Comment on lines +131 to +144
load(id) {
if (id === resolvedClientModuleId) {
return `export default ${JSON.stringify(
// Yes the client bundle map is in the server dist because
// it's the SSR build that generates the client bundle map
path.join(config.root || "", serverDist, clientBundleMapFilename)
)}`;
}
if (id === resolvedServerModuleId) {
return `export default ${JSON.stringify(
path.join(config.root || "", clientDist, serverBundleMapFilename)
)}`;
}
},
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are virtual modules that just provide the path to the module map JSON files. This lets the prerender script or server load the JSON file without needing to know the path. Instead it can import the module and get the filename.

Comment on lines +118 to +122
configResolved(resolvedConfig) {
config = resolvedConfig;
isSsr = !!config.build.ssr;
isBuild = config.command === "build";
},
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache the config to use later

Comment on lines +75 to +81
export default function plugin({
serverDist = "dist/server",
clientDist = "dist/static",
}: {
serverDist?: string;
clientDist?: string;
}): Plugin {
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are not read from the config because we need to know the value of the server dist during client build and vice versa. This does not set the value for the builds - it's just for reference so we can find the manifests.

Comment on lines +5 to +19
interface ASTNode {
type: string;
start: number;
end: number;
body?: Array<ASTNode>;
id?: ASTNode;
expression?: ASTNode;
declaration?: ASTNode;
declarations?: Array<ASTNode>;
name?: string;
specifiers?: Array<ASTNode>;

value?: string;
exported?: ASTNode;
}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vite doesn't provide proper types for these

@gaearon
Copy link

gaearon commented Apr 3, 2023

Thanks for writing this up! A few first thoughts without looking at the code:

The client build happens first, and currently the only special thing that happens is that we ensure that any module that has "use client" is written to a chunk of its own. The manifest is written out, which we can read later to find that chunk filename.

  • Are you able to "discover" 'use client' that were never imported from the client? You need to somehow know which entry points to consider since they initially don't exist in the client graph. Consider that these entry points could also be deep inside some npm dependency. There are two strategies for this:
    • Scan the entire filesystem looking for 'use client' files. This is what the webpack plugin in React repo currently does. It's pretty slow and not good.
    • "Discover" 'use client' files by trying to walk the RSC module system graph. Since you need to walk the RSC graph anyway to "shim" the 'use client' files, this gives you a list to visit later. But this requires a tight integration between the passes. I think it's how @unstubbable's webpack plugins work. This is better.

SSR build

I would call this RSC build — I think that's what you're describing. The "SSR build" might also be necessary as a separate thing, but SSR needs to render client components. By the point you do SSR, RSC has already executed. This is because the first load is RSC -> SSR -> DOM, next loads are RSC -> diff -> DOM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants