Skip to content
Merged
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
15 changes: 8 additions & 7 deletions packages/vite/configuration/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import { getCliFlags } from '../helpers/cli-flags.js';
import NativeScriptPlugin from '../helpers/resolver.js';
import nsConfigAsJsonPlugin from '../helpers/config-as-json.js';
import { findMonorepoWorkspaceRoot, getProjectRootPath } from '../helpers/project.js';
import { getDevServerFsAllow, getProjectRootPath } from '../helpers/project.js';
import { aliasCssTree } from '../helpers/css-tree.js';
import { getGlobalDefines } from '../helpers/global-defines.js';
import { getWorkerPlugins, workerHmrUrlPlugin, workerUrlPlugin, nativescriptWorkerLoaderStubPlugin } from '../helpers/workers.js';
Expand Down Expand Up @@ -505,13 +505,14 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
// though the file is right there on disk.
//
// Detect the real workspace root once at config time and
// add it to `fs.allow`. `mergeConfig` concatenates arrays,
// so apps that pass their own `server.fs.allow` keep
// their entries on top of this baseline.
// add it to `fs.allow`, alongside the workspace roots of any
// linked dependency checkouts whose files the dev server must
// serve. `mergeConfig` concatenates arrays, so apps that pass
// their own `server.fs.allow` keep their entries on top of
// this baseline.
...(() => {
const monorepoRoot = findMonorepoWorkspaceRoot(projectRoot);
if (!monorepoRoot || monorepoRoot === projectRoot) return {};
return { fs: { allow: [monorepoRoot, projectRoot] } };
const allow = getDevServerFsAllow(projectRoot);
return allow.length ? { fs: { allow } } : {};
})(),
}
: {},
Expand Down
109 changes: 107 additions & 2 deletions packages/vite/helpers/project.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

import { clearMonorepoWorkspaceRootCache, findMonorepoWorkspaceRoot, getMonorepoWorkspaceRoot } from './project.js';
import { clearLinkedDependencyRootsCache, clearMonorepoWorkspaceRootCache, findMonorepoWorkspaceRoot, getDevServerFsAllow, getLinkedDependencyRoots, getMonorepoWorkspaceRoot } from './project.js';

function createTempTree(): { root: string; cleanup: () => void } {
const root = mkdtempSync(join(tmpdir(), 'ns-vite-monorepo-'));
Expand Down Expand Up @@ -210,3 +210,108 @@ describe('getMonorepoWorkspaceRoot (memoized)', () => {
}
});
});

describe('getLinkedDependencyRoots', () => {
beforeEach(() => {
clearLinkedDependencyRootsCache();
clearMonorepoWorkspaceRootCache();
});

it('returns only real source checkouts, in declaration order', () => {
const { root, cleanup } = createTempTree();
try {
const sandbox = realpathSync(root);
const app = join(sandbox, 'app');
mkdirSync(join(app, 'node_modules'), { recursive: true });

const checkout = join(sandbox, 'checkout');
const linkedIcons = join(sandbox, 'linked-icons');
const themeCheckout = join(sandbox, 'theme-checkout');
mkdirSync(checkout, { recursive: true });
mkdirSync(linkedIcons, { recursive: true });
mkdirSync(themeCheckout, { recursive: true });

// Registry-looking spec satisfied by an install-time symlink (a
// pnpm readPackage hook can produce this without changing the
// manifest).
symlinkSync('../../checkout', join(app, 'node_modules/auth'), 'dir');
// A registry store entry realpaths under node_modules — not a
// source checkout.
mkdirSync(join(sandbox, 'store/node_modules/dep'), { recursive: true });
symlinkSync('../../store/node_modules/dep', join(app, 'node_modules/dep'), 'dir');
// `file:` can name a tarball; only directories can be served.
writeFileSync(join(app, 'pkg.tgz'), 'tar-bytes');

writeFileSync(
join(app, 'package.json'),
JSON.stringify({
name: 'app',
version: '1.0.0',
dependencies: {
auth: '1.0.0',
icons: 'link:../linked-icons',
theme: 'file:../theme-checkout',
lib: 'file:./pkg.tgz',
dep: '1.0.0',
gone: '1.0.0',
},
}),
);

expect(getLinkedDependencyRoots(app)).toEqual([checkout, linkedIcons, themeCheckout]);
} finally {
cleanup();
}
});
});

describe('getDevServerFsAllow', () => {
beforeEach(() => {
clearLinkedDependencyRootsCache();
clearMonorepoWorkspaceRootCache();
});

it('returns [] when nothing needs widening', () => {
const { root, cleanup } = createTempTree();
try {
const app = join(root, 'app');
mkdirSync(app, { recursive: true });
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0' }));

expect(getDevServerFsAllow(app)).toEqual([]);
} finally {
cleanup();
}
});

it('allows the linked checkout workspace root alongside the app', () => {
const { root, cleanup } = createTempTree();
try {
const sandbox = realpathSync(root);
const app = join(sandbox, 'app');
const checkout = join(sandbox, 'checkout');
mkdirSync(join(app, 'node_modules'), { recursive: true });
mkdirSync(join(checkout, 'packages/auth'), { recursive: true });
writeFileSync(join(checkout, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n');
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { auth: '1.0.0' } }));
symlinkSync('../../checkout/packages/auth', join(app, 'node_modules/auth'), 'dir');

expect(getDevServerFsAllow(app)).toEqual([app, checkout]);
} finally {
cleanup();
}
});

it('returns [] for a link: dep that stays inside the project root', () => {
const { root, cleanup } = createTempTree();
try {
const app = join(root, 'app');
mkdirSync(join(app, 'framework/packages/auth'), { recursive: true });
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { auth: 'link:./framework/packages/auth' } }));

expect(getDevServerFsAllow(app)).toEqual([]);
} finally {
cleanup();
}
});
});
121 changes: 119 additions & 2 deletions packages/vite/helpers/project.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { existsSync, readFileSync, realpathSync, statSync } from 'fs';
import { dirname, join, resolve, sep } from 'path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);

Expand Down Expand Up @@ -141,6 +141,123 @@ export function clearMonorepoWorkspaceRootCache(): void {
monorepoWorkspaceRootCache.clear();
}

const linkedDependencyRootsCache = new Map<string, string[]>();

/**
* Directories of the app's dependencies that are installed as source
* checkouts rather than registry packages — and therefore keep their own
* `node_modules` outside the app's install tree:
* - `link:`, `file:`, and `portal:` specs name the checkout directly.
* - Install-time hooks (e.g. a pnpm `readPackage` hook) can swap a
* registry spec for an on-disk symlink without touching the manifest,
* so `<projectRoot>/node_modules/<name>` is realpath'd as well; the
* result counts only when it lives outside any `node_modules` dir,
* which excludes registry installs and pnpm/external stores.
*
* `file:` entries that name a tarball, and declared-but-missing deps, are
* skipped. Results are deduped in declaration order and memoized per
* project root; the set only changes on install.
* @param projectRoot app root to inspect
*/
export function getLinkedDependencyRoots(projectRoot: string = getProjectRootPath()): string[] {
const key = resolve(projectRoot);
const cached = linkedDependencyRootsCache.get(key);
if (cached) {
return cached;
}

const roots: string[] = [];
const seen = new Set<string>();
try {
const pkg = JSON.parse(readFileSync(join(key, 'package.json'), 'utf-8'));
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, spec] of Object.entries(pkg[section] ?? {})) {
const candidates: string[] = [];
const linkTarget = /^(?:link|file|portal):(.+)$/.exec(String(spec))?.[1];
if (linkTarget) {
candidates.push(resolve(key, linkTarget));
}
try {
const installed = realpathSync(join(key, 'node_modules', name));
if (!installed.split(sep).includes('node_modules')) {
candidates.push(installed);
}
} catch {}
for (const candidate of candidates) {
try {
const depDir = realpathSync(candidate);
if (!seen.has(depDir) && statSync(depDir).isDirectory()) {
seen.add(depDir);
roots.push(depDir);
}
} catch {}
}
}
}
} catch {}

linkedDependencyRootsCache.set(key, roots);
return roots;
}

/**
* Clear the {@link getLinkedDependencyRoots} memoization cache. Intended
* for tests that mutate the filesystem inside a temp dir between calls.
*/
export function clearLinkedDependencyRootsCache(): void {
linkedDependencyRootsCache.clear();
}

/**
* Compute the dev server's `server.fs.allow` list for `projectRoot`.
*
* Returns the existing `[monorepoRoot, projectRoot]` pair when a distinct
* monorepo workspace root exists, extended with the workspace root of
* every linked dependency checkout (`getMonorepoWorkspaceRoot(root)` so
* sibling workspace packages' private `node_modules` stay readable, with
* the linked dir itself as fallback) — skipping entries equal to or
* inside `projectRoot`. Returns `[]` when nothing needs widening so the
* caller can leave `fs.allow` unset and keep Vite's default.
* @param projectRoot app root to inspect
*/
export function getDevServerFsAllow(projectRoot: string = getProjectRootPath()): string[] {
const resolvedProject = resolve(projectRoot);
// Linked roots come back realpath'd, so containment checks need the
// real project root too (/var vs /private/var on macOS).
let realProject = resolvedProject;
try {
realProject = realpathSync(resolvedProject);
} catch {}
const allow: string[] = [];
const seen = new Set<string>();
const push = (dir: string | null) => {
if (dir && !seen.has(dir)) {
seen.add(dir);
allow.push(dir);
}
};
const monorepoRoot = findMonorepoWorkspaceRoot(resolvedProject);
if (monorepoRoot && monorepoRoot !== resolvedProject) {
push(monorepoRoot);
}
const linkedRoots: string[] = [];
for (const root of getLinkedDependencyRoots(resolvedProject)) {
const allowRoot = getMonorepoWorkspaceRoot(root) ?? root;
if (allowRoot === resolvedProject || allowRoot === realProject || allowRoot.startsWith(realProject + sep)) {
continue;
}
linkedRoots.push(allowRoot);
}
if (!allow.length && !linkedRoots.length) {
return [];
}
push(resolvedProject);
for (const root of linkedRoots) {
push(root);
}
return allow;
}

// Get current directory for ES modules (equivalent to __dirname)
export const __dirname = import.meta.dirname;

Expand Down
Loading
Loading