const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const readline = require('node:readline');
function createDemoServer() {
const log = [], pending = new Map(), receipts = new Map();
const labels = { alpha: 'A', beta: 'B' };
const ids = { alpha: 'a-01', beta: 'b-01' };
let sequence = 0;
const add = (type, fields) => {
const entry = { sequence: ++sequence, at: new Date().toISOString(), type, ...fields };
log.push(entry);
return entry;
};
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(fs.readFileSync(path.join(__dirname, 'index.html')));
return;
}
if (url.pathname !== '/search') { res.writeHead(404); res.end(); return; }
const query = url.searchParams.get('q'), label = labels[query];
if (!label || pending.has(label)) { res.writeHead(400); res.end(); return; }
const requestId = 'req-' + (pending.size + 1);
let resolveFinish;
const finished = new Promise(resolve => { resolveFinish = resolve; });
const entry = { label, query, requestId, res, released: false, finished };
pending.set(label, entry);
res.once('finish', () => resolveFinish(add('response-finished', { label, query, requestId })));
add('received', { label, query, requestId });
if (receipts.has(label)) receipts.get(label)(entry);
// Hold the real HTTP response until release() is explicitly called.
});
return {
server, log,
received(label) {
if (pending.has(label)) return Promise.resolve(pending.get(label));
return new Promise(resolve => receipts.set(label, resolve));
},
release(label, outcome) {
if (pending.size !== 2) throw new Error('Wait until both A and B are received.');
const entry = pending.get(label);
if (!entry || entry.released || !['success', 'failure'].includes(outcome)) throw new Error('Invalid release.');
entry.released = true;
const status = outcome === 'success' ? 200 : 503;
const body = { requestId: entry.requestId, query: entry.query,
...(outcome === 'success' ? { id: ids[entry.query] } : { error: 'demo-' + entry.query + '-failure' }) };
add('released', { label, requestId: entry.requestId, query: entry.query, status, body });
entry.res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
entry.res.end(JSON.stringify(body));
return entry.finished;
}
};
}
module.exports = { createDemoServer };
if (require.main === module) {
const demo = createDemoServer();
demo.server.listen(0, '127.0.0.1', () => {
console.log('Open http://127.0.0.1:' + demo.server.address().port);
console.log('Submit alpha, then beta. Type B success then A success here; use failure for HTTP 503.');
});
const cli = readline.createInterface({ input: process.stdin, output: process.stdout });
cli.on('line', async line => {
const [label, outcome] = line.trim().split(/\s+/);
try { await demo.release(label, outcome); console.log(JSON.stringify(demo.log, null, 2)); }
catch (error) { console.error(error.message); }
});
}
手动复现时,在仅含这两个演示文件的目录执行 node server.cjs,打开终端显示的本机地址。提交 alpha,再改为 beta 并提交;然后在服务端终端输入 B success,观察四个页面字段,再输入 A success,确认没有回跳。服务端如果尚未收到两份请求,会拒绝释放。
其余三种顺序分别是 A success 后 B success、B success 后 A failure、B failure 后 A success。每换一个场景先停止并重新启动服务,打开新地址,再按 A、B 顺序提交。服务日志中的 received、released、response-finished 应与动作相符;浏览器控制台只读查看 window.__searchAudit,核对旧请求的 success-ignored 或 error-ignored,以及 finally-ignored。
自动化实际调用的是这个文件导出的 createDemoServer(),直接等待服务端收到 A、收到 B 的 Promise,再逐个调用 release();上述交互式命令行入口便于人工复现,没有单独做命令行交互验收。每次释放后还等待浏览器完成该请求的收尾,再读取 DOM 中的四个字段和审计记录;不是等固定几百毫秒就假定响应已经处理。
最关键的三个观察都和预期相符:A 先返回时,B 仍在加载且没有结果;B 成功后旧 A 的错误不覆盖页面;当前 B 失败后旧 A 的成功不清除 HTTP 503。旧请求的成功或错误处理,以及 finally,都有实际执行记录;忽略分支的处理前后状态精确相等,随后读取的 DOM 也匹配独立预期。截图用于确认关键状态可读,完整状态比对来自 DOM 和日志。