spoint 0.1.588 → 0.1.589
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/room-orchestrator-boot.js +36 -1
- package/bin/room-worker-boot.js +146 -0
- package/package.json +1 -1
- package/src/sdk/RoomOrchestrator.js +65 -7
|
@@ -35,7 +35,7 @@ import { existsSync } from 'node:fs'
|
|
|
35
35
|
import { join, dirname } from 'node:path'
|
|
36
36
|
import { fileURLToPath } from 'node:url'
|
|
37
37
|
import { createServer as createHttpServer } from 'node:http'
|
|
38
|
-
import { RoomOrchestrator } from '../src/sdk/RoomOrchestrator.js'
|
|
38
|
+
import { RoomOrchestrator, readJsonBody } from '../src/sdk/RoomOrchestrator.js'
|
|
39
39
|
import { assertNodeModulesLinked } from '../src/sdk/server.js'
|
|
40
40
|
|
|
41
41
|
const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -130,6 +130,41 @@ async function main() {
|
|
|
130
130
|
res.end(JSON.stringify({ roomId, stopped }))
|
|
131
131
|
return
|
|
132
132
|
}
|
|
133
|
+
if (req.method === 'POST' && url.pathname === '/workers/register') {
|
|
134
|
+
const body = await readJsonBody(req)
|
|
135
|
+
if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
|
|
136
|
+
try {
|
|
137
|
+
const result = await orchestrator.registerWorker({ host: body.host, portRange: body.portRange, commandPort: body.commandPort })
|
|
138
|
+
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
139
|
+
res.end(JSON.stringify(result))
|
|
140
|
+
} catch (e) {
|
|
141
|
+
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
142
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
143
|
+
}
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (req.method === 'DELETE' && url.pathname.match(/^\/workers\/(\d+)$/)) {
|
|
147
|
+
const wm = url.pathname.match(/^\/workers\/(\d+)$/)
|
|
148
|
+
const ok = await orchestrator.deregisterWorker(parseInt(wm[1], 10))
|
|
149
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
150
|
+
res.end(JSON.stringify({ deregistered: ok }))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname === '/workers') {
|
|
154
|
+
const list = []
|
|
155
|
+
for (let i = 0; i < orchestrator.workers.length; i++) {
|
|
156
|
+
const w = orchestrator.workers[i]
|
|
157
|
+
if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
|
|
158
|
+
}
|
|
159
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
160
|
+
res.end(JSON.stringify(list))
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
if (url.pathname === '/crash-stats') {
|
|
164
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
165
|
+
res.end(JSON.stringify(orchestrator.getCrashStats()))
|
|
166
|
+
return
|
|
167
|
+
}
|
|
133
168
|
if (url.pathname === '/elastic-stats') {
|
|
134
169
|
const stats = orchestrator.getElasticStats()
|
|
135
170
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
|
|
6
|
+
import { request as httpsRequest } from 'node:https'
|
|
7
|
+
import { RoomDirectory } from '../src/sdk/RoomDirectory.js'
|
|
8
|
+
import { assertNodeModulesLinked } from '../src/sdk/server.js'
|
|
9
|
+
|
|
10
|
+
const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
11
|
+
|
|
12
|
+
function readJsonBody(req) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
let buf = ''
|
|
15
|
+
req.on('data', (chunk) => { buf += chunk })
|
|
16
|
+
req.on('end', () => { try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) } })
|
|
17
|
+
req.on('error', () => resolve(null))
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function httpPostJson(url, body) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const data = JSON.stringify(body)
|
|
24
|
+
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
25
|
+
const req = requestFn(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
|
28
|
+
}, (res) => {
|
|
29
|
+
let buf = ''
|
|
30
|
+
res.on('data', (c) => { buf += c })
|
|
31
|
+
res.on('end', () => {
|
|
32
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(buf) }) } catch (e) { reject(e) }
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
req.on('error', reject)
|
|
36
|
+
req.write(data)
|
|
37
|
+
req.end()
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function httpDelete(url) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
44
|
+
const req = requestFn(url, { method: 'DELETE' }, (res) => {
|
|
45
|
+
let buf = ''
|
|
46
|
+
res.on('data', (c) => { buf += c })
|
|
47
|
+
res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(buf) }) } catch (e) { resolve({ status: res.statusCode, body: null }) } })
|
|
48
|
+
})
|
|
49
|
+
req.on('error', reject)
|
|
50
|
+
req.end()
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function main() {
|
|
55
|
+
assertNodeModulesLinked(SDK_ROOT)
|
|
56
|
+
const PROJECT = process.cwd()
|
|
57
|
+
const orchestratorUrl = process.env.ORCHESTRATOR_URL || 'http://localhost:3400'
|
|
58
|
+
const workerHost = process.env.WORKER_HOST || '127.0.0.1'
|
|
59
|
+
const workerCommandPort = parseInt(process.env.WORKER_COMMAND_PORT || '0', 10)
|
|
60
|
+
const portRangeMin = parseInt(process.env.ROOM_PORT_MIN || '19100', 10)
|
|
61
|
+
const portRangeMax = parseInt(process.env.ROOM_PORT_MAX || '19199', 10)
|
|
62
|
+
|
|
63
|
+
const directory = new RoomDirectory({
|
|
64
|
+
sdkRoot: SDK_ROOT,
|
|
65
|
+
projectRoot: existsSync(join(PROJECT, 'apps')) ? PROJECT : SDK_ROOT,
|
|
66
|
+
portRange: [portRangeMin, portRangeMax],
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const commandServer = createHttpServer(async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(req.url, 'http://localhost')
|
|
72
|
+
if (req.method === 'POST' && url.pathname === '/rooms') {
|
|
73
|
+
const body = await readJsonBody(req)
|
|
74
|
+
if (!body || !body.roomId) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'roomId required' })); return }
|
|
75
|
+
try {
|
|
76
|
+
const handle = await directory.createRoom(body.roomId, body.worldName || 'tps-game', body.opts || {})
|
|
77
|
+
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
78
|
+
res.end(JSON.stringify({ roomId: body.roomId, port: handle.port, worldName: handle.worldName }))
|
|
79
|
+
} catch (e) {
|
|
80
|
+
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
81
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (req.method === 'DELETE' && url.pathname.startsWith('/rooms/')) {
|
|
86
|
+
const roomId = decodeURIComponent(url.pathname.slice('/rooms/'.length))
|
|
87
|
+
const stopped = await directory.stopRoom(roomId)
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
89
|
+
res.end(JSON.stringify({ roomId, stopped }))
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (url.pathname === '/status') {
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
94
|
+
res.end(JSON.stringify({ rooms: directory.getStatus() }))
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (req.method === 'POST' && url.pathname === '/shutdown') {
|
|
98
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
99
|
+
res.end(JSON.stringify({ shuttingDown: true }))
|
|
100
|
+
await directory.stopAll()
|
|
101
|
+
process.exit(0)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
res.writeHead(404); res.end('not found')
|
|
105
|
+
} catch (e) {
|
|
106
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
107
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const { port: boundCommandPort } = await new Promise((resolve, reject) => {
|
|
112
|
+
commandServer.once('error', reject)
|
|
113
|
+
commandServer.listen(workerCommandPort, () => resolve({ port: commandServer.address().port }))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
console.log(`[room-worker] command server listening on http://${workerHost}:${boundCommandPort}`)
|
|
117
|
+
|
|
118
|
+
const registration = await httpPostJson(`${orchestratorUrl}/workers/register`, {
|
|
119
|
+
host: workerHost,
|
|
120
|
+
portRange: [portRangeMin, portRangeMax],
|
|
121
|
+
commandPort: boundCommandPort,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
if (registration.status !== 201) {
|
|
125
|
+
console.error(`[room-worker] registration failed: ${JSON.stringify(registration.body)}`)
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const workerIndex = registration.body.workerIndex
|
|
130
|
+
console.log(`[room-worker] registered as worker ${workerIndex} with orchestrator at ${orchestratorUrl}`)
|
|
131
|
+
|
|
132
|
+
const shutdown = async (signal) => {
|
|
133
|
+
console.log(`[room-worker] received ${signal}, deregistering and shutting down...`)
|
|
134
|
+
try { await httpDelete(`${orchestratorUrl}/workers/${workerIndex}`) } catch (_) {}
|
|
135
|
+
await directory.stopAll()
|
|
136
|
+
await new Promise((resolve) => commandServer.close(() => resolve()))
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
140
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
main().catch(err => {
|
|
144
|
+
console.error('[room-worker] FATAL:', err)
|
|
145
|
+
process.exit(1)
|
|
146
|
+
})
|
package/package.json
CHANGED
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
import { fork } from 'node:child_process'
|
|
48
48
|
import { fileURLToPath } from 'node:url'
|
|
49
49
|
import { join, dirname } from 'node:path'
|
|
50
|
-
import { createServer as createHttpServer } from 'node:http'
|
|
50
|
+
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
|
|
51
|
+
import { request as httpsRequest } from 'node:https'
|
|
51
52
|
|
|
52
53
|
const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
53
54
|
const WORKER_ENTRY = join(SDK_ROOT, 'src', 'sdk', 'RoomProcessWorker.js')
|
|
@@ -205,15 +206,15 @@ export class RoomOrchestrator {
|
|
|
205
206
|
* for route() lookups -- no child_process is forked, and crash auto-restart does not apply
|
|
206
207
|
* (the orchestrator cannot fork a process on a different machine).
|
|
207
208
|
* Returns { workerIndex, host } once registered. */
|
|
208
|
-
async registerWorker({ host, portRange }) {
|
|
209
|
+
async registerWorker({ host, portRange, commandPort }) {
|
|
209
210
|
if (!host) throw new Error('registerWorker requires { host }')
|
|
210
211
|
const workerIndex = this._nextWorkerIndex++
|
|
211
212
|
const range = portRange || this._subRangeFor(workerIndex)
|
|
212
213
|
this._workerHosts[workerIndex] = host
|
|
213
|
-
const entry = { proc: null, ready: true, roomIds: new Set(), host, isExternal: true }
|
|
214
|
+
const entry = { proc: null, ready: true, roomIds: new Set(), host, isExternal: true, commandPort: commandPort || null }
|
|
214
215
|
this.workers[workerIndex] = entry
|
|
215
216
|
this.workerCount = Math.max(this.workerCount, workerIndex + 1)
|
|
216
|
-
console.log(`[RoomOrchestrator] registered external worker ${workerIndex} at ${host} (port range ${range[0]}-${range[1]})`)
|
|
217
|
+
console.log(`[RoomOrchestrator] registered external worker ${workerIndex} at ${host}:${commandPort || '?'} (port range ${range[0]}-${range[1]})`)
|
|
217
218
|
return { workerIndex, host, portRange: range }
|
|
218
219
|
}
|
|
219
220
|
|
|
@@ -384,7 +385,7 @@ export class RoomOrchestrator {
|
|
|
384
385
|
_send(workerIndex, payload) {
|
|
385
386
|
const entry = this.workers[workerIndex]
|
|
386
387
|
if (!entry || !entry.ready) return Promise.reject(new Error(`RoomOrchestrator: worker ${workerIndex} is not ready`))
|
|
387
|
-
if (entry.isExternal) return
|
|
388
|
+
if (entry.isExternal) return this._sendExternal(entry, payload)
|
|
388
389
|
const reqId = this._nextReqId++
|
|
389
390
|
return new Promise((resolve, reject) => {
|
|
390
391
|
this._pending.set(reqId, { resolve, reject })
|
|
@@ -392,6 +393,45 @@ export class RoomOrchestrator {
|
|
|
392
393
|
})
|
|
393
394
|
}
|
|
394
395
|
|
|
396
|
+
async _sendExternal(entry, payload) {
|
|
397
|
+
if (!entry.commandPort) throw new Error('RoomOrchestrator: external worker registered without a commandPort -- cannot route commands to it')
|
|
398
|
+
const base = `http://${entry.host}:${entry.commandPort}`
|
|
399
|
+
try {
|
|
400
|
+
if (payload.type === 'CREATE_ROOM') {
|
|
401
|
+
const res = await httpJsonRequest(`${base}/rooms`, 'POST', { roomId: payload.roomId, worldName: payload.worldName, opts: payload.opts })
|
|
402
|
+
if (res.status !== 201) throw new Error(res.body?.error || `external worker CREATE_ROOM failed (status ${res.status})`)
|
|
403
|
+
return { type: 'ROOM_CREATED', roomId: payload.roomId, port: res.body.port, worldName: res.body.worldName }
|
|
404
|
+
}
|
|
405
|
+
if (payload.type === 'STOP_ROOM') {
|
|
406
|
+
const res = await httpJsonRequest(`${base}/rooms/${encodeURIComponent(payload.roomId)}`, 'DELETE')
|
|
407
|
+
return { type: 'ROOM_STOPPED', roomId: payload.roomId, stopped: !!res.body?.stopped }
|
|
408
|
+
}
|
|
409
|
+
if (payload.type === 'GET_STATUS') {
|
|
410
|
+
const res = await httpJsonRequest(`${base}/status`, 'GET')
|
|
411
|
+
return { type: 'STATUS', rooms: res.body?.rooms || [] }
|
|
412
|
+
}
|
|
413
|
+
if (payload.type === 'SHUTDOWN') {
|
|
414
|
+
const res = await httpJsonRequest(`${base}/shutdown`, 'POST')
|
|
415
|
+
return { type: 'SHUTDOWN_DONE' }
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`RoomOrchestrator: unsupported external command type ${payload.type}`)
|
|
418
|
+
} catch (e) {
|
|
419
|
+
if (e && (e.code === 'ECONNREFUSED' || e.code === 'ECONNRESET' || e.code === 'ETIMEDOUT')) {
|
|
420
|
+
this._markExternalWorkerDead(entry)
|
|
421
|
+
}
|
|
422
|
+
throw e
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
_markExternalWorkerDead(entry) {
|
|
427
|
+
if (!entry.ready) return
|
|
428
|
+
const workerIndex = this.workers.indexOf(entry)
|
|
429
|
+
console.error(`[RoomOrchestrator] external worker ${workerIndex} (${entry.host}:${entry.commandPort}) unreachable -- marking dead, evicting ${entry.roomIds.size} room(s)`)
|
|
430
|
+
for (const roomId of entry.roomIds) this.roomToWorker.delete(roomId)
|
|
431
|
+
entry.roomIds.clear()
|
|
432
|
+
entry.ready = false
|
|
433
|
+
}
|
|
434
|
+
|
|
395
435
|
/** Picks the worker currently hosting the fewest rooms (ties broken by lowest index). Pure room-count fallback -- used when no live per-room status is available yet (e.g. a worker that just came up with zero rooms/zero tick history), and as _pickWeightedWorker's own tie-break when every ready worker's weighted score is identical (all-idle fleet). Skips workers that are currently retiring. */
|
|
396
436
|
_pickLeastLoadedWorker() {
|
|
397
437
|
let best = -1, bestCount = Infinity
|
|
@@ -504,7 +544,7 @@ export class RoomOrchestrator {
|
|
|
504
544
|
/** Directory-wide status: real per-room rows fetched live from every worker (parallel GET_STATUS), flattened -- the shape a Prometheus /metrics `rooms` source (see src/sdk/Metrics.js) can consume directly across an ENTIRE multi-process fleet, not just one process's RoomDirectory. */
|
|
505
545
|
async getStatus() {
|
|
506
546
|
const perWorker = await Promise.all(
|
|
507
|
-
this.workers.map((w, i) => (w?.ready
|
|
547
|
+
this.workers.map((w, i) => (w?.ready ? this._send(i, { type: 'GET_STATUS' }).then(r => r.rooms).catch(() => []) : Promise.resolve([])))
|
|
508
548
|
)
|
|
509
549
|
return perWorker.flat()
|
|
510
550
|
}
|
|
@@ -597,7 +637,7 @@ export class RoomOrchestrator {
|
|
|
597
637
|
}
|
|
598
638
|
|
|
599
639
|
/** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
|
|
600
|
-
function readJsonBody(req) {
|
|
640
|
+
export function readJsonBody(req) {
|
|
601
641
|
return new Promise((resolve) => {
|
|
602
642
|
let buf = ''
|
|
603
643
|
req.on('data', (chunk) => { buf += chunk })
|
|
@@ -606,4 +646,22 @@ function readJsonBody(req) {
|
|
|
606
646
|
})
|
|
607
647
|
req.on('error', () => resolve(null))
|
|
608
648
|
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function httpJsonRequest(url, method, body) {
|
|
652
|
+
return new Promise((resolve, reject) => {
|
|
653
|
+
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
654
|
+
const data = body !== undefined ? JSON.stringify(body) : null
|
|
655
|
+
const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
|
|
656
|
+
const req = requestFn(url, { method, headers }, (res) => {
|
|
657
|
+
let buf = ''
|
|
658
|
+
res.on('data', (c) => { buf += c })
|
|
659
|
+
res.on('end', () => {
|
|
660
|
+
try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
|
|
661
|
+
})
|
|
662
|
+
})
|
|
663
|
+
req.on('error', reject)
|
|
664
|
+
if (data) req.write(data)
|
|
665
|
+
req.end()
|
|
666
|
+
})
|
|
609
667
|
}
|