Skip to content

Commit 63cdc12

Browse files
committed
feat: add vite plugin for RSC
1 parent b8c901a commit 63cdc12

4 files changed

Lines changed: 271 additions & 0 deletions

File tree

packages/vite-plugin-rsc/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# @impalajs/vite-plugin-extract-server-components
2+
3+
<p align="center">
4+
5+
![impala](https://user-images.githubusercontent.com/213306/227727009-a4dc391f-efb1-4489-ad73-c3d3a327704a.png)
6+
7+
</p>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "@impalajs/vite-plugin-extract-server-components",
3+
"version": "0.0.6",
4+
"description": "",
5+
"scripts": {
6+
"build": "tsup src/plugin.ts --format esm --dts --clean"
7+
},
8+
"module": "./dist/plugin.mjs",
9+
"types": "./dist/plugin.d.ts",
10+
"exports": {
11+
".": {
12+
"types": "./dist/plugin.d.ts",
13+
"import": "./dist/plugin.mjs"
14+
}
15+
},
16+
"keywords": [],
17+
"author": "",
18+
"license": "MIT",
19+
"devDependencies": {
20+
"tsup": "^6.7.0"
21+
},
22+
"peerDependencies": {
23+
"vite": ">=4"
24+
},
25+
"engines": {
26+
"node": ">=18.0.0"
27+
},
28+
"publishConfig": {
29+
"access": "public"
30+
}
31+
}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { Plugin, ResolvedConfig, Manifest } from "vite";
2+
import path from "node:path";
3+
import { pathToFileURL } from "node:url";
4+
import { existsSync, readFileSync } from "node:fs";
5+
interface ASTNode {
6+
type: string;
7+
start: number;
8+
end: number;
9+
body?: Array<ASTNode>;
10+
id?: ASTNode;
11+
expression?: ASTNode;
12+
declaration?: ASTNode;
13+
declarations?: Array<ASTNode>;
14+
name?: string;
15+
specifiers?: Array<ASTNode>;
16+
17+
value?: string;
18+
exported?: ASTNode;
19+
}
20+
21+
/**
22+
* Checks if the node has a string literal at the top level that matches the statement
23+
*/
24+
const hasPragma = (ast: ASTNode, statement: string) =>
25+
ast.body?.some((node) => {
26+
return (
27+
node.type === "ExpressionStatement" &&
28+
node.expression?.type === "Literal" &&
29+
node.expression.value === statement
30+
);
31+
});
32+
33+
const getExports = (ast: ASTNode) => {
34+
const exports: Array<string> = [];
35+
ast.body?.forEach((node) => {
36+
if (node.type === "ExportDefaultDeclaration") {
37+
exports.push("default");
38+
}
39+
if (node.type === "ExportNamedDeclaration") {
40+
if (node.declaration?.type === "VariableDeclaration") {
41+
node.declaration?.declarations?.forEach((declaration) => {
42+
const name = declaration?.id?.name;
43+
if (name) {
44+
exports.push(name);
45+
}
46+
});
47+
return;
48+
}
49+
50+
if (node.declaration?.type === "FunctionDeclaration") {
51+
const name = node.declaration?.id?.name;
52+
if (name) {
53+
exports.push(name);
54+
}
55+
return;
56+
}
57+
58+
if (node.specifiers?.length) {
59+
node.specifiers.forEach((specifier) => {
60+
const name = specifier?.exported?.name;
61+
if (name) {
62+
exports.push(name);
63+
}
64+
});
65+
}
66+
}
67+
});
68+
return exports;
69+
};
70+
71+
export default function plugin({
72+
serverDist = "dist/server",
73+
clientDist = "dist/static",
74+
}: {
75+
serverDist?: string;
76+
clientDist?: string;
77+
}): Plugin {
78+
const clientPragma = "use client";
79+
const serverPragma = "use server";
80+
let externals = new Set<string>();
81+
let config: ResolvedConfig;
82+
let isSsr: boolean;
83+
let isBuild: boolean;
84+
let manifest: Manifest = {};
85+
86+
const bundleMap = new Map();
87+
const clientModuleId = "virtual:client-bundle-map";
88+
const resolvedClientModuleId = "\0" + clientModuleId;
89+
const serverModuleId = "virtual:server-bundle-map";
90+
const resolvedServerModuleId = "\0" + serverModuleId;
91+
92+
const clientBundleMapFilename = "client-bundle-map.json";
93+
const serverBundleMapFilename = "server-bundle-map.json";
94+
95+
return {
96+
name: "vite-plugin-extract-server-components",
97+
98+
config(config) {
99+
config.build ||= {};
100+
if (config.build.ssr) {
101+
const manifestPath = path.join(
102+
config.root || "",
103+
clientDist,
104+
"manifest.json"
105+
);
106+
if (existsSync(manifestPath)) {
107+
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
108+
}
109+
config.build.rollupOptions ||= {};
110+
config.build.rollupOptions.external = (id) => externals.has(id);
111+
}
112+
},
113+
114+
configResolved(resolvedConfig) {
115+
config = resolvedConfig;
116+
isSsr = !!config.build.ssr;
117+
isBuild = config.command === "build";
118+
},
119+
resolveId(id, source) {
120+
if (id === clientModuleId) {
121+
return resolvedClientModuleId;
122+
}
123+
if (id === serverModuleId) {
124+
return resolvedServerModuleId;
125+
}
126+
},
127+
load(id) {
128+
if (id === resolvedClientModuleId) {
129+
return `export default ${JSON.stringify(
130+
// Yes the client bundle map is in the server dist because
131+
// it's the SSR build that generates the client bundle map
132+
path.join(config.root || "", serverDist, clientBundleMapFilename)
133+
)}`;
134+
}
135+
if (id === resolvedServerModuleId) {
136+
return `export default ${JSON.stringify(
137+
path.join(config.root || "", clientDist, serverBundleMapFilename)
138+
)}`;
139+
}
140+
},
141+
transform(code, id) {
142+
// Short circuit if the file doesn't have the literal string
143+
if (!code?.includes(clientPragma)) {
144+
return;
145+
}
146+
// Check properly for the pragma
147+
const ast = this.parse(code, { sourceType: "module" });
148+
const localId = path.relative(config.root || "", id);
149+
if (hasPragma(ast, clientPragma)) {
150+
if (isSsr) {
151+
const bundlePath = pathToFileURL(
152+
path.join(config.root, clientDist, manifest[localId].file)
153+
);
154+
externals.add(bundlePath.href);
155+
if (manifest[localId]) {
156+
const exports = getExports(ast);
157+
const exportProxies = exports
158+
.map((name) => {
159+
const symbolName = `${manifest[localId].file}#${name}`;
160+
bundleMap.set(symbolName, {
161+
id: symbolName,
162+
chunks: [],
163+
name,
164+
async: true,
165+
});
166+
const localName = name === "default" ? "DefaultExport" : name;
167+
168+
return `
169+
import { ${
170+
name === "default" ? "default as DefaultExport" : name
171+
} } from ${JSON.stringify(
172+
bundlePath.href
173+
)};${localName}.$$typeof = Symbol.for("react.client.reference");${localName}.$$id=${JSON.stringify(
174+
symbolName
175+
)}; export ${
176+
name === "default" ? "default DefaultExport" : `{ ${name} }`
177+
} `;
178+
})
179+
.join("\n");
180+
return {
181+
code: exportProxies,
182+
map: { mappings: "" },
183+
};
184+
}
185+
} else {
186+
this.emitFile({
187+
type: "chunk",
188+
id,
189+
preserveSignature: "allow-extension",
190+
});
191+
}
192+
}
193+
194+
// todo, work out how to handle server only code
195+
// if (hasPragma(ast, serverPragma)) {
196+
// }
197+
},
198+
generateBundle() {
199+
if (isBuild && isSsr) {
200+
this.emitFile({
201+
type: "asset",
202+
fileName: serverBundleMapFilename,
203+
source: JSON.stringify(Object.fromEntries(bundleMap)),
204+
});
205+
}
206+
},
207+
};
208+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"useDefineForClassFields": true,
5+
"lib": [
6+
"ESNext",
7+
"DOM"
8+
],
9+
"allowJs": false,
10+
"skipLibCheck": true,
11+
"esModuleInterop": false,
12+
"allowSyntheticDefaultImports": true,
13+
"strict": true,
14+
"forceConsistentCasingInFileNames": true,
15+
"module": "ESNext",
16+
"moduleResolution": "Node",
17+
"resolveJsonModule": true,
18+
"isolatedModules": true,
19+
"noEmit": true,
20+
"jsx": "react-jsx"
21+
},
22+
"include": [
23+
"src"
24+
]
25+
}

0 commit comments

Comments
 (0)