-
Notifications
You must be signed in to change notification settings - Fork 4
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
base: main
Are you sure you want to change the base?
Conversation
✅ Deploy Preview for impala-preact-js canceled.
|
✅ Deploy Preview for impala-preact-ts canceled.
|
✅ Deploy Preview for impala-react-js canceled.
|
✅ Deploy Preview for impala-react-ts canceled.
|
b6dc64a
to
63cdc12
Compare
const manifestPath = path.join( | ||
config.root || "", | ||
clientDist, | ||
"manifest.json" | ||
); | ||
if (existsSync(manifestPath)) { | ||
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); | ||
} |
There was a problem hiding this comment.
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
if (hasPragma(ast, clientPragma)) { | ||
if (isSsr) { | ||
const bundlePath = pathToFileURL( | ||
path.join(config.root, clientDist, manifest[localId].file) | ||
); | ||
externals.add(bundlePath.href); |
There was a problem hiding this comment.
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.
if (manifest[localId]) { | ||
const exports = getExports(ast); | ||
const exportProxies = exports | ||
.map((name) => { | ||
const symbolName = `${manifest[localId].file}#${name}`; |
There was a problem hiding this comment.
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.
bundleMap.set(symbolName, { | ||
id: symbolName, | ||
chunks: [], | ||
name, | ||
async: true, | ||
}); | ||
const localName = name === "default" ? "DefaultExport" : name; |
There was a problem hiding this comment.
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.
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"); |
There was a problem hiding this comment.
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.
} else { | ||
this.emitFile({ | ||
type: "chunk", | ||
id, | ||
preserveSignature: "allow-extension", | ||
}); | ||
} |
There was a problem hiding this comment.
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.
generateBundle() { | ||
if (isBuild && isSsr) { | ||
this.emitFile({ | ||
type: "asset", | ||
fileName: serverBundleMapFilename, | ||
source: JSON.stringify(Object.fromEntries(bundleMap)), | ||
}); | ||
} | ||
}, | ||
}; |
There was a problem hiding this comment.
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
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) | ||
)}`; | ||
} | ||
}, |
There was a problem hiding this comment.
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.
configResolved(resolvedConfig) { | ||
config = resolvedConfig; | ||
isSsr = !!config.build.ssr; | ||
isBuild = config.command === "build"; | ||
}, |
There was a problem hiding this comment.
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
export default function plugin({ | ||
serverDist = "dist/server", | ||
clientDist = "dist/static", | ||
}: { | ||
serverDist?: string; | ||
clientDist?: string; | ||
}): Plugin { |
There was a problem hiding this comment.
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.
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; | ||
} |
There was a problem hiding this comment.
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
Thanks for writing this up! A few first thoughts without looking at the code:
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. |
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 themanifest.json
orssr-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 theuse 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:
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:
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.