Merge pull request #11 from lancatlin/server-socket-graph

New features:
1. collect 2 levels file list when user type 'ls' in container
2. send the file graph event via socket

Breaking changes:
1. change the channel of terminal connection from 'message' to 'terminal'
3. move client.js to standalone package packages/client
4. change frontend api to call the same origin instead, setup vite proxy for developement. (Now it's localhost on default cuz the server updates includes in this PR as well)
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-08-21 22:14:06 +08:00
commit fcaa088a31
23 files changed
+266 -107

No files matched your search

+4 -1
View File
@@ -48,8 +48,11 @@ docker compose -f docker-compose.build.yml push
Source: `app/` Source: `app/`
``` ```
# Connect to dev server (https://odyssey.wancat.cc)
yarn app yarn app
# Open http://localhost:5173/
# Connect to localhost (http://localhost:3000)
yarn app:local
``` ```
## Swagger ## Swagger
+2 -1
View File
@@ -4,7 +4,8 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "API_TARGET=https://odyssey.wancat.cc vite",
"dev:local": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
+1
View File
@@ -19,6 +19,7 @@ onMounted(async () => {
socket.on('disconnect', function close() { socket.on('disconnect', function close() {
console.log('Disconnected from the server.') console.log('Disconnected from the server.')
}) })
socket.on('message', console.log)
}) })
</script> </script>
+2 -2
View File
@@ -55,11 +55,11 @@ onMounted(() => {
term.open(terminal.value) term.open(terminal.value)
term.focus() term.focus()
resizeScreen() resizeScreen()
socket.on('message', (message) => { socket.on('terminal', (message) => {
term.write(message) term.write(message)
}) })
term.onData((key) => { term.onData((key) => {
socket.send(key) socket.emit('terminal', key)
}) })
}) })
</script> </script>
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios' import axios from 'axios'
const api = axios.create({ const api = axios.create({
baseURL: 'https://odyssey.wancat.cc/api/v1', baseURL: '/api',
}) })
export default api export default api
+1 -3
View File
@@ -1,7 +1,5 @@
import io from 'socket.io-client' import io from 'socket.io-client'
const host = 'wss://odyssey.wancat.cc'
class SocketWrapper { class SocketWrapper {
constructor() { constructor() {
this.socket = null this.socket = null
@@ -14,7 +12,7 @@ class SocketWrapper {
this.socket.disconnect() this.socket.disconnect()
} }
this.socket = io(host, { this.socket = io('', {
query: { query: {
session_id: session._id, session_id: session._id,
}, },
+7 -2
View File
@@ -7,8 +7,13 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
// target: 'http://localhost:3000', // the server url target: `${process.env.API_TARGET || 'http://localhost:3000'}/api/v1`,
target: 'http://odyssey.wancat.cc ', changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/socket.io': {
target: process.env.API_TARGET || 'ws://localhost:3000',
changeOrigin: true,
ws: true, ws: true,
}, },
}, },
+2 -1
View File
@@ -12,8 +12,9 @@
], ],
"scripts": { "scripts": {
"app": "yarn workspace app dev", "app": "yarn workspace app dev",
"app:local": "yarn workspace app dev:local",
"server": "yarn workspace server dev", "server": "yarn workspace server dev",
"cli": "yarn workspace server cli", "cli": "yarn workspace client start",
"lint": "eslint --ext .js,.vue .", "lint": "eslint --ext .js,.vue .",
"format": "prettier --ignore-path .prettierignore --write app server" "format": "prettier --ignore-path .prettierignore --write app server"
}, },
-7
View File
@@ -1,7 +0,0 @@
{
"name": "cli",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {}
}
@@ -2,8 +2,14 @@
import { stdin, stdout, exit } from 'process' import { stdin, stdout, exit } from 'process'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import { program } from 'commander' import { program } from 'commander'
import { get } from './utils/env.js'
function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
return value
}
return defaultValue
}
stdin.setRawMode(true) stdin.setRawMode(true)
stdin.resume() stdin.resume()
stdin.setEncoding('utf8') stdin.setEncoding('utf8')
@@ -60,10 +66,17 @@ async function connect(sessionId) {
console.log('Connected to the server.') console.log('Connected to the server.')
}) })
socket.on('message', function incoming(data) { socket.on('message', console.log)
socket.on('terminal', function incoming(data) {
stdout.write(data) stdout.write(data)
}) })
socket.on('graph', (data) => {
console.log('receive graph:')
console.log(data)
})
socket.on('close', function close() { socket.on('close', function close() {
console.log('Disconnected from the server.') console.log('Disconnected from the server.')
exit() exit()
@@ -74,7 +87,7 @@ async function connect(sessionId) {
socket.close() socket.close()
exit() exit()
} else { } else {
socket.send(key) socket.emit('terminal', key)
} }
}) })
} }
+14
View File
@@ -0,0 +1,14 @@
{
"name": "client",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"commander": "^11.0.0",
"socket.io-client": "^4.7.2"
}
}
+81
View File
@@ -0,0 +1,81 @@
/* eslint-disable no-restricted-syntax */
const fs = require('fs').promises
const { exit } = require('process')
const minimist = require('minimist')
const { colorize, printResponses } = require('./print.js')
const discoverFiles = require('./discover.js')
async function readOrNone(file) {
try {
return await fs.readFile(file, 'utf8')
} catch (err) {
return ''
}
}
const commandListeners = {
ls: [discoverFiles],
}
async function handleCommand(command) {
const argv = minimist(command.split(' '))
const name = argv._[0]
const listeners = commandListeners[name]
if (!listeners) return {}
return listeners.reduce(async (result, listener) => {
const res = await listener(argv)
return { ...res, ...result }
}, {})
}
async function main() {
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
if (!CMD_NAME) exit(0)
const additionalData = await handleCommand(CMD_NAME)
const output = await readOrNone(CMD_OUTPUT_FILE)
const error = await readOrNone(CMD_ERROR_FILE)
const payload = {
command: CMD_NAME,
output,
error,
exit_code: CMD_EXIT_CODE,
token: TOKEN,
pwd: PWD,
...additionalData,
}
try {
const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.responses) {
await printResponses(data.responses, 60)
}
if (data.hints) {
for (const hint of data.hints) {
console.log(colorize(hint, 'yellow'))
}
}
if (data.end) {
console.log(colorize('Quest completed!', 'blue'))
}
} catch (err) {
console.error(err)
}
}
main().then(exit)
+45
View File
@@ -0,0 +1,45 @@
const fs = require('fs').promises
const path = require('path')
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
const stats = await fs.stat(inputPath)
const name = path.basename(inputPath)
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
const files = [
{
path: inputPath,
name,
type: stats.isDirectory() ? 'folder' : 'file',
discovered,
},
]
if (stats.isDirectory() && level < 2) {
try {
let entries = await fs.readdir(inputPath)
if (level > 0) {
entries = entries.slice(0, 5)
}
const result = await Promise.all(
entries.map((entry) =>
collectFilesInfo(path.join(inputPath, entry), level + 1, hiddenFiles)
)
)
files.push(...result.flat())
} catch (err) {
// ignore
}
}
return files
}
async function discoverFiles(argv) {
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
const result = await Promise.all(
targetPath
.map((p) => path.resolve(process.cwd(), p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
)
return { discover: result.flat() }
}
module.exports = discoverFiles
+10
View File
@@ -0,0 +1,10 @@
{
"name": "container",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"chokidar": "^3.5.3",
"minimist": "^1.2.8"
}
}
@@ -1,7 +1,5 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
const fs = require('fs').promises /* eslint-disable no-await-in-loop */
const { exit } = require('process')
const readline = require('readline') const readline = require('readline')
const RETURN_SYMBOL = '\u21B5' const RETURN_SYMBOL = '\u21B5'
@@ -78,59 +76,7 @@ async function printResponses(responses, delay = 100) {
rl.close() rl.close()
} }
async function readOrNone(file) { module.exports = {
try { printResponses,
return await fs.readFile(file, 'utf8') colorize,
} catch (err) {
return ''
}
} }
async function main() {
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
if (!CMD_NAME) exit(0)
const output = await readOrNone(CMD_OUTPUT_FILE)
const error = await readOrNone(CMD_ERROR_FILE)
const payload = {
command: CMD_NAME,
output,
error,
exit_code: CMD_EXIT_CODE,
token: TOKEN,
pwd: PWD,
}
try {
const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.responses) {
await printResponses(data.responses, 60)
}
if (data.hints) {
for (const hint of data.hints) {
console.log(colorize(hint, 'yellow'))
}
}
if (data.end) {
console.log(colorize('Quest completed!', 'blue'))
}
} catch (err) {
console.error(err)
}
}
main().then(exit).catch(console.error)
+1 -2
View File
@@ -14,10 +14,9 @@
"dockerode": "^3.3.5", "dockerode": "^3.3.5",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.1", "jsonwebtoken": "^9.0.1",
"marked": "^5.1.1", "minimist": "^1.2.8",
"mongoose": "^7.4.0", "mongoose": "^7.4.0",
"socket.io": "^4.7.1", "socket.io": "^4.7.1",
"socket.io-client": "^4.7.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },
+10 -2
View File
@@ -6,6 +6,16 @@ RUN apt-get update && apt-get install -y \
zsh \ zsh \
jq jq
WORKDIR /usr/local/lib/container
COPY ./packages/container/package.json ./yarn.lock ./
RUN yarn install --modules-folder /usr/src/node_modules
ENV NODE_PATH=/usr/src/node_modules
COPY ./packages/container /usr/local/lib/container
RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus
USER rudeus USER rudeus
@@ -14,6 +24,4 @@ WORKDIR /home/rudeus
COPY ./server/quests/helloworld/home /home/rudeus/ COPY ./server/quests/helloworld/home /home/rudeus/
COPY ./packages/cli/cli.js /usr/local/bin/cli.js
CMD [ "/bin/zsh" ] CMD [ "/bin/zsh" ]
+8 -2
View File
@@ -29,8 +29,14 @@ precmd() {
export CMD_ERROR_FILE=/tmp/cmd.error export CMD_ERROR_FILE=/tmp/cmd.error
# Now run your Node.js script with the result as an argument # Now run your Node.js script with the result as an argument
node /usr/local/bin/cli.js node /usr/local/lib/container/cli.js; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null
rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # cleanup # Reset variables
unset CMD_START_TIME
unset CMD_END_TIME
unset CMD_EXIT_CODE
unset CMD_OUTPUT_FILE
unset CMD_ERROR_FILE
unset CMD_NAME
} }
@@ -6,7 +6,8 @@ import commandHandler from '../../game/commandHandler.js'
export async function newCommand(req, res) { export async function newCommand(req, res) {
console.log('new command:', req.body) console.log('new command:', req.body)
const { token, command, pwd, output, error } = req.body const { token, command, pwd, output, error, ...additionalData } = req.body
console.log('additionalData:', additionalData)
if (!command) { if (!command) {
res.status(400).json({ message: 'command is required' }) res.status(400).json({ message: 'command is required' })
@@ -39,7 +40,7 @@ export async function newCommand(req, res) {
await c.save() await c.save()
const response = await commandHandler(session, c) const response = await commandHandler(session, c, additionalData)
console.log(response) console.log(response)
res.status(201).json(response) res.status(201).json(response)
+23 -2
View File
@@ -3,6 +3,21 @@ import { getOrCreateContainer, attachContainer } from '../utils/docker.js'
import Session from '../models/session.js' import Session from '../models/session.js'
import { defaultUser, genSessionJWT } from '../utils/auth.js' import { defaultUser, genSessionJWT } from '../utils/auth.js'
const sessions = new Map()
function listenToSession(sessionId, event, callback) {
const callbacks = sessions.get((sessionId, event)) || []
sessions.set((sessionId, event), callbacks.concat(callback))
}
export function pushToSession(sessionId, event, data) {
console.log('Push:', sessionId, event)
const callbacks = sessions.get((sessionId, event))
if (callbacks) {
callbacks.forEach((callback) => callback(data))
}
}
export default (server) => { export default (server) => {
const io = new Server(server) const io = new Server(server)
@@ -50,10 +65,12 @@ export default (server) => {
const stream = await attachContainer(container, { token }) const stream = await attachContainer(container, { token })
stream.on('data', (chunk) => { stream.on('data', (chunk) => {
socket.send(chunk.toString()) socket.emit('terminal', chunk.toString())
}) })
socket.on('message', function incoming(message) { socket.on('message', console.log)
socket.on('terminal', function incoming(message) {
stream.write(message) stream.write(message)
}) })
@@ -61,6 +78,10 @@ export default (server) => {
console.log('Disconnected from the client.') console.log('Disconnected from the client.')
}) })
listenToSession(session.id, 'graph', (data) => {
socket.emit('graph', data)
})
socket.send(`${container.id}\n`) socket.send(`${container.id}\n`)
}) })
} }
+14 -1
View File
@@ -1,4 +1,6 @@
import minimist from 'minimist'
import Quest from '../models/quest.js' import Quest from '../models/quest.js'
import { pushToSession } from '../api/socket.js'
// 檢查 pattern 是否符合 input // 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => { const checkMatch = (pattern, input) => {
@@ -9,7 +11,7 @@ const checkMatch = (pattern, input) => {
}) })
} }
const commandHandler = async (session, commandInput) => { async function commandHandler(session, commandInput, additionalData) {
const quest = await Quest.findById(session.quest) const quest = await Quest.findById(session.quest)
const stage = quest.stages.find((s) => s.id === session.progress) const stage = quest.stages.find((s) => s.id === session.progress)
if (!stage) { if (!stage) {
@@ -17,6 +19,17 @@ const commandHandler = async (session, commandInput) => {
return {} return {}
} }
const argv = minimist(commandInput.command.split(' '))
if ('discover' in additionalData) {
pushToSession(session.id, 'graph', {
pwd: commandInput.pwd,
discover: additionalData.discover,
})
} else if (argv._[0] === 'cd') {
pushToSession(session.id, 'graph', { pwd: commandInput.pwd })
}
const commandMatch = checkMatch(stage.condition.command, commandInput.command) const commandMatch = checkMatch(stage.condition.command, commandInput.command)
const outputMatch = checkMatch(stage.condition.output, commandInput.output) const outputMatch = checkMatch(stage.condition.output, commandInput.output)
const errorMatch = checkMatch(stage.condition.error, commandInput.output) const errorMatch = checkMatch(stage.condition.error, commandInput.output)
+1 -1
View File
@@ -18,7 +18,7 @@ const containerOptions = {
? [] ? []
: [ : [
`${projectPath}/server/quests/helloworld/home:/home/rudeus`, `${projectPath}/server/quests/helloworld/home:/home/rudeus`,
`${projectPath}/packages/cli/cli.js:/usr/local/bin/cli.js`, `${projectPath}/packages/container:/usr/local/lib/container`,
], ],
}, },
} }
+16 -16
View File
@@ -1811,14 +1811,14 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1:
dependencies: dependencies:
once "^1.4.0" once "^1.4.0"
engine.io-client@~6.5.1: engine.io-client@~6.5.2:
version "6.5.1" version "6.5.2"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.1.tgz#1735fb8ae3bae5ae13115e18d2f484daf005dd9c" resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.2.tgz#8709e22c291d4297ae80318d3c8baeae71f0e002"
integrity sha512-hE5wKXH8Ru4L19MbM1GgYV/2Qo54JSMh1rlJbfpa40bEWkCKNo3ol2eOtGmowcr+ysgbI7+SGL+by42Q3pt/Ng== integrity sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==
dependencies: dependencies:
"@socket.io/component-emitter" "~3.1.0" "@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1" debug "~4.3.1"
engine.io-parser "~5.1.0" engine.io-parser "~5.2.1"
ws "~8.11.0" ws "~8.11.0"
xmlhttprequest-ssl "~2.0.0" xmlhttprequest-ssl "~2.0.0"
@@ -1827,6 +1827,11 @@ engine.io-parser@~5.1.0:
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.1.0.tgz#d593d6372d7f79212df48f807b8cace1ea1cb1b8" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.1.0.tgz#d593d6372d7f79212df48f807b8cace1ea1cb1b8"
integrity sha512-enySgNiK5tyZFynt3z7iqBR+Bto9EVVVvDFuTT0ioHCGbzirZVGDGiQjZzEp8hWl6hd5FSVytJGuScX1C1C35w== integrity sha512-enySgNiK5tyZFynt3z7iqBR+Bto9EVVVvDFuTT0ioHCGbzirZVGDGiQjZzEp8hWl6hd5FSVytJGuScX1C1C35w==
engine.io-parser@~5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.1.tgz#9f213c77512ff1a6cc0c7a86108a7ffceb16fcfb"
integrity sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==
engine.io@~6.5.0: engine.io@~6.5.0:
version "6.5.1" version "6.5.1"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.1.tgz#59725f8593ccc891abb47f1efcdc52a089525a56" resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.1.tgz#59725f8593ccc891abb47f1efcdc52a089525a56"
@@ -3432,11 +3437,6 @@ makeerror@1.0.12:
dependencies: dependencies:
tmpl "1.0.5" tmpl "1.0.5"
marked@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/marked/-/marked-5.1.1.tgz#40b3963bb9da225314f746d5012ba7e34942f636"
integrity sha512-bTmmGdEINWmOMDjnPWDxGPQ4qkDLeYorpYbEtFOXzOruTwUE671q4Guiuchn4N8h/v6NGd7916kXsm3Iz4iUSg==
media-typer@0.3.0: media-typer@0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -3509,7 +3509,7 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies: dependencies:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
minimist@^1.2.0, minimist@^1.2.6: minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@@ -4269,14 +4269,14 @@ socket.io-adapter@~2.5.2:
dependencies: dependencies:
ws "~8.11.0" ws "~8.11.0"
socket.io-client@^4.7.1: socket.io-client@^4.7.2:
version "4.7.1" version "4.7.2"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.1.tgz#48e5f703abe4fb0402182bcf9c06b7820fb3453b" resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08"
integrity sha512-Qk3Xj8ekbnzKu3faejo4wk2MzXA029XppiXtTF/PkbTg+fcwaTw1PlDrTrrrU4mKoYC4dvlApOnSeyLCKwek2w== integrity sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==
dependencies: dependencies:
"@socket.io/component-emitter" "~3.1.0" "@socket.io/component-emitter" "~3.1.0"
debug "~4.3.2" debug "~4.3.2"
engine.io-client "~6.5.1" engine.io-client "~6.5.2"
socket.io-parser "~4.2.4" socket.io-parser "~4.2.4"
socket.io-parser@~4.2.4: socket.io-parser@~4.2.4: