fix(desktop): serve index.html for renderer SPA routes on reload

This commit is contained in:
李瑞丰 2026-07-16 09:59:09 +00:00
parent c302846d99
commit 273fa1aeef
6 changed files with 106 additions and 7 deletions

View file

@ -51,6 +51,14 @@ export function registerRendererScheme(): void {
]);
}
async function isRegularFile(filePath: string): Promise<boolean> {
try {
return (await stat(filePath)).isFile();
} catch {
return false;
}
}
const DEFAULT_RENDERER_BASE = `${RENDERER_SCHEME}://${RENDERER_HOST}/index.html`;
export function rendererUrl(
@ -114,17 +122,24 @@ export async function handleRendererRequest(
if (!filePath.startsWith(root)) {
return new Response('forbidden', { status: 403 });
}
try {
const info = await stat(filePath);
if (!info.isFile()) {
// SPA fallback (mirrors kap-server's webAssets): the renderer's history
// routing pushes extensionless navigation URLs (`/sessions/<id>`, `/login`)
// that have no file in desktop-dist — serve index.html so a native reload
// (cmd+r) lands back in the app. Asset misses with an extension keep their
// 404 instead of being fed HTML.
let target = filePath;
if (!(await isRegularFile(target))) {
if (extname(decodedPathname) !== '') {
return new Response('not found', { status: 404 });
}
target = join(root, 'index.html');
if (!(await isRegularFile(target))) {
return new Response('not found', { status: 404 });
}
} catch {
return new Response('not found', { status: 404 });
}
const stream = createReadStream(filePath);
const stream = createReadStream(target);
return new Response(stream as unknown as ResponseBody, {
headers: { 'content-type': mimeFor(filePath) },
headers: { 'content-type': mimeFor(target) },
});
}

View file

@ -87,6 +87,39 @@ describe('handleRendererRequest', () => {
expect(res.status).toBe(404);
});
// SPA fallback: the renderer's history routing pushes paths like
// `/sessions/<id>` (and `/login` from the auth gate) onto the URL; a native
// reload re-requests that path, which has no file in desktop-dist.
it('serves index.html for SPA session routes (reload on a deep link)', async () => {
const root = await makeRoot();
const res = await handleRendererRequest(
{ url: 'app://renderer/sessions/abc' } as any,
() => root,
);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(await res.text()).toBe('<h1>ok</h1>');
});
it('serves index.html for the auth-gate /login path', async () => {
const root = await makeRoot();
const res = await handleRendererRequest(
{ url: 'app://renderer/login' } as any,
() => root,
);
expect(res.status).toBe(200);
expect(await res.text()).toBe('<h1>ok</h1>');
});
it('404s an extensionless path when index.html itself is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'kimi-renderer-'));
const res = await handleRendererRequest(
{ url: 'app://renderer/sessions/abc' } as any,
() => root,
);
expect(res.status).toBe(404);
});
it('serves files from the configured desktop-dist root (app://renderer/<path>)', async () => {
const root = await makeRoot();
await writeFile(join(root, 'assets', 'marker.js'), 'desktop-dist-root');

20
apps/web/vitest.config.ts Normal file
View file

@ -0,0 +1,20 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { kimiRendererViteConfig } from '@moonshot-ai/vite-preset';
// Web tests import src code, which pulls in unplugin-icons virtual modules
// (`~icons/*`) and `?raw` SVGs. Mirror the web build's plugin set (Vue + the
// `kimi` custom icon collection) so those imports resolve under vitest too —
// same preset as vite.config.ts, but only the plugins (root/build stay
// test-scoped).
const preset = kimiRendererViteConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
iconsDir: fileURLToPath(new URL('./src/icons/kimi', import.meta.url)),
});
export default defineConfig({
plugins: preset.plugins,
test: {
include: ['src/**/*.test.ts'],
},
});

View file

@ -17,6 +17,8 @@
"test": "vitest run"
},
"devDependencies": {
"@iconify-json/ri": "^1.2.10",
"@iconify-json/tabler": "^1.2.35",
"@types/node": "^22.15.3",
"oxlint": "1.59.0",
"oxlint-tsgolint": "0.20.0",

6
pnpm-lock.yaml generated
View file

@ -13,6 +13,12 @@ importers:
.:
devDependencies:
'@iconify-json/ri':
specifier: ^1.2.10
version: 1.2.10
'@iconify-json/tabler':
specifier: ^1.2.35
version: 1.2.35
'@types/node':
specifier: ^22.15.3
version: 22.20.1

23
vitest.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
// Root test entry (`pnpm test`). Runs only code-app's own packages as vitest
// projects, each loading its own config (plugin pipelines included). The
// `kimi-code/` submodule is intentionally left out: its tests run in its own
// repo/CI and need its own environment.
export default defineConfig({
test: {
projects: [
// apps/* each carry a vitest.config.ts with their renderer plugin set
// (Vue + unplugin-icons), so `~icons/*` and `?raw` imports resolve.
'apps/*',
// packages/* have no build-time plugin needs; run their tests with the
// default pipeline from their own roots.
{
test: {
name: 'packages',
include: ['packages/{web-core,web-i18n,web-markdown,web-ui,vite-preset}/{src,test}/**/*.test.ts'],
},
},
],
},
});