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
10 changes: 4 additions & 6 deletions packages/vite/hmr/server/process-code-for-device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,12 +299,10 @@ function processCodeForDevice(code: string, isVitePreBundled: boolean, preserveV
resolvedSpecifierOverrides,
// Worker entries evaluate in their own realm, where the main realm's
// vendor registry / __nsRequire never exist — route their vendor
// imports to the /ns/m/node_modules HTTP ESM form instead (realm-local
// copy via the deps-bundle bridge, matching webpack's per-worker
// bundling semantics). Entry-level detection by the `.worker` filename
// convention; a worker's TRANSITIVE imports share URLs with the main
// realm and cannot be forked per-realm, so plugins consumed by worker
// code should be imported from the worker entry itself.
// imports to the per-module /ns/m/node_modules HTTP ESM form instead,
// matching webpack's per-worker bundling semantics. The entry is
// detected by the `.worker` filename convention; its transitive
// imports by the `?ns_worker=1` marker the /ns/m route propagates.
vendorImportsAsHttp: options?.workerRealm || isWorkerEntryModuleId(sourceId),
};

Expand Down
49 changes: 48 additions & 1 deletion packages/vite/hmr/server/websocket-bindings.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 { parse as babelParse } from '@babel/parser';
import { afterEach, describe, it, expect } from 'vitest';
import { ensureNativeScriptModuleBindings } from './websocket-module-bindings.js';
import { ensureNativeScriptModuleBindings, getProcessCodeResolvedSpecifierOverrides } from './websocket-module-bindings.js';
import { rewriteImports } from './websocket-device-transform.js';

// Helper to normalize whitespace for robust assertions
Expand Down Expand Up @@ -204,6 +204,53 @@ describe('ensureNativeScriptModuleBindings — package metadata NativeScript det
expect(text).not.toContain('__nsVendorModule_');
});

it('routes node_modules re-exports to the /ns/m worker form with vendorImportsAsHttp, even without imports', () => {
const input = [`export * from "/node_modules/@nativescript/canvas/Canvas2D/Path2D/index.js?v=1a2b";`, `export { Helpers } from "/node_modules/@nativescript/canvas/helpers.js";`, `export * from "/node_modules/@nativescript/core/index.js";`, `export { local } from "./local";`].join('\n');

const worker = ensureNativeScriptModuleBindings(input, { vendorImportsAsHttp: true });
expect(worker).toContain(`export * from "/ns/m/node_modules/@nativescript/canvas/Canvas2D/Path2D/index.js?ns_worker=1";`);
expect(worker).toContain(`export { Helpers } from "/ns/m/node_modules/@nativescript/canvas/helpers.js?ns_worker=1";`);
expect(worker).toContain(`export * from "/node_modules/@nativescript/core/index.js";`);
expect(worker).toContain(`export { local } from "./local";`);

expect(ensureNativeScriptModuleBindings(input)).toBe(input);
});

it('names the resolved file for worker imports and re-exports alike, ignoring the authored spelling', () => {
const root = mkdtempSync(join(tmpdir(), 'ns-websocket-bindings-'));
tempRoots.push(root);

const pkg = join(root, 'node_modules', '@nativescript', 'canvas');
mkdirSync(join(pkg, 'Canvas2D', 'Path2D'), { recursive: true });
writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture-app' }, null, 2));
writeFileSync(join(pkg, 'package.json'), JSON.stringify({ name: '@nativescript/canvas', main: './index', nativescript: { platforms: { ios: '6.0.0' } } }, null, 2));
writeFileSync(join(pkg, 'Canvas2D', 'Path2D', 'index.js'), `export class Path2D {}\n`);
writeFileSync(join(pkg, 'Canvas2D', 'index.js'), `export * from './Path2D';\n`);
writeFileSync(join(pkg, 'Canvas2D', 'context.js'), `import { Path2D } from './Path2D';\nexport const make = () => new Path2D();\n`);

process.chdir(root);

const served = `"/node_modules/@nativescript/canvas/Canvas2D/Path2D/index.js?v=1a2b"`;
const reExport = ensureNativeScriptModuleBindings(`export * from ${served};\n`, {
vendorImportsAsHttp: true,
resolvedSpecifierOverrides: getProcessCodeResolvedSpecifierOverrides('/node_modules/@nativescript/canvas/Canvas2D/index.js', root),
});
const importer = ensureNativeScriptModuleBindings(`import { Path2D } from ${served};\nexport const make = () => new Path2D();\n`, {
vendorImportsAsHttp: true,
resolvedSpecifierOverrides: getProcessCodeResolvedSpecifierOverrides('/node_modules/@nativescript/canvas/Canvas2D/context.js', root),
});

expect(reExport).toContain(`export * from "/ns/m/node_modules/@nativescript/canvas/Canvas2D/Path2D/index.js?ns_worker=1";`);
expect(importer).toContain(`import { Path2D } from "/ns/m/node_modules/@nativescript/canvas/Canvas2D/Path2D/index.js?ns_worker=1";`);

// The main realm keeps the authored spelling for the import map.
const mainImporter = ensureNativeScriptModuleBindings(`import { Path2D } from ${served};\nexport const make = () => new Path2D();\n`, {
preserveNonPluginVendorImports: true,
resolvedSpecifierOverrides: getProcessCodeResolvedSpecifierOverrides('/node_modules/@nativescript/canvas/Canvas2D/context.js', root),
});
expect(mainImporter).toContain(`from "@nativescript/canvas/Canvas2D/Path2D"`);
});

