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: 6 additions & 9 deletions src/plugins/pluginAddEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'pathe';
import { Plugin } from 'vite';
import { mapCodeToCodeWithSourcemap } from '../utils/mapCodeToCodeWithSourcemap';

import { inlineEntryScripts, sanitizeDevEntryPath } from '../utils/htmlEntryUtils';
import { NormalizedModuleFederationOptions } from '../utils/normalizeModuleFederationOptions';

interface AddEntryOptions {
Expand Down Expand Up @@ -56,7 +57,7 @@ const addEntry = ({
config.base +
devEntryPath
.replace(/\\\\?/g, '/')
.replace(/.+?\:([/\\])[/\\]?/, '$1')
.replace(/^[^:]+:([/\\])[/\\]?/, '$1')
.replace(/^\//, '');
},
configureServer(server) {
Expand All @@ -74,23 +75,19 @@ const addEntry = ({
transformIndexHtml(c) {
if (!injectHtml()) return;
clientInjected = true;
return c.replace(
'<head>',
`<head><script type="module" src=${JSON.stringify(
devEntryPath.replace(/.+?\:([/\\])[/\\]?/, '$1').replace(/\\\\?/g, '/')
)}></script>`
);
return inlineEntryScripts(c, devEntryPath);
},
transform(code, id) {
if (id.includes('node_modules') || inject !== 'html' || htmlFilePath) {
return;
}

if (id.includes('.svelte-kit') && id.includes('internal.js')) {
const src = devEntryPath.replace(/.+?\:([/\\])[/\\]?/, '$1').replace(/\\\\?/g, '/');
return code.replace(
/<head>/g,
'<head><script type=\\"module\\" src=\\"' + src + '\\"></script>'
'<head><script type=\\"module\\" src=\\"' +
sanitizeDevEntryPath(devEntryPath) +
'\\"></script>'
);
}
},
Expand Down
76 changes: 76 additions & 0 deletions src/utils/__tests__/htmlEntryUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { inlineEntryScripts, sanitizeDevEntryPath } from '../htmlEntryUtils';

const INIT_SRC = '/__mf__virtual/hostAutoInit.js';

describe('inlineEntryScripts', () => {
it('inlines init import into a module script tag', () => {
const html = '<html><body><script type="module" src="/src/main.js"></script></body></html>';
const result = inlineEntryScripts(html, INIT_SRC);
expect(result).toContain(
`<script type="module">await import("/__mf__virtual/hostAutoInit.js");await import("/src/main.js");</script>`
);
});

it('preserves @vite/client script tag', () => {
const html =
'<head><script type="module" src="/@vite/client"></script></head>' +
'<body><script type="module" src="/src/main.js"></script></body>';
const result = inlineEntryScripts(html, INIT_SRC);
expect(result).toContain('src="/@vite/client"');
expect(result).toContain(`await import("/src/main.js")`);
});

it('handles multiple entry scripts', () => {
const html =
'<body>' +
'<script type="module" src="/src/app1.js"></script>' +
'<script type="module" src="/src/app2.js"></script>' +
'</body>';
const result = inlineEntryScripts(html, INIT_SRC);
expect(result).toContain(`await import("/src/app1.js")`);
expect(result).toContain(`await import("/src/app2.js")`);
expect(result).not.toContain('src="/src/app1.js"');
expect(result).not.toContain('src="/src/app2.js"');
});

it('falls back to separate script tag when no entry scripts exist', () => {
const html = '<html><head></head><body></body></html>';
const result = inlineEntryScripts(html, INIT_SRC);
expect(result).toContain(
`<head><script type="module" src="/__mf__virtual/hostAutoInit.js"></script>`
);
});

it('handles single-quoted src attributes', () => {
const html = "<body><script type='module' src='/src/main.js'></script></body>";
const result = inlineEntryScripts(html, INIT_SRC);
expect(result).toContain(`await import("/src/main.js")`);
});

it('sanitizes initSrc with protocol prefix', () => {
const html = '<body><script type="module" src="/src/main.js"></script></body>';
const result = inlineEntryScripts(html, 'file:///home/user/project/init.js');
expect(result).toContain(`await import("//home/user/project/init.js")`);
});

it('sanitizes initSrc with backslashes', () => {
const html = '<body><script type="module" src="/src/main.js"></script></body>';
const result = inlineEntryScripts(html, 'C:\\Users\\project\\init.js');
expect(result).toContain(`await import("/Users/project/init.js")`);
});
});

describe('sanitizeDevEntryPath', () => {
it('returns path unchanged when no protocol prefix', () => {
expect(sanitizeDevEntryPath('/src/main.js')).toBe('/src/main.js');
});

it('strips protocol prefix', () => {
expect(sanitizeDevEntryPath('file:///home/user/init.js')).toBe('//home/user/init.js');
});

it('converts backslashes to forward slashes', () => {
expect(sanitizeDevEntryPath('C:\\Users\\project\\init.js')).toBe('/Users/project/init.js');
});
});
41 changes: 41 additions & 0 deletions src/utils/htmlEntryUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export function sanitizeDevEntryPath(devEntryPath: string): string {
return devEntryPath.replace(/^[^:]+:([/\\])[/\\]?/, '$1').replace(/\\\\?/g, '/');
}

/**
* Inlines the federation init import into existing module script tags to fix
* the race condition (#396) where separate `<script type="module">` tags
* don't guarantee execution order with top-level await.
*
* If no entry scripts are found, falls back to injecting a separate script tag.
*
* @example
* // Before (two separate scripts, race condition):
* // <script type="module" src="/__mf__virtual/hostAutoInit.js"></script>
* // <script type="module" src="/src/main.js"></script>
* // After (single inline script, sequential execution):
* // <script type="module">await import("/__mf__virtual/hostAutoInit.js");await import("/src/main.js");</script>
*/
export function inlineEntryScripts(html: string, initSrc: string): string {
const src = sanitizeDevEntryPath(initSrc);
// Match opening <script ...> tags, then filter for type="module" with src.
// We avoid matching closing tags like </script> with a regex, since browser
// HTML parsing is more tolerant (for example </script foo="bar">, </script >).
const scriptTagRegex = /<script\s+([^>]*\btype=["']module["'][^>]*\bsrc=["'][^"']+["'][^>]*)>/gi;

let hasEntry = false;
const result = html.replace(scriptTagRegex, (match, attrs) => {
const srcMatch = attrs.match(/\bsrc=["']([^"']+)["']/i);
if (!srcMatch) return match;
const originalSrc = srcMatch[1];
if (originalSrc.includes('@vite/client')) return match;
hasEntry = true;
const attrsWithoutSrc = attrs.replace(/\s*\bsrc=["'][^"']+["']/i, '');
return `<script ${attrsWithoutSrc}>await import(${JSON.stringify(src)});await import(${JSON.stringify(originalSrc)});`;
});

if (hasEntry) return result;

// Inject a separate script tag
return html.replace('<head>', `<head><script type="module" src=${JSON.stringify(src)}></script>`);
}
Loading