cc-haha/src/server/staticH5.ts
程序员阿江(Relakkes) 232855fe77 fix(desktop): serve packaged H5 shell from Electron builds
Electron's sidecar runs outside app.asar, so H5 static files must be available as normal unpacked files. Point the sidecar at the unpacked renderer dist and keep a server fallback for stale app.asar-style paths.

Constraint: Packaged Bun sidecars cannot read app.asar paths with ordinary fs stat calls.
Rejected: Serve H5 from app.asar directly | the external sidecar is not Electron and does not get asar filesystem support.
Confidence: high
Scope-risk: narrow
Directive: Keep package-smoke checking app.asar.unpacked/dist/index.html before changing asarUnpack or H5 dist paths.
Tested: bun test src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/h5-access-policy.test.ts
Tested: bun test desktop/electron/services/sidecarManager.test.ts scripts/quality-gate/package-smoke/index.test.ts
Tested: bun run check:server
Tested: cd desktop && bun run check:electron
Tested: git diff --check
Tested: SKIP_INSTALL=1 SIGN_BUILD=0 MAC_TARGETS=dmg desktop/scripts/build-macos-arm64.sh
Tested: packaged sidecar curl /?serverUrl=...&h5Token=... returned HTTP 200
Not-tested: Gatekeeper notarization for the local ad-hoc DMG
2026-06-01 23:32:56 +08:00

148 lines
4.0 KiB
TypeScript

import fs from 'node:fs/promises'
import path from 'node:path'
const CACHEABLE_ASSET_RE = /^\/assets\//
const MIME_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8',
'.gif': 'image/gif',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.txt': 'text/plain; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
export async function handleStaticH5Request(req: Request, url: URL): Promise<Response | null> {
if (req.method !== 'GET' && req.method !== 'HEAD') {
return null
}
const distDir = await resolveH5DistDir()
if (!distDir) {
return null
}
const filePath = await resolveStaticFilePath(distDir, url.pathname)
if (!filePath) {
return null
}
const headers = new Headers({
'Content-Type': contentTypeForPath(filePath),
'Cache-Control': CACHEABLE_ASSET_RE.test(url.pathname)
? 'public, max-age=31536000, immutable'
: 'no-store',
})
if (req.method === 'HEAD') {
const stat = await fs.stat(filePath)
headers.set('Content-Length', String(stat.size))
return new Response(null, { status: 200, headers })
}
return new Response(Bun.file(filePath), { status: 200, headers })
}
async function resolveH5DistDir(): Promise<string | null> {
const candidates = [
process.env.CLAUDE_H5_DIST_DIR,
unpackedAsarDistDir(process.env.CLAUDE_H5_DIST_DIR),
process.env.CLAUDE_APP_ROOT
? path.resolve(process.env.CLAUDE_APP_ROOT, '..', 'Resources', '_up_', 'dist')
: undefined,
process.env.CLAUDE_APP_ROOT
? unpackedAsarDistDir(path.resolve(process.env.CLAUDE_APP_ROOT, 'dist'))
: undefined,
process.env.CLAUDE_APP_ROOT
? path.resolve(process.env.CLAUDE_APP_ROOT, '..', 'Resources', 'dist')
: undefined,
process.env.CLAUDE_APP_ROOT
? path.resolve(process.env.CLAUDE_APP_ROOT, 'dist')
: undefined,
path.resolve(process.cwd(), 'desktop', 'dist'),
path.resolve(process.cwd(), 'dist'),
].filter((candidate): candidate is string => !!candidate)
for (const candidate of candidates) {
try {
const stat = await fs.stat(path.join(candidate, 'index.html'))
if (stat.isFile()) {
return path.resolve(candidate)
}
} catch {
// Try the next candidate.
}
}
return null
}
function unpackedAsarDistDir(value: string | undefined): string | undefined {
if (!value || !value.includes('.asar')) {
return undefined
}
return value.replace(/\.asar(?=$|[/\\])/, '.asar.unpacked')
}
async function resolveStaticFilePath(distDir: string, pathname: string): Promise<string | null> {
const requested = containedPath(distDir, pathname)
if (!requested) {
return null
}
const direct = await fileIfExists(requested)
if (direct) {
return direct
}
const nestedIndex = await fileIfExists(path.join(requested, 'index.html'))
if (nestedIndex) {
return nestedIndex
}
if (path.extname(requested)) {
return null
}
return fileIfExists(path.join(distDir, 'index.html'))
}
function containedPath(root: string, pathname: string): string | null {
let decoded: string
try {
decoded = decodeURIComponent(pathname)
} catch {
return null
}
const relativePath = decoded.replace(/^\/+/, '') || 'index.html'
const candidate = path.resolve(root, relativePath)
const relativeToRoot = path.relative(root, candidate)
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
return null
}
return candidate
}
async function fileIfExists(filePath: string): Promise<string | null> {
try {
const stat = await fs.stat(filePath)
return stat.isFile() ? filePath : null
} catch {
return null
}
}
function contentTypeForPath(filePath: string): string {
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'
}