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
123 changes: 123 additions & 0 deletions packages/vite/hmr/server/served-module-sourcemap.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { decode } from '@jridgewell/sourcemap-codec';
import { transformSync } from 'esbuild';
import { describe, expect, it } from 'vitest';

import { appendInlineSourceMap, buildServedModuleSourceMap, type InputSourceMap } from './served-module-sourcemap.js';

const MODULE_FILE = '/project/src/components/counter.ts';

const SOURCE = [
`import { signal } from 'solid-js';`, // 1
``, // 2
`interface Props {`, // 3
` start: number;`, // 4
`}`, // 5
``, // 6
`export function counter(props: Props) {`, // 7
` const [count, setCount] = signal<number>(props.start);`, // 8
` const bump = (by: number) => setCount(count() + by);`, // 9
` const lazy = () => import('./lazy');`, // 10
` return { count, bump, lazy };`, // 11
`}`, // 12
].join('\n');

function transform(): { code: string; map: InputSourceMap } {
const result = transformSync(SOURCE, { loader: 'ts', format: 'esm', sourcemap: 'external', sourcefile: 'counter.ts' });
return { code: result.code, map: JSON.parse(result.map) };
}

/** 1-based source line that generated position (line 1-based, column 0-based) maps to. */
function sourceLineAt(mappings: string, line: number, column: number): number | undefined {
const segments = decode(mappings)[line - 1] ?? [];
let hit: number[] | undefined;
for (const segment of segments) {
if (segment[0] <= column && segment.length > 1) hit = segment;
}
return hit ? hit[2] + 1 : undefined;
}

function lineOf(code: string, needle: string): number {
const index = code.split('\n').findIndex((line) => line.includes(needle));
if (index < 0) throw new Error(`"${needle}" not in code`);
return index + 1;
}

describe('buildServedModuleSourceMap', () => {
it('maps the module body to the original source through a prepended preamble and rewritten lines', () => {
const { code, map } = transform();
const served = [`const __nsBootGlobal = globalThis;`, `try { __nsBootGlobal.__NS_HMR_BOOT_LAST_MODULE__ = "/src/components/counter"; } catch {}`, `;(function(){ /* require guard */ })();`, code.replace(`from "solid-js"`, `from "http://localhost:5173/ns/m/node_modules/solid-js/dist/solid.dev.js"`).replace(`import("./lazy")`, `__nsDynamicImport("http://localhost:5173/ns/m/src/components/lazy")`)].join('\n');

const result = buildServedModuleSourceMap(code, served, map, MODULE_FILE)!;

expect(result.sources).toEqual(['file:///project/src/components/counter.ts']);
expect(result.sourcesContent).toEqual([SOURCE]);
expect(sourceLineAt(result.mappings, 1, 0)).toBeUndefined();
expect(sourceLineAt(result.mappings, 2, 0)).toBeUndefined();

const bumpLine = lineOf(served, 'const bump');
const bumpColumn = served.split('\n')[bumpLine - 1].indexOf('setCount(count()');
expect(sourceLineAt(result.mappings, bumpLine, bumpColumn)).toBe(9);
expect(sourceLineAt(result.mappings, lineOf(served, 'return {'), 2)).toBe(11);
expect(sourceLineAt(result.mappings, lineOf(served, '__nsDynamicImport'), 0)).toBe(10);
});

it('leaves lines that only exist in the served code unmapped', () => {
const { code, map } = transform();
const served = code.replace(' const bump', ' const __injected = 1;\n const bump');

const result = buildServedModuleSourceMap(code, served, map, MODULE_FILE)!;

expect(sourceLineAt(result.mappings, lineOf(served, '__injected'), 0)).toBeUndefined();
expect(sourceLineAt(result.mappings, lineOf(served, 'const bump'), 2)).toBe(9);
});

it('follows lines that were only re-indented, shifting columns by the indentation change', () => {
const { code, map } = transform();
const served = `/* [ast-normalized] */\n${code.replace(/^ {2}/gm, '\t\t')}`;
const bumpLine = lineOf(served, 'const bump');
const bumpColumn = served.split('\n')[bumpLine - 1].indexOf('setCount(count()');
const vite = code.split('\n')[lineOf(code, 'const bump') - 1];
const viteColumn = vite.indexOf('setCount(count()');

const result = buildServedModuleSourceMap(code, served, map, MODULE_FILE)!;

expect(sourceLineAt(result.mappings, bumpLine, bumpColumn)).toBe(9);
const segment = decode(result.mappings)[bumpLine - 1].find((s) => s[0] === bumpColumn);
const original = decode(map.mappings)[lineOf(code, 'const bump') - 1].find((s) => s[0] === viteColumn);
expect(segment?.slice(1)).toEqual(original?.slice(1));
});

it('resolves sources against sourceRoot and keeps URL sources as they are', () => {
const { code, map } = transform();
const result = buildServedModuleSourceMap(code, code, { ...map, sources: ['counter.ts', 'virtual:helpers'], sourceRoot: '../../original' }, MODULE_FILE)!;

expect(result.sources).toEqual(['file:///project/original/counter.ts', 'virtual:helpers']);
});

it('returns null without usable mappings', () => {
const { code, map } = transform();
expect(buildServedModuleSourceMap(code, code, null, MODULE_FILE)).toBeNull();
expect(buildServedModuleSourceMap(code, code, { ...map, mappings: '' }, MODULE_FILE)).toBeNull();
expect(buildServedModuleSourceMap(code, 'export {};\n', map, MODULE_FILE)).toBeNull();
});
});