it('preserves exact bare runtime-plugin subpaths instead of collapsing them to the root package', () => {
const root = mkdtempSync(join(tmpdir(), 'ns-websocket-bindings-'));
tempRoots.push(root);
Expand Down
73 changes: 70 additions & 3 deletions packages/vite/hmr/server/websocket-module-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ export interface EnsureNativeScriptModuleBindingsOptions {
* shim's fallback to the native `require()` fails for every vendor
* package (observed with `@nativescript/zip` in a zip worker on a fresh
* install — the first boot's DB unzip was the first code path to ever
* exercise a vendor require inside a worker). The HTTP form gives the
* worker its own realm-local copy via the deps-bundle bridge — the same
* isolation semantics webpack's per-worker bundles had.
* exercise a vendor require inside a worker). The `?ns_worker=1` HTTP form
* gives the worker its own per-module copy, bypassing the deps bundle —
* the same isolation semantics webpack's per-worker bundles had.
*/
vendorImportsAsHttp?: boolean;
}
Expand Down Expand Up @@ -104,6 +104,65 @@ function collectTopLevelImportRecords(code: string): TopLevelImportRecord[] {
}
}

/** Value re-exports (`export * from 'x'`, `export { a } from 'x'`), as the positions of their source literals. */
function collectTopLevelReExportSources(code: string): Array<{ start: number; end: number; source: string }> {
try {
const ast = babelParse(code, {
sourceType: 'module',
plugins: [...MODULE_IMPORT_ANALYSIS_PLUGINS],
}) as any;
const body = ast?.program?.body;
if (!Array.isArray(body)) {
return [];
}

return body
.filter((node: any) => (node?.type === 'ExportAllDeclaration' || node?.type === 'ExportNamedDeclaration') && node.exportKind !== 'type' && typeof node.source?.value === 'string')
.map((node: any) => ({
start: node.source.start as number,
end: node.source.end as number,
source: node.source.value as string,
}));
} catch {
return [];
}
}

/**
* The worker serve's URL for a node_modules module Vite already resolved
* (`/node_modules/<pkg>/<file>`, `/@fs/.../node_modules/<pkg>/<file>`), or
* null when the specifier is not one. A worker realm has no vendor registry
* and must not reach the deps-bundle bridge, so its imports and re-exports
* both name the resolved file: one URL per module, as on the web. The
* authored spelling (`getPreservedImportSpecifier`) is for the main realm's
* import map; a re-export never carries it, so a worker using it for imports
* would load a module once per spelling.
*/
function getWorkerNodeModulesSpecifier(specifier: string): string | null {
const nodeModulesSpecifier = normalizeNodeModulesSpecifier(specifier);
if (!nodeModulesSpecifier || /^@nativescript\/core(\b|\/)/i.test(nodeModulesSpecifier) || isEsmFrameworkPackageSpecifier(specifier)) {
return null;
}
return `/ns/m/node_modules/${nodeModulesSpecifier}?ns_worker=1`;
}

/**
* Left alone, `rewriteImports` hands a package's own `export * from './sub'`
* to the import map as a bare specifier, which routes it to the deps-bundle
* shim: the worker then evaluates the bundle too, and with it a second copy
* of the package it already loaded per module.
*/
function rewriteWorkerReExportSpecifiers(code: string): string {
const records = collectTopLevelReExportSources(code);
for (const record of [...records].sort((left, right) => right.start - left.start)) {
const workerSpecifier = getWorkerNodeModulesSpecifier(record.source.replace(PAT.QUERY_PATTERN, ''));
if (workerSpecifier) {
code = code.slice(0, record.start) + JSON.stringify(workerSpecifier) + code.slice(record.end);
}
}
return code;
}

function stripTopLevelImportRecords(code: string, records: TopLevelImportRecord[]): string {
let stripped = code;
for (const record of [...records].sort((left, right) => right.start - left.start)) {
Expand All @@ -113,6 +172,9 @@ function stripTopLevelImportRecords(code: string, records: TopLevelImportRecord[
}

export function ensureNativeScriptModuleBindings(code: string, options?: EnsureNativeScriptModuleBindingsOptions): string {
if (options?.vendorImportsAsHttp) {
code = rewriteWorkerReExportSpecifiers(code);
}
const importRecords = collectTopLevelImportRecords(code);
if (!importRecords.length) {
return code;
Expand Down Expand Up @@ -166,6 +228,11 @@ export function ensureNativeScriptModuleBindings(code: string, options?: EnsureN

const rawSpec = record.source;
const specifier = rawSpec.replace(PAT.QUERY_PATTERN, '');
const workerSpecifier = options?.vendorImportsAsHttp ? getWorkerNodeModulesSpecifier(specifier) : null;
if (workerSpecifier) {
preservedImports.push(rewritePreservedImportSpecifier(original, rawSpec, workerSpecifier));
continue;
}
const preservedSpecifier = getPreservedImportSpecifier(specifier, options);

if (!record.clause) {
Expand Down
Loading