|
| 1 | +import express from 'express' |
| 2 | +import { WebSocketServer } from 'ws' |
| 3 | +import Docker from 'dockerode' |
| 4 | +import * as http from 'http' |
| 5 | +import * as net from 'net' |
| 6 | +import * as fs from 'fs' |
| 7 | +import * as stream from 'stream' |
| 8 | + |
| 9 | +const docker = new Docker() |
| 10 | +const image = 'ghcr.io/graalvm/graalvm-ce:21.2.0' |
| 11 | + |
| 12 | +async function createContainer () { |
| 13 | + const stream = await docker.pull(image) |
| 14 | + await new Promise<void>((resolve, reject) => { |
| 15 | + docker.modem.followProgress(stream, err => err == null ? resolve() : reject(err)) |
| 16 | + }) |
| 17 | + const container = await docker.createContainer({ |
| 18 | + name: 'graalvm-debugger', |
| 19 | + Image: image, |
| 20 | + Entrypoint: ['sleep', 'infinity'], |
| 21 | + HostConfig: { |
| 22 | + NetworkMode: 'host', |
| 23 | + Mounts: [{ |
| 24 | + Type: 'bind', |
| 25 | + Target: '/tmp', |
| 26 | + Source: '/tmp' |
| 27 | + }], |
| 28 | + AutoRemove: true |
| 29 | + } |
| 30 | + }) |
| 31 | + return container |
| 32 | +} |
| 33 | + |
| 34 | +async function prepareContainer (container: Docker.Container) { |
| 35 | + await container.start() |
| 36 | + // eslint-disable-next-line no-console |
| 37 | + console.log('Installing node') |
| 38 | + const exec = await container.exec({ |
| 39 | + Cmd: ['gu', 'install', 'nodejs'], |
| 40 | + AttachStdout: true, |
| 41 | + AttachStderr: true |
| 42 | + }) |
| 43 | + const execStream = await exec.start({ |
| 44 | + hijack: true |
| 45 | + }) |
| 46 | + execStream.pipe(process.stdout) |
| 47 | + await new Promise(resolve => execStream.on('end', resolve)) |
| 48 | + // eslint-disable-next-line no-console |
| 49 | + console.log('Node installed') |
| 50 | +} |
| 51 | + |
| 52 | +// eslint-disable-next-line no-console |
| 53 | +console.log('Pulling image/starting container...') |
| 54 | +const containerPromise = createContainer() |
| 55 | + |
| 56 | +async function exitHandler () { |
| 57 | + // eslint-disable-next-line no-console |
| 58 | + console.log('Exiting...') |
| 59 | + try { |
| 60 | + const container = await containerPromise |
| 61 | + await container.remove({ |
| 62 | + force: true |
| 63 | + }) |
| 64 | + } catch (err) { |
| 65 | + console.error(err) |
| 66 | + } finally { |
| 67 | + process.exit() |
| 68 | + } |
| 69 | +} |
| 70 | +process.on('exit', exitHandler.bind(null, { cleanup: true })) |
| 71 | +process.on('SIGINT', exitHandler.bind(null, { exit: true })) |
| 72 | +process.on('SIGUSR1', exitHandler.bind(null, { exit: true })) |
| 73 | +process.on('SIGUSR2', exitHandler.bind(null, { exit: true })) |
| 74 | +process.on('uncaughtException', exitHandler.bind(null, { exit: true })) |
| 75 | + |
| 76 | +const container = await containerPromise |
| 77 | +await prepareContainer(container) |
| 78 | + |
| 79 | +class DAPSocket { |
| 80 | + private socket: net.Socket |
| 81 | + private rawData = Buffer.allocUnsafe(0) |
| 82 | + private contentLength = -1 |
| 83 | + constructor (private onMessage: (message: string) => void) { |
| 84 | + this.socket = new net.Socket() |
| 85 | + this.socket.on('data', this.onData) |
| 86 | + } |
| 87 | + |
| 88 | + private onData = (data: Buffer) => { |
| 89 | + this.rawData = Buffer.concat([this.rawData, data]) |
| 90 | + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition |
| 91 | + while (true) { |
| 92 | + if (this.contentLength >= 0) { |
| 93 | + if (this.rawData.length >= this.contentLength) { |
| 94 | + const message = this.rawData.toString('utf8', 0, this.contentLength) |
| 95 | + this.rawData = this.rawData.subarray(this.contentLength) |
| 96 | + this.contentLength = -1 |
| 97 | + if (message.length > 0) { |
| 98 | + this.onMessage(message) |
| 99 | + } |
| 100 | + continue |
| 101 | + } |
| 102 | + } else { |
| 103 | + const idx = this.rawData.indexOf(TWO_CRLF) |
| 104 | + if (idx !== -1) { |
| 105 | + const header = this.rawData.toString('utf8', 0, idx) |
| 106 | + const lines = header.split(HEADER_LINESEPARATOR) |
| 107 | + for (const h of lines) { |
| 108 | + const kvPair = h.split(HEADER_FIELDSEPARATOR) |
| 109 | + if (kvPair[0] === 'Content-Length') { |
| 110 | + this.contentLength = Number(kvPair[1]) |
| 111 | + } |
| 112 | + } |
| 113 | + this.rawData = this.rawData.subarray(idx + TWO_CRLF.length) |
| 114 | + continue |
| 115 | + } |
| 116 | + } |
| 117 | + break |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + public connect (port: number) { |
| 122 | + this.socket.connect(port) |
| 123 | + } |
| 124 | + |
| 125 | + public sendMessage (message: string) { |
| 126 | + this.socket.write(`Content-Length: ${Buffer.byteLength(message, 'utf8')}${TWO_CRLF}${message}`, 'utf8') |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +const TWO_CRLF = '\r\n\r\n' |
| 131 | +const HEADER_LINESEPARATOR = /\r?\n/ |
| 132 | +const HEADER_FIELDSEPARATOR = /: */ |
| 133 | + |
| 134 | +const PORT = 5555 |
| 135 | +const app = express() |
| 136 | + |
| 137 | +const server = http.createServer(app) |
| 138 | + |
| 139 | +const wss = new WebSocketServer({ server }) |
| 140 | + |
| 141 | +async function findPortFree () { |
| 142 | + return new Promise<number>(resolve => { |
| 143 | + const srv = net.createServer() |
| 144 | + srv.listen(0, () => { |
| 145 | + const port = (srv.address() as net.AddressInfo).port |
| 146 | + srv.close(() => resolve(port)) |
| 147 | + }) |
| 148 | + }) |
| 149 | +} |
| 150 | + |
| 151 | +function sequential<T, P extends unknown[]> (fn: (...params: P) => Promise<T>): (...params: P) => Promise<T> { |
| 152 | + let promise = Promise.resolve() |
| 153 | + return (...params: P) => { |
| 154 | + const result = promise.then(() => { |
| 155 | + return fn(...params) |
| 156 | + }) |
| 157 | + |
| 158 | + promise = result.then(() => {}, () => {}) |
| 159 | + return result |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +wss.on('connection', (ws) => { |
| 164 | + const socket = new DAPSocket(message => ws.send(message)) |
| 165 | + |
| 166 | + let initialized = false |
| 167 | + |
| 168 | + ws.on('message', sequential(async (message: string) => { |
| 169 | + if (!initialized) { |
| 170 | + try { |
| 171 | + initialized = true |
| 172 | + const init: { main: string, files: Record<string, string> } = JSON.parse(message) |
| 173 | + for (const [file, content] of Object.entries(init.files)) { |
| 174 | + fs.writeFileSync(file, content) |
| 175 | + } |
| 176 | + const debuggerPort = await findPortFree() |
| 177 | + const exec = await container.exec({ |
| 178 | + Cmd: ['node', `--dap=${debuggerPort}`, '--dap.WaitAttached', '--dap.Suspend=false', `${init.main}`], |
| 179 | + AttachStdout: true, |
| 180 | + AttachStderr: true |
| 181 | + }) |
| 182 | + |
| 183 | + const execStream = await exec.start({ |
| 184 | + hijack: true |
| 185 | + }) |
| 186 | + const stdout = new stream.PassThrough() |
| 187 | + const stderr = new stream.PassThrough() |
| 188 | + container.modem.demuxStream(execStream, stdout, stderr) |
| 189 | + function sendOutput (category: 'stdout' | 'stderr', output: Buffer) { |
| 190 | + ws.send(JSON.stringify({ |
| 191 | + type: 'event', |
| 192 | + event: 'output', |
| 193 | + body: { |
| 194 | + category, |
| 195 | + output: output.toString() |
| 196 | + } |
| 197 | + })) |
| 198 | + } |
| 199 | + stdout.on('data', sendOutput.bind(undefined, 'stdout')) |
| 200 | + stderr.on('data', sendOutput.bind(undefined, 'stderr')) |
| 201 | + |
| 202 | + execStream.on('end', () => { |
| 203 | + ws.close() |
| 204 | + }) |
| 205 | + |
| 206 | + await new Promise(resolve => setTimeout(resolve, 1000)) |
| 207 | + socket.connect(debuggerPort) |
| 208 | + |
| 209 | + return |
| 210 | + } catch (err) { |
| 211 | + console.error('Failed to initialize', err) |
| 212 | + } |
| 213 | + } |
| 214 | + socket.sendMessage(message) |
| 215 | + })) |
| 216 | +}) |
| 217 | + |
| 218 | +server.listen(PORT, () => { |
| 219 | + // eslint-disable-next-line no-console |
| 220 | + console.log(`Server started on port ${PORT} :)`) |
| 221 | +}) |
0 commit comments