spoint 0.1.652 → 0.1.653
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/package.json +1 -1
- package/src/sdk/RoomOrchestrator.js +9 -120
- package/src/sdk/RoomOrchestratorHttp.js +139 -0
package/package.json
CHANGED
|
@@ -47,8 +47,11 @@
|
|
|
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 {
|
|
51
|
-
|
|
50
|
+
import { readJsonBody, httpJsonRequest, scoreWorkerRooms, startRoomOrchestratorRouter } from './RoomOrchestratorHttp.js'
|
|
51
|
+
|
|
52
|
+
// Re-exported from RoomOrchestratorHttp.js for backward compatibility -- bin/room-orchestrator-boot.js
|
|
53
|
+
// imports readJsonBody from this file's own path.
|
|
54
|
+
export { readJsonBody }
|
|
52
55
|
|
|
53
56
|
const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
54
57
|
const WORKER_ENTRY = join(SDK_ROOT, 'src', 'sdk', 'RoomProcessWorker.js')
|
|
@@ -305,17 +308,11 @@ export class RoomOrchestrator {
|
|
|
305
308
|
const perWorkerRooms = await Promise.all(
|
|
306
309
|
readyIdx.map(i => this._send(i, { type: 'GET_STATUS' }).then(r => r.rooms).catch(() => null))
|
|
307
310
|
)
|
|
308
|
-
const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
|
|
309
311
|
let allOverThreshold = readyIdx.length > 0
|
|
310
312
|
for (let k = 0; k < readyIdx.length; k++) {
|
|
311
|
-
const i = readyIdx[k]
|
|
312
313
|
const rooms = perWorkerRooms[k]
|
|
313
314
|
if (!rooms || rooms.length === 0) { allOverThreshold = false; break }
|
|
314
|
-
const score = rooms
|
|
315
|
-
+ (r.players || 0) * PLAYER_WEIGHT
|
|
316
|
-
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
317
|
-
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
318
|
-
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
|
|
315
|
+
const score = scoreWorkerRooms(rooms)
|
|
319
316
|
if (score < this._elasticScaleUpThreshold) { allOverThreshold = false; break }
|
|
320
317
|
}
|
|
321
318
|
if (allOverThreshold) {
|
|
@@ -490,7 +487,6 @@ export class RoomOrchestrator {
|
|
|
490
487
|
* failed) so a freshly-spawned empty worker is never starved of placement by a transient status gap.
|
|
491
488
|
*/
|
|
492
489
|
async _pickWeightedWorker() {
|
|
493
|
-
const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
|
|
494
490
|
const readyIdx = []
|
|
495
491
|
for (let i = 0; i < this.workers.length; i++) if (this.workers[i]?.ready && !this._retiring.has(i)) readyIdx.push(i)
|
|
496
492
|
if (readyIdx.length === 0) throw new Error('RoomOrchestrator: no ready worker available to host a new room')
|
|
@@ -503,13 +499,7 @@ export class RoomOrchestrator {
|
|
|
503
499
|
const rooms = perWorkerRooms[k]
|
|
504
500
|
// No usable status (fetch failed, or genuinely zero rooms -> zero weight anyway) -- treat as
|
|
505
501
|
// pure room-count load so an empty/unreachable-status worker is never unfairly skipped.
|
|
506
|
-
const score = rooms
|
|
507
|
-
? rooms.reduce((sum, r) => sum
|
|
508
|
-
+ (r.players || 0) * PLAYER_WEIGHT
|
|
509
|
-
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
510
|
-
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
511
|
-
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
|
|
512
|
-
: this.workers[i].roomIds.size
|
|
502
|
+
const score = rooms ? scoreWorkerRooms(rooms) : this.workers[i].roomIds.size
|
|
513
503
|
if (score < bestScore) { bestScore = score; best = i }
|
|
514
504
|
}
|
|
515
505
|
return best
|
|
@@ -571,81 +561,9 @@ export class RoomOrchestrator {
|
|
|
571
561
|
}
|
|
572
562
|
|
|
573
563
|
/** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. */
|
|
564
|
+
/** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. Delegates to RoomOrchestratorHttp.js's startRoomOrchestratorRouter, which only reaches this instance through its public methods. */
|
|
574
565
|
startRouter(port) {
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
const url = new URL(req.url, 'http://localhost')
|
|
578
|
-
|
|
579
|
-
// POST /workers/register -- register an external worker (running on a different Machine)
|
|
580
|
-
// Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
|
|
581
|
-
if (req.method === 'POST' && url.pathname === '/workers/register') {
|
|
582
|
-
const body = await readJsonBody(req)
|
|
583
|
-
if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
|
|
584
|
-
try {
|
|
585
|
-
const result = await this.registerWorker({ host: body.host, portRange: body.portRange })
|
|
586
|
-
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
587
|
-
res.end(JSON.stringify(result))
|
|
588
|
-
} catch (e) {
|
|
589
|
-
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
590
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
591
|
-
}
|
|
592
|
-
return
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// DELETE /workers/:index -- deregister an external worker
|
|
596
|
-
if (req.method === 'DELETE') {
|
|
597
|
-
const wm = url.pathname.match(/^\/workers\/(\d+)$/)
|
|
598
|
-
if (wm) {
|
|
599
|
-
const ok = await this.deregisterWorker(parseInt(wm[1], 10))
|
|
600
|
-
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
601
|
-
res.end(JSON.stringify({ deregistered: ok }))
|
|
602
|
-
return
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// GET /workers -- list all workers with their hosts
|
|
607
|
-
if (url.pathname === '/workers') {
|
|
608
|
-
const list = []
|
|
609
|
-
for (let i = 0; i < this.workers.length; i++) {
|
|
610
|
-
const w = this.workers[i]
|
|
611
|
-
if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
|
|
612
|
-
}
|
|
613
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
614
|
-
res.end(JSON.stringify(list))
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// GET /crash-stats -- crash/restart stats for monitoring
|
|
619
|
-
if (url.pathname === '/crash-stats') {
|
|
620
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
621
|
-
res.end(JSON.stringify(this.getCrashStats()))
|
|
622
|
-
return
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (url.pathname === '/status') {
|
|
626
|
-
const rooms = await this.getStatus()
|
|
627
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
628
|
-
res.end(JSON.stringify({ workerCount: this.workers.length, rooms }))
|
|
629
|
-
return
|
|
630
|
-
}
|
|
631
|
-
const m = url.pathname.match(/^\/route\/(.+)$/)
|
|
632
|
-
if (m) {
|
|
633
|
-
const loc = this.route(decodeURIComponent(m[1]))
|
|
634
|
-
if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
|
|
635
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
636
|
-
res.end(JSON.stringify(loc))
|
|
637
|
-
return
|
|
638
|
-
}
|
|
639
|
-
res.writeHead(404); res.end('not found')
|
|
640
|
-
} catch (e) {
|
|
641
|
-
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
642
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
643
|
-
}
|
|
644
|
-
})
|
|
645
|
-
return new Promise((resolve, reject) => {
|
|
646
|
-
this.httpServer.once('error', reject)
|
|
647
|
-
this.httpServer.listen(port, () => resolve({ port: this.httpServer.address().port }))
|
|
648
|
-
})
|
|
566
|
+
return startRoomOrchestratorRouter(this, port)
|
|
649
567
|
}
|
|
650
568
|
|
|
651
569
|
/** Stops every worker process (each drains its own rooms via RoomDirectory.stopAll first) and the router HTTP listener. */
|
|
@@ -657,32 +575,3 @@ export class RoomOrchestrator {
|
|
|
657
575
|
}
|
|
658
576
|
}
|
|
659
577
|
|
|
660
|
-
/** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
|
|
661
|
-
export function readJsonBody(req) {
|
|
662
|
-
return new Promise((resolve) => {
|
|
663
|
-
let buf = ''
|
|
664
|
-
req.on('data', (chunk) => { buf += chunk })
|
|
665
|
-
req.on('end', () => {
|
|
666
|
-
try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
|
|
667
|
-
})
|
|
668
|
-
req.on('error', () => resolve(null))
|
|
669
|
-
})
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function httpJsonRequest(url, method, body) {
|
|
673
|
-
return new Promise((resolve, reject) => {
|
|
674
|
-
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
675
|
-
const data = body !== undefined ? JSON.stringify(body) : null
|
|
676
|
-
const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
|
|
677
|
-
const req = requestFn(url, { method, headers }, (res) => {
|
|
678
|
-
let buf = ''
|
|
679
|
-
res.on('data', (c) => { buf += c })
|
|
680
|
-
res.on('end', () => {
|
|
681
|
-
try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
|
|
682
|
-
})
|
|
683
|
-
})
|
|
684
|
-
req.on('error', reject)
|
|
685
|
-
if (data) req.write(data)
|
|
686
|
-
req.end()
|
|
687
|
-
})
|
|
688
|
-
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Pure HTTP helpers for RoomOrchestrator.js: JSON request-body reading (for the router's own
|
|
2
|
+
// listener) and JSON-over-HTTP(S) request/response (for talking to an EXTERNAL worker's command
|
|
3
|
+
// port). No reference to RoomOrchestrator's own instance state -- split out as the one genuinely
|
|
4
|
+
// stateless piece of that file.
|
|
5
|
+
|
|
6
|
+
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
|
|
7
|
+
import { request as httpsRequest } from 'node:https'
|
|
8
|
+
|
|
9
|
+
/** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
|
|
10
|
+
export function readJsonBody(req) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let buf = ''
|
|
13
|
+
req.on('data', (chunk) => { buf += chunk })
|
|
14
|
+
req.on('end', () => {
|
|
15
|
+
try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
|
|
16
|
+
})
|
|
17
|
+
req.on('error', () => resolve(null))
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function httpJsonRequest(url, method, body) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
24
|
+
const data = body !== undefined ? JSON.stringify(body) : null
|
|
25
|
+
const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
|
|
26
|
+
const req = requestFn(url, { method, headers }, (res) => {
|
|
27
|
+
let buf = ''
|
|
28
|
+
res.on('data', (c) => { buf += c })
|
|
29
|
+
res.on('end', () => {
|
|
30
|
+
try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
req.on('error', reject)
|
|
34
|
+
if (data) req.write(data)
|
|
35
|
+
req.end()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// server-scale-room-orchestrator-load-aware-placement's weight formula (see RoomOrchestrator.js's
|
|
40
|
+
// _pickWeightedWorker header for the full rationale) -- pure given a room-status row, no orchestrator
|
|
41
|
+
// instance state, so it is shared verbatim between _pickWeightedWorker (placement) and _elasticCheck
|
|
42
|
+
// (scale-up trigger) rather than kept as two independently-maintained copies of the same weights.
|
|
43
|
+
export const PLACEMENT_WEIGHTS = { PLAYER_WEIGHT: 1.0, ENTITY_WEIGHT: 0.02, TICKMS_WEIGHT: 0.5, DILATION_PENALTY: 50 }
|
|
44
|
+
|
|
45
|
+
export function scoreRoom(r) {
|
|
46
|
+
const { PLAYER_WEIGHT, ENTITY_WEIGHT, TICKMS_WEIGHT, DILATION_PENALTY } = PLACEMENT_WEIGHTS
|
|
47
|
+
return (r.players || 0) * PLAYER_WEIGHT
|
|
48
|
+
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
49
|
+
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
50
|
+
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scoreWorkerRooms(rooms) {
|
|
54
|
+
return rooms.reduce((sum, r) => sum + scoreRoom(r), 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Starts the minimal HTTP router listener on `port` for a RoomOrchestrator instance `orch`: GET
|
|
58
|
+
// /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full
|
|
59
|
+
// fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index ->
|
|
60
|
+
// deregister, GET /workers -> list, GET /crash-stats -> crash/restart stats. Not a traffic proxy --
|
|
61
|
+
// see RoomOrchestrator.js's class doc comment. Only reaches `orch` through its public methods
|
|
62
|
+
// (registerWorker/deregisterWorker/getCrashStats/getStatus/route) plus a read of orch.workers, so
|
|
63
|
+
// this is safely split from the class despite touching orchestrator state.
|
|
64
|
+
export function startRoomOrchestratorRouter(orch, port) {
|
|
65
|
+
orch.httpServer = createHttpServer(async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(req.url, 'http://localhost')
|
|
68
|
+
|
|
69
|
+
// POST /workers/register -- register an external worker (running on a different Machine)
|
|
70
|
+
// Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
|
|
71
|
+
if (req.method === 'POST' && url.pathname === '/workers/register') {
|
|
72
|
+
const body = await readJsonBody(req)
|
|
73
|
+
if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
|
|
74
|
+
try {
|
|
75
|
+
const result = await orch.registerWorker({ host: body.host, portRange: body.portRange })
|
|
76
|
+
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
77
|
+
res.end(JSON.stringify(result))
|
|
78
|
+
} catch (e) {
|
|
79
|
+
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
80
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DELETE /workers/:index -- deregister an external worker
|
|
86
|
+
if (req.method === 'DELETE') {
|
|
87
|
+
const wm = url.pathname.match(/^\/workers\/(\d+)$/)
|
|
88
|
+
if (wm) {
|
|
89
|
+
const ok = await orch.deregisterWorker(parseInt(wm[1], 10))
|
|
90
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify({ deregistered: ok }))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// GET /workers -- list all workers with their hosts
|
|
97
|
+
if (url.pathname === '/workers') {
|
|
98
|
+
const list = []
|
|
99
|
+
for (let i = 0; i < orch.workers.length; i++) {
|
|
100
|
+
const w = orch.workers[i]
|
|
101
|
+
if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
104
|
+
res.end(JSON.stringify(list))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GET /crash-stats -- crash/restart stats for monitoring
|
|
109
|
+
if (url.pathname === '/crash-stats') {
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
111
|
+
res.end(JSON.stringify(orch.getCrashStats()))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === '/status') {
|
|
116
|
+
const rooms = await orch.getStatus()
|
|
117
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
118
|
+
res.end(JSON.stringify({ workerCount: orch.workers.length, rooms }))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const m = url.pathname.match(/^\/route\/(.+)$/)
|
|
122
|
+
if (m) {
|
|
123
|
+
const loc = orch.route(decodeURIComponent(m[1]))
|
|
124
|
+
if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
|
|
125
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
126
|
+
res.end(JSON.stringify(loc))
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(404); res.end('not found')
|
|
130
|
+
} catch (e) {
|
|
131
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
132
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
orch.httpServer.once('error', reject)
|
|
137
|
+
orch.httpServer.listen(port, () => resolve({ port: orch.httpServer.address().port }))
|
|
138
|
+
})
|
|
139
|
+
}
|