fix: avoid static previews for frontend index HTML

Project entry HTML often only mounts a dev-server app, while built output can be served statically. The desktop open-with policy now only offers the in-app browser for static HTML candidates, and preview-fs rewrites built HTML root-relative assets under the workspace preview path.

Constraint: Frontend project index.html needs a dev server instead of preview-fs static serving
Rejected: Treat every .html file as browser-previewable | project entry HTML can render blank or with missing assets
Confidence: high
Scope-risk: moderate
Tested: cd desktop && bun run check:desktop
Tested: bun run check:server
Tested: git diff --check
Not-tested: bun run verify was intentionally stopped at user request
This commit is contained in:
程序员阿江(Relakkes) 2026-06-01 03:16:52 +08:00
parent d222b47cc4
commit ebc78befbe
8 changed files with 172 additions and 15 deletions

View File

@ -280,7 +280,7 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'file')
})
it('clicking index.html open-with opens menu with in-app browser item', async () => {
it('clicking project index.html open-with opens menu with workspace preview item', async () => {
renderCard(['/w/proj/index.html'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
@ -288,6 +288,18 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
fireEvent.click(openWithBtn!)
})
expect(await screen.findByText('openWith.workspacePreview')).toBeInTheDocument()
expect(screen.queryByText('openWith.inAppBrowser')).not.toBeInTheDocument()
})
it('clicking built dist index.html open-with opens menu with in-app browser item', async () => {
renderCard(['/w/proj/dist/index.html'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })
await act(async () => {
fireEvent.click(openWithBtn!)
})
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
})

View File

@ -52,7 +52,7 @@ describe('handlePreviewLink', () => {
)
})
it('routes a relative .html file to openBrowser with the preview-fs (workspace) url', () => {
it('routes a built relative .html file to openBrowser with the preview-fs (workspace) url', () => {
const deps = makeDeps()
const handled = handlePreviewLink('out/index.html', deps)
expect(handled).toBe(true)
@ -64,6 +64,14 @@ describe('handlePreviewLink', () => {
expect(deps.openFilePreview).not.toHaveBeenCalled()
})
it('routes a frontend project index.html to workspace preview instead of static browser preview', () => {
const deps = makeDeps()
const handled = handlePreviewLink('todo-app/index.html', deps)
expect(handled).toBe(true)
expect(deps.openFilePreview).toHaveBeenCalledWith('s1', 'todo-app/index.html')
expect(deps.openBrowser).not.toHaveBeenCalled()
})
it('routes relative previewable docs to openFilePreview with the relative path', () => {
const deps = makeDeps()
const handled = handlePreviewLink('docs/report.md', deps)

View File

@ -1,4 +1,5 @@
import { classifyPreviewLink } from './previewLinkRouter'
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
export type PreviewLinkDeps = {
sessionId: string
@ -61,6 +62,10 @@ export function handlePreviewLink(href: string, deps: PreviewLinkDeps): boolean
// Absolute paths (incl. file:// → absolute) may live OUTSIDE the session
// workspace, so serve them via the $HOME-sandboxed /local-file route.
// Relative paths stay workspace-scoped via /preview-fs.
if (!isAbsoluteLocalPath(filePath) && !shouldOfferStaticHtmlPreview(filePath)) {
deps.openFilePreview(deps.sessionId, filePath)
return true
}
const url = isAbsoluteLocalPath(filePath)
? localFileUrl(deps.serverBaseUrl, filePath)
: previewFsUrl(deps.serverBaseUrl, deps.sessionId, filePath)

View File

@ -0,0 +1,36 @@
const HTML_EXT = /\.(html?|xhtml)$/i
const INDEX_HTML_RE = /(^|\/)index\.html?$/i
const STATIC_OUTPUT_DIRS = new Set([
'build',
'coverage',
'dist',
'docs',
'lcov-report',
'out',
'public',
'site',
'storybook-static',
])
function normalizePathForPolicy(filePath: string): string {
return filePath
.split(/[?#]/, 1)[0]!
.replace(/\\/g, '/')
.replace(/^\.\//, '')
}
export function isHtmlFilePath(filePath: string): boolean {
return HTML_EXT.test(normalizePathForPolicy(filePath))
}
export function shouldOfferStaticHtmlPreview(filePath: string): boolean {
const normalized = normalizePathForPolicy(filePath)
if (!HTML_EXT.test(normalized)) return false
if (!INDEX_HTML_RE.test(normalized)) return true
return normalized
.split('/')
.filter(Boolean)
.some((segment) => STATIC_OUTPUT_DIRS.has(segment.toLowerCase()))
}

View File

@ -58,14 +58,24 @@ describe('openWithContextForWorkspaceFile', () => {
expect(result).toEqual({ kind: 'file', absolutePath: '/w/proj/README.md', relPath: 'README.md', previewable: true })
})
it('index.html rel path → also has inAppBrowserUrl equal to previewFsUrl', () => {
const result = openWithContextForWorkspaceFile('index.html', '/w/proj/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
it('frontend project index.html rel path → no inAppBrowserUrl because it needs a dev server', () => {
const result = openWithContextForWorkspaceFile('todo-app/index.html', '/w/proj/todo-app/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({
kind: 'file',
absolutePath: '/w/proj/index.html',
relPath: 'index.html',
absolutePath: '/w/proj/todo-app/index.html',
relPath: 'todo-app/index.html',
previewable: true,
inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'index.html'),
})
})
it('built dist index.html rel path → has inAppBrowserUrl equal to previewFsUrl', () => {
const result = openWithContextForWorkspaceFile('todo-app/dist/index.html', '/w/proj/todo-app/dist/index.html', { sessionId: SESSION, serverBaseUrl: BASE })
expect(result).toEqual({
kind: 'file',
absolutePath: '/w/proj/todo-app/dist/index.html',
relPath: 'todo-app/dist/index.html',
previewable: true,
inAppBrowserUrl: previewFsUrl(BASE, SESSION, 'todo-app/dist/index.html'),
})
})

View File

@ -1,9 +1,8 @@
import { classifyPreviewLink } from './previewLinkRouter'
import { shouldOfferStaticHtmlPreview } from './htmlPreviewPolicy'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from './handlePreviewLink'
import type { OpenWithContext } from './openWithItems'
const HTML_EXT = /\.(html?|xhtml)$/i
/** Build an open-with context for a workspace file (we have both its relative + absolute path). */
export function openWithContextForWorkspaceFile(
relPath: string,
@ -15,7 +14,7 @@ export function openWithContextForWorkspaceFile(
absolutePath,
relPath,
previewable: true,
inAppBrowserUrl: HTML_EXT.test(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined,
inAppBrowserUrl: shouldOfferStaticHtmlPreview(relPath) ? previewFsUrl(opts.serverBaseUrl, opts.sessionId, relPath) : undefined,
}
}
@ -38,10 +37,14 @@ export function openWithContextForHref(
if (c.kind === 'browser-file' && c.path) {
// Absolute paths may be outside the session workspace → serve via the
// $HOME-sandboxed /local-file route; relative paths stay workspace-scoped.
const inAppBrowserUrl = isAbsoluteLocalPath(c.path)
? localFileUrl(opts.serverBaseUrl, c.path)
: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path)
return { kind: 'file', absolutePath: resolveAbsolute(opts.workDir, c.path), inAppBrowserUrl }
const absolutePath = resolveAbsolute(opts.workDir, c.path)
if (isAbsoluteLocalPath(c.path)) {
return { kind: 'file', absolutePath, inAppBrowserUrl: localFileUrl(opts.serverBaseUrl, c.path) }
}
if (shouldOfferStaticHtmlPreview(c.path)) {
return { kind: 'file', absolutePath, inAppBrowserUrl: previewFsUrl(opts.serverBaseUrl, opts.sessionId, c.path) }
}
return { kind: 'file', absolutePath, relPath: c.path, previewable: true }
}
return null
}

View File

@ -35,6 +35,13 @@ function setupWorkspace() {
writeFileSync(path.join(root, 'index.html'), '<h1>ok</h1>')
mkdirSync(path.join(root, 'assets'))
writeFileSync(path.join(root, 'assets', 'a.css'), 'body{}')
mkdirSync(path.join(root, 'dist', 'assets'), { recursive: true })
writeFileSync(
path.join(root, 'dist', 'index.html'),
'<!doctype html><html><head><link rel="stylesheet" href="/assets/app.css"></head><body><script type="module" src="/assets/app.js"></script></body></html>',
)
writeFileSync(path.join(root, 'dist', 'assets', 'app.css'), 'body{color:red}')
writeFileSync(path.join(root, 'dist', 'assets', 'app.js'), 'console.log("ok")')
writeFileSync(path.join(root, 'clip.mp4'), VIDEO_BYTES)
return root
}
@ -87,6 +94,17 @@ describe('handlePreviewFs', () => {
expect(res.headers.get('content-type')).toBe('text/css; charset=utf-8')
})
it('rewrites root-relative HTML assets to stay under the preview-fs file directory', async () => {
const root = setupWorkspace()
const resolve = async () => root
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/dist/index.html'), resolve)
expect(res.status).toBe(200)
const body = await res.text()
expect(body).toContain('<base href="/preview-fs/s1/dist/">')
expect(body).toContain('href="/preview-fs/s1/dist/assets/app.css"')
expect(body).toContain('src="/preview-fs/s1/dist/assets/app.js"')
})
it('blocks path traversal with 403', async () => {
const root = setupWorkspace()
const resolve = async () => root

View File

@ -52,6 +52,7 @@ const PREFIX = '/preview-fs/'
* against pathological / runaway files not as a memory limit.
*/
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const ROOT_RELATIVE_HTML_ATTR_RE = /\b(src|href)=(["'])\/(?!\/)([^"']*)\2/gi
export interface ParsedRange {
start: number
@ -149,7 +150,71 @@ export async function handlePreviewFs(
return new Response('forbidden', { status: 403 })
}
return serveFileWithRange(target, reqHeaders)
return servePreviewFsFile(target, url.pathname, reqHeaders)
}
function previewHtmlBasePath(pathname: string): string {
const slash = pathname.lastIndexOf('/')
if (slash < 0) return '/'
return pathname.slice(0, slash + 1)
}
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function rewritePreviewHtml(content: string, basePath: string): string {
const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`
const rewrittenRootAssets = content.replace(
ROOT_RELATIVE_HTML_ATTR_RE,
(_match, attr: string, quote: string, value: string) =>
`${attr}=${quote}${normalizedBase}${value}${quote}`,
)
if (/<base\b/i.test(rewrittenRootAssets)) {
return rewrittenRootAssets
}
return rewrittenRootAssets.replace(
/<head\b[^>]*>/i,
(head) => `${head}<base href="${escapeHtmlAttribute(normalizedBase)}">`,
)
}
async function servePreviewFsFile(
target: string,
requestPathname: string,
reqHeaders?: Headers,
): Promise<Response> {
const ext = path.extname(target).toLowerCase()
if ((ext !== '.html' && ext !== '.htm') || reqHeaders?.has('range')) {
return serveFileWithRange(target, reqHeaders)
}
let stat: fs.Stats
try {
stat = fs.statSync(target)
} catch {
return new Response('not found', { status: 404 })
}
if (!stat.isFile()) return new Response('not a file', { status: 404 })
if (stat.size > MAX_FILE_BYTES) return new Response('too large', { status: 413 })
const content = fs.readFileSync(target, 'utf8')
const transformed = rewritePreviewHtml(content, previewHtmlBasePath(requestPathname))
return new Response(transformed, {
status: 200,
headers: {
'Content-Type': contentTypeForPath(target),
'Content-Length': String(Buffer.byteLength(transformed)),
'Cache-Control': 'no-cache',
},
})
}
/**