From 94757e8cc35f6e64db01dadd54e7f30624752cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20L=C3=B6vqvist?= Date: Wed, 18 Feb 2026 23:47:03 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + cli-post.mjs | 20 ++++++ package.json | 8 +++ server.mjs | 137 ++++++++++++++++++++++++++++++++++++++++ static/html-view.html | 92 +++++++++++++++++++++++++++ static/image-view.html | 92 +++++++++++++++++++++++++++ static/post-event.html | 29 +++++++++ static/subscription.mjs | 41 ++++++++++++ 8 files changed, 421 insertions(+) create mode 100644 .gitignore create mode 100644 cli-post.mjs create mode 100644 package.json create mode 100644 server.mjs create mode 100644 static/html-view.html create mode 100644 static/image-view.html create mode 100644 static/post-event.html create mode 100644 static/subscription.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccb2c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json \ No newline at end of file diff --git a/cli-post.mjs b/cli-post.mjs new file mode 100644 index 0000000..4dfb133 --- /dev/null +++ b/cli-post.mjs @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; + +const [, , resource, filename] = process.argv; + +if (!resource || !filename) { + console.error('usage: node clip-post.mjs '); + process.exit(1); +} + +const data = await readFile(filename); + +const res = await fetch(`http://localhost:3888/emit/${encodeURIComponent(resource)}`, { + method: 'POST', + body: data +}); + +if (res.status !== 204) { + console.error('POST failed:', res.status); + process.exit(1); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ed713d0 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "busboy": "^1.6.0", + "express": "^5.2.1", + "serve-index": "^1.9.1", + "ws": "^8.19.0" + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..c97c63f --- /dev/null +++ b/server.mjs @@ -0,0 +1,137 @@ +import express from 'express'; +import { WebSocketServer } from 'ws'; +import http from 'node:http'; +import serveIndex from 'serve-index'; +import Busboy from 'busboy'; + + +const app = express(); +const server = http.createServer(app); +const wss = new WebSocketServer({ noServer: true }); + + +// resource -> Set +const subscriptions = new Map(); + +function subscribe(ws, resource) { + if (!subscriptions.has(resource)) { + subscriptions.set(resource, new Set()); + } + subscriptions.get(resource).add(ws); + ws._subscriptions ??= new Set(); + ws._subscriptions.add(resource); +} + +function unsubscribeAll(ws) { + if (!ws._subscriptions) return; + for (const resource of ws._subscriptions) { + subscriptions.get(resource)?.delete(ws); + } +} + +// WebSocket upgrade +server.on('upgrade', (req, socket, head) => { + if (req.url !== '/subscription') { + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, ws => { + wss.emit('connection', ws, req); + }); +}); + +wss.on('connection', ws => { + ws.on('message', msg => { + let data; + try { + data = JSON.parse(msg.toString()); + } catch { + return; + } + + if (data.cmd === 'subscribe' && typeof data.resource === 'string') { + subscribe(ws, data.resource); + } + }); + + ws.on('close', () => { + unsubscribeAll(ws); + }); +}); + +// Emit endpoint + +app.post('/emit/:resource', (req, res) => { + const { resource } = req.params; + const chunks = []; + + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + const payload = Buffer.concat(chunks); + res.status(204).end(); + + const subs = subscriptions.get(resource); + if (subs) { + for (const ws of subs) { + try { + ws.send(payload); + console.log('[emit] sent to subscriber'); + } catch (err) { + console.log('[emit] send failed, removing subscriber:', err?.message); + subs.delete(ws); + } + } + } + + }); +}); + + +app.post('/emit-multipart', (req, res) => { + const bb = Busboy({ headers: req.headers }); + + let resource = null; + const chunks = []; + + bb.on('field', (name, value) => { + if (name === 'resource') { + resource = value; + } + }); + + bb.on('file', (name, file) => { + file.on('data', chunk => chunks.push(chunk)); + }); + + bb.on('close', () => { + if (!resource) { + res.status(400).end('missing resource'); + return; + } + + const payload = Buffer.concat(chunks); + res.status(204).end(); + + const subs = subscriptions.get(resource); + if (subs) { + for (const ws of subs) { + try { + ws.send(payload); + console.log('[emit-multipart] sent to subscriber'); + } catch (err) { + console.log('[emit-multipart] send failed, removing subscriber:', err?.message); + subs.delete(ws); + } + } + } + }); + + req.pipe(bb); +}); + + +app.use('/', express.static('static'), serveIndex('static', { icons: true })); + + +server.listen(3888); diff --git a/static/html-view.html b/static/html-view.html new file mode 100644 index 0000000..8b5da3d --- /dev/null +++ b/static/html-view.html @@ -0,0 +1,92 @@ + + + + HTML Viewer + + + + + + + + + diff --git a/static/image-view.html b/static/image-view.html new file mode 100644 index 0000000..2750392 --- /dev/null +++ b/static/image-view.html @@ -0,0 +1,92 @@ + + + + Test of remote view + + + + + + + + + + + + \ No newline at end of file diff --git a/static/post-event.html b/static/post-event.html new file mode 100644 index 0000000..87559c1 --- /dev/null +++ b/static/post-event.html @@ -0,0 +1,29 @@ + + + + Test of posting event + + + +
+
+ +
+ +
+ +
+ +
+ +
+
+ + + diff --git a/static/subscription.mjs b/static/subscription.mjs new file mode 100644 index 0000000..e998485 --- /dev/null +++ b/static/subscription.mjs @@ -0,0 +1,41 @@ + +export class Subscription_Endpoint extends EventTarget { + + async connect(path) { + const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${location.host}${path}`; + + const ws = new WebSocket(wsUrl); + + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', reject, { once: true }); + }); + + ws.addEventListener('message', ev => { + this.dispatchEvent(new CustomEvent('message', { detail: ev.data })); + }); + + ws.addEventListener('close', () => { + this.dispatchEvent(new Event('close')); + }); + + ws.addEventListener('error', err => { + this.dispatchEvent(new CustomEvent('error', { detail: err })); + }); + + this.ws = ws; + return ws; + } + + subscribe(resource) { + const { ws } = this; + ws.send(JSON.stringify({ + cmd: 'subscribe', + resource + })); + } + + + +}