Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
20
cli-post.mjs
Normal file
20
cli-post.mjs
Normal 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
8
package.json
Normal 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
137
server.mjs
Normal 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
92
static/html-view.html
Normal 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
92
static/image-view.html
Normal 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 8 px; 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
29
static/post-event.html
Normal 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
41
static/subscription.mjs
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user