-
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?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
# @impalajs/vite-plugin-extract-server-components | ||
|
||
<p align="center"> | ||
|
||
![impala](https://user-images.githubusercontent.com/213306/227727009-a4dc391f-efb1-4489-ad73-c3d3a327704a.png) | ||
|
||
</p> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
{ | ||
"name": "@impalajs/vite-plugin-extract-server-components", | ||
"version": "0.0.6", | ||
"description": "", | ||
"scripts": { | ||
"build": "tsup src/plugin.ts --format esm --dts --clean" | ||
}, | ||
"module": "./dist/plugin.mjs", | ||
"types": "./dist/plugin.d.ts", | ||
"exports": { | ||
".": { | ||
"types": "./dist/plugin.d.ts", | ||
"import": "./dist/plugin.mjs" | ||
} | ||
}, | ||
"keywords": [], | ||
"author": "", | ||
"license": "MIT", | ||
"devDependencies": { | ||
"tsup": "^6.7.0" | ||
}, | ||
"peerDependencies": { | ||
"vite": ">=4" | ||
}, | ||
"engines": { | ||
"node": ">=18.0.0" | ||
}, | ||
"publishConfig": { | ||
"access": "public" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,212 @@ | ||
import { Plugin, ResolvedConfig, Manifest } from "vite"; | ||
import path from "node:path"; | ||
import { pathToFileURL } from "node:url"; | ||
import { existsSync, readFileSync } from "node:fs"; | ||
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; | ||
} | ||
|
||
/** | ||
* Checks if the node has a string literal at the top level that matches the statement | ||
*/ | ||
const hasPragma = (ast: ASTNode, statement: string) => | ||
ast.body?.some((node) => { | ||
return ( | ||
node.type === "ExpressionStatement" && | ||
node.expression?.type === "Literal" && | ||
node.expression.value === statement | ||
); | ||
}); | ||
|
||
/** | ||
* Finds all the named and default exports of a module | ||
*/ | ||
|
||
const getExports = (ast: ASTNode) => { | ||
const exports: Array<string> = []; | ||
ast.body?.forEach((node) => { | ||
if (node.type === "ExportDefaultDeclaration") { | ||
exports.push("default"); | ||
} | ||
if (node.type === "ExportNamedDeclaration") { | ||
if (node.declaration?.type === "VariableDeclaration") { | ||
node.declaration?.declarations?.forEach((declaration) => { | ||
const name = declaration?.id?.name; | ||
if (name) { | ||
exports.push(name); | ||
} | ||
}); | ||
return; | ||
} | ||
|
||
if (node.declaration?.type === "FunctionDeclaration") { | ||
const name = node.declaration?.id?.name; | ||
if (name) { | ||
exports.push(name); | ||
} | ||
return; | ||
} | ||
|
||
if (node.specifiers?.length) { | ||
node.specifiers.forEach((specifier) => { | ||
const name = specifier?.exported?.name; | ||
if (name) { | ||
exports.push(name); | ||
} | ||
}); | ||
} | ||
} | ||
}); | ||
return exports; | ||
}; | ||
|
||
export default function plugin({ | ||
serverDist = "dist/server", | ||
clientDist = "dist/static", | ||
}: { | ||
serverDist?: string; | ||
clientDist?: string; | ||
}): Plugin { | ||
Comment on lines
+75
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
const clientPragma = "use client"; | ||
// const serverPragma = "use server"; | ||
let externals = new Set<string>(); | ||
let config: ResolvedConfig; | ||
let isSsr: boolean; | ||
let isBuild: boolean; | ||
let manifest: Manifest = {}; | ||
|
||
const bundleMap = new Map(); | ||
const clientModuleId = "virtual:client-bundle-map"; | ||
const resolvedClientModuleId = "\0" + clientModuleId; | ||
const serverModuleId = "virtual:server-bundle-map"; | ||
const resolvedServerModuleId = "\0" + serverModuleId; | ||
|
||
const clientBundleMapFilename = "client-bundle-map.json"; | ||
const serverBundleMapFilename = "server-bundle-map.json"; | ||
|
||
return { | ||
name: "vite-plugin-extract-server-components", | ||
|
||
config(config) { | ||
config.build ||= {}; | ||
if (config.build.ssr) { | ||
const manifestPath = path.join( | ||
config.root || "", | ||
clientDist, | ||
"manifest.json" | ||
); | ||
if (existsSync(manifestPath)) { | ||
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); | ||
} | ||
Comment on lines
+105
to
+112
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
config.build.rollupOptions ||= {}; | ||
config.build.rollupOptions.external = (id) => externals.has(id); | ||
} | ||
}, | ||
|
||
configResolved(resolvedConfig) { | ||
config = resolvedConfig; | ||
isSsr = !!config.build.ssr; | ||
isBuild = config.command === "build"; | ||
}, | ||
Comment on lines
+118
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache the config to use later |
||
resolveId(id, source) { | ||
if (id === clientModuleId) { | ||
return resolvedClientModuleId; | ||
} | ||
if (id === serverModuleId) { | ||
return resolvedServerModuleId; | ||
} | ||
}, | ||
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) | ||
)}`; | ||
} | ||
}, | ||
Comment on lines
+131
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
transform(code, id) { | ||
// Short circuit if the file doesn't have the literal string | ||
if (!code?.includes(clientPragma)) { | ||
return; | ||
} | ||
// Check properly for the pragma | ||
const ast = this.parse(code, { sourceType: "module" }); | ||
const localId = path.relative(config.root || "", id); | ||
if (hasPragma(ast, clientPragma)) { | ||
if (isSsr) { | ||
const bundlePath = pathToFileURL( | ||
path.join(config.root, clientDist, manifest[localId].file) | ||
); | ||
externals.add(bundlePath.href); | ||
Comment on lines
+153
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}`; | ||
Comment on lines
+159
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
Comment on lines
+164
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||
Comment on lines
+172
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace the client module code with the React symbol reference. |
||
return { | ||
code: exportProxies, | ||
map: { mappings: "" }, | ||
}; | ||
} | ||
} else { | ||
this.emitFile({ | ||
type: "chunk", | ||
id, | ||
preserveSignature: "allow-extension", | ||
}); | ||
} | ||
Comment on lines
+189
to
+195
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
} | ||
|
||
// todo, work out how to handle server only code | ||
// if (hasPragma(ast, serverPragma)) { | ||
// } | ||
}, | ||
generateBundle() { | ||
if (isBuild && isSsr) { | ||
this.emitFile({ | ||
type: "asset", | ||
fileName: serverBundleMapFilename, | ||
source: JSON.stringify(Object.fromEntries(bundleMap)), | ||
}); | ||
} | ||
}, | ||
}; | ||
Comment on lines
+202
to
+211
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
{ | ||
"compilerOptions": { | ||
"target": "ESNext", | ||
"useDefineForClassFields": true, | ||
"lib": [ | ||
"ESNext", | ||
"DOM" | ||
], | ||
"allowJs": false, | ||
"skipLibCheck": true, | ||
"esModuleInterop": false, | ||
"allowSyntheticDefaultImports": true, | ||
"strict": true, | ||
"forceConsistentCasingInFileNames": true, | ||
"module": "ESNext", | ||
"moduleResolution": "Node", | ||
"resolveJsonModule": true, | ||
"isolatedModules": true, | ||
"noEmit": true, | ||
"jsx": "react-jsx" | ||
}, | ||
"include": [ | ||
"src" | ||
] | ||
} |
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