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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.652",
3
+ "version": "0.1.653",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -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 { createServer as createHttpServer, request as httpRequest } from 'node:http'
51
- import { request as httpsRequest } from 'node:https'
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.reduce((sum, r) => sum
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
- this.httpServer = createHttpServer(async (req, res) => {
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
+ }