file support

main
Antonio De Lucreziis 2 years ago
parent 6e6c05519d
commit 931dedb79b

2
.gitignore vendored

@ -22,3 +22,5 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
*.local*

3
package-lock.json generated

@ -23,6 +23,7 @@
"express": "^4.21.0",
"node-pty": "^1.0.0",
"preact": "^10.24.0",
"signal-exit": "^4.1.0",
"ws": "^8.18.0",
"xterm-theme": "^1.1.0"
},
@ -5855,6 +5856,8 @@
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"

@ -24,6 +24,7 @@
"express": "^4.21.0",
"node-pty": "^1.0.0",
"preact": "^10.24.0",
"signal-exit": "^4.1.0",
"ws": "^8.18.0",
"xterm-theme": "^1.1.0"
},

@ -0,0 +1,89 @@
import * as pty from 'node-pty'
import { getContainerPath, runCommand } from './utils.js'
import fs from 'fs/promises'
import { randomUUID } from 'crypto'
const stat = path =>
new Promise(resolve =>
fs
.access(path)
.then(() => resolve(true))
.catch(() => resolve(false))
)
const containerIds = new Set()
await cleanContainers()
async function storeContainers() {
await fs.writeFile('containers-db.local.json', JSON.stringify([...containerIds]))
}
async function cleanContainers() {
if (!(await stat('containers-db.local.json'))) {
storeContainers()
}
const raw = JSON.parse(await fs.readFile('containers-db.local.json', 'utf8'))
await Promise.all(raw.map(id => destroyContainer(id)))
}
async function createContainer(tag) {
const id = randomUUID()
await fs.mkdir(getContainerPath('phc', id), { recursive: true })
await runCommand('docker', [
'run',
'-d',
'--name',
id,
'--volume',
`${getContainerPath('phc', id)}:/project`,
tag,
'sleep',
'infinity',
])
containerIds.add(id)
await storeContainers()
return id
}
export async function createContainerPty(
tag,
options = {
onExit,
onData,
shellCommand: ['/bin/sh'],
}
) {
const shellCommand = options.shellCommand ?? ['/bin/sh']
const { onData, onExit } = options
const id = await createContainer(tag)
const container = pty.spawn('docker', ['exec', '-it', id, ...shellCommand])
container.onExit(async e => {
onExit?.(e)
await destroyContainer(id)
})
container.onData(data => {
onData?.(data)
})
return { id, pty: container }
}
async function destroyContainer(id) {
await runCommand('docker', ['rm', '-f', id])
containerIds.delete(id)
await storeContainers()
}

@ -1,18 +1,56 @@
import express from 'express'
import { WebSocketServer } from 'ws'
import * as pty from 'node-pty'
import { createContainerPty } from './docker.js'
import { runCommand } from './utils.js'
const app = express()
app.use(express.json())
app.get('/api/foo', c => c.json({ foo: 42 }))
app.get('/api/status', c => c.json(42))
app.get('/api/docker/:tag', (req, res) => {
wss.handleUpgrade(req, res.socket, req.headers, ws => {
wss.emit('connection', ws, req)
})
app.get('/api/container/:uuid([a-zA-Z0-9]+)/exec/ls', async (req, res) => {
const uuid = req.params.uuid
try {
const files = await runCommand('/bin/ls', ['exec', uuid, '/bin/ls'])
res.json(files.trim().split('\n'))
} catch (e) {
res.json({ error: e.toString() })
}
})
app.get('/api/container/:uuid([a-zA-Z0-9]+)/fs/*', async (req, res) => {
const uuid = req.params.uuid
const path = req.params[0]
console.log('Getting', path)
try {
const content = await runCommand('docker', ['exec', uuid, '/bin/cat', '/' + path])
res.json(content)
} catch (e) {
res.json({ error: e.toString() })
}
})
app.put('/api/container/:uuid([a-zA-Z0-9]+)/fs/*', async (req, res) => {
const path = req.params[0]
const content = req.body
console.log(content)
try {
await runCommand('docker', ['exec', uuid, '/bin/sh', '-c', `cat - > "${path}"`], {
stdin: content,
})
res.json('ok')
} catch (e) {
res.json({ error: e.toString() })
}
})
const server = app.listen(5432, () => {
@ -21,30 +59,44 @@ const server = app.listen(5432, () => {
const wss = new WebSocketServer({ server })
wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`)
const tag = url.pathname.split('/')[3]
wss.on('connection', async (ws, req) => {
const tag = req.url.split('/').at(-2)
console.log(`Client connected, initializing "${tag}"...`)
console.log(`Client connected to "${tag}"`)
const docker = pty.spawn('docker', ['run', '--init', '-it', '--rm', tag, '/bin/sh'])
docker.onExit(e => {
ws.send(JSON.stringify(e))
ws.close()
console.log('Client requested stop')
const container = await createContainerPty(tag, {
onData(data) {
ws.send(
JSON.stringify({
type: 'pty',
data,
})
)
},
onExit(e) {
ws.send(
JSON.stringify({
type: 'destroyed',
...e,
})
)
ws.close()
},
})
docker.onData(data => {
ws.send(data)
})
ws.send(
JSON.stringify({
type: 'created',
id: container.id,
})
)
ws.on('message', message => {
docker.write(message)
ws.on('message', data => {
container.pty.write(data)
})
ws.on('close', () => {
docker.kill()
console.log('Client disconnected')
container.pty.kill()
console.log(`Client disconnected`)
})
})

@ -0,0 +1,40 @@
import { spawn } from 'child_process'
export const CONTAINERS_ROOT = '/tmp/phc-run/containers'
export const getContainerPath = (ns, uuid) => `${CONTAINERS_ROOT}/${ns}/${uuid}`
export function runCommand(command, args, options = { stdin: null, capture: ['stdout', 'stderr'] }) {
console.log(`Running: ${command} ${JSON.stringify(args)}`)
return new Promise((resolve, reject) => {
const child = spawn(command, args)
let output = ''
if (options.capture.includes('stdout')) {
child.stdout.on('data', data => {
output += data.toString()
})
}
if (options.capture.includes('stderr')) {
child.stderr.on('data', data => {
output += data.toString()
})
}
if (options.stdin) {
child.stdin.write(options.stdin)
}
child.on('close', code => {
if (code === 0) {
resolve(output)
} else {
reject(new Error(`Command failed with code ${code}: ${output}`))
console.error('Command error:', output)
}
})
})
}

@ -16,20 +16,37 @@ term.open(document.getElementById('terminal'))
fitAddon.fit()
const socket = new WebSocket(`ws://${location.host}/api/docker/alpine:latest`)
const ws = new WebSocket(`ws://${location.host}/api/docker/alpine:latest/pty`)
term.onData(data => {
socket.send(data)
ws.send(data)
})
socket.addEventListener('message', event => {
term.write(event.data)
let id
ws.addEventListener('message', e => {
const event = JSON.parse(e.data)
if (event.type === 'pty') {
term.write(event.data)
} else if (event.type === 'created') {
id = event.id
} else {
console.log(event)
}
})
socket.addEventListener('open', () => {
ws.addEventListener('open', () => {
term.write('Connected to backend container\r\n')
})
socket.addEventListener('close', () => {
ws.addEventListener('close', () => {
term.write('\r\nDisconnected from backend container')
})
setInterval(async () => {
const req = await fetch(`/api/container/${id}/ls`)
const files = await req.json()
console.log(files)
}, 1000)