mirror of
https://github.com/lobehub/lobehub.git
synced 2026-09-20 04:56:13 +08:00
2df9829a5e
* ✨ feat(share): extract share pages into a standalone SSR micro app `/share/t/:id` and `/share/page/:id` were client-only routes inside the main SPA, so a shared link opened on a blank shell and link previews carried the generic app card. They now live in `apps/share`, built the same way as `apps/workbench`: a React Router framework-mode app that server-renders on a Cloudflare Worker for cloud, plus a plain SPA build that Next serves for self-hosted deployments. What the server renders is the shell, the hero and a complete meta set — title, description and the OG/Twitter card — resolved from the route loader through a per-request tRPC client. The conversation and page-viewer stacks stay on the client: `clientOnly()` gates them and, in the SSR environment, a resolver stub cuts every `*.client` module out of the worker graph entirely. Gating the render alone does not help, because the dynamic import still pulls the graph into the bundle. Keeping that worker deployable took three more cuts, each found by tracing the module graph rather than guessing: - `@/store/user` is stubbed for SSR — `GroupAvatar` reads the viewer profile, and the store drags the model catalog and every provider behind it (39MB→19MB) - `shiki` and `shiki/wasm` are stubbed for SSR — the `@lobehub/ui` barrel reads the bundled-language catalog at module scope, registering ~400 grammars for a highlighter the server never runs (19MB→7.6MB, 1.78MB gzip) - SSR i18n resolves only the namespaces the shell renders; the generic loader would register every locale x namespace pair as a worker chunk The conversation list keeps its `headerSlot` while it shows the skeleton. It returned early before, so mounting the list to fetch blanked a title the server had already rendered. `/share` is removed from the desktop and mobile routers and rewritten by the Next middleware to `/spa-share/<locale>/…`, mirroring how `/verify` reaches workbench. The cloud worker is published from lobehub-cloud, which builds this same app against its business overlay, so this repo ships no deploy workflow for it. * 🐛 fix(share): preload the error namespace so the boundary renders translated The root ErrorBoundary reaches for the `error` namespace, which SSR did not bundle, so a failed share page rendered `error.title error.desc error.retry` verbatim. Fetching it on demand does not fix that case: a chunk-load failure is exactly when the client can never load the dictionary to correct itself. * 🐛 fix(share): drop the dev template fetch that served the main SPA The handler asked the main Vite server for `/index.share.html`, which does not exist at the repo root. Vite answers that with the HTML fallback — 200, main SPA shell — so `/share/*` in `bun run dev` silently booted an app that no longer has those routes. Share is developed against its own Vite server, so the branch goes rather than the file being added. `spaDevShells.test.ts` guards both directions: every remaining dev template must exist at the root and point at its own app entry, and share must not reintroduce one. * 📝 docs(skill): record the cloud-overlay split in split-micro-app Share is the second micro app and the first whose rendering code lives in the lobehub-cloud business overlay, so the skill needed the ownership rules that decides which repo builds and deploys, and the Vite 8 tsconfig-nearest-importer landmine that makes an overlay build ship the open-source fallback with a green build. Also folds in what the extraction turned up: the full Docker chain, the dev shell that answers 200 with the wrong app, shiki and @lobehub/ui bundle cuts, the error namespace, hydration and virtualized-prefill rules, the asset prefix convention, and the prod promote gate.
249 lines
8.8 KiB
TypeScript
249 lines
8.8 KiB
TypeScript
import { writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { cloudflare } from '@cloudflare/vite-plugin';
|
|
import { lobeStaticCssPlugin } from '@lobehub/ui/static-css/vite';
|
|
import { reactRouter } from '@react-router/dev/vite';
|
|
import { defineConfig, type Plugin, type PluginOption } from 'vite';
|
|
|
|
import { lobeIconImports } from '../../plugins/vite/lobeIconImports';
|
|
import { viteMarkdownImport } from '../../plugins/vite/markdownImport';
|
|
import { viteNodeModuleStub } from '../../plugins/vite/nodeModuleStub';
|
|
import { vitePlatformResolve } from '../../plugins/vite/platformResolve';
|
|
import {
|
|
sharedRendererDedupe,
|
|
sharedRendererDefine,
|
|
} from '../../plugins/vite/sharedRendererConfig';
|
|
|
|
interface StaticCssOptions {
|
|
hrefTemplate: (hash: string) => string;
|
|
}
|
|
|
|
export interface ShareRrConfigOptions {
|
|
/** Directory holding `app/` and `src/` — the submodule copy when a host repo overlays this app. */
|
|
appRoot: string;
|
|
/**
|
|
* Extra SSR module stubs, by import specifier. A host repo overlaying this
|
|
* app adds the store hubs only its own code reaches — the worker renders no
|
|
* signed-in surface, so those must not enter its graph.
|
|
*/
|
|
extraSsrStubs?: Record<string, string>;
|
|
/** Repo whose files count as build inputs and are allowed by the dev server. */
|
|
repoRoot: string;
|
|
/**
|
|
* Resolution for `@/*`. Vite 8 native tsconfigPaths walks to the tsconfig
|
|
* nearest each importer, which loses a host repo's overlay paths — those
|
|
* hosts pass their own resolver plugin here instead.
|
|
*/
|
|
resolvePlugins?: PluginOption[];
|
|
staticCss: { antd: StaticCssOptions; themeVars: StaticCssOptions };
|
|
}
|
|
|
|
const CLIENT_MODULE_RE = /\.client(?:\.[jt]sx?)?$/;
|
|
|
|
export const createShareRrConfig = ({
|
|
appRoot,
|
|
extraSsrStubs,
|
|
repoRoot,
|
|
resolvePlugins,
|
|
staticCss,
|
|
}: ShareRrConfigOptions) => {
|
|
const define = sharedRendererDefine({ isElectron: false, isMobile: false });
|
|
const { 'process.env': _processEnvFallback, ...ssrDefine } = define;
|
|
|
|
const stub = (file: string) => path.resolve(appRoot, 'app/stubs', file);
|
|
|
|
const ssrStubs: Record<string, string> = {
|
|
'@/libs/trpc/client': stub('trpcClient.ts'),
|
|
'@/services/global': stub('globalService.ts'),
|
|
'@/spa/initialize/toolSurfaces': stub('toolSurfaces.ts'),
|
|
'@/store/electron': stub('electronStore.ts'),
|
|
'@/store/file': stub('fileStore.ts'),
|
|
'@/store/user': stub('userStore.ts'),
|
|
'@/utils/i18n/loadI18nNamespaceModule': stub('loadI18nNamespaceModule.ts'),
|
|
'shiki': stub('shiki.ts'),
|
|
'shiki/wasm': stub('shikiWasm.ts'),
|
|
...extraSsrStubs,
|
|
};
|
|
|
|
const shareSsrStubs = (): Plugin => ({
|
|
applyToEnvironment: (environment) => environment.name === 'ssr',
|
|
enforce: 'pre',
|
|
name: 'share-ssr-stubs',
|
|
resolveId(source) {
|
|
return ssrStubs[source];
|
|
},
|
|
});
|
|
|
|
// `clientOnly()` gates rendering, but the dynamic import still pulls the gated
|
|
// graph into the worker bundle. Cutting it at resolve time is what actually
|
|
// keeps the conversation stack out of build/server.
|
|
const shareClientOnlyStub = (): Plugin => ({
|
|
applyToEnvironment: (environment) => environment.name === 'ssr',
|
|
name: 'share-client-only-stub',
|
|
async resolveId(source, importer, options) {
|
|
if (!CLIENT_MODULE_RE.test(source)) return null;
|
|
|
|
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
|
|
if (!resolved || !resolved.id.startsWith(appRoot)) return null;
|
|
|
|
return stub('clientOnlyModule.tsx');
|
|
},
|
|
});
|
|
|
|
const ssrChunkReport = (): Plugin => ({
|
|
applyToEnvironment: (environment) => environment.name === 'ssr',
|
|
generateBundle(_options, bundle) {
|
|
const report: Record<string, unknown> = {};
|
|
for (const [file, chunk] of Object.entries(bundle)) {
|
|
if (chunk.type !== 'chunk') continue;
|
|
report[file] = {
|
|
dynamicImports: chunk.dynamicImports,
|
|
imports: chunk.imports,
|
|
modules: Object.keys(chunk.modules ?? {}),
|
|
size: chunk.code.length,
|
|
};
|
|
}
|
|
writeFileSync(path.resolve('ssr-chunk-report.json'), JSON.stringify(report));
|
|
},
|
|
name: 'share-ssr-chunk-report',
|
|
});
|
|
|
|
const ssrModuleTrace = (needle: string): Plugin => ({
|
|
applyToEnvironment: (environment) =>
|
|
environment.name === (process.env.SHARE_TRACE_ENV || 'ssr'),
|
|
buildEnd() {
|
|
const targets = [...this.getModuleIds()].filter((id) => id.includes(needle));
|
|
for (const target of targets.slice(0, 2)) {
|
|
const parent = new Map<string, string | null>([[target, null]]);
|
|
const queue = [target];
|
|
let found: string | null = null;
|
|
while (queue.length > 0 && !found) {
|
|
const current = queue.shift()!;
|
|
const info = this.getModuleInfo(current);
|
|
for (const importer of [...(info?.importers ?? []), ...(info?.dynamicImporters ?? [])]) {
|
|
if (parent.has(importer)) continue;
|
|
parent.set(importer, current);
|
|
queue.push(importer);
|
|
if (importer.startsWith(appRoot) || importer.includes('virtual:react-router')) {
|
|
found = importer;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
const chain: string[] = [];
|
|
let cursor: string | null = found;
|
|
while (cursor) {
|
|
chain.push(cursor);
|
|
cursor = parent.get(cursor) ?? null;
|
|
}
|
|
console.info(`\n=== module trace: ${target}`);
|
|
console.info(chain.join('\n -> ') || '(no importer path found)');
|
|
}
|
|
},
|
|
name: 'share-ssr-module-trace',
|
|
});
|
|
|
|
const buildInputIds = new Set<string>();
|
|
|
|
const buildInputsManifest = (): Plugin => ({
|
|
apply: 'build',
|
|
buildEnd() {
|
|
for (const id of this.getModuleIds()) {
|
|
if (!id.startsWith(repoRoot)) continue;
|
|
if (id.includes('/node_modules/') || id.includes('\0')) continue;
|
|
buildInputIds.add(path.relative(repoRoot, id.split('?')[0]!));
|
|
}
|
|
},
|
|
name: 'share-build-inputs-manifest',
|
|
writeBundle(options) {
|
|
if (!options.dir?.includes('build/server')) return;
|
|
const manifest = [...buildInputIds].sort().join('\n');
|
|
writeFileSync(path.resolve('build-inputs.txt'), `${manifest}\n`);
|
|
},
|
|
});
|
|
|
|
const staticCssDevServe = (): Plugin => {
|
|
const cache = new Map<string, string>();
|
|
const serve = async (kind: 'antd' | 'themeVars') => {
|
|
if (!cache.has(kind)) {
|
|
const { buildAntdStaticCss, buildThemeVarsCss } = await import('@lobehub/ui/static-css');
|
|
cache.set(
|
|
kind,
|
|
kind === 'antd'
|
|
? buildAntdStaticCss(staticCss.antd).css
|
|
: buildThemeVarsCss(staticCss.themeVars).css,
|
|
);
|
|
}
|
|
return cache.get(kind)!;
|
|
};
|
|
|
|
return {
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
const url = req.url ?? '';
|
|
const kind = /^\/assets\/antd-[a-f0-9]+\.css/.test(url)
|
|
? ('antd' as const)
|
|
: /^\/assets\/theme-vars-[a-f0-9]+\.css/.test(url)
|
|
? ('themeVars' as const)
|
|
: undefined;
|
|
if (!kind) return next();
|
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.end(await serve(kind));
|
|
});
|
|
},
|
|
name: 'share-static-css-dev-serve',
|
|
};
|
|
};
|
|
|
|
const proxyTarget = process.env.SHARE_API_PROXY || 'https://app.lobehub.com';
|
|
const backendProxy = Object.fromEntries(
|
|
['/api', '/oidc', '/trpc', '/webapi'].map((prefix) => [
|
|
prefix,
|
|
{ changeOrigin: true, target: proxyTarget },
|
|
]),
|
|
);
|
|
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
|
|
return defineConfig({
|
|
base: isDev ? '/' : process.env.VITE_CDN_BASE || '/',
|
|
environments: {
|
|
client: { define },
|
|
ssr: { define: ssrDefine },
|
|
},
|
|
plugins: [
|
|
process.env.SHARE_CHUNK_REPORT ? ssrChunkReport() : undefined,
|
|
process.env.SHARE_TRACE_MODULE ? ssrModuleTrace(process.env.SHARE_TRACE_MODULE) : undefined,
|
|
shareSsrStubs(),
|
|
shareClientOnlyStub(),
|
|
buildInputsManifest(),
|
|
viteMarkdownImport(),
|
|
viteNodeModuleStub(),
|
|
vitePlatformResolve('web'),
|
|
cloudflare({ viteEnvironment: { name: 'ssr' } }),
|
|
lobeStaticCssPlugin({ antd: staticCss.antd, themeVars: staticCss.themeVars }),
|
|
staticCssDevServe(),
|
|
reactRouter(),
|
|
...lobeIconImports(),
|
|
...(resolvePlugins ?? []),
|
|
],
|
|
resolve: {
|
|
// The builtin-tool packages declare `@lobehub/ui: ^5` and resolve to an
|
|
// older copy than the app's; without deduping, their render surfaces fail
|
|
// to import components that only exist in the app's version.
|
|
dedupe: [...sharedRendererDedupe, '@lobehub/ui'],
|
|
tsconfigPaths: !resolvePlugins,
|
|
},
|
|
server: {
|
|
fs: {
|
|
allow: [repoRoot],
|
|
},
|
|
port: Number(process.env.SHARE_SPA_PORT_RR) || 3017,
|
|
proxy: backendProxy,
|
|
},
|
|
});
|
|
};
|