Runner: Proxy und Startseite, register/wake (R12)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent 6cd50bba9f
commit 4afe7eb5cd
7 files changed
+360 -2

No files matched your search

+19 -1
View File
@@ -42,6 +42,7 @@ export function createDev({
setTimer = (fn, ms) => setTimeout(fn, ms).unref?.() ?? 0, prepareDirs = defaultPrepareDirs,
rmDir = (p) => nodeFs.rm(p, { recursive: true, force: true }),
}) {
const specs = new Map(); // R12: letzte Job-Spezifikation je sub (bleibt auch im Zustand 'sleeping')
const entries = new Map(); // sub ⇒ { sub, state, sha, lastUsed, log, port, job, release, promise, cancelled }
const sleep = (ms) => new Promise((r) => setTimer(r, ms));
const isRunning = (e) => e.state === 'online' || e.state === 'starting';
@@ -123,6 +124,7 @@ export function createDev({
}
async function ensure(sub, job) {
specs.set(sub, job);
const cur = entries.get(sub);
if (cur && cur.sha === job.sha) {
if (cur.state === 'failed' && now() - cur.failedAt < RETRY_MS) return { state: 'failed' }; // nicht öfter als alle 60 s neu versuchen
@@ -142,8 +144,24 @@ export function createDev({
return { state: 'starting' };
}
// R12: Spezifikation merken, ohne zu starten. Läuft der sub mit anderem sha, wird nach den üblichen Regeln neu gestartet.
async function register(sub, job) {
const cur = entries.get(sub);
if (cur && isRunning(cur) && cur.sha !== job.sha) return ensure(sub, job);
specs.set(sub, job);
if (cur && cur.sha === job.sha && (cur.state === 'online' || cur.state === 'starting' || cur.state === 'failed')) return { state: cur.state };
return { state: 'sleeping' };
}
// R12: Start mit der gemerkten Spezifikation (Proxy kennt nur die Subdomain).
async function wake(sub) {
const job = specs.get(sub);
if (!job) return { state: 'unknown' };
return ensure(sub, job);
}
const touch = (sub) => { const e = entries.get(sub); if (e) e.lastUsed = now(); };
async function stop(sub) {
specs.delete(sub); // 'off': Spezifikation vergessen, bis das Portal neu registriert
const e = entries.get(sub);
if (!e) return;
await shutdown(e, 'off');
@@ -166,5 +184,5 @@ export function createDev({
}
const idle = () => Promise.all([...entries.values()].map((e) => e.promise)).then(() => {});
return { ensure, touch, stop, list, status, sweep, cleanupOrphans, idle };
return { ensure, register, wake, touch, stop, list, status, sweep, cleanupOrphans, idle };
}
+4 -1
View File
@@ -3,8 +3,8 @@ import { createApiServer } from './server.js';
import { createDocker } from './docker.js';
import { createJobs } from './jobs.js';
import { createDev } from './dev.js';
import { createProxy } from './proxy.js';
// Proxy kommt in einem späteren Task.
const config = loadConfig(process.env);
const docker = createDocker();
const realJobs = createJobs({ config, docker });
@@ -19,3 +19,6 @@ setInterval(() => dev.sweep().catch((e) => console.error('sweep', e)), 60e3).unr
createApiServer({ config, jobs, dev }).listen(config.apiPort, () => {
console.log(`Preview-Runner API auf Port ${config.apiPort}`);
});
createProxy({ config, dev }).listen(config.proxyPort, () => {
console.log(`Preview-Runner Proxy auf Port ${config.proxyPort}`);
});
+126
View File
@@ -0,0 +1,126 @@
// Reverse-Proxy für Dev-Previews: Host <sub>.<baseHost> ⇒ laufender Dev-Container (R5: Docker-DNS im Netz preview-sandbox).
// Keine Authentifizierung hier: die Hosts liegen hinter Traefiks preview-chain (ForwardAuth). Weitergeleitet wird
// ausschließlich an das Ziel, das dev.wake/dev.ensure liefert (preview-dev-<sub>).
import http from 'node:http';
import { isValidSub } from './validate.js';
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function page(title, text, { refresh = false, hint = '' } = {}) {
return `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refresh ? '<meta http-equiv="refresh" content="3">' : ''}<title>${esc(title)}</title><style>body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:system-ui,sans-serif;background:#f5f6f8;color:#1c2330}main{max-width:26rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:.25rem 0;color:#5b6575}</style></head><body><main><h1>${esc(title)}</h1><p>${esc(text)}</p>${hint ? `<p>${esc(hint)}</p>` : ''}</main></body></html>`;
}
const PAGES = {
starting: page('Preview startet …', 'Der Dev-Server wird gestartet. Die Seite lädt automatisch neu.', { refresh: true }),
failed: page('Preview konnte nicht starten', 'Der Dev-Server ist nicht hochgekommen.', { hint: 'Details stehen im Protokoll im WOMS.' }),
unknown: page('Preview nicht verfügbar', 'Diese Preview ist gerade nicht verfügbar.'),
busy: page('Preview nicht verfügbar', 'Der Server ist gerade ausgelastet. Bitte später erneut versuchen.'),
};
function html(res, status, body, extra = {}) {
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', ...extra });
res.end(body);
}
export function createProxy({ config, dev, request = http.request }) {
// Sub aus dem Host-Header: exakt <sub>.<baseHost>, klein geschrieben, Port entfernt.
function subOf(hostHeader) {
const host = String(hostHeader || '').replace(/:\d+$/, '');
const suffix = `.${config.baseHost}`;
if (host !== host.toLowerCase() || !host.endsWith(suffix)) return null;
const sub = host.slice(0, -suffix.length);
return isValidSub(sub) ? sub : null;
}
function fwdHeaders(req) {
const h = { ...req.headers };
const prior = req.headers['x-forwarded-for'];
const ip = req.socket.remoteAddress || '';
h['x-forwarded-for'] = prior ? `${prior}, ${ip}` : ip;
h['x-forwarded-host'] = req.headers.host || '';
h['x-forwarded-proto'] = req.headers['x-forwarded-proto'] || 'https';
return h;
}
// Ergebnis: { target } wenn online, sonst { state }.
async function resolve(sub) {
let r;
try { r = await dev.wake(sub); } catch (e) { return { state: e?.status === 503 ? 'busy' : 'unknown' }; }
if (r.state === 'online' && r.target) return { target: new URL(r.target) };
return { state: r.state };
}
const server = http.createServer(async (req, res) => {
try {
const sub = subOf(req.headers.host);
if (!sub) return html(res, 404, page('Nicht gefunden', 'Diese Adresse gibt es nicht.'));
const r = await resolve(sub);
if (!r.target) {
const key = PAGES[r.state] ? r.state : 'unknown';
return html(res, 503, PAGES[key], key === 'starting' ? { 'Retry-After': '3' } : {});
}
dev.touch(sub);
const up = request({
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req),
}, (ures) => {
res.writeHead(ures.statusCode, ures.headers);
ures.pipe(res);
ures.on('error', () => res.destroy());
});
up.on('error', () => {
if (!res.headersSent) {
const body = 'Die Preview ist gerade nicht erreichbar (502).';
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
} else res.destroy();
});
res.on('close', () => { if (!res.writableFinished) up.destroy(); });
req.pipe(up);
} catch (e) {
console.error('Proxy-Fehler:', e);
if (!res.headersSent) html(res, 500, page('Fehler', 'Interner Fehler.'));
else res.destroy();
}
});
server.on('upgrade', async (req, socket, head) => {
socket.on('error', () => socket.destroy());
try {
const sub = subOf(req.headers.host);
if (!sub) return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
const r = await resolve(sub);
if (!r.target) return socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
dev.touch(sub);
const up = request({
hostname: r.target.hostname, port: r.target.port, method: req.method, path: req.url, headers: fwdHeaders(req),
});
up.on('response', (ures) => { // Upstream lehnt das Upgrade ab: Antwort durchreichen und schließen
let raw = `HTTP/1.1 ${ures.statusCode} ${ures.statusMessage}\r\n`;
for (let i = 0; i < ures.rawHeaders.length; i += 2) raw += `${ures.rawHeaders[i]}: ${ures.rawHeaders[i + 1]}\r\n`;
socket.write(`${raw}\r\n`);
ures.pipe(socket);
});
up.on('upgrade', (ures, usock, uhead) => {
usock.on('error', () => { usock.destroy(); socket.destroy(); });
// allowHalfOpen-Sockets bleiben nach 'end' offen: bei Ende oder Schließen einer Seite beide zerstören
const both = () => { usock.destroy(); socket.destroy(); };
for (const ev of ['end', 'close']) { socket.on(ev, both); usock.on(ev, both); }
let raw = `HTTP/1.1 ${ures.statusCode} ${ures.statusMessage}\r\n`;
for (let i = 0; i < ures.rawHeaders.length; i += 2) raw += `${ures.rawHeaders[i]}: ${ures.rawHeaders[i + 1]}\r\n`;
socket.write(`${raw}\r\n`);
if (uhead?.length) socket.write(uhead);
if (head?.length) usock.write(head);
usock.pipe(socket);
socket.pipe(usock);
});
up.on('error', () => socket.destroy());
socket.on('close', () => up.destroy());
up.end();
} catch (e) {
console.error('Proxy-Upgrade-Fehler:', e);
socket.destroy();
}
});
return server;
}
+8
View File
@@ -67,6 +67,14 @@ export function createApiServer({ config, jobs, dev, mem = readMemAvailable }) {
const run = jobs.get(parts[1]);
return run ? send(res, 200, run) : send(res, 404, { error: 'Lauf nicht gefunden' });
}
if (method === 'PUT' && parts[0] === 'dev' && parts.length === 2) {
const sub = parts[1];
if (!isValidSub(sub)) return send(res, 400, { error: 'Ungültige Subdomain' });
const v = validateJob(await readJson(req));
if (!v.ok) return send(res, 400, { error: v.error });
if (v.job.sub !== sub) return send(res, 400, { error: 'Subdomain stimmt nicht überein' });
return send(res, 200, { state: (await dev.register(sub, v.job)).state });
}
if (method === 'POST' && parts[0] === 'dev' && parts.length === 3 && ['start', 'stop'].includes(parts[2])) {
const sub = parts[1];
if (!isValidSub(sub)) return send(res, 400, { error: 'Ungültige Subdomain' });