Files
swyxffplugin/test-server/server.js
T
SvenandClaude Opus 5 99757361ef first commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 11:05:58 +02:00

375 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
"use strict";
/**
* Testserver für das Add-on "Swyx Tab Bridge" simuliert SwyxTray.
* Ohne externe Abhängigkeiten (RFC 6455, nur Text-Frames).
*
* node test-server/server.js [port] [--log <datei>|--no-log] # Default 17655
*
* Verbindungen werden mit Zeitstempel protokolliert auf der Konsole und in
* test-server/swyx-tray.log. Das ist zugleich die Vorlage dafuer, was SwyxTray
* mitschreiben sollte: Aufbau, Anmeldung (hello), Abbau samt Dauer.
*
* Im Terminal können Tab-Nachrichten an das Add-on geschickt werden:
*
* list [current] -> {"type":"tab","action":"list"}
* open <url> [background] -> {"type":"tab","action":"open","url":"…"}
* close <tabId> -> {"type":"tab","action":"close","tabId":42}
* activate <tabId> -> {"type":"tab","action":"activate","tabId":42}
* focus <url> [titel…] -> {"type":"tab","action":"focus","url":"…"}
* raw {"type":"tab","action":"list"} -> beliebiges JSON (Nachrichten ohne
* type:"tab" ignoriert das Add-on)
* quit
*/
const http = require("http");
const crypto = require("crypto");
const readline = require("readline");
const fs = require("fs");
const path = require("path");
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/** Wie lange nach dem Verbindungsaufbau auf die hello-Nachricht gewartet wird. */
const HELLO_GRACE_MS = 3000;
const { port: PORT, logFile: LOG_FILE } = parseArgs(process.argv.slice(2));
let client = null; // aktuell verbundener Socket
let clientInfo = null; // { since, remote, identity }
let nextId = 1;
function parseArgs(argv) {
let port = 17655;
let logFile = path.join(__dirname, "swyx-tray.log");
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--log") logFile = argv[++i];
else if (argv[i] === "--no-log") logFile = null;
else if (/^\d+$/.test(argv[i])) port = Number(argv[i]);
}
return { port, logFile };
}
/* --------------------------------------------------- HTTP + Upgrade */
const server = http.createServer((req, res) => {
res.writeHead(426, { "Content-Type": "text/plain" });
res.end("WebSocket only\n");
});
server.on("upgrade", (req, socket) => {
const key = req.headers["sec-websocket-key"];
if (req.headers.upgrade?.toLowerCase() !== "websocket" || !key) {
socket.destroy();
return;
}
const accept = crypto.createHash("sha1").update(key + GUID).digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`
);
socket.setNoDelay(true);
if (client) {
log("Neue Verbindung alte wird ersetzt.");
try { client.end(); } catch (_) {}
}
const remote = req.socket.remoteAddress;
client = socket;
clientInfo = { since: Date.now(), remote, identity: null };
log(`Verbindung geöffnet von ${remote}.`);
// Ohne hello laesst sich nicht sagen, wer da verbunden ist. Das ist keine
// Fehlersituation nur bemerkenswert, wenn das Add-on erwartet wurde.
const helloTimer = setTimeout(() => {
if (client === socket && clientInfo && !clientInfo.identity) {
log(`Client ${remote} hat sich nicht angemeldet (kein hello) unbekannte Gegenstelle.`);
rl.prompt();
}
}, HELLO_GRACE_MS);
attachFrameReader(socket, onMessage);
// Der Socket aus dem HTTP-Upgrade hat allowHalfOpen = true: schickt die
// Gegenstelle ein FIN, feuert ausschliesslich 'end' und niemals 'close'.
// Ohne die eigene Gegenschliessung bliebe die Verbindung hier ewig als
// "verbunden" stehen und Nachrichten gingen ins Leere.
let finished = false;
const finish = (reason) => {
if (finished) return;
finished = true;
clearTimeout(helloTimer);
try { socket.end(); } catch (_) {}
if (client !== socket) return; // bereits durch eine neue Verbindung ersetzt
const who = clientInfo && clientInfo.identity ? clientInfo.identity : "Unbekannte Gegenstelle";
const held = clientInfo ? duration(Date.now() - clientInfo.since) : "?";
log(`Verbindung getrennt: ${who} (Dauer ${held})${reason ? " " + reason : ""}.`);
client = null;
clientInfo = null;
rl.prompt();
};
socket.on("end", () => finish(null));
socket.on("close", () => finish(null));
socket.on("error", (err) => finish(err && err.message ? err.message : "Verbindungsfehler"));
});
server.listen(PORT, "127.0.0.1", () => {
log(`WebSocket-Server läuft auf ws://127.0.0.1:${PORT}`);
log(LOG_FILE ? `Protokoll: ${LOG_FILE}` : "Protokoll: nur Konsole (--no-log)");
log("Kommandos: list [current] | open <url> [background] | close <id> | activate <id> | focus <url> | raw <json> | quit");
rl.prompt();
});
/* ------------------------------------------------------ Frame-Handling */
function attachFrameReader(socket, onText) {
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
for (;;) {
if (buffer.length < 2) return;
const fin = (buffer[0] & 0x80) !== 0;
const opcode = buffer[0] & 0x0f;
const masked = (buffer[1] & 0x80) !== 0;
let len = buffer[1] & 0x7f;
let offset = 2;
if (len === 126) {
if (buffer.length < offset + 2) return;
len = buffer.readUInt16BE(offset);
offset += 2;
} else if (len === 127) {
if (buffer.length < offset + 8) return;
const big = buffer.readBigUInt64BE(offset);
if (big > 64n * 1024n * 1024n) { socket.destroy(); return; }
len = Number(big);
offset += 8;
}
let mask = null;
if (masked) {
if (buffer.length < offset + 4) return;
mask = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + len) return;
const payload = Buffer.from(buffer.subarray(offset, offset + len));
buffer = buffer.subarray(offset + len);
if (mask) {
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
}
switch (opcode) {
case 0x1: // Text
if (!fin) { log("Fragmentierte Frames werden nicht unterstützt."); break; }
onText(payload.toString("utf8"));
break;
case 0x8: // Close
socket.end(encodeFrame(Buffer.alloc(0), 0x8));
return;
case 0x9: // Ping
socket.write(encodeFrame(payload, 0xa));
break;
case 0xa: // Pong
break;
default:
break;
}
}
});
}
function encodeFrame(payload, opcode = 0x1) {
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.alloc(2);
header[1] = len;
} else if (len < 65536) {
header = Buffer.alloc(4);
header[1] = 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[1] = 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
header[0] = 0x80 | opcode; // FIN + Opcode, Serverframes bleiben unmaskiert
return Buffer.concat([header, payload]);
}
function sendJson(obj) {
if (!client) {
log("Kein Client verbunden.");
return null;
}
client.write(encodeFrame(Buffer.from(JSON.stringify(obj), "utf8"), 0x1));
return obj.id;
}
/* -------------------------------------------------- Eingehende Nachrichten */
function onMessage(text) {
let msg;
try {
msg = JSON.parse(text);
} catch (_) {
log("Ungültiges JSON empfangen: " + text);
return;
}
// Antwort des Add-ons auf eine Tab-Nachricht
if (msg.cmd === "tabresult") {
printResult(msg);
rl.prompt();
return;
}
// Anmeldung des Add-ons. Erst hierdurch ist die Gegenstelle identifiziert.
if (msg.cmd === "hello") {
const identity = `${msg.client || "unbekannt"} v${msg.version || "?"}`;
if (clientInfo) clientInfo.identity = identity;
const details = [];
if (Array.isArray(msg.actions)) details.push(`Aktionen: ${msg.actions.join(", ")}`);
details.push(msg.token ? "mit Token" : "ohne Token");
log(`Angemeldet: ${identity} (${details.join("; ")})`);
rl.prompt();
return;
}
log("Unbekannte Nachricht: " + text);
rl.prompt();
}
function printResult(msg) {
const { cmd, id, ok, error, tabs, ...rest } = msg;
if (ok === false) {
log(`Fehler (#${id}): ${error || "(ohne Text)"}`);
return;
}
if (Array.isArray(tabs)) {
log(`tabresult #${id}: ${tabs.length} Tab(s)`);
for (const t of tabs) {
console.log(` [${t.id}] ${t.active ? "*" : " "} ${t.title}\n ${t.url}`);
}
return;
}
log(`tabresult #${id}: ok${Object.keys(rest).length ? " " + JSON.stringify(rest) : ""}`);
}
/* ------------------------------------------------------------- Konsole */
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "> " });
rl.on("line", (line) => {
const input = line.trim();
if (!input) return rl.prompt();
const [cmd, ...rest] = input.split(/\s+/);
switch (cmd) {
case "list":
sendJson({ type: "tab", id: nextId++, action: "list", currentWindowOnly: rest[0] === "current" });
break;
case "open":
if (!rest[0]) { log("Verwendung: open <url> [background]"); break; }
sendJson({ type: "tab", id: nextId++, action: "open", url: rest[0], active: rest[1] !== "background" });
break;
case "close": {
const tabId = Number(rest[0]);
if (!Number.isInteger(tabId)) { log("Verwendung: close <tabId>"); break; }
sendJson({ type: "tab", id: nextId++, action: "close", tabId });
break;
}
case "activate": {
const tabId = Number(rest[0]);
if (!Number.isInteger(tabId)) { log("Verwendung: activate <tabId>"); break; }
sendJson({ type: "tab", id: nextId++, action: "activate", tabId });
break;
}
case "focus": {
if (!rest[0]) { log("Verwendung: focus <url> [titel…]"); break; }
const request = { type: "tab", id: nextId++, action: "focus", url: rest[0] };
if (rest.length > 1) request.title = rest.slice(1).join(" ");
sendJson(request);
break;
}
case "raw":
try {
const obj = JSON.parse(input.slice(4));
if (obj.id === undefined) obj.id = nextId++;
sendJson(obj);
} catch (err) {
log("Ungültiges JSON: " + err.message);
}
break;
case "quit":
case "exit":
rl.close();
return;
default:
log(`Unbekanntes Kommando: ${cmd}`);
}
rl.prompt();
});
rl.on("close", () => {
try { client?.end(); } catch (_) {}
server.close();
process.exit(0);
});
/** Zeitspanne in Worten, fuer die Verbindungsdauer im Log. */
function duration(ms) {
const seconds = Math.round(ms / 1000);
if (seconds < 60) return `${seconds} s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} min ${seconds % 60} s`;
return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
}
function timestamp() {
const d = new Date();
const pad = (n, width = 2) => String(n).padStart(width, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
function log(msg) {
const line = `[${timestamp()}] ${msg}`;
console.log(line);
if (!LOG_FILE) return;
try {
fs.appendFileSync(LOG_FILE, line + "\n");
} catch (err) {
// Ein nicht schreibbares Log darf den Dienst nicht anhalten.
console.log(`(Log nicht schreibbar: ${err.message})`);
}
}