🐛 fix: skew plugin (#12669)

* fix: skew plugin

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(vite): enhance vercelSkewProtection to handle static imports and improve coverage

- Added handling for static import/export declarations to ensure correct deployment links.
- Updated coverage documentation to reflect new handling for static imports and additional cases.
- Adjusted comment numbering for clarity in the processing steps.

Signed-off-by: Innei <tukon479@gmail.com>

* fix: dev proxy

Signed-off-by: Innei <tukon479@gmail.com>

* refactor(AssistantGroup): streamline contentId handling in GroupMessage component

- Simplified the logic for determining contentId by directly using lastAssistantMsg?.id.
- Moved the creation and generation state checks to follow the contentId assignment for better clarity.

Signed-off-by: Innei <tukon479@gmail.com>

* ♻️ refactor: remove chunk error reload retry, keep notification only

Made-with: Cursor

* fix: inject

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2026-03-04 21:47:31 +08:00
committed by GitHub
parent 5d19dbf430
commit 07997b44a5
7 changed files with 297 additions and 87 deletions

View File

@@ -0,0 +1,160 @@
/**
* Vite plugin: Vercel Skew Protection
*
* Injects ?dpl=<VERCEL_DEPLOYMENT_ID> into built asset URLs so Vercel Edge
* routes requests to the correct deployment, preventing "Failed to fetch
* dynamically imported module" errors caused by version skew.
*
* Coverage:
* 1. static import/export — renderChunk (post-enforce, after Vite internals)
* 2. dynamic import() — renderChunk
* 3. CSS url() — generateBundle
* 4. HTML <script>/<link> — transformIndexHtml
* 5. Web Worker URLs — renderChunk
* 6. __vite__mapDeps — renderChunk (preload deps array)
*
* Why enforce:'post'?
* Vite's buildImportAnalysisPlugin rewrites dynamic imports in its own
* renderChunk hook. Using renderDynamicImport is ineffective because Vite
* regenerates the import() expressions afterward. By running post-enforce,
* our renderChunk sees the FINAL chunk code and can reliably modify it.
*
* Prerequisite: Enable Skew Protection in Vercel Dashboard.
*/
import type { Plugin } from 'vite';
export function vercelSkewProtection(deploymentId?: string): Plugin {
const id = deploymentId || process.env.VERCEL_DEPLOYMENT_ID || '';
let enabled = false;
const dplParam = `dpl=${id}`;
function appendDpl(url: string): string {
if (url.includes('dpl=')) return url;
return url + (url.includes('?') ? '&' : '?') + dplParam;
}
return {
name: 'vite-plugin-vercel-skew-protection',
enforce: 'post',
// ── 0. Only active in production builds with a valid deployment ID ──
config(_, env) {
enabled = env.command === 'build' && id.length > 0;
if (!enabled) return;
return {
define: {
'import.meta.env.VITE_VERCEL_DEPLOYMENT_ID': JSON.stringify(id),
},
};
},
// ── 1+2. Rewrite JS chunks (runs AFTER Vite's internal plugins) ──
renderChunk(code) {
if (!enabled) return;
let modified = code;
let changed = false;
// 1a. Rewrite static import/export declarations
//
// After Vite processing, static imports/exports between chunks look like:
// import { x } from "./chunk-hash.js";
// import "./chunk-hash.js";
// export { foo } from "./chunk-hash.js";
// We append ?dpl= to the specifier so browsers request the correct deployment.
const staticImportRe =
/((?:import|export)\s*(?:\{[^}]*\}\s*from\s*)?["'])(\.\.?\/[^"']+)(["'])/g;
modified = modified.replaceAll(
staticImportRe,
(_, before: string, path: string, after: string) => {
changed = true;
return before + appendDpl(path) + after;
},
);
// 1b. Rewrite dynamic import() with relative paths
//
// After Vite processing, dynamic imports look like:
// import("./chunk-hash.js")
// We append ?dpl= directly to the specifier.
const importRe = /(import\(["'])(\.\.?\/[^"']+)(["']\))/g;
modified = modified.replaceAll(importRe, (_, before: string, path: string, after: string) => {
changed = true;
return before + appendDpl(path) + after;
});
// 1c. Rewrite __vite__mapDeps dep array
//
// Vite 7 format:
// const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[
// "assets/index-BN2fWmdo.js","vendor/vendor-motion-xxx.js",...
// ])))=>i.map(i=>d[i]);
const mapDepsRe = /(m\.f\|\|\(m\.f=\[)([\s\S]*?)(\]\))/g;
modified = modified.replaceAll(
mapDepsRe,
(_, before: string, paths: string, after: string) => {
const rewritten = paths.replaceAll(/"([^"]+)"/g, (_m: string, p: string) => {
return `"${appendDpl(p)}"`;
});
changed = true;
return before + rewritten + after;
},
);
// 1d. Rewrite Worker URLs
// new Worker(new URL("./worker-hash.js", import.meta.url))
const workerRe = /(new\s+(?:Shared)?Worker\(\s*new\s+URL\(\s*")([^"]+)(")/g;
modified = modified.replaceAll(workerRe, (_, before: string, path: string, after: string) => {
changed = true;
return before + appendDpl(path) + after;
});
if (changed) return { code: modified, map: null };
},
// ── 2. Rewrite CSS url() references ──
generateBundle(_, bundle) {
if (!enabled) return;
for (const [fileName, asset] of Object.entries(bundle)) {
if (
asset.type !== 'asset' ||
!fileName.endsWith('.css') ||
typeof asset.source !== 'string'
)
continue;
// Match url("...") or url('...') or url(...) — avoid data:/blob:/#
const urlRe = /url\(["'](?!data:|#|blob:)([^"']+)["']\)|url\((?!data:|#|blob:)([^)]+)\)/g;
asset.source = asset.source.replaceAll(urlRe, (match, quoted: string, bare: string) => {
const url = quoted || bare;
if (!url || url.includes('dpl=')) return match;
return match.replace(url, appendDpl(url));
});
}
},
// ── 3. Rewrite HTML <script src> and <link href> ──
transformIndexHtml(html) {
if (!enabled) return;
// <script ... src="...">
html = html.replaceAll(
/(<script[^>]+src=["'])([^"']+)(["'][^>]*>)/g,
(match, before, src, after) => {
if (src.startsWith('data:') || src.includes('dpl=')) return match;
return `${before}${appendDpl(src)}${after}`;
},
);
// <link rel="stylesheet|modulepreload" href="...">
html = html.replaceAll(
/(<link[^>]+href=["'])([^"']+)(["'][^>]*>)/g,
(match, before, href, after) => {
if (href.startsWith('data:') || href.includes('dpl=')) return match;
if (!match.includes('stylesheet') && !match.includes('modulepreload')) return match;
return `${before}${appendDpl(href)}${after}`;
},
);
return html;
},
};
}

View File

@@ -8,6 +8,17 @@
globalThis['__DEBUG_PROXY__'] = true;
// --- 1. 解析 debug host ---
function isValidDevHost(urlStr) {
try {
var u = new URL(urlStr);
if (['http:', 'https:'].indexOf(u.protocol) === -1) return false;
var h = u.hostname.toLowerCase();
return h === 'localhost' || h === '127.0.0.1' || h === '[::1]';
} catch (e) {
return false;
}
}
var searchParams = new URLSearchParams(window.location.search);
var debugHost = searchParams.get('debug-host');
@@ -16,31 +27,38 @@
}
var storedHost = sessionStorage.getItem('debug-host');
var host = debugHost || storedHost || 'http://localhost:9876';
if (debugHost) {
var host = 'http://localhost:9876';
if (debugHost && isValidDevHost(debugHost)) {
host = debugHost;
sessionStorage.setItem('debug-host', debugHost);
} else if (storedHost && isValidDevHost(storedHost)) {
host = storedHost;
}
// --- 2. Worker 跨域补丁(必须在任何模块加载前注入)---
var workerPatch = document.createElement('script');
workerPatch.textContent = '(function(){' +
'var O=globalThis.Worker;' +
'globalThis.Worker=function(u,o){' +
'var h=typeof u==="string"?u:u instanceof URL?u.href:"";' +
'if(h.startsWith("'+host+'")){' +
'var b=new Blob(["import \\\\""+h+"\\\\";"],{type:"application/javascript"});' +
'return new O(URL.createObjectURL(b),Object.assign({},o,{type:"module"}));' +
'}return new O(u,o)};' +
'globalThis.Worker.prototype=O.prototype;' +
'})();';
workerPatch.textContent = `(function () {
var O = globalThis.Worker;
globalThis.Worker = function (u, o) {
var h = typeof u === "string" ? u : u instanceof URL ? u.href : "";
if (h.startsWith(${JSON.stringify(host)})) {
var code = 'import "' + h + '";';
var b = new Blob([code], { type: "application/javascript" });
return new O(URL.createObjectURL(b), Object.assign({}, o, { type: "module" }));
}
return new O(u, o);
};
globalThis.Worker.prototype = O.prototype;
})();`;
document.head.insertBefore(workerPatch, document.head.firstChild);
// --- 3. 注入 React Refresh runtimeHMR 前置条件)---
var refreshScript = document.createElement('script');
refreshScript.type = 'module';
refreshScript.textContent =
'import RefreshRuntime from "' + host + '/@react-refresh";' +
'import RefreshRuntime from "' +
host +
'/@react-refresh";' +
'RefreshRuntime.injectIntoGlobalHook(window);' +
'window.$RefreshReg$ = () => {};' +
'window.$RefreshSig$ = () => (type) => type;' +
@@ -54,11 +72,11 @@
) || 'en-US';
var configReady = fetch('/spa/' + locale + '/chat')
.then(function(res) { return res.text(); })
.then(function (res) {
return res.text();
})
.then(function (html) {
var match = html.match(
/window\.__SERVER_CONFIG__\s*=\s*(\{[\s\S]*?\});/,
);
var match = html.match(/window\.__SERVER_CONFIG__\s*=\s*(\{[\s\S]*?\});/);
if (match) {
var configScript = document.createElement('script');
configScript.textContent = 'window.__SERVER_CONFIG__ = ' + match[1] + ';';
@@ -68,21 +86,23 @@
// --- 5. Fetch dev server HTML 并注入 ---
var devHtmlReady = fetch(host)
.then(function(res) { return res.text(); })
.then(function (res) {
return res.text();
})
.then(function (html) {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var scripts = doc.querySelectorAll('script');
scripts.forEach(function(s) { s.remove(); });
scripts.forEach(function (s) {
s.remove();
});
doc.head.querySelectorAll('meta').forEach(function (meta) {
document.head.append(meta);
});
doc.head
.querySelectorAll('style, link[rel="stylesheet"]')
.forEach(function(el) {
doc.head.querySelectorAll('style, link[rel="stylesheet"]').forEach(function (el) {
if (el.tagName === 'LINK') {
var href = el.getAttribute('href');
if (href && href.startsWith('/')) {
@@ -107,7 +127,9 @@
} else if (script.textContent) {
s.textContent = script.textContent.replace(
/from\s+["'](\/[@\w].*?)["']/g,
function(_, p) { return 'from "' + new URL(p, host).toString() + '"'; },
function (_, p) {
return 'from "' + new URL(p, host).toString() + '"';
},
);
} else {
return;
@@ -118,10 +140,7 @@
});
Promise.all([configReady, devHtmlReady]).then(function () {
console.log(
'%c[Debug Proxy] Loaded from ' + host,
'color: #52c41a; font-weight: bold;',
);
console.log('%c[Debug Proxy] Loaded from ' + host, 'color: #52c41a; font-weight: bold;');
});
</script>
</head>

View File

@@ -5,6 +5,8 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import { enableMapSet } from 'immer';
import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError';
enableMapSet();
// Dayjs plugins - extend once at app init to avoid duplicate extensions in components
@@ -12,3 +14,18 @@ dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(isToday);
dayjs.extend(isYesterday);
// Global fallback: catch async chunk-load failures that escape Error Boundaries
if (typeof window !== 'undefined') {
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault();
notifyChunkError();
});
window.addEventListener('unhandledrejection', (event) => {
if (isChunkLoadError(event.reason)) {
event.preventDefault();
notifyChunkError();
}
});
}

View File

@@ -3,6 +3,7 @@ import '../initialize';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import BootErrorBoundary from '@/components/BootErrorBoundary';
import SPAGlobalProvider from '@/layout/SPAGlobalProvider';
import { createAppRouter } from '@/utils/router';
@@ -17,7 +18,9 @@ const basename =
const router = createAppRouter(desktopRoutes, { basename });
createRoot(document.getElementById('root')!).render(
<BootErrorBoundary>
<SPAGlobalProvider>
<RouterProvider router={router} />
</SPAGlobalProvider>,
</SPAGlobalProvider>
</BootErrorBoundary>,
);

31
src/utils/chunkError.ts Normal file
View File

@@ -0,0 +1,31 @@
import { toast } from '@lobehub/ui';
const CHUNK_ERROR_PATTERNS = [
'Failed to fetch dynamically imported module', // Chrome / Vite
'error loading dynamically imported module', // Firefox
'Importing a module script failed', // Safari
'Failed to load module script', // Safari variant
'Loading chunk', // Webpack
'Loading CSS chunk', // Webpack CSS
'ChunkLoadError', // Webpack error name
];
/**
* Detect whether an error (or its message) was caused by a failed chunk / dynamic import.
*/
export function isChunkLoadError(error: unknown): boolean {
if (!error) return false;
const name = (error as Error).name ?? '';
const message = (error as Error).message ?? String(error);
const combined = `${name} ${message}`;
return CHUNK_ERROR_PATTERNS.some((p) => combined.includes(p));
}
/**
* Show user notification for chunk load error (no reload).
*/
export function notifyChunkError(): void {
toast.info('Web app has been updated so it needs to be reloaded.');
}

View File

@@ -1,8 +1,7 @@
'use client';
import { toast } from '@lobehub/ui';
import { type ComponentType, type ReactElement } from 'react';
import { createElement, lazy, memo, Suspense, useCallback, useEffect, useRef } from 'react';
import { createElement, lazy, memo, Suspense, useCallback, useEffect } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter,
@@ -16,6 +15,21 @@ import BusinessGlobalProvider from '@/business/client/BusinessGlobalProvider';
import ErrorCapture from '@/components/Error';
import Loading from '@/components/Loading/BrandTextLoading';
import { useGlobalStore } from '@/store/global';
import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError';
async function importModule<T>(importFn: () => Promise<T>): Promise<T> {
return importFn();
}
function resolveLazyModule<P>(module: { default: ComponentType<P> } | ComponentType<P>) {
if (typeof module === 'function') {
return { default: module };
}
if ('default' in module) {
return module as { default: ComponentType<P> };
}
return { default: module as unknown as ComponentType<P> };
}
/**
* Helper function to create a dynamic page element directly for router configuration
@@ -34,15 +48,8 @@ export function dynamicElement<P = NonNullable<unknown>>(
debugId?: string,
): ReactElement {
const LazyComponent = lazy(async () => {
// eslint-disable-next-line @next/next/no-assign-module-variable
const module = await importFn();
if (typeof module === 'function') {
return { default: module };
}
if ('default' in module) {
return module as { default: ComponentType<P> };
}
return { default: module as unknown as ComponentType<P> };
const mod = await importModule(importFn);
return resolveLazyModule(mod);
});
// @ts-ignore
@@ -63,15 +70,8 @@ export function dynamicLayout<P = NonNullable<unknown>>(
debugId?: string,
): ReactElement {
const LazyComponent = lazy(async () => {
// eslint-disable-next-line @next/next/no-assign-module-variable
const module = await importFn();
if (typeof module === 'function') {
return { default: module };
}
if ('default' in module) {
return module as { default: ComponentType<P> };
}
return { default: module as unknown as ComponentType<P> };
const mod = await importModule(importFn);
return resolveLazyModule(mod);
});
// @ts-ignore
@@ -102,35 +102,13 @@ export interface ErrorBoundaryProps {
export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => {
const error = useRouteError() as Error;
const reloadRef = useRef(false);
const navigate = useNavigate();
const reset = useCallback(() => {
navigate(resetPath);
}, [navigate, resetPath]);
let message = '';
if (error instanceof Error) {
message = error.message;
} else if (typeof error === 'string') {
message = error;
} else if (error && typeof error === 'object' && 'statusText' in error) {
const statusText = (error as { statusText?: unknown }).statusText;
if (typeof statusText === 'string') message = statusText;
}
if (
typeof window !== 'undefined' &&
message?.startsWith('Failed to fetch dynamically imported module') &&
window.sessionStorage.getItem('reload') !== '1'
) {
if (reloadRef.current) return null;
toast.info('Web app has been updated so it needs to be reloaded.');
window.sessionStorage.setItem('reload', '1');
window.location.reload();
reloadRef.current = true;
return null;
if (typeof window !== 'undefined' && isChunkLoadError(error)) {
notifyChunkError();
}
return createElement(ErrorCapture, { error, reset });

View File

@@ -11,6 +11,7 @@ import {
sharedRendererPlugins,
sharedRollupOutput,
} from './plugins/vite/sharedRendererConfig';
import { vercelSkewProtection } from './plugins/vite/vercelSkewProtection';
const isMobile = process.env.MOBILE === 'true';
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
@@ -32,6 +33,7 @@ export default defineConfig({
define: sharedRendererDefine({ isMobile, isElectron: false }),
optimizeDeps: sharedOptimizeDeps,
plugins: [
vercelSkewProtection(),
viteEnvRestartKeys(['APP_URL']),
...sharedRendererPlugins({ platform }),