diff options
| -rw-r--r-- | layer/.gitignore | 34 | ||||
| -rw-r--r-- | layer/broadlayer.ts | 238 | ||||
| -rw-r--r-- | layer/bun.lock | 26 | ||||
| -rw-r--r-- | layer/package.json | 10 | ||||
| -rw-r--r-- | layer/tsconfig.json | 29 |
5 files changed, 337 insertions, 0 deletions
diff --git a/layer/.gitignore b/layer/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/layer/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/layer/broadlayer.ts b/layer/broadlayer.ts new file mode 100644 index 0000000..1e0d415 --- /dev/null +++ b/layer/broadlayer.ts @@ -0,0 +1,238 @@ +import { randomUUIDv7 } from "bun" + +if (process.argv.length < 4) { + console.log(`more args.\n${process.argv[0]} <ip> <port>`) +} + +const ip = process.argv[2] ?? '0.0.0.0' +const port = Number(process.argv[3]) + +if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.log("bad port") + process.exit(1) +} + +type tcpsocket = { + mode: 'tcp' + socket?: Bun.Socket +} +type udpsocket = { + mode: 'udp' + handler: (buf: Buffer) => void + die: () => void + heartbeat?: Timer +} +type socket = tcpsocket | udpsocket +type connection = { + ip: string + port: number + queue: Buffer[] + remoteId?: string + lastSeen: number +} & socket + +const udp = await Bun.udpSocket({ + socket: { + data(socket, data, port, ip) { + const id = Object.values(sessions).findIndex(s => + s.mode === 'udp' && + s.ip === ip && + s.port === port + ) + + if (id === -1) return + + // send to handler + if (sessions[id] && sessions[id].mode === 'udp') { + sessions[id].handler(data) + } + }, + error(socket, error) { + // dead. + for (const sess of Object.values(sessions).filter(e => e.mode === 'udp')) { + sess.die() + } + } + } +}) + +const connectionsPerIp = new Map<string, number>() +const sessions: Record<string, connection> = {} + +async function establishTCP(ip: string, port: number): Promise<string | false> { + const id = randomUUIDv7() + + const conn: connection = { + port, + ip, + mode: 'tcp', + socket: undefined, + queue: [], + lastSeen: Date.now() + } + + const sockPromise = Bun.connect({ + port, + hostname: ip, + reusePort: true, + tls: false, + socket: { + data(socket, data) { conn.queue.push(data) }, + close(socket, error) { delete sessions[id] }, + end(socket) { delete sessions[id] }, + error(socket, error) { delete sessions[id] } + } + }) + + const timeoutPromise: Promise<0> = new Promise((r) => setTimeout(() => r(0), 5000)) + + const sock: Bun.Socket | 0 = await Promise.race([sockPromise, timeoutPromise]).catch(err => { + console.log(err) + return 0 + }) + + if (sock === 0) return false + + conn.socket = sock + sessions[id] = conn + return id +} + +async function establishUDP(ip: string, port: number): Promise<string> { + const id = randomUUIDv7() + + const conn: connection = { + port, + ip, + mode: 'udp', + handler: undefined as any, + die: undefined as any, + heartbeat: undefined, + queue: [], + lastSeen: Date.now() + } + + conn.handler = (data) => { + if (!conn.heartbeat) { + const str = data.toString() + const bnI = str.indexOf('\n') + if (str.startsWith('T') && bnI !== -1) { + const tm = Number(str.substring(1, bnI)) + if (!isNaN(tm)) { + conn.heartbeat = setInterval(() => { + udp.send('N\n', conn.port, conn.ip) + }, tm * 1000) + } + const temp_len = bnI + 1 + if (str.length > temp_len) { + data = Buffer.from(str.substring(temp_len)) + } else { + return + } + } + } + conn.queue.push(data) + } + conn.die = () => { + clearInterval(conn.heartbeat) + conn.heartbeat = undefined + } + + sessions[id] = conn + udp.send("N\n", port, ip) // handshake + + return id +} + +const eviction_timeout = 60_000 + +setInterval(() => { + for (const key of Object.keys(sessions)) { + const sess = sessions[key]! + if (sess.lastSeen > Date.now() - eviction_timeout) continue + // evict + if (sess.mode === 'tcp') sess.socket?.close() + else sess.die() + delete sessions[key] + } +}, eviction_timeout) + +Bun.serve({ + port, + hostname: ip, + routes: { + '/': new Response('this is a broadlayer server.'), + '/tcp/connect/:ip/:port': async req => { + const port = parseInt(req.params.port) + if (isNaN(port)) return new Response('err_badport') + if (port > 65535 || port < 0) return new Response('err_badport') + + const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + let count = connectionsPerIp.get(clientIp) || 0 + if (count >= 4) { + return new Response('err_toomany', { status: 429 }) + } + connectionsPerIp.set(clientIp, count + 1) + + const res = await establishTCP(req.params.ip, port) + if (res === false) return new Response('err_timeout') + return new Response('ok_' + res) + }, + '/udp/connect/:ip/:port': async req => { + const port = parseInt(req.params.port) + if (isNaN(port)) return new Response('err_badport') + if (port > 65535 || port < 0) return new Response('err_badport') + + const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + let count = connectionsPerIp.get(clientIp) || 0 + if (count >= 4) { + return new Response('err_toomany', { status: 429 }) + } + connectionsPerIp.set(clientIp, count + 1) + + return new Response('ok_' + await establishUDP(req.params.ip, port)) + }, + '/poll/:id': async req => { // read and consume queue + const sess = sessions[req.params.id] + if (!sess) return new Response('err_badid') + const resp = JSON.stringify(sess.queue) + sess.queue = [] + sess.lastSeen = Date.now() + return new Response(resp) + }, + '/peek/:id': async req => { // read queue without consuming + const sess = sessions[req.params.id] + if (!sess) return new Response('err_badid') + const resp = JSON.stringify(sess.queue) + sess.lastSeen = Date.now() + return new Response(resp) + }, + '/close/:id': async req => { // closes a connection + const sess = sessions[req.params.id] + if (!sess) return new Response('err_badid') + if (sess.mode === 'tcp') sess.socket?.close() + + delete sessions[req.params.id] + + const clientIp = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' + let count = connectionsPerIp.get(clientIp) || 0 + if (count > 0) connectionsPerIp.set(clientIp, count - 1) + if (count <= 1) connectionsPerIp.delete(clientIp) + + return new Response('ok') + }, + '/send/:id': { + POST: async req => { + const sess = sessions[req.params.id] + if (!sess) return new Response('err_badid') + + if (!req.body) return new Response('err_empty') + const buf = await (await req.body.blob())!.arrayBuffer() + if (sess.mode === 'tcp') sess.socket?.write(buf) + else udp.send(buf, sess.port, sess.ip) + + return new Response('ok') + } + } + } +})
\ No newline at end of file diff --git a/layer/bun.lock b/layer/bun.lock new file mode 100644 index 0000000..69eee26 --- /dev/null +++ b/layer/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "layer", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/layer/package.json b/layer/package.json new file mode 100644 index 0000000..206fc3d --- /dev/null +++ b/layer/package.json @@ -0,0 +1,10 @@ +{ + "name": "layer", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/layer/tsconfig.json b/layer/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/layer/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} |
