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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/vite-plugin-rsc/README.md
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>
31 changes: 31 additions & 0 deletions packages/vite-plugin-rsc/package.json
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"
}
}
212 changes: 212 additions & 0 deletions packages/vite-plugin-rsc/src/plugin.ts
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;
}
Comment on lines +5 to +19
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


/**
* 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
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.

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
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

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
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

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
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.

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
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.

if (manifest[localId]) {
const exports = getExports(ast);
const exportProxies = exports
.map((name) => {
const symbolName = `${manifest[localId].file}#${name}`;
Comment on lines +159 to +163
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.

bundleMap.set(symbolName, {
id: symbolName,
chunks: [],
name,
async: true,
});
const localName = name === "default" ? "DefaultExport" : name;
Comment on lines +164 to +170
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.


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
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.

return {
code: exportProxies,
map: { mappings: "" },
};
}
} else {
this.emitFile({
type: "chunk",
id,
preserveSignature: "allow-extension",
});
}
Comment on lines +189 to +195
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.

}

// 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
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

}
25 changes: 25 additions & 0 deletions packages/vite-plugin-rsc/tsconfig.json
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"
]
}