You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
255 lines
7.8 KiB
JavaScript
255 lines
7.8 KiB
JavaScript
import { WebSocketServer } from 'ws'
|
|
import express from 'express'
|
|
import * as cp from 'child_process'
|
|
import * as url from 'url'
|
|
import * as rpc from 'vscode-ws-jsonrpc'
|
|
import * as path from 'path'
|
|
import * as jsonrpcserver from 'vscode-ws-jsonrpc/server'
|
|
// import nocache from 'nocache'
|
|
import anonymize from 'ip-anonymize'
|
|
import os from 'os'
|
|
import fs from 'fs'
|
|
import http from 'http'
|
|
import https from 'https'
|
|
|
|
import { importTrigger, importStatus } from './import.mjs'
|
|
|
|
/**
|
|
* Add a game here if the server should keep a queue of pre-loaded games ready at all times.
|
|
*
|
|
* IMPORTANT! Tags here need to be lower case!
|
|
*/
|
|
const queueLength = {
|
|
"g/hhu-adam/robo": 2,
|
|
"g/hhu-adam/nng4": 5,
|
|
"g/djvelleman/stg4": 0,
|
|
"g/trequetrum/lean4game-logic": 0,
|
|
}
|
|
|
|
let socketCounter = 0
|
|
|
|
function logStats() {
|
|
console.log(`[${new Date()}] Number of open sockets - ${socketCounter}`)
|
|
console.log(`[${new Date()}] Free RAM - ${Math.round(os.freemem() / 1024 / 1024)} / ${Math.round(os.totalmem() / 1024 / 1024)} MB`)
|
|
}
|
|
|
|
const __filename = url.fileURLToPath(import.meta.url)
|
|
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
|
|
|
|
|
const environment = process.env.NODE_ENV
|
|
const isDevelopment = environment === 'development'
|
|
|
|
const crtFile = process.env.SSL_CRT_FILE
|
|
const keyFile = process.env.SSL_KEY_FILE
|
|
|
|
const app = express()
|
|
|
|
var router = express.Router()
|
|
|
|
// Paths for game import logic
|
|
router.get('/import/status/:owner/:repo', importStatus)
|
|
router.get('/import/trigger/:owner/:repo', importTrigger)
|
|
|
|
app.use(express.static(path.join(__dirname, '..', 'client', 'dist'))) // TODO: add a dist folder from inside the game
|
|
app.use('/i18n/g/:owner/:repo/:lang/*', (req, res, next) => {
|
|
const owner = req.params.owner
|
|
const repo = req.params.repo
|
|
const lang = req.params.lang
|
|
const filename = req.params[0]
|
|
req.url = filename
|
|
express.static(path.join(getGameDir(owner,repo),".i18n",lang))(req, res, next)
|
|
})
|
|
app.use('/data/g/:owner/:repo/*', (req, res, next) => {
|
|
const owner = req.params.owner
|
|
const repo = req.params.repo
|
|
const filename = req.params[0]
|
|
req.url = filename
|
|
express.static(path.join(getGameDir(owner,repo),".lake","gamedata"))(req, res, next)
|
|
})
|
|
app.use('/', router)
|
|
|
|
let server
|
|
if (crtFile && keyFile) {
|
|
var privateKey = fs.readFileSync(keyFile, 'utf8')
|
|
var certificate = fs.readFileSync(crtFile, 'utf8')
|
|
var credentials = {key: privateKey, cert: certificate}
|
|
|
|
const PORT = process.env.PORT ?? 443
|
|
server = https.createServer(credentials, app).listen(PORT,
|
|
() => console.log(`HTTPS on port ${PORT}`))
|
|
|
|
// redirect http to https
|
|
express().get('*', function(req, res) {
|
|
res.redirect('https://' + req.headers.host + req.url).listen(80)
|
|
})
|
|
} else {
|
|
const PORT = process.env.PORT ?? 8080
|
|
server = app.listen(PORT,
|
|
() => console.log(`HTTP on port ${PORT}`))
|
|
}
|
|
|
|
const wss = new WebSocketServer({ server })
|
|
|
|
/** We keep queues of started Lean Server processes to be ready when a user arrives */
|
|
const queue = {}
|
|
|
|
function getTag(owner, repo) {
|
|
return `g/${owner.toLowerCase()}/${repo.toLowerCase()}`
|
|
}
|
|
|
|
function getGameDir(owner, repo) {
|
|
owner = owner.toLowerCase()
|
|
if (owner == 'local') {
|
|
if(!isDevelopment) {
|
|
console.error(`No local games in production mode.`)
|
|
return ""
|
|
}
|
|
} else {
|
|
if(!fs.existsSync(path.join(__dirname, '..', 'games'))) {
|
|
console.error(`Did not find the following folder: ${path.join(__dirname, '..', 'games')}`)
|
|
console.error('Did you already import any games?')
|
|
return ""
|
|
}
|
|
}
|
|
|
|
let game_dir = (owner == 'local') ?
|
|
// note: in the local case we need `repo` to be case sensitive
|
|
path.join(__dirname, '..', '..', repo) :
|
|
path.join(__dirname, '..', 'games', `${owner}`, `${repo.toLowerCase()}`)
|
|
|
|
if(!fs.existsSync(game_dir)) {
|
|
console.error(`Game '${game_dir}' does not exist!`)
|
|
return ""
|
|
}
|
|
|
|
return game_dir
|
|
}
|
|
|
|
function startServerProcess(owner, repo) {
|
|
let game_dir = getGameDir(owner, repo)
|
|
if (!game_dir) { return }
|
|
|
|
let serverProcess
|
|
if (isDevelopment) {
|
|
console.warn("Running without Bubblewrap container!")
|
|
|
|
// TODO: use the gameserver
|
|
// serverProcess = cp.spawn("lean", ["--server"], { cwd: game_dir })
|
|
|
|
let args = ["--server", game_dir]
|
|
let binDir = path.join(game_dir, ".lake", "packages", "GameServer", "server", ".lake", "build", "bin")
|
|
// Note: `cwd` is important to be the `bin` directory as `Watchdog` calls `./gameserver` again
|
|
if (fs.existsSync(binDir)) {
|
|
// Try to use the game's own copy of `gameserver`.
|
|
serverProcess = cp.spawn("./gameserver", args, { cwd: binDir })
|
|
} else {
|
|
// If the game is built with `-Klean4game.local` there is no copy in the lake packages.
|
|
serverProcess = cp.spawn("./gameserver", args,
|
|
{ cwd: path.join(__dirname, "..", "server", ".lake", "build", "bin") })
|
|
}
|
|
} else {
|
|
console.info("Running with Bubblewrap container.")
|
|
serverProcess = cp.spawn(
|
|
"./bubblewrap.sh",
|
|
[ game_dir, path.join(__dirname, '..')],
|
|
{ cwd: __dirname }
|
|
)
|
|
}
|
|
|
|
serverProcess.stderr.on('data', data =>
|
|
console.error(`Lean Server: ${data}`)
|
|
)
|
|
serverProcess.on('error', error =>
|
|
console.error(`Launching Lean Server failed: ${error}`)
|
|
)
|
|
serverProcess.on('close', (code, _signal) => {
|
|
console.log(`Lean server exited with code ${code}`)
|
|
})
|
|
|
|
return serverProcess
|
|
}
|
|
|
|
/** start Lean Server processes to refill the queue */
|
|
function fillQueue(tag) {
|
|
while (queue[tag].length < queueLength[tag]) {
|
|
let serverProcess
|
|
serverProcess = startServerProcess(tag)
|
|
if (serverProcess == null) {
|
|
console.error('serverProcess was undefined/null')
|
|
return
|
|
}
|
|
queue[tag].push(serverProcess)
|
|
}
|
|
}
|
|
|
|
// // TODO: We disabled queue for now
|
|
// if (!isDevelopment) { // Don't use queue in development
|
|
// for (let tag in queueLength) {
|
|
// queue[tag] = []
|
|
// fillQueue(tag)
|
|
// }
|
|
// }
|
|
|
|
wss.addListener("connection", function(ws, req) {
|
|
// server expects URL of the form `/websocket/g/{owner}/{repo}`
|
|
const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)\/?$/
|
|
const reRes = urlRegEx.exec(req.url)
|
|
if (!reRes) { console.error(`Connection refused because of invalid URL: ${req.url}`); return }
|
|
const owner = reRes[1]
|
|
const repo = reRes[2]
|
|
const tag = getTag(owner, repo)
|
|
|
|
const ip = anonymize(req.headers['x-forwarded-for'] || req.socket.remoteAddress)
|
|
let ps
|
|
if (!queue[tag] || queue[tag].length == 0) {
|
|
ps = startServerProcess(owner, repo)
|
|
} else {
|
|
console.info('Got process from the queue')
|
|
ps = queue[tag].shift() // Pick the first Lean process; it's likely to be ready immediately
|
|
// TODO
|
|
// async () => {
|
|
// fillQueue(tag)
|
|
// }
|
|
}
|
|
if (ps == null) {
|
|
console.error('server process is undefined/null')
|
|
return
|
|
}
|
|
|
|
const socket = {
|
|
onMessage: (cb) => { ws.on("message", cb) },
|
|
onError: (cb) => { ws.on("error", cb) },
|
|
onClose: (cb) => { ws.on("close", cb) },
|
|
send: (data, cb) => { ws.send(data,cb) }
|
|
}
|
|
const reader = new rpc.WebSocketMessageReader(socket)
|
|
const writer = new rpc.WebSocketMessageWriter(socket)
|
|
const socketConnection = jsonrpcserver.createConnection(reader, writer, () => ws.close())
|
|
const serverConnection = jsonrpcserver.createProcessStreamConnection(ps)
|
|
socketConnection.forward(serverConnection, message => {
|
|
if (isDevelopment) {
|
|
console.log(`CLIENT: ${JSON.stringify(message)}`)
|
|
}
|
|
return message
|
|
})
|
|
serverConnection.forward(socketConnection, message => {
|
|
if (isDevelopment) {
|
|
console.log(`SERVER: ${JSON.stringify(message)}`)
|
|
}
|
|
return message
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
console.log(`[${new Date()}] Socket closed - ${ip}`)
|
|
socketCounter -= 1
|
|
})
|
|
|
|
socketConnection.onClose(() => serverConnection.dispose())
|
|
serverConnection.onClose(() => socketConnection.dispose())
|
|
|
|
console.log(`[${new Date()}] Socket opened - ${ip}`)
|
|
socketCounter += 1
|
|
logStats()
|
|
})
|