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
58 changes: 57 additions & 1 deletion packages/vite/hmr/server/websocket-module-specifiers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { resolveInternalRuntimePluginBareSpecifier } from './websocket-module-specifiers.js';
import { resolveCandidateFilePath, resolveInternalRuntimePluginBareSpecifier } from './websocket-module-specifiers.js';

const CORE_PEER = { peerDependencies: { '@nativescript/core': '>=9.0.0' } };

Expand Down Expand Up @@ -58,3 +58,59 @@ describe('resolveInternalRuntimePluginBareSpecifier', () => {
expect(resolveInternalRuntimePluginBareSpecifier('/node_modules/@nativescript-community/main-field-fixture/dist/index.ios.js', root)).toBe('@nativescript-community/main-field-fixture');
});
});

describe('resolveCandidateFilePath — pnpm-isolated transitive deps', () => {
let ws: string;

beforeEach(() => {
ws = mkdtempSync(join(tmpdir(), 'ns-vite-pnpm-isolated-'));
});

afterEach(() => {
rmSync(ws, { recursive: true, force: true });
});

it('finds a dep that only exists under a workspace package node_modules', () => {
const appRoot = join(ws, 'apps/demo');
const depFile = join(ws, 'packages/gif/node_modules/@scope/transitive-dep/index.js');
mkdirSync(appRoot, { recursive: true });
mkdirSync(join(ws, 'packages/gif/node_modules/@scope/transitive-dep'), { recursive: true });
writeFileSync(depFile, 'export {}\n');

expect(resolveCandidateFilePath('/node_modules/@scope/transitive-dep/index.js', appRoot, ws)).toBe(depFile);
});

it('prefers app-root and workspace-root hits over the fallback', () => {
const appRoot = join(ws, 'apps/demo');
const appHit = join(appRoot, 'node_modules/pkg/index.js');
mkdirSync(join(appRoot, 'node_modules/pkg'), { recursive: true });
writeFileSync(appHit, 'export {}\n');

expect(resolveCandidateFilePath('/node_modules/pkg/index.js', appRoot, ws)).toBe(appHit);
});

it('finds a dep that only exists in the pnpm virtual store', () => {
const appRoot = join(ws, 'apps/demo');
const depDir = join(ws, 'node_modules/.pnpm/node_modules/@scope/store-only');
mkdirSync(appRoot, { recursive: true });
mkdirSync(depDir, { recursive: true });
writeFileSync(join(depDir, 'index.js'), 'export {}\n');

expect(resolveCandidateFilePath('/node_modules/@scope/store-only/index.js', appRoot, ws)).toBe(join(depDir, 'index.js'));
});

it('does not let the fallback escape a node_modules dir', () => {
const appRoot = join(ws, 'apps/demo');
mkdirSync(appRoot, { recursive: true });
mkdirSync(join(ws, 'packages/gif/node_modules'), { recursive: true });
writeFileSync(join(ws, 'packages/gif/secret.txt'), 'x');

expect(resolveCandidateFilePath('/node_modules/../secret.txt', appRoot, ws)).toBeNull();
});

it('returns null when the file exists nowhere', () => {
const appRoot = join(ws, 'apps/demo');
mkdirSync(appRoot, { recursive: true });
expect(resolveCandidateFilePath('/node_modules/pkg/nope.js', appRoot, ws)).toBeNull();
});
});
55 changes: 53 additions & 2 deletions packages/vite/hmr/server/websocket-module-specifiers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, realpathSync } from 'fs';
import { existsSync, readdirSync, readFileSync, realpathSync } from 'fs';
import * as path from 'path';

import * as PAT from './constants.js';
Expand Down Expand Up @@ -332,6 +332,44 @@ export function resolveVendorFromCandidate(specifier: string | null | undefined)
return null;
}

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

/**
* `node_modules` dirs that can hold a package the served URL no longer
* anchors: pnpm's hoisted virtual-store dir first, then every workspace
* package's private `node_modules` (one group level deep, e.g.
* `<ws>/packages/<pkg>/node_modules`). Cached per workspace root; the set
* only changes on install.
*/
function listWorkspaceNodeModulesDirs(workspaceRoot: string): string[] {
const cached = workspaceNodeModulesDirsCache.get(workspaceRoot);
if (cached) {
return cached;
}
const dirs: string[] = [];
const pushIfPresent = (dir: string) => {
if (existsSync(dir)) dirs.push(dir);
};
const listSubdirs = (dir: string): string[] => {
try {
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.'))
.map((entry) => path.join(dir, entry.name));
} catch {
return [];
}
};
pushIfPresent(path.join(workspaceRoot, 'node_modules', '.pnpm', 'node_modules'));
for (const groupDir of listSubdirs(workspaceRoot)) {
pushIfPresent(path.join(groupDir, 'node_modules'));
for (const packageDir of listSubdirs(groupDir)) {
pushIfPresent(path.join(packageDir, 'node_modules'));
}
}
workspaceNodeModulesDirsCache.set(workspaceRoot, dirs);
return dirs;
}

