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/`
```
# Connect to dev server (https://odyssey.wancat.cc)
yarn app
# Open http://localhost:5173/
# Connect to localhost (http://localhost:3000)
yarn app:local
```
## Swagger
+2 -1
View File
@@ -4,7 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "API_TARGET=https://odyssey.wancat.cc vite",
"dev:local": "vite",
"build": "vite build",
"preview": "vite preview"
},
+1
View File
@@ -19,6 +19,7 @@ onMounted(async () => {
socket.on('disconnect', function close() {
console.log('Disconnected from the server.')
})
socket.on('message', console.log)
})
</script>
+2 -2
View File
@@ -55,11 +55,11 @@ onMounted(() => {
term.open(terminal.value)
term.focus()
resizeScreen()
socket.on('message', (message) => {
socket.on('terminal', (message) => {
term.write(message)
})
term.onData((key) => {
socket.send(key)
socket.emit('terminal', key)
})
})
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
import axios from 'axios'
const api = axios.create({
baseURL: 'https://odyssey.wancat.cc/api/v1',
baseURL: '/api',
})
export default api
+1 -3
View File
@@ -1,7 +1,5 @@
import io from 'socket.io-client'
const host = 'wss://odyssey.wancat.cc'
class SocketWrapper {
constructor() {
this.socket = null
@@ -14,7 +12,7 @@ class SocketWrapper {
this.socket.disconnect()
}
this.socket = io(host, {
this.socket = io('', {
query: {
session_id: session._id,
},
+7 -2
View File
@@ -7,8 +7,13 @@ export default defineConfig({
server: {
proxy: {
'/api': {
// target: 'http://localhost:3000', // the server url
target: 'http://odyssey.wancat.cc ',
target: `${process.env.API_TARGET || 'http://localhost:3000'}/api/v1`,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/socket.io': {
target: process.env.API_TARGET || 'ws://localhost:3000',
changeOrigin: true,
ws: true,
},
},
+2 -1
View File
@@ -12,8 +12,9 @@
],
"scripts": {
"app": "yarn workspace app dev",
"app:local": "yarn workspace app dev:local",
"server": "yarn workspace server dev",
"cli": "yarn workspace server cli",
"cli": "yarn workspace client start",
"lint": "eslint --ext .js,.vue .",
"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 { io } from 'socket.io-client'
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.resume()
stdin.setEncoding('utf8')
@@ -60,10 +66,17 @@ async function connect(sessionId) {
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)
})
socket.on('graph', (data) => {
console.log('receive graph:')
console.log(data)
})
socket.on('close', function close() {
console.log('Disconnected from the server.')
exit()
@@ -74,7 +87,7 @@ async function connect(sessionId) {
socket.close()
exit()
} 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 */
const fs = require('fs').promises
const { exit } = require('process')
/* eslint-disable no-await-in-loop */
const readline = require('readline')
const RETURN_SYMBOL = '\u21B5'
@@ -78,59 +76,7 @@ async function printResponses(responses, delay = 100) {
rl.close()
}
async function readOrNone(file) {
try {
return await fs.readFile(file, 'utf8')
} catch (err) {
return ''
}
module.exports = {
printResponses,
colorize,
}
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",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.1",
"marked": "^5.1.1",
"minimist": "^1.2.8",
"mongoose": "^7.4.0",
"socket.io": "^4.7.1",
"socket.io-client": "^4.7.1",
"swagger-ui-express": "^5.0.0",
"yaml": "^2.3.1"
},
+10 -2
View File
@@ -6,6 +6,16 @@ RUN apt-get update && apt-get install -y \
zsh \
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
USER rudeus
@@ -14,6 +24,4 @@ WORKDIR /home/rudeus
COPY ./server/quests/helloworld/home /home/rudeus/
COPY ./packages/cli/cli.js /usr/local/bin/cli.js
CMD [ "/bin/zsh" ]
+8 -2
View File
@@ -29,8 +29,14 @@ precmd() {
export CMD_ERROR_FILE=/tmp/cmd.error
# 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) {
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) {
res.status(400).json({ message: 'command is required' })
@@ -39,7 +40,7 @@ export async function newCommand(req, res) {
await c.save()
const response = await commandHandler(session, c)
const response = await commandHandler(session, c, additionalData)
console.log(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 { 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) => {
const io = new Server(server)
@@ -50,10 +65,12 @@ export default (server) => {
const stream = await attachContainer(container, { token })
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)
})
@@ -61,6 +78,10 @@ export default (server) => {
console.log('Disconnected from the client.')
})
listenToSession(session.id, 'graph', (data) => {
socket.emit('graph', data)
})
socket.send(`${container.id}\n`)
})
}
+14 -1
View File
@@ -1,4 +1,6 @@
import minimist from 'minimist'
import Quest from '../models/quest.js'
import { pushToSession } from '../api/socket.js'
// 檢查 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 stage = quest.stages.find((s) => s.id === session.progress)
if (!stage) {
@@ -17,6 +19,17 @@ const commandHandler = async (session, commandInput) => {
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 outputMatch = checkMatch(stage.condition.output, 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}/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:
once "^1.4.0"
engine.io-client@~6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.1.tgz#1735fb8ae3bae5ae13115e18d2f484daf005dd9c"
integrity sha512-hE5wKXH8Ru4L19MbM1GgYV/2Qo54JSMh1rlJbfpa40bEWkCKNo3ol2eOtGmowcr+ysgbI7+SGL+by42Q3pt/Ng==
engine.io-client@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.2.tgz#8709e22c291d4297ae80318d3c8baeae71f0e002"
integrity sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.1.0"
engine.io-parser "~5.2.1"
ws "~8.11.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"
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:
version "6.5.1"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.1.tgz#59725f8593ccc891abb47f1efcdc52a089525a56"
@@ -3432,11 +3437,6 @@ makeerror@1.0.12:
dependencies:
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:
version "0.3.0"
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:
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"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
@@ -4269,14 +4269,14 @@ socket.io-adapter@~2.5.2:
dependencies:
ws "~8.11.0"
socket.io-client@^4.7.1:
version "4.7.1"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.1.tgz#48e5f703abe4fb0402182bcf9c06b7820fb3453b"
integrity sha512-Qk3Xj8ekbnzKu3faejo4wk2MzXA029XppiXtTF/PkbTg+fcwaTw1PlDrTrrrU4mKoYC4dvlApOnSeyLCKwek2w==
socket.io-client@^4.7.2:
version "4.7.2"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08"
integrity sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
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: