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
21 changes: 19 additions & 2 deletions packages/vite/helpers/global-defines.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';

import { getGlobalDefines, isHmrProgressOverlayEnabled } from './global-defines.js';
import { getGlobalDefines, getUserDefineEntries, isHmrProgressOverlayEnabled, setUserDefineEntries } from './global-defines.js';

describe('setUserDefineEntries / getUserDefineEntries', () => {
afterEach(() => setUserDefineEntries(undefined));

it('captures __FOO__ keys as [key, expression] pairs, JSON-encoding non-string values', () => {
setUserDefineEntries({ __VUE_OPTIONS_API__: true, __APP_VERSION__: '"1.2.3"', 'process.env.FOO': '"bar"', 'global.isIOS': 'true', __not_a_define: '1' });
expect(getUserDefineEntries()).toEqual([
['__VUE_OPTIONS_API__', 'true'],
['__APP_VERSION__', '"1.2.3"'],
]);
});

it('is empty when no config has been captured', () => {
setUserDefineEntries(undefined);
expect(getUserDefineEntries()).toEqual([]);
});
});

describe('isHmrProgressOverlayEnabled (NS_VITE_PROGRESS_OVERLAY)', () => {
it('defaults to enabled when the env var is unset', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/vite/helpers/global-defines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ export function setUserDefineEntries(define: Record<string, unknown> | undefined
userProcessEnvDefineEntries = envEntries;
}

/** The captured `__FOO__` define entries as `[key, expression]` pairs (see setUserDefineEntries). */
export function getUserDefineEntries(): ReadonlyArray<[string, string]> {
return userDefineEntries;
}

/** The captured `process.env.<KEY>` define values (see setUserDefineEntries). */
export function getUserProcessEnvDefineEntries(): Record<string, string> {
return userProcessEnvDefineEntries;
Expand Down
81 changes: 77 additions & 4 deletions packages/vite/hmr/server/deps-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';

import { setUserDefineEntries } from '../../helpers/global-defines.js';
import { DEPS_BUNDLE_PATH, buildDepsBundleEntryCode, buildDepsCandidateSpecs, buildDepsShimCode, buildDepsVendorRuntimeModule, collectDepsModuleExportInfo, computeDepsBundleCacheKey, createDepsBundleService, depsRegistryKeyForFile, generateDepsBundle, isDepsPerModuleServingEnabled, resolveDepsEntriesFromRecording, resolveDepsEntriesFromVendorCollection } from './deps-bundle.js';

describe('isDepsPerModuleServingEnabled', () => {
Expand Down Expand Up @@ -120,10 +121,41 @@ describe('collectDepsModuleExportInfo', () => {
expect(collectDepsModuleExportInfo(abs, 'android', new Set()).names).toEqual(['AndroidCanvas', 'own']);
});

it('bails to null for bare export * (name set lives in another package) WITHOUT the opaqueCjs flag', () => {
// ESM bails must stay per-module: their bundled namespace has real named
// exports the server cannot enumerate, so a default-only shim would
// break `import { x }` consumers. Only CJS bails get opaqueCjs.
it('follows a bare export * into the package it resolves to (nativescript-vue → @vue/runtime-core shape)', () => {
// A bundled barrel with no shim is evaluated a second time per-module.
write('node_modules/star-peer/package.json', JSON.stringify({ name: 'star-peer', module: 'dist/peer.mjs' }));
write('node_modules/star-peer/dist/peer.mjs', `export const peerA = 1;\nexport * from './peer-more.mjs';\n`);
write('node_modules/star-peer/dist/peer-more.mjs', `export const peerB = 2;\n`);
const abs = write('node_modules/star-root/index.js', `export * from 'star-peer';\nexport const own = 1;\n`);
const info = collectDepsModuleExportInfo(abs, 'ios');
expect(info.names).toEqual(['own', 'peerA', 'peerB']);
expect(info.hasDefault).toBe(false);
});

it('resolves bare export * subpaths through exports maps (exact and pattern entries) and platform suffixes', () => {
write('node_modules/sub-peer/package.json', JSON.stringify({ name: 'sub-peer', exports: { '.': './index.js', './exact': { import: './dist/exact.mjs' }, './lib/*': './dist/lib/*.mjs' } }));
write('node_modules/sub-peer/index.js', `export const root = 1;\n`);
write('node_modules/sub-peer/dist/exact.mjs', `export const exact = 1;\n`);
write('node_modules/sub-peer/dist/lib/deep.mjs', `export const deep = 1;\n`);
write('node_modules/plat-peer/package.json', JSON.stringify({ name: 'plat-peer', main: 'index.js' }));
write('node_modules/plat-peer/impl.ios.js', `export const impl = 'ios';\n`);
write('node_modules/plat-peer/impl.android.js', `export const androidImpl = 'android';\n`);
const abs = write('node_modules/sub-root/index.js', `export * from 'sub-peer/exact';\nexport * from 'sub-peer/lib/deep';\nexport * from 'plat-peer/impl';\n`);
expect(collectDepsModuleExportInfo(abs, 'ios').names).toEqual(['deep', 'exact', 'impl']);
expect(collectDepsModuleExportInfo(abs, 'android').names).toEqual(['androidImpl', 'deep', 'exact']);
});

it('resolves a bare export * from the nearest node_modules ancestor, like Node', () => {
write('node_modules/nested-peer/package.json', JSON.stringify({ name: 'nested-peer', main: 'index.js' }));
write('node_modules/nested-peer/index.js', `export const hoisted = 1;\n`);
write('node_modules/nested-root/node_modules/nested-peer/package.json', JSON.stringify({ name: 'nested-peer', main: 'index.js' }));
write('node_modules/nested-root/node_modules/nested-peer/index.js', `export const nested = 1;\n`);
const abs = write('node_modules/nested-root/index.js', `export * from 'nested-peer';\n`);
expect(collectDepsModuleExportInfo(abs, 'ios').names).toEqual(['nested']);
});

it('bails to null for a bare export * that resolves nowhere, WITHOUT the opaqueCjs flag', () => {
// Unresolvable ESM stars keep per-module serving; only CJS bails get opaqueCjs.
const abs = write('bare-star.js', `export * from 'other-pkg';\nexport const own = 1;\n`);
const info = collectDepsModuleExportInfo(abs, 'ios');
expect(info.names).toBeNull();
Expand Down Expand Up @@ -249,6 +281,9 @@ function createFixtureProject(): string {
write('node_modules/pkg-j/package.json', JSON.stringify({ name: 'pkg-j', version: '1.0.0', main: 'dist/index.js' }));
write('node_modules/pkg-j/index.ts', `export * from './src/not-shipped';\n`);
write('node_modules/pkg-j/dist/index.js', `export const J = 'j-dist';\n`);
// Dep code reading app-level `__FOO__` defines (Vue feature-flag shape).
write('node_modules/pkg-flags/package.json', JSON.stringify({ name: 'pkg-flags', version: '1.0.0', module: 'index.js' }));
write('node_modules/pkg-flags/index.js', `export const optionsApi = typeof __VUE_OPTIONS_API__ === 'boolean' ? __VUE_OPTIONS_API__ : 'unset';\nexport const bad = typeof __BAD_DEFINE__ === 'undefined' ? 'unset' : __BAD_DEFINE__;\n`);
return projectRoot;
}

Expand Down Expand Up @@ -360,6 +395,18 @@ describe('generateDepsBundle', () => {
const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'typescript', recordedPaths });
expect(state!.code).toContain('platform polyfills');
});

it("substitutes the app's __FOO__ defines in bundled dep code, skipping values esbuild cannot define", async () => {
setUserDefineEntries({ __VUE_OPTIONS_API__: true, __BAD_DEFINE__: 'compute()' });
try {
const state = await generateDepsBundle({ projectRoot, platform: 'ios', mode: 'development', flavor: 'vue', recordedPaths: ['/ns/m/node_modules/pkg-flags/index.js'] });
expect(state).not.toBeNull();
expect(state!.code).not.toContain('__VUE_OPTIONS_API__');
expect(state!.code).toContain('__BAD_DEFINE__');
} finally {
setUserDefineEntries(undefined);
}
});
});

// ============================================================================
Expand Down Expand Up @@ -530,6 +577,32 @@ describe('createDepsBundleService', () => {
rmSync(seedRoot, { recursive: true, force: true });
});

it('serves shims for a bundled root whose barrel re-exports a bare dependency (nativescript-vue shape)', async () => {
// Per-module serving here would re-run the root's top-level init().
const root = mkdtempSync(path.join(realpathSync(tmpdir()), 'ns-deps-bare-star-'));
const write = (rel: string, contents: string) => {
const abs = path.join(root, rel);
mkdirSync(path.dirname(abs), { recursive: true });
writeFileSync(abs, contents);
};
write('package.json', JSON.stringify({ name: 'fixture-app', version: '1.0.0' }));
write('node_modules/ns-framework/package.json', JSON.stringify({ name: 'ns-framework', version: '1.0.0', main: 'dist/index.js' }));
write('node_modules/ns-framework/dist/index.js', `import { init } from './runtime.js';\ninit();\nexport * from 'ns-framework-core';\nexport { init };\n`);
write('node_modules/ns-framework/dist/runtime.js', `export function init() {}\n`);
write('node_modules/ns-framework-core/package.json', JSON.stringify({ name: 'ns-framework-core', version: '1.0.0', module: 'dist/core.mjs' }));
write('node_modules/ns-framework-core/dist/core.mjs', `export const ref = () => 1;\nexport const createApp = () => 2;\n`);
const service = createDepsBundleService({ projectRoot: root, platform: 'ios', mode: 'development', flavor: 'vue', getRecordedPaths: () => ['/ns/m/node_modules/ns-framework'] });
await service.ensureBuilt();
for (const spec of ['/node_modules/ns-framework', '/node_modules/ns-framework/dist/index.js']) {
const shim = service.getShimForSpec(spec);
expect(shim).toContain(`import "${DEPS_BUNDLE_PATH}";`);
expect(shim).toContain('export const init = ');
expect(shim).toContain('export const createApp = ');
expect(shim).toContain('export const ref = ');
}
rmSync(root, { recursive: true, force: true });
});

it('stops handing out new shims after disableServingForSession but keeps the payload servable', async () => {
const service = makeService();
await service.ensureBuilt();
Expand Down
141 changes: 106 additions & 35 deletions packages/vite/hmr/server/deps-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import * as esbuild from 'esbuild';
import type { ViteDevServer } from 'vite';

import { resolvePlatform } from '../../helpers/cli-flags.js';
import { getGlobalDefines } from '../../helpers/global-defines.js';
import { getGlobalDefines, getUserDefineEntries } from '../../helpers/global-defines.js';
import { getProjectFlavor } from '../../helpers/flavor.js';
import { getMonorepoWorkspaceRoot } from '../../helpers/project.js';
import { createNativeClassEsbuildPlugin } from '../../helpers/nativeclass-esbuild-plugin.js';
Expand Down Expand Up @@ -132,6 +132,35 @@ export function buildDepsCandidateSpecs(spec: string, platform: string): string[
return [...(hasExt ? [spec] : []), ...exts.map((ext) => baseNoExt + ext), ...exts.map((ext) => baseNoExt + '/index' + ext)];
}

function collectExportsMapTargets(node: unknown, out: string[]): void {
if (!node) return;
if (typeof node === 'string') {
out.push(node);
return;
}
if (typeof node === 'object' && !Array.isArray(node)) {
// Prefer client/ESM conditions in the order esbuild would.
for (const cond of ['module', 'import', 'browser', 'default', 'require']) {
const next = (node as Record<string, unknown>)[cond];
if (next !== undefined) collectExportsMapTargets(next, out);
}
}
}

function resolvePackageFileCandidates(pkgDir: string, candidates: readonly string[], platform: string): string | null {
const exts = platformResolveExtensions(platform);
for (const cand of candidates) {
const abs = path.resolve(pkgDir, cand);
if (!abs.startsWith(pkgDir + path.sep)) continue;
if (existsSync(abs) && statSync(abs).isFile()) return abs;
const base = abs.replace(SCRIPT_EXT_RE, '');
for (const candidate of [...exts.map((ext) => base + ext), ...exts.map((ext) => base + '/index' + ext)]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
}
return null;
}

/**
* Resolve a package ROOT spec (`/node_modules/<pkg>`) to its entry file via
* the package's own `exports['.']`/`module`/`main`, honoring platform
Expand All @@ -154,34 +183,41 @@ function resolvePackageRootEntry(spec: string, roots: readonly string[], platfor
continue;
}
const candidates: string[] = [];
const visitExports = (node: any) => {
if (!node) return;
if (typeof node === 'string') {
candidates.push(node);
return;
}
if (typeof node === 'object' && !Array.isArray(node)) {
// Prefer client/ESM conditions in the order esbuild would.
for (const cond of ['module', 'import', 'browser', 'default', 'require']) {
if (node[cond] !== undefined) visitExports(node[cond]);
}
}
};
visitExports(pkg.exports?.['.'] ?? (typeof pkg.exports === 'string' ? pkg.exports : undefined));
collectExportsMapTargets(pkg.exports?.['.'] ?? (typeof pkg.exports === 'string' ? pkg.exports : undefined), candidates);
if (typeof pkg.module === 'string') candidates.push(pkg.module);
if (typeof pkg.main === 'string') candidates.push(pkg.main);
candidates.push('index');
for (const cand of candidates) {
const abs = path.resolve(pkgDir, cand);
if (!abs.startsWith(pkgDir + path.sep)) continue;
if (existsSync(abs) && statSync(abs).isFile()) return abs;
const base = abs.replace(SCRIPT_EXT_RE, '');
for (const ext of platformResolveExtensions(platform)) {
if (existsSync(base + ext)) return base + ext;
const resolved = resolvePackageFileCandidates(pkgDir, candidates, platform);
if (resolved) return resolved;
}
return null;
}

function resolvePackageSubpathEntry(pkgDir: string, subpath: string, platform: string): string | null {
const candidates: string[] = [];
try {
const pkg = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
const exportsMap = pkg.exports && typeof pkg.exports === 'object' && !Array.isArray(pkg.exports) ? (pkg.exports as Record<string, unknown>) : null;
if (exportsMap) {
const key = `./${subpath}`;
collectExportsMapTargets(exportsMap[key], candidates);
for (const [pattern, target] of Object.entries(exportsMap)) {
const star = pattern.indexOf('*');
if (star === -1) continue;
const prefix = pattern.slice(0, star);
const suffix = pattern.slice(star + 1);
if (key.length < prefix.length + suffix.length || !key.startsWith(prefix) || !key.endsWith(suffix)) continue;
const captured = key.slice(prefix.length, key.length - suffix.length);
const targets: string[] = [];
collectExportsMapTargets(target, targets);
for (const t of targets) candidates.push(t.split('*').join(captured));
}
}
} catch {
// Unreadable manifest: fall through to the on-disk lookup.
}
return null;
candidates.push(subpath);
return resolvePackageFileCandidates(pkgDir, candidates, platform);
}

/** `/node_modules/<pkg>` or `/node_modules/@scope/<pkg>` with no subpath. */
Expand Down Expand Up @@ -567,6 +603,19 @@ export function saveDepsBundleToDisk(projectRoot: string, platform: string, key:
}
}

// esbuild `define` takes JSON literals or identifier/member chains only.
const DEFINE_IDENTIFIER_CHAIN_RE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/;

function isEsbuildDefineValue(expr: string): boolean {
if (DEFINE_IDENTIFIER_CHAIN_RE.test(expr)) return true;
try {
JSON.parse(expr);
return true;
} catch {
return false;
}
}

export async function generateDepsBundle(options: GenerateDepsBundleOptions): Promise<DepsBundleState | null> {
const { projectRoot, platform, mode, flavor, verbose } = options;
const t0 = Date.now();
Expand Down Expand Up @@ -605,6 +654,10 @@ export async function generateDepsBundle(options: GenerateDepsBundleOptions): Pr
// substitutes free references, so CJS-wrapped code (where `module` is a
// bound variable) is untouched.
out['module.hot'] = 'undefined';
// App `__FOO__` defines reach dep code here exactly as in bundle.mjs.
for (const [key, expr] of getUserDefineEntries()) {
if (out[key] === undefined && isEsbuildDefineValue(expr)) out[key] = expr;
}
return out;
})();

Expand Down Expand Up @@ -770,6 +823,23 @@ function resolveLocalReExportTarget(spec: string, importerPath: string, platform
return null;
}

// Node-style resolution from the importer: nearest node_modules ancestor wins.
function resolveBareReExportTarget(spec: string, importerPath: string, platform: string): string | null {
const match = /^((?:@[^/]+\/)?[^/]+)(?:\/(.+))?$/.exec(spec);
if (!match) return null;
const [, pkgName, subpath] = match;
let dir = path.dirname(importerPath);
for (;;) {
const pkgDir = path.join(dir, 'node_modules', pkgName);
if (existsSync(path.join(pkgDir, 'package.json'))) {
return subpath ? resolvePackageSubpathEntry(pkgDir, subpath, platform) : resolvePackageRootEntry(`/node_modules/${pkgName}`, [dir], platform);
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}

// Static CJS export-name discovery: `exports.NAME = ...` and
// `Object.defineProperty(exports, "NAME", ...)` assignments (transpiled-to-CJS
// packages like downsample). UMD factories (`module.exports = fn()`) expose no
Expand All @@ -792,16 +862,18 @@ function collectCjsExportInfo(code: string): DepsModuleExportInfo {

/**
* Static export-name discovery for a node_modules file. ESM: direct exports
* plus recursion through RELATIVE `export * from` chains (the shape deep
* package barrels like rxjs's index take). CJS: `exports.NAME` assignment
* scanning (esbuild's interop exposes those names on the bundled namespace at
* runtime). Returns `names: null` for files whose export surface is not
* statically enumerable — with two DIFFERENT consequences downstream:
* CJS/UMD files additionally carry `opaqueCjs: true` and still get a
* default-only shim (their bundled namespace has nothing but `default`);
* ESM files with a bare `export * from 'pkg'` or an unresolvable relative
* `export *` target keep per-module serving, where the transform pipeline
* resolves the star through the import map.
* plus recursion through `export * from` chains — relative targets (the
* shape deep package barrels like rxjs's index take) and bare package targets
* resolved from the importer like Node would (nativescript-vue re-exporting
* `@vue/runtime-core`). CJS: `exports.NAME` assignment scanning (esbuild's
* interop exposes those names on the bundled namespace at runtime). Returns
* `names: null` for files whose export surface is not statically enumerable —
* with two DIFFERENT consequences downstream: CJS/UMD files additionally carry
* `opaqueCjs: true` and still get a default-only shim (their bundled
* namespace has nothing but `default`); ESM files with an `export *` target
* that resolves nowhere on disk keep per-module serving, where the transform
* pipeline resolves the star through the import map. A BUNDLED file served
* per-module evaluates a second time, so any resolvable star must be followed.
*/
export function collectDepsModuleExportInfo(absPath: string, platform: string, seen: Set<string> = new Set()): DepsModuleExportInfo {
if (seen.has(absPath)) return { names: [], hasDefault: false };
Expand All @@ -828,8 +900,7 @@ export function collectDepsModuleExportInfo(absPath: string, platform: string, s
const starRe = /^[ \t]*export\s+\*\s+from\s+["']([^"']+)["']/gm;
while ((match = starRe.exec(code)) !== null) {
const spec = match[1];
if (!spec.startsWith('.')) return { names: null, hasDefault: false };
const target = resolveLocalReExportTarget(spec, absPath, platform);
const target = spec.startsWith('.') ? resolveLocalReExportTarget(spec, absPath, platform) : resolveBareReExportTarget(spec, absPath, platform);
if (!target) return { names: null, hasDefault: false };
const child = collectDepsModuleExportInfo(target, platform, seen);
if (child.names === null) return { names: null, hasDefault: false };
Expand Down
Loading