Initial commit

This commit is contained in:
2026-02-18 23:47:03 +01:00
commit 94757e8cc3
8 changed files with 421 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
package-lock.json

20
cli-post.mjs Normal file
View File

@@ -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 <resource_id> <filename>');
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);
}

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"busboy": "^1.6.0",
"express": "^5.2.1",
"serve-index": "^1.9.1",
"ws": "^8.19.0"
}
}

137
server.mjs Normal file
View File

@@ -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<WebSocket>
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);

92
static/html-view.html Normal file
View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<title>HTML Viewer</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
background-color: #222;
}
body {
display: flex;
align-items: stretch;
justify-content: stretch;
}
#viewer-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
</style>
<script type="module">
import { Subscription_Endpoint } from './subscription.mjs';
function get_source_name() {
const params = new URLSearchParams(location.search);
return params.get('source') || 'default-html';
}
document.addEventListener('DOMContentLoaded', async () => {
const viewer_frame = document.getElementById('viewer-frame');
const ep = new Subscription_Endpoint();
await ep.connect('/subscription');
const source = get_source_name();
ep.subscribe(source);
let current_url = null;
let pending_scroll = null;
ep.addEventListener('message', (ev) => {
// Capture current scroll position (best-effort)
try {
const win = viewer_frame.contentWindow;
if (win) {
pending_scroll = {
x: win.scrollX,
y: win.scrollY
};
}
} catch {
pending_scroll = null;
}
if (current_url) {
URL.revokeObjectURL(current_url);
}
current_url = URL.createObjectURL(ev.detail);
viewer_frame.src = current_url;
});
// Restore scroll after the new document loads
viewer_frame.addEventListener('load', () => {
if (!pending_scroll) return;
try {
viewer_frame.contentWindow.scrollTo(
pending_scroll.x,
pending_scroll.y
);
} catch {
/* ignore */
}
pending_scroll = null;
});
});
</script>
</head>
<body>
<iframe id="viewer-frame"></iframe>
</body>
</html>

92
static/image-view.html Normal file
View File

@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<title>Test of remote view</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
background-color: #222;
}
body {
display: flex;
align-items: center;
justify-content: center;
/* Two 50/50 linear gradients one horizontal, one vertical */
background-image:
linear-gradient(0deg, #f1f1f1 0% 50%, #d1d1d1 50% 100%),
linear-gradient(90deg, #f1f1f1 0% 50%, #d1d1d1 50% 100%);
/* Each square is 8px; the pattern repeats */
background-size: 16px 16px;
background-repeat: repeat;
/* Blend the two gradients multiply gives a proper “XOR” look */
background-blend-mode: multiply;
}
body.full {
display: block;
width: none;
height: none;
}
#viewer-image {
display: block;
border: 1px solid #864;
max-width: 100vw;
max-height: 100vh;
}
body.full #viewer-image {
border: none;
max-width: none;
max-height: none;
}
</style>
<script type="module">
import { Subscription_Endpoint } from './subscription.mjs';
document.addEventListener('DOMContentLoaded', async () => {
const viewer_image = document.getElementById('viewer-image')
viewer_image.addEventListener('click', () => {
document.body.classList.toggle('full');
})
const ep = new Subscription_Endpoint();
await ep.connect('/subscription');
ep.subscribe('demo');
ep.addEventListener('message', (ev) => {
viewer_image.src = URL.createObjectURL(ev.detail);
console.log("Got image", ev.detail);
});
});
</script>
</head>
<body>
<img id="viewer-image">
</body>
</html>

29
static/post-event.html Normal file
View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Test of posting event</title>
</head>
<body>
<form action="/emit-multipart" method="post" enctype="multipart/form-data">
<div>
<label>
Resource ID:
<input type="text" name="resource" required>
</label>
</div>
<div>
<label>
File:
<input type="file" name="file" required>
</label>
</div>
<div>
<button type="submit">Post</button>
</div>
</form>
</body>
</html>

41
static/subscription.mjs Normal file
View File

@@ -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
}));
}
}