htmx/test/manual/server.js
Christian Tanul d99095eb3c
Fix iOS Safari SSE disconnect on background (#3634) (#3640)
* Close SSE stream on visibility change to fix iOS Safari background disconnect (#3634)

iOS Safari silently kills TCP connections when the app is backgrounded,
leaving SSE streams dead with no error event. The new `closeOnHide`
config option proactively cancels the stream reader when the page
becomes hidden and reconnects when visible again (requires
`reconnect: true`).

Also renames `pauseInBackground` to `closeOnHide` across source, docs,
types, and tests to reflect the new disconnect/reconnect behavior
(previously it only paused processing).

* Improve SSE closeOnHide with reconnectRequested flag and cancellable backoff

- Create reader outside __parseSSE and pass as argument
- Add reconnectRequested flag so visibility reconnects skip backoff delay
- Symmetric visibility handler: cancel on hidden, reconnect on visible
- Cancellable backoff delay via delayCanceller so tab return is instant
2026-01-29 13:15:13 -07:00

129 lines
4.6 KiB
JavaScript

const http = require('http');
const fs = require('fs').promises;
const serve = (file, type = 'text/html') => async (req, res) => {
res.writeHead(200, { 'Content-Type': type });
res.end(await fs.readFile(file));
};
const sse = (handler) => (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
handler(req, res);
};
const routes = {
'/': serve('test/manual/index.html'),
'/sse': serve('test/manual/sse.html'),
'/ios-sse': serve('test/manual/ios-sse.html'),
'/htmx.js': serve('src/htmx.js', 'application/javascript'),
'/heartbeat': sse((req, res) => {
let count = 0;
const send = () => {
count++;
const time = new Date().toLocaleTimeString();
res.write(`data: <div>#${count} - ${time}</div>\n\n`);
};
send();
const interval = setInterval(send, 2000);
req.on('close', () => clearInterval(interval));
}),
'/matrix-stream': sse((req, res) => {
const fullText = "Wake up, Neo... The Matrix has you.";
let index = 0;
const typeChar = () => {
if (index < fullText.length) {
index++;
res.write(`data: <div class="matrix-text">${fullText.slice(0, index)}</div>\n\n`);
setTimeout(typeChar, index === 16 ? 250 : 20 + Math.random() * 20);
} else {
setTimeout(() => {
const deleteChar = () => {
if (index > 0) {
index--;
res.write(`data: <div class="matrix-text">${fullText.slice(0, index)}</div>\n\n`);
setTimeout(deleteChar, 20);
} else {
res.write(`data: <div>\u200E</div>\n\n`);
res.end();
}
};
deleteChar();
}, 750);
}
};
typeChar();
}),
'/events': sse((req, res) => {
const activities = ['User joined', 'File uploaded', 'Comment added', 'Task completed', 'Message sent'];
const statuses = ['Paused', 'Active', 'Overdrive'];
let status = 'Active';
let statusChangeTimer = Date.now() + 2000;
res.write(`data: <hx-partial hx-target="#events-output" hx-swap="beforeend"><div class="events-active"></div></hx-partial>\n\n`);
res.write(`data: <hx-partial hx-target="#system-status" hx-swap="innerHTML">${status}</hx-partial>\n\n`);
const send = () => {
const now = Date.now();
if (now >= statusChangeTimer) {
const available = statuses.filter(s => s !== status);
status = available[Math.floor(Math.random() * available.length)];
res.write(`data: <hx-partial hx-target="#system-status" hx-swap="innerHTML">${status}</hx-partial>\n\n`);
statusChangeTimer = now + 2000;
}
if (status === 'Paused') return setTimeout(send, 100);
const delay = status === 'Overdrive' ? 100 : 500;
res.write(`data: <hx-partial hx-target="#activity" hx-swap="beforeend"><div>${activities[Math.floor(Math.random() * activities.length)]}</div></hx-partial>\n\n`);
setTimeout(send, delay);
};
send();
}),
'/progress-stream': sse((req, res) => {
let progress = 0;
const update = () => {
if (progress < 100) {
progress += Math.floor(Math.random() * 5) + 5;
if (progress > 100) progress = 100;
res.write(`event: progress\ndata: ${progress}\n\n`);
setTimeout(update, 50);
} else {
res.write(`event: done\ndata: complete\n\n`);
res.end();
}
};
update();
})
};
http.createServer(async (req, res) => {
const pathname = new URL(req.url, 'http://localhost').pathname;
const handler = routes[pathname];
if (handler) {
try {
await handler(req, res);
} catch (err) {
res.writeHead(500);
res.end('Server Error');
}
} else {
res.writeHead(404);
res.end('404 Not Found');
}
}).listen(3000, '0.0.0.0', () => {
const nets = require('os').networkInterfaces();
const ip = Object.values(nets).flat().find(n => n.family === 'IPv4' && !n.internal)?.address;
console.log('Local: http://localhost:3000');
if (ip) console.log(`Network: http://${ip}:3000`);
});