import assert from 'node:assert/strict'; import { spawn, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { httpRequest } from '../server-node/src/testHttp.ts'; const scriptPath = fileURLToPath(import.meta.url); const repoRoot = path.resolve(path.dirname(scriptPath), '..'); const bundledNodePath = path.join( repoRoot, '.tools', 'node-v22.22.2-win-x64', process.platform === 'win32' ? 'node.exe' : 'bin/node', ); const runtimeNodePath = fs.existsSync(bundledNodePath) ? bundledNodePath : process.execPath; const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.cjs'); const webBuildPath = path.join(repoRoot, 'dist', 'index.html'); const publicRoot = path.join(repoRoot, 'public'); const proxyPort = 18080; const nodePort = 18081; const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`; const nodeBaseUrl = `http://127.0.0.1:${nodePort}`; type ManagedChild = { name: string; process: ChildProcess; }; function assertBuildArtifacts() { if (!fs.existsSync(serverBuildPath)) { throw new Error( 'server-node/dist/server.cjs 不存在,请先运行 npm run server-node:build', ); } if (!fs.existsSync(webBuildPath)) { throw new Error('dist/index.html 不存在,请先运行 npm run build'); } } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForReady( label: string, url: string, validate: (bodyText: string, status: number) => void, timeoutMs = 20000, ) { const startedAt = Date.now(); let lastError: unknown = null; while (Date.now() - startedAt < timeoutMs) { try { const response = await httpRequest(url); const bodyText = await response.text(); validate(bodyText, response.status); return; } catch (error) { lastError = error; await sleep(250); } } throw new Error( `[smoke:proxy] ${label} 未在 ${timeoutMs}ms 内就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`, ); } function spawnManagedChild( name: string, command: string, args: string[], env: NodeJS.ProcessEnv, ): ManagedChild { const child = spawn(command, args, { cwd: repoRoot, env, stdio: 'inherit', shell: false, }); child.on('error', (error) => { console.error(`[smoke:proxy] ${name} 启动失败`, error); }); return { name, process: child, }; } async function stopChild(child: ManagedChild | null) { if (!child || child.process.exitCode !== null) { return; } child.process.kill('SIGTERM'); await Promise.race([ new Promise((resolve) => { child.process.once('exit', () => resolve()); }), sleep(2000), ]); if (child.process.exitCode === null) { child.process.kill('SIGKILL'); await new Promise((resolve) => { child.process.once('exit', () => resolve()); }); } } function contentTypeFor(filePath: string) { if (filePath.endsWith('.html')) { return 'text/html; charset=utf-8'; } if (filePath.endsWith('.js')) { return 'text/javascript; charset=utf-8'; } if (filePath.endsWith('.css')) { return 'text/css; charset=utf-8'; } if (filePath.endsWith('.json')) { return 'application/json; charset=utf-8'; } if (filePath.endsWith('.png')) { return 'image/png'; } if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) { return 'image/jpeg'; } if (filePath.endsWith('.webp')) { return 'image/webp'; } if (filePath.endsWith('.svg')) { return 'image/svg+xml; charset=utf-8'; } return 'application/octet-stream'; } function resolveStaticFile(urlPath: string) { const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/'); const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath; const trimmedRelativePath = normalizedPath.replace(/^\/+/u, ''); const distRoot = path.resolve(repoRoot, 'dist'); const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath); const distCandidatePath = path.resolve(distRoot, trimmedRelativePath); if ( publicCandidatePath.startsWith(publicRoot) && fs.existsSync(publicCandidatePath) && fs.statSync(publicCandidatePath).isFile() ) { return publicCandidatePath; } if ( distCandidatePath.startsWith(distRoot) && fs.existsSync(distCandidatePath) && fs.statSync(distCandidatePath).isFile() ) { return distCandidatePath; } return webBuildPath; } async function startSameOriginProxy() { const server = http.createServer((request, response) => { const requestUrl = request.url || '/'; if (requestUrl === '/healthz') { response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', }); response.end('ok'); return; } if (requestUrl.startsWith('/api/')) { const upstream = http.request( { hostname: '127.0.0.1', port: nodePort, path: requestUrl, method: request.method, headers: { ...request.headers, host: `127.0.0.1:${nodePort}`, }, }, (upstreamResponse) => { response.writeHead( upstreamResponse.statusCode ?? 502, upstreamResponse.headers, ); upstreamResponse.pipe(response); }, ); upstream.on('error', (error) => { response.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8', }); response.end( JSON.stringify({ error: { message: error instanceof Error ? error.message : 'proxy upstream failed', }, }), ); }); request.pipe(upstream); return; } const filePath = resolveStaticFile(requestUrl); response.writeHead(200, { 'Content-Type': contentTypeFor(filePath), }); fs.createReadStream(filePath).pipe(response); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(proxyPort, '127.0.0.1', () => resolve()); }); return server; } async function stopProxyServer(server: http.Server | null) { if (!server) { return; } await new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }); } async function authEntry(baseUrl: string) { const requestId = 'proxy-smoke-auth-entry'; const username = `proxy_${Date.now().toString(36)}`; const response = await httpRequest(`${baseUrl}/api/auth/entry`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId, }, body: JSON.stringify({ username, password: 'proxy-secret-123', }), }); const payload = (await response.json()) as { token: string; user: { id: string; username: string; }; }; assert.equal(response.status, 200); assert.equal(response.headers.get('x-request-id'), requestId); assert.equal(payload.user.username, username); assert.ok(payload.token); return payload; } async function main() { assertBuildArtifacts(); let serverChild: ManagedChild | null = null; let proxyServer: http.Server | null = null; try { console.log('[smoke:proxy] starting built node server'); serverChild = spawnManagedChild( 'server-node', runtimeNodePath, [serverBuildPath], { ...process.env, PROJECT_ROOT: repoRoot, NODE_ENV: 'test', NODE_SERVER_ADDR: `:${nodePort}`, DATABASE_URL: 'pg-mem://genarrative-proxy-smoke', LOG_LEVEL: 'silent', JWT_SECRET: 'proxy-smoke-secret', JWT_ISSUER: 'genarrative-proxy-smoke', LLM_API_KEY: '', DASHSCOPE_API_KEY: '', }, ); await waitForReady( 'node server', `${nodeBaseUrl}/healthz`, (bodyText, status) => { assert.equal(status, 200); const payload = JSON.parse(bodyText) as { ok: boolean; service: string; }; assert.equal(payload.ok, true); assert.equal(payload.service, 'genarrative-node-server'); }, ); console.log('[smoke:proxy] node server ready'); console.log('[smoke:proxy] starting same-origin reverse proxy harness'); proxyServer = await startSameOriginProxy(); await waitForReady( 'reverse proxy', `${proxyBaseUrl}/healthz`, (bodyText, status) => { assert.equal(status, 200); assert.equal(bodyText.trim(), 'ok'); }, ); console.log('[smoke:proxy] reverse proxy ready'); const homeResponse = await httpRequest(`${proxyBaseUrl}/`); const homeHtml = await homeResponse.text(); assert.equal(homeResponse.status, 200); assert.match(homeHtml, /
<\/div>/u); console.log('[smoke:proxy] static web entry ok'); const entry = await authEntry(proxyBaseUrl); console.log('[smoke:proxy] proxied auth entry ok'); const meResponse = await httpRequest(`${proxyBaseUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${entry.token}`, }, }); const mePayload = (await meResponse.json()) as { user: { username: string; }; }; assert.equal(meResponse.status, 200); assert.equal(mePayload.user.username, entry.user.username); console.log('[smoke:proxy] proxied auth me ok'); const saveResponse = await httpRequest( `${proxyBaseUrl}/api/runtime/save/snapshot`, { method: 'PUT', headers: { Authorization: `Bearer ${entry.token}`, 'Content-Type': 'application/json', 'X-Genarrative-Response-Envelope': 'v1', }, body: JSON.stringify({ gameState: { worldType: 'WUXIA', chapter: 2, }, bottomTab: 'adventure', currentStory: { text: 'proxy smoke story', }, }), }, ); const savePayload = (await saveResponse.json()) as { ok: true; data: { gameState: { chapter: number; }; }; meta: { requestId: string; operation: string; }; }; assert.equal(saveResponse.status, 200); assert.equal(savePayload.ok, true); assert.equal(savePayload.data.gameState.chapter, 2); assert.equal(savePayload.meta.operation, 'runtime.snapshot.put'); assert.ok(savePayload.meta.requestId); console.log('[smoke:proxy] proxied runtime save ok'); const getResponse = await httpRequest( `${proxyBaseUrl}/api/runtime/save/snapshot`, { headers: { Authorization: `Bearer ${entry.token}`, }, }, ); const getPayload = (await getResponse.json()) as { gameState: { chapter: number; }; bottomTab: string; }; assert.equal(getResponse.status, 200); assert.equal(getPayload.gameState.chapter, 2); assert.equal(getPayload.bottomTab, 'adventure'); console.log('[smoke:proxy] proxied runtime snapshot read ok'); console.log('[smoke:proxy] all checks passed'); } finally { await stopProxyServer(proxyServer); await stopChild(serverChild); } } void main().catch((error) => { console.error('[smoke:proxy] failed'); console.error(error); process.exit(1); });