describe('appendInlineSourceMap', () => {
it('appends a base64 data URL that round-trips the map', () => {
const { code, map } = transform();
const sourceMap = buildServedModuleSourceMap(code, code, map, MODULE_FILE);

const out = appendInlineSourceMap('export {};', sourceMap);

const [, base64] = out.match(/\n\/\/# sourceMappingURL=data:application\/json;charset=utf-8;base64,(\S+)\n$/)!;
expect(JSON.parse(Buffer.from(base64, 'base64').toString('utf8'))).toEqual(sourceMap);
});

it('does not add a second map to code that already references one', () => {
const { code, map } = transform();
const existing = 'export {};\n//# sourceMappingURL=lib.js.map\n';

expect(appendInlineSourceMap(existing, buildServedModuleSourceMap(code, code, map, MODULE_FILE))).toBe(existing);
expect(appendInlineSourceMap('export {};', null)).toBe('export {};');
});
});
184 changes: 184 additions & 0 deletions packages/vite/hmr/server/served-module-sourcemap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { decode, encode, type SourceMapMappings, type SourceMapSegment } from '@jridgewell/sourcemap-codec';

export interface InputSourceMap {
version: number | string;
mappings: string;
sources: Array<string | null>;
sourcesContent?: Array<string | null>;
names?: string[];
sourceRoot?: string;
ignoreList?: number[];
}

export interface ServedSourceMap {
version: 3;
file: string;
sources: Array<string | null>;
sourcesContent?: Array<string | null>;
names: string[];
mappings: string;
ignoreList?: number[];
}

/** Beyond this many differing lines the alignment is too expensive and too unreliable to be useful. */
const MAX_LINE_EDITS = 1000;

const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const SOURCE_MAPPING_URL_RE = /^\s*\/\/[#@]\s*sourceMappingURL=/m;

/**
* Carries Vite's transform map for a module onto the body the `/ns/m` route
* actually serves. The serve pipeline is a long chain of string passes
* (prepended guards, import rewrites, hoisting) that cannot each maintain a
* map, but they leave the body of the module line-for-line intact. So the
* served text is aligned to Vite's output by a line diff:
*
* - lines identical up to indentation reuse Vite's segments, shifted by the
* indentation change (the AST normalizer re-prints with retainLines, which
* keeps lines but not indentation);
* - a changed hunk with as many lines on both sides is an in-place rewrite, so
* each line maps to the start of its counterpart;
* - every other served line (injected preamble, hoisted imports) is unmapped.
*
* Sources are emitted as absolute file:// URLs because the device loads the
* module from an http URL that does not mirror the project layout.
*/
export function buildServedModuleSourceMap(transformedCode: string, servedCode: string, map: InputSourceMap | null | undefined, moduleFile: string): ServedSourceMap | null {
if (!map || typeof map.mappings !== 'string' || !map.mappings || !Array.isArray(map.sources)) return null;

let decoded: SourceMapMappings;
try {
decoded = decode(map.mappings);
} catch {
return null;
}

const before = transformedCode.split('\n');
const after = servedCode.split('\n');
const alignment = alignLines(
before.map((line) => line.trim()),
after.map((line) => line.trim()),
);
if (!alignment) return null;

const mappings: SourceMapMappings = alignment.map((match, servedLine) => {
if (!match) return [];
const segments = decoded[match.line];
if (!segments?.length) return [];
if (match.sameText) {
const shift = indentOf(after[servedLine]) - indentOf(before[match.line]);
return shift === 0 ? segments : segments.map(([column, ...rest]) => [Math.max(0, column + shift), ...rest] as SourceMapSegment);
}
const first = segments.find((segment) => segment.length > 1);
return first ? [[0, ...first.slice(1)] as SourceMapSegment] : [];
});
if (!mappings.some((line) => line.length)) return null;

const moduleDir = path.dirname(moduleFile);
return {
version: 3,
file: path.basename(moduleFile),
sources: map.sources.map((source) => (source == null ? null : toSourceUrl(source, moduleDir, map.sourceRoot))),
...(map.sourcesContent ? { sourcesContent: map.sourcesContent } : {}),
names: map.names ?? [],
mappings: encode(mappings),
...(map.ignoreList?.length ? { ignoreList: map.ignoreList } : {}),
};
}

/** Returns `code` with `map` appended as an inline sourceMappingURL, leaving code that already names a map untouched. */
export function appendInlineSourceMap(code: string, map: ServedSourceMap | null): string {
if (!map || SOURCE_MAPPING_URL_RE.test(code)) return code;
const base64 = Buffer.from(JSON.stringify(map), 'utf8').toString('base64');
return `${code}${code.endsWith('\n') ? '' : '\n'}//# sourceMappingURL=data:application/json;charset=utf-8;base64,${base64}\n`;
}

function toSourceUrl(source: string, moduleDir: string, sourceRoot: string | undefined): string {
if (URL_SCHEME_RE.test(source) && !/^[a-z]:[\\/]/i.test(source)) return source;
const root = sourceRoot && !URL_SCHEME_RE.test(sourceRoot) ? path.resolve(moduleDir, sourceRoot) : moduleDir;
return pathToFileURL(path.resolve(root, source)).href;
}

function indentOf(line: string): number {
return line.length - line.trimStart().length;
}

interface LineMatch {
line: number;
sameText: boolean;
}

/**
* For each line of `after`, the line of `before` it corresponds to (Myers
* shortest edit script), or null when it was inserted. Returns null when the
* texts differ by more than MAX_LINE_EDITS lines.
*/
function alignLines(before: string[], after: string[]): Array<LineMatch | null> | null {
const n = before.length;
const m = after.length;
const max = Math.min(n + m, MAX_LINE_EDITS);
const offset = max + 1;
const v = new Int32Array(2 * max + 3);
const trace: Int32Array[] = [];

let found = false;
for (let d = 0; d <= max && !found; d++) {
trace.push(v.slice());
for (let k = -d; k <= d; k += 2) {
let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
let y = x - k;
while (x < n && y < m && before[x] === after[y]) {
x++;
y++;
}
v[offset + k] = x;
if (x >= n && y >= m) {
found = true;
break;
}
}
}
if (!found) return null;

// Walk the trace backwards collecting matched pairs and the edit hunks between them.
const result: Array<LineMatch | null> = new Array(m).fill(null);
let x = n;
let y = m;
let hunkBeforeEnd = n;
let hunkAfterEnd = m;
const closeHunk = () => {
const removed = hunkBeforeEnd - x;
const inserted = hunkAfterEnd - y;
if (removed > 0 && removed === inserted) {
for (let i = 0; i < inserted; i++) result[y + i] = { line: x + i, sameText: false };
}
};

for (let d = trace.length - 1; d >= 0; d--) {
const vd = trace[d];
const k = x - y;
const prevK = k === -d || (k !== d && vd[offset + k - 1] < vd[offset + k + 1]) ? k + 1 : k - 1;
const prevX = d === 0 ? 0 : vd[offset + prevK];
const prevY = prevX - prevK;
const snakeX = d === 0 ? 0 : prevK === k + 1 ? prevX : prevX + 1;
const snakeY = snakeX - k;
if (x > snakeX) {
closeHunk();
while (x > snakeX) {
x--;
y--;
result[y] = { line: x, sameText: true };
}
hunkBeforeEnd = x;
hunkAfterEnd = y;
}
if (d > 0) {
x = prevX;
y = prevY;
}
}
closeHunk();
return result;
}
16 changes: 16 additions & 0 deletions packages/vite/hmr/server/websocket-ns-m.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { cleanCode, collectImportDependencies, isWorkerEntryModuleId, processCod
import { REQUIRE_GUARD_SNIPPET } from './require-guard.js';
import { getServerOrigin } from './server-origin.js';
import { createNsMResponseMemo } from './ns-m-response-memo.js';
import { appendInlineSourceMap, buildServedModuleSourceMap, type InputSourceMap } from './served-module-sourcemap.js';
import { getSharedDepsBundleService, registerDepsBundleRoute, type DepsBundleService } from './deps-bundle.js';
import type { FrameworkServedModuleContext, FrameworkServerStrategy } from './framework-strategy.js';

Expand Down Expand Up @@ -607,6 +608,15 @@ export function registerNsModuleServerRoute(server: ViteDevServer, options: Regi
});
}

if (!isNodeMod && transformed.map) {
try {
const moduleFile = await resolveServedModuleFile(server, resolvedCandidate || spec);
if (moduleFile) {
code = appendInlineSourceMap(code, buildServedModuleSourceMap(transformed.code, code, transformed.map as InputSourceMap, moduleFile));
}
} catch {}
}

// Only fully successful serves are memoized — every early-return
// above (link-check throw module, 404 stubs, 500s) stays uncached.
if (memoKey) {
Expand All @@ -621,3 +631,9 @@ export function registerNsModuleServerRoute(server: ViteDevServer, options: Regi
}
});
}

async function resolveServedModuleFile(server: ViteDevServer, candidate: string): Promise<string | null> {
const graph = server.moduleGraph;
const mod = graph.getModuleById(candidate) ?? graph.getModuleById(candidate.replace(/[?#].*$/, '')) ?? (await graph.getModuleByUrl(candidate).catch(() => undefined));
return mod?.file ?? null;
}
1 change: 1 addition & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"@babel/plugin-transform-unicode-property-regex": "^7.25.9",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.0",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@rollup/plugin-alias": "^6.0.0",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-replace": "^6.0.2",
Expand Down
Loading