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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.588",
3
+ "version": "0.1.589",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -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 Promise.reject(new Error(`RoomOrchestrator: worker ${workerIndex} is external (no local IPC) -- use HTTP to communicate with it`))
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 && !w.isExternal ? this._send(i, { type: 'GET_STATUS' }).then(r => r.rooms).catch(() => []) : Promise.resolve([])))
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
  }