Skip to content
Open
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
238 changes: 213 additions & 25 deletions packages/vinext/src/plugins/client-reference-dedup.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,208 @@
import { readFile as readFileFromFs } from "node:fs/promises";
import type { Plugin } from "vite";

type ReadPackageJson = (path: string) => Promise<string>;

type ClientReferenceDedupOptions = {
readFile?: ReadPackageJson;
};

type PackageImportSpecifier = {
packageName: string;
specifier: string;
};

type PackagePath = {
packageName: string;
packageRoot: string;
relativePath: string;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function defaultReadPackageJson(path: string): Promise<string> {
return readFileFromFs(path, "utf8");
}

function parsePackagePath(absolutePath: string): PackagePath | null {
const marker = "/node_modules/";
const lastIdx = absolutePath.lastIndexOf(marker);
if (lastIdx === -1) return null;

const rest = absolutePath.slice(lastIdx + marker.length);
const parts = rest.split("/");
const packagePartCount = rest.startsWith("@") ? 2 : 1;
const packageParts = parts.slice(0, packagePartCount);

if (packageParts.length < packagePartCount || packageParts.some((part) => part === "")) {
return null;
}

const packageName = packageParts.join("/");
const relativeParts = parts.slice(packagePartCount);

return {
packageName,
packageRoot: absolutePath.slice(0, lastIdx + marker.length + packageName.length),
relativePath: relativeParts.join("/"),
};
}

/**
* Extract the bare package name from an absolute file path containing node_modules.
*
* Handles scoped packages (`@org/name`) and nested node_modules.
* Returns `null` if the path doesn't contain `/node_modules/`.
*/
export function extractPackageName(absolutePath: string): string | null {
const marker = "/node_modules/";
const lastIdx = absolutePath.lastIndexOf(marker);
if (lastIdx === -1) return null;
return parsePackagePath(absolutePath)?.packageName ?? null;
}

const rest = absolutePath.slice(lastIdx + marker.length);
if (rest.startsWith("@")) {
// Scoped package: @org/name
const parts = rest.split("/");
if (parts.length < 2) return null;
return `${parts[0]}/${parts[1]}`;
function normalizeExportTarget(target: string): string {
return target.startsWith("./") ? target.slice(2) : target;
}

function matchExportTarget(target: string, relativePath: string): string | null {
const normalizedTarget = normalizeExportTarget(target);
const wildcardIndex = normalizedTarget.indexOf("*");

if (wildcardIndex === -1) {
return normalizedTarget === relativePath ? "" : null;
}

const beforeWildcard = normalizedTarget.slice(0, wildcardIndex);
const afterWildcard = normalizedTarget.slice(wildcardIndex + 1);

if (!relativePath.startsWith(beforeWildcard) || !relativePath.endsWith(afterWildcard)) {
return null;
}

return relativePath.slice(beforeWildcard.length, relativePath.length - afterWildcard.length);
}

function exportKeyToSpecifier(
packageName: string,
exportKey: string,
wildcardMatch: string,
): string {
if (exportKey === ".") return packageName;

const subpath = exportKey.startsWith("./") ? exportKey.slice(2) : exportKey;
const resolvedSubpath = subpath.includes("*") ? subpath.replace("*", wildcardMatch) : subpath;

return `${packageName}/${resolvedSubpath}`;
}

function collectExportTargets(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.flatMap((entry) => collectExportTargets(entry));
if (!isRecord(value)) return [];

return Object.values(value).flatMap((entry) => collectExportTargets(entry));
}

function findExportSpecifier(
packageName: string,
exportsValue: unknown,
relativePath: string,
): string | null {
if (!isRecord(exportsValue)) {
for (const target of collectExportTargets(exportsValue)) {
const wildcardMatch = matchExportTarget(target, relativePath);
if (wildcardMatch !== null) {
return exportKeyToSpecifier(packageName, ".", wildcardMatch);
}
}
return null;
}

const entries = Object.entries(exportsValue);
const hasSubpathKeys = entries.some(([key]) => key === "." || key.startsWith("./"));
if (!hasSubpathKeys) {
for (const target of collectExportTargets(exportsValue)) {
const wildcardMatch = matchExportTarget(target, relativePath);
if (wildcardMatch !== null) {
return exportKeyToSpecifier(packageName, ".", wildcardMatch);
}
}
return null;
}
// Regular package: name
const slashIdx = rest.indexOf("/");
return slashIdx === -1 ? rest : rest.slice(0, slashIdx);

let bestMatch: { key: string; wildcard: string } | null = null;

for (const [key, value] of entries) {
if (key !== "." && !key.startsWith("./")) continue;

for (const target of collectExportTargets(value)) {
const wildcardMatch = matchExportTarget(target, relativePath);
if (wildcardMatch === null) continue;

if (!bestMatch || key.length > bestMatch.key.length) {
bestMatch = { key, wildcard: wildcardMatch };
}
}
}

return bestMatch ? exportKeyToSpecifier(packageName, bestMatch.key, bestMatch.wildcard) : null;
}

function getLegacyEntry(packageJson: Record<string, unknown>): string {
const browser = packageJson.browser;
if (typeof browser === "string") return browser;

const module = packageJson.module;
if (typeof module === "string") return module;

const main = packageJson.main;
return typeof main === "string" ? main : "index.js";
}

function matchesLegacyEntry(legacyEntry: string, relativePath: string): boolean {
const normalizedEntry = normalizeExportTarget(legacyEntry);
return normalizedEntry === relativePath || `${normalizedEntry}.js` === relativePath;
}

/**
* Convert an absolute package file path into the least lossy bare import
* specifier that can be handed back to Vite's dependency optimizer.
*/
export async function extractPackageImportSpecifier(
absolutePath: string,
readPackageJson: ReadPackageJson = defaultReadPackageJson,
): Promise<PackageImportSpecifier | null> {
const packagePath = parsePackagePath(absolutePath);
if (!packagePath) return null;

const { packageName, packageRoot, relativePath } = packagePath;
if (relativePath === "") {
return { packageName, specifier: packageName };
}

let packageJson: Record<string, unknown> | null = null;
try {
const rawPackageJson = await readPackageJson(`${packageRoot}/package.json`);
const parsedPackageJson: unknown = JSON.parse(rawPackageJson);
packageJson = isRecord(parsedPackageJson) ? parsedPackageJson : null;
} catch {
packageJson = null;
}

if (!packageJson) {
return { packageName, specifier: packageName };
}

if ("exports" in packageJson) {
const exportedSpecifier = findExportSpecifier(packageName, packageJson.exports, relativePath);
return { packageName, specifier: exportedSpecifier ?? packageName };
}

const specifier = matchesLegacyEntry(getLegacyEntry(packageJson), relativePath)
? packageName
: `${packageName}/${relativePath}`;

return { packageName, specifier };
}

const DEDUP_PREFIX = "\0vinext:dedup/";
Expand All @@ -37,8 +219,10 @@ const PROXY_MARKER = "virtual:vite-rsc/client-in-server-package-proxy/";
*
* Dev-only — production builds use the SSR manifest which handles this correctly.
*/
export function clientReferenceDedupPlugin(): Plugin {
export function clientReferenceDedupPlugin(options: ClientReferenceDedupOptions = {}): Plugin {
let excludeSet = new Set<string>();
const readPackageJson = options.readFile ?? defaultReadPackageJson;
const packageImportCache = new Map<string, Promise<PackageImportSpecifier | null>>();

return {
name: "vinext:client-reference-dedup",
Expand All @@ -55,7 +239,7 @@ export function clientReferenceDedupPlugin(): Plugin {

resolveId: {
filter: { id: /node_modules/ },
handler(id, importer) {
async handler(id, importer) {
// Only operate in the client environment
if (this.environment?.name !== "client") return;

Expand All @@ -65,19 +249,23 @@ export function clientReferenceDedupPlugin(): Plugin {
// Only handle absolute paths through node_modules
if (!id.startsWith("/") || !id.includes("/node_modules/")) return;

const pkgName = extractPackageName(id);
if (!pkgName) return;
const packageName = extractPackageName(id);
if (!packageName) return;

// Respect user's optimizeDeps.exclude
if (excludeSet.has(pkgName)) return;

// Lossy mapping: we collapse submodule paths (e.g. `pkg/dist/Button.js`)
// to the bare package name (`pkg`), assuming the package entry barrel-exports
// the same symbols. This holds for well-designed component libraries — the
// primary target of this plugin. A more precise approach would resolve through
// the package's `exports` map to find an exact subpath, but the barrel-export
// assumption is sufficient for the common case.
return `${DEDUP_PREFIX}${pkgName}`;
if (excludeSet.has(packageName)) return;

let packageImportPromise = packageImportCache.get(id);
if (!packageImportPromise) {
packageImportPromise = extractPackageImportSpecifier(id, readPackageJson);
packageImportCache.set(id, packageImportPromise);
}

const packageImport = await packageImportPromise;
if (!packageImport) return;
if (excludeSet.has(packageImport.specifier)) return;

return `${DEDUP_PREFIX}${packageImport.specifier}`;
},
},

Expand Down
Loading
Loading