No service is bound to ${host} yet.
Open the kebab delivery center (交付中心) and map a local port to this showcase.
`); + } + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const reqId = crypto.randomUUID(); + pendingHttp.set(reqId, res); + try { bind.ws.send(JSON.stringify({ type: 'http_req', reqId, method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('base64') })); } + catch { pendingHttp.delete(reqId); try { res.writeHead(502); res.end('broker gone'); } catch {} } + setTimeout(() => { if (pendingHttp.has(reqId)) { pendingHttp.delete(reqId); try { res.writeHead(504); res.end('upstream timeout'); } catch {} } }, 30000); + }); +}); + +const wss = new WebSocketServer({ noServer: true }); +server.on('upgrade', (req, socket, head) => { + const url = new URL(req.url, 'http://x'); const host = hostOf(req); + if (url.pathname === '/showcase/ws' && url.searchParams.get('role') === 'edge') { + wss.handleUpgrade(req, socket, head, (ws) => onBroker(ws, url)); + } else { + const bind = binds.get(host); + if (!bind || bind.ws.readyState !== WebSocket.OPEN) { socket.destroy(); return; } + wss.handleUpgrade(req, socket, head, (ws) => onBrowserWs(ws, req, bind)); + } +}); + +function onBroker(ws, url) { + const tok = verifyEdgeToken(url.searchParams.get('token')); + if (!tok) { try { ws.close(4001, 'bad token'); } catch {} return; } + ws.on('message', (data) => { + let m; try { m = JSON.parse(data); } catch { return; } + if (m.type === 'bind') { if (m.host === tok.host) { binds.set(m.host, { ws, port: m.port }); ws.send(JSON.stringify({ type: 'bound', host: m.host })); } else ws.send(JSON.stringify({ type: 'error', error: 'token not valid for ' + m.host })); } + else if (m.type === 'http_res') { const res = pendingHttp.get(m.reqId); if (res) { pendingHttp.delete(m.reqId); try { const h = m.headers || {}; delete h['content-length']; delete h['transfer-encoding']; res.writeHead(m.status || 200, h); res.end(Buffer.from(m.body || '', 'base64')); } catch {} } } + else if (m.type === 'ws_msg') { const c = browserWs.get(m.connId); if (c && c.readyState === WebSocket.OPEN) c.send(m.binary ? Buffer.from(m.data, 'base64') : m.data); } + else if (m.type === 'ws_close') { const c = browserWs.get(m.connId); if (c) try { c.close(); } catch {} browserWs.delete(m.connId); } + }); + ws.on('close', () => { for (const [h, b] of binds) if (b.ws === ws) binds.delete(h); }); + ws.on('error', () => {}); +} + +function onBrowserWs(ws, req, bind) { + const connId = crypto.randomUUID(); browserWs.set(connId, ws); + try { bind.ws.send(JSON.stringify({ type: 'ws_open', connId, url: req.url, headers: req.headers })); } catch {} + ws.on('message', (data, isBinary) => { try { bind.ws.send(JSON.stringify({ type: 'ws_msg', connId, data: isBinary ? data.toString('base64') : data.toString(), binary: !!isBinary })); } catch {} }); + ws.on('close', () => { try { bind.ws.send(JSON.stringify({ type: 'ws_close', connId })); } catch {} browserWs.delete(connId); }); + ws.on('error', () => {}); +} + +server.listen(PORT, '127.0.0.1', () => console.log(`showcase-edge on 127.0.0.1:${PORT}`)); diff --git a/server/showcase-edge/package-lock.json b/server/showcase-edge/package-lock.json new file mode 100644 index 0000000..b8f54d6 --- /dev/null +++ b/server/showcase-edge/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "amerc-showcase-edge", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "amerc-showcase-edge", + "dependencies": { + "ws": "^8.21.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/showcase-edge/package.json b/server/showcase-edge/package.json new file mode 100644 index 0000000..daefe19 --- /dev/null +++ b/server/showcase-edge/package.json @@ -0,0 +1 @@ +{"name":"amerc-showcase-edge","private":true,"type":"module","dependencies":{"ws":"^8.21.0"}} \ No newline at end of file diff --git a/src/Mansion.jsx b/src/Mansion.jsx new file mode 100644 index 0000000..2e66a85 --- /dev/null +++ b/src/Mansion.jsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { api } from './api.js'; + +// amerc Mansion — a hub of amerc services. First service: Showcase (reverse proxy). +export default function Mansion({ auth, setRoute }) { + if (!auth.ready) returnLoading…
Log in to use amerc services.
amerc hosts your services behind its edge. Each one lives in a room of the mansion.
+Publish a web service from a local port to a public subdomain. Register a name, then in kebab's delivery center (交付中心) map your local port — requests to the public URL tunnel through your broker.
+ + {err &&{err}
} + {created && ( +✅ {created.host} reserved · DNS {created.dns}
+ + + +In the kebab delivery center: connect a broker to the WS with this token, send {`{type:"bind",host:"${created.host}",port:, and your local service is live at the public URL.
No showcases yet — register one above.
} +One amerc account across tavern, docs, PM and git.