/**
* Resolve a candidate URL ('/node_modules/...', '/@fs/...', or an
* absolute fs path) to a real file on disk under one of the allowed
Expand Down Expand Up @@ -380,7 +418,20 @@ export function resolveCandidateFilePath(candidate: string, projectRoot: string,
const resolvedProject = path.resolve(projectRoot);
const resolvedWorkspace = path.resolve(workspaceRoot);
if (resolvedWorkspace !== resolvedProject) {
return tryUnderRoot(resolvedWorkspace);
const fromWorkspace = tryUnderRoot(resolvedWorkspace);
if (fromWorkspace) return fromWorkspace;

// Served URLs keep only the last `/node_modules/<pkg>/...` tail, so a
// package installed solely in the pnpm store or in a workspace
// package's private node_modules has no root to resolve under.
// First hit wins: the importer is gone from the URL.
if (cleaned.includes('/node_modules/')) {
const tail = cleaned.slice(cleaned.lastIndexOf('/node_modules/') + '/node_modules/'.length);
for (const nmDir of listWorkspaceNodeModulesDirs(resolvedWorkspace)) {
const absPath = path.resolve(nmDir, tail);
if (absPath.startsWith(nmDir + path.sep) && existsSync(absPath)) return absPath;
}
}
}
}

Expand Down
19 changes: 18 additions & 1 deletion packages/vite/hmr/server/websocket-ns-m-request.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand Down Expand Up @@ -30,6 +30,23 @@ describe('createNsMRequestContext', () => {
});
});

describe('createNsMRequestContext — platform-suffixed candidates', () => {
afterEach(() => {
vi.unstubAllEnvs();
});

it('probes only the active platform suffix, ahead of unsuffixed variants', () => {
vi.stubEnv('NATIVESCRIPT_BUNDLER_ENV', JSON.stringify({ ios: true }));
const result = createNsMRequestContext('/ns/m/src/plugin', '/workspace', '/src/');
if (result.kind !== 'context') throw new Error('expected context');
const { candidates } = result.value;

expect(candidates.indexOf('/src/plugin/index.ios.js')).toBeGreaterThan(-1);
expect(candidates.indexOf('/src/plugin/index.ios.js')).toBeLessThan(candidates.indexOf('/src/plugin/index.js'));
expect(candidates.some((c) => c.includes('.android.'))).toBe(false);
});
});

describe('resolveNsMTransformedModule', () => {
let fixtureRoot: string | null = null;

Expand Down
22 changes: 21 additions & 1 deletion packages/vite/hmr/server/websocket-ns-m-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TransformResult } from 'vite';
import * as path from 'path';

import { filterExistingNodeModulesTransformCandidates, getBlockedDeviceNodeModulesReason, resolveCandidateFilePath, tryReadRawExplicitJavaScriptModule } from './websocket-module-specifiers.js';
import { resolvePlatform } from '../../helpers/cli-flags.js';
import { collapseLegacyNsMTags } from './websocket-ns-m-paths.js';

export interface NsMRequestContext {
Expand Down Expand Up @@ -59,6 +60,25 @@ function normalizeAbsoluteFilesystemSpec(spec: string, serverRoot: string): stri
return relative;
}

const MODULE_CANDIDATE_EXTS = ['ts', 'js', 'tsx', 'jsx', 'mjs', 'mts', 'cts', 'vue'];

/**
* Extension/index variants of an extensionless spec, active-platform files
* first: plugins often ship only `index.ios.js`/`index.android.js` behind a
* `main: './index'`, which has no unsuffixed file to find.
*/
function expandModuleCandidates(spec: string, hasExt: boolean, baseNoExt: string): string[] {
const platform = resolvePlatform();
const candidates = hasExt ? [spec] : [];
for (const base of [baseNoExt, `${baseNoExt}/index`]) {
if (platform) {
candidates.push(...MODULE_CANDIDATE_EXTS.map((ext) => `${base}.${platform}.${ext}`));
}
candidates.push(...MODULE_CANDIDATE_EXTS.map((ext) => `${base}.${ext}`));
}
return candidates;
}

export function createNsMRequestContext(requestUrl: string, serverRoot: string, appVirtualWithSlash: string, workspaceRoot?: string | null): NsMRequestContextResult {
try {
const urlObj = new URL(requestUrl || '', 'http://localhost');
Expand Down Expand Up @@ -123,7 +143,7 @@ export function createNsMRequestContext(requestUrl: string, serverRoot: string,

const hasExt = /\.(ts|tsx|js|jsx|mjs|mts|cts|vue)$/i.test(spec);
const baseNoExt = hasExt ? spec.replace(/\.(ts|tsx|js|jsx|mjs|mts|cts)$/i, '') : spec;
const candidates = [...(hasExt ? [spec] : []), `${baseNoExt}.ts`, `${baseNoExt}.js`, `${baseNoExt}.tsx`, `${baseNoExt}.jsx`, `${baseNoExt}.mjs`, `${baseNoExt}.mts`, `${baseNoExt}.cts`, `${baseNoExt}.vue`, `${baseNoExt}/index.ts`, `${baseNoExt}/index.js`, `${baseNoExt}/index.tsx`, `${baseNoExt}/index.jsx`, `${baseNoExt}/index.mjs`];
const candidates = expandModuleCandidates(spec, hasExt, baseNoExt);
const transformCandidates = filterExistingNodeModulesTransformCandidates(spec, candidates, serverRoot, workspaceRoot);

return {
Expand Down
Loading