file support
This commit is contained in:
@@ -22,3 +22,5 @@ pnpm-debug.log*
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
*.local*
|
||||
|
||||
Generated
+3
@@ -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()
|
||||
}
|
||||
+76
-24
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
+23
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user