spoint 0.1.654 → 0.1.656

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.654",
3
+ "version": "0.1.656",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,17 +1,16 @@
1
1
  import { createServer as createHttpServer } from 'node:http'
2
2
  import { WebSocketServer as WSServer } from 'ws'
3
- import { MSG } from '../protocol/MessageTypes.js'
4
3
  import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
5
4
  import { createStaticHandler } from './StaticHandler.js'
6
5
  import { WebSocketTransport } from '../transport/WebSocketTransport.js'
7
6
  import { WebTransportServer } from '../transport/WebTransportServer.js'
8
7
  import { createUploadHandler } from './UploadHandler.js'
9
8
  import { setupTerrainStreaming } from '../terrain/TerrainPhysics.js'
10
- import { timingSafeTokenEqual } from './authCompare.js'
11
- import { renderMetrics } from './Metrics.js'
12
9
  import { restoreWorldSnapshot, saveWorldSnapshot } from './WorldPersistence.js'
13
- import { collectBenchmark } from './PublicBenchmark.js'
14
- import { validateMessage, KIND_PLACE, KIND_UPDATE, KIND_REMOVE, KIND_CLEAR, KIND_DATASET, KIND_CAMERA } from './FreddieBridge.js'
10
+ import {
11
+ handleUploadModel, handleDebugLog, handleClientError, handleDebugServer,
12
+ handleMetrics, handleBenchmark, handleFreddieViz
13
+ } from './ServerAPIRoutes.js'
15
14
 
16
15
  // Top-down color+height minimap bake-if-missing, keyed by seed (real artifact: apps/world/<worldName>.<seed>.minimap.png
17
16
  // + a sibling .json header). Reuses scripts/bake-minimap.mjs's bakeMinimap() directly (pure-Node CPU height+climate
@@ -56,50 +55,6 @@ export async function bakeMinimapIfMissing(worldName, tcfg, opts = {}) {
56
55
  console.log(`[minimap] baked ${base}.png (${header.N}x${header.N}, ${(png.length / 1024).toFixed(1)}KB, height ${header.minHeight}..${header.maxHeight}m) in ${Date.now() - t0}ms`)
57
56
  }
58
57
 
59
- // Per-IP token bucket for /debug-log: caps sustained log-line volume from any single origin even after
60
- // the loopback/EDITOR_TOKEN gate passes, so a single misbehaving/malicious client on an allowed origin
61
- // can't still spam the server console / consume CPU by hammering the endpoint at wire speed.
62
- const DEBUG_LOG_BUCKET_CAPACITY = 20 // burst allowance, lines
63
- const DEBUG_LOG_BUCKET_REFILL_PER_SEC = 5 // steady-state cap, lines/sec
64
- const _debugLogBuckets = new Map() // ip -> { tokens, lastRefillMs }
65
-
66
- function debugLogRateLimited(ip) {
67
- const now = Date.now()
68
- let b = _debugLogBuckets.get(ip)
69
- if (!b) { b = { tokens: DEBUG_LOG_BUCKET_CAPACITY, lastRefillMs: now }; _debugLogBuckets.set(ip, b) }
70
- const elapsedSec = (now - b.lastRefillMs) / 1000
71
- if (elapsedSec > 0) {
72
- b.tokens = Math.min(DEBUG_LOG_BUCKET_CAPACITY, b.tokens + elapsedSec * DEBUG_LOG_BUCKET_REFILL_PER_SEC)
73
- b.lastRefillMs = now
74
- }
75
- if (b.tokens < 1) return true // no tokens left -> rate limited
76
- b.tokens -= 1
77
- return false
78
- }
79
-
80
- // Per-IP token bucket for /client-error: same shape as debugLogRateLimited above, but this
81
- // endpoint is PUBLIC (real deployed players, not loopback-only dev tooling) so the bucket is the
82
- // only defense against a hostile or buggy client flooding the server with crash reports -- tighter
83
- // than the debug-log bucket since a real crash storm (e.g. every connected player hitting the same
84
- // bug at once) should still log a representative sample, not every single occurrence.
85
- const CLIENT_ERROR_BUCKET_CAPACITY = 5
86
- const CLIENT_ERROR_BUCKET_REFILL_PER_SEC = 0.2 // 1 report per 5s steady-state per IP
87
- const _clientErrorBuckets = new Map() // ip -> { tokens, lastRefillMs }
88
-
89
- function clientErrorRateLimited(ip) {
90
- const now = Date.now()
91
- let b = _clientErrorBuckets.get(ip)
92
- if (!b) { b = { tokens: CLIENT_ERROR_BUCKET_CAPACITY, lastRefillMs: now }; _clientErrorBuckets.set(ip, b) }
93
- const elapsedSec = (now - b.lastRefillMs) / 1000
94
- if (elapsedSec > 0) {
95
- b.tokens = Math.min(CLIENT_ERROR_BUCKET_CAPACITY, b.tokens + elapsedSec * CLIENT_ERROR_BUCKET_REFILL_PER_SEC)
96
- b.lastRefillMs = now
97
- }
98
- if (b.tokens < 1) return true
99
- b.tokens -= 1
100
- return false
101
- }
102
-
103
58
  export function createServerAPI(ctx) {
104
59
  const { config, port, tickRate, staticDirs, appLoader, appRuntime, physics, physicsIntegration, stageLoader } = ctx
105
60
  const { tickSystem, playerManager, networkState, lagCompensator, connections, sessions, inspector, emitter, reloadManager, eventBus, eventLog, storage } = ctx
@@ -279,227 +234,13 @@ export function createServerAPI(ctx) {
279
234
  })
280
235
  const staticHandler = staticDirs.length > 0 ? createStaticHandler(staticDirs, { getWorldInfo }) : null
281
236
  const httpHandler = (req, res) => {
282
- if (req.method === 'POST' && req.url === '/upload-model') {
283
- // EDITOR_TOKEN unset = open (dev default); configured = required via X-Editor-Token header
284
- const _tok = process.env.EDITOR_TOKEN
285
- if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
286
- uploadHandler(req, res); return
287
- }
288
- if (req.method === 'POST' && req.url === '/debug-log') {
289
- // gated: loopback origin is always allowed (local dev console passthrough); a non-loopback
290
- // origin must present a valid X-Editor-Token when EDITOR_TOKEN is configured, and is refused
291
- // outright when it isn't (an unset EDITOR_TOKEN must not leave this endpoint open to the world).
292
- const _remote = req.socket?.remoteAddress || ''
293
- const _isLoopback = _remote === '127.0.0.1' || _remote === '::1' || _remote === '::ffff:127.0.0.1'
294
- if (!_isLoopback) {
295
- const _tok = process.env.EDITOR_TOKEN
296
- if (!_tok || !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
297
- }
298
- // token-bucket rate limit per-IP: caps sustained lines/sec even from an already-authorized origin
299
- if (debugLogRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
300
- // size-capped: unbounded body buffering here let any origin exhaust server memory
301
- const _DEBUG_LOG_MAX = 256 * 1024
302
- let _len = 0, _over = false
303
- const chunks = []
304
- req.on('data', d => {
305
- if (_over) return
306
- _len += d.length
307
- if (_len > _DEBUG_LOG_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
308
- chunks.push(d)
309
- })
310
- req.on('end', () => { if (_over) return; try { const d = JSON.parse(Buffer.concat(chunks).toString()); console.log('[browser]', ...d) } catch(_) {}; res.writeHead(200); res.end() })
311
- return
312
- }
313
- if (req.method === 'POST' && req.url === '/client-error') {
314
- // PUBLIC, opt-in-only-on-the-CLIENT-side endpoint (client/core/ErrorTelemetry.js) --
315
- // unlike /debug-log and /upload-model above, this is intentionally reachable from any
316
- // real deployed player, not loopback/EDITOR_TOKEN-gated, since the whole point is to
317
- // hear from crashes on machines the operator has no console access to. The gate here is
318
- // purely anti-abuse (rate limit + size cap), not an identity/auth check -- the payload
319
- // itself carries no PII by construction (see ErrorTelemetry.js's schema comment).
320
- const _remote = req.socket?.remoteAddress || ''
321
- if (clientErrorRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
322
- const _CLIENT_ERROR_MAX = 16 * 1024 // payload is a small structured JSON object, not a log dump
323
- let _len = 0, _over = false
324
- const chunks = []
325
- req.on('data', d => {
326
- if (_over) return
327
- _len += d.length
328
- if (_len > _CLIENT_ERROR_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
329
- chunks.push(d)
330
- })
331
- req.on('end', () => {
332
- if (_over) return
333
- try {
334
- const report = JSON.parse(Buffer.concat(chunks).toString())
335
- // Structured, one-line-per-report console surface (an operator greps/aggregates
336
- // this today; a real dashboard/store is explicitly out of scope for this first
337
- // slice -- see the sibling PRD row filed for that). kind/message/stack/url/ua/ts
338
- // are the ErrorTelemetry.js schema fields; renderControls/deviceTier are attached
339
- // objects, logged inline so `console.log`'s default object formatting keeps them
340
- // inspectable rather than flattened into an unreadable string.
341
- console.error(`[client-error] ${report.kind || 'error'}: ${String(report.message || '').slice(0, 500)}`,
342
- { url: report.url, ua: report.ua, stack: String(report.stack || '').slice(0, 2000), renderControls: report.renderControls, deviceTier: report.deviceTier, remote: _remote })
343
- } catch (_) { /* malformed payload from a hostile/buggy client -- drop silently, still 200 so sendBeacon doesn't retry-storm */ }
344
- res.writeHead(200); res.end()
345
- })
346
- return
347
- }
348
- if (req.method === 'GET' && req.url === '/debug/server') {
349
- // loopback-only: leaks tick/player/entity/session counts + process memory internals
350
- const remote = req.socket?.remoteAddress || ''
351
- if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
352
- const data = JSON.stringify({
353
- tick: tickSystem.currentTick,
354
- tickRate: ctx.tickRate,
355
- players: playerManager.getPlayerCount(),
356
- entities: appRuntime.entities.size,
357
- connections: connections.getAllStats(),
358
- sessions: sessions.getActiveCount(),
359
- heap: process.memoryUsage()
360
- })
361
- res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(data); return
362
- }
363
- if (req.method === 'GET' && req.url === '/metrics') {
364
- // server-scale-prometheus-metrics-endpoint-dashboard: same loopback-only gate as /debug/server
365
- // immediately above -- this leaks the identical class of operational internals (tick/player/
366
- // entity counts, process memory), just reformatted for Prometheus scrape instead of a one-shot
367
- // JSON GET. A Prometheus server itself is expected to run co-located (or reached via an
368
- // operator-controlled reverse-proxy/tunnel that terminates on loopback), matching how every
369
- // other loopback-gated route in this file is already meant to be consumed.
370
- const remote = req.socket?.remoteAddress || ''
371
- if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
372
- const body = renderMetrics({
373
- tick: tickSystem.currentTick,
374
- tickRate: ctx.tickRate,
375
- players: playerManager.getPlayerCount(),
376
- entities: appRuntime.entities.size,
377
- sessionCount: sessions.getActiveCount(),
378
- uptimeSec: process.uptime(),
379
- memoryUsage: () => process.memoryUsage(),
380
- // TickHandler.js's onTick.getMetrics() -- see ctx.tickHandlerFn (server.js/WorkerEntry.js
381
- // setTickHandler), a stable alias reload-swappable handlerState.fn is mirrored onto so this
382
- // route never reaches into reload-internal plumbing directly. Absent (fresh boot before the
383
- // first tick, or a handler build that predates this alias) degrades to no tickTiming section
384
- // rather than throwing -- /metrics must stay a safe, always-200 operational surface.
385
- tickTiming: typeof ctx.tickHandlerFn?.getMetrics === 'function' ? ctx.tickHandlerFn.getMetrics() : null,
386
- // RoomDirectory (src/sdk/RoomDirectory.js) is a standalone, opt-in multi-room primitive not
387
- // constructed by every boot path -- its own getStatus() doc comment already names this route
388
- // as its intended consumer, so a caller that DOES wire one up onto ctx.roomDirectory gets
389
- // per-room rows for free with zero further ServerAPI.js changes; every other boot path simply
390
- // omits the rooms section (Array.isArray guard in renderMetrics).
391
- rooms: typeof ctx.roomDirectory?.getStatus === 'function' ? ctx.roomDirectory.getStatus() : undefined,
392
- })
393
- res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); res.end(body); return
394
- }
395
- if (req.method === 'GET' && req.url === '/benchmark') {
396
- // Public benchmark endpoint (see PRD rows ugc-platform + ugc-public-benchmark-dashboard):
397
- // exposes standardized server performance data as JSON with CORS headers so a static HTML
398
- // dashboard page (client/benchmark.html) can consume it from any origin. Deliberately UN-gated
399
- // (no loopback/EDITOR_TOKEN check) -- this is a public brag surface, not an operational secret.
400
- // The data shape is deliberately high-level (tick stats, player counts, memory, build info) and
401
- // carries zero PII, internal IPs, auth tokens, or player-identifying data.
402
- try {
403
- const data = collectBenchmark(ctx)
404
- const json = JSON.stringify(data)
405
- res.writeHead(200, {
406
- 'Content-Type': 'application/json',
407
- 'Cache-Control': 'no-cache',
408
- 'Access-Control-Allow-Origin': '*',
409
- })
410
- res.end(json)
411
- } catch (err) {
412
- res.writeHead(500, { 'Content-Type': 'application/json' })
413
- res.end(JSON.stringify({ error: 'benchmark collection failed', detail: err.message }))
414
- }
415
- return
416
- }
417
- if (req.method === 'POST' && req.url === '/freddie/viz') {
418
- // FreddieBridge viz endpoint: accepts FreddieBridge messages (JSON), validates them,
419
- // and creates/updates/destroys entities in the live world. EDITOR_TOKEN-gated when
420
- // configured (same discipline as /upload-model above); an unset EDITOR_TOKEN leaves
421
- // this endpoint open (dev default). Rate-limited by body size for safety.
422
- const _tok = process.env.EDITOR_TOKEN
423
- if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
424
- const _FREDDIE_MAX = 256 * 1024
425
- let _len = 0, _over = false
426
- const chunks = []
427
- req.on('data', d => {
428
- if (_over) return
429
- _len += d.length
430
- if (_len > _FREDDIE_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
431
- chunks.push(d)
432
- })
433
- req.on('end', () => {
434
- if (_over) return
435
- let body
436
- try { body = JSON.parse(Buffer.concat(chunks).toString()) } catch (_) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON' })); return }
437
- // Accept a single message or an array of messages
438
- const messages = Array.isArray(body) ? body : [body]
439
- const results = []
440
- for (const msg of messages) {
441
- const v = validateMessage(msg)
442
- if (!v.valid) { results.push({ id: msg.id, ok: false, error: 'validation failed', detail: v.errors }); continue }
443
- try {
444
- if (msg.kind === KIND_PLACE) {
445
- const p = msg.payload
446
- const entityId = p.entityId
447
- // Remove existing entity with same id if present (idempotent place)
448
- if (appRuntime.entities.has(entityId)) appRuntime.destroyEntity(entityId)
449
- const cfg = {
450
- position: p.position || [0, 0, 0],
451
- scale: p.scale || [1, 1, 1],
452
- custom: {
453
- mesh: p.primitive || 'box',
454
- color: p.color ?? 0xffffff,
455
- emissive: p.emissive ?? 0x000000,
456
- opacity: p.opacity ?? 1,
457
- label: p.label || null,
458
- _freddieSource: msg.source,
459
- _freddieId: entityId,
460
- },
461
- config: {},
462
- }
463
- if (p.primitive === 'model' && p.model) cfg.model = p.model
464
- appRuntime.spawnEntity(entityId, cfg)
465
- results.push({ id: msg.id, ok: true, entityId })
466
- } else if (msg.kind === KIND_UPDATE) {
467
- const p = msg.payload
468
- const e = appRuntime.entities.get(p.entityId)
469
- if (!e) { results.push({ id: msg.id, ok: false, error: 'entity not found', entityId: p.entityId }); continue }
470
- if (p.position) e.position = [...p.position]
471
- if (p.scale) e.scale = [...p.scale]
472
- if (e.custom) {
473
- if (p.color !== undefined) e.custom.color = p.color
474
- if (p.emissive !== undefined) e.custom.emissive = p.emissive
475
- if (p.opacity !== undefined) e.custom.opacity = p.opacity
476
- if (p.label !== undefined) e.custom.label = p.label
477
- }
478
- results.push({ id: msg.id, ok: true, entityId: p.entityId })
479
- } else if (msg.kind === KIND_REMOVE) {
480
- appRuntime.destroyEntity(msg.payload.entityId)
481
- results.push({ id: msg.id, ok: true, entityId: msg.payload.entityId })
482
- } else if (msg.kind === KIND_CLEAR) {
483
- // Remove all entities created by this source
484
- const source = msg.source
485
- const toRemove = []
486
- for (const [id, e] of appRuntime.entities) {
487
- if (e.custom?._freddieSource === source) toRemove.push(id)
488
- }
489
- for (const id of toRemove) appRuntime.destroyEntity(id)
490
- results.push({ id: msg.id, ok: true, removed: toRemove.length })
491
- } else {
492
- results.push({ id: msg.id, ok: false, error: `unhandled kind: ${msg.kind}` })
493
- }
494
- } catch (e) {
495
- results.push({ id: msg.id, ok: false, error: e.message })
496
- }
497
- }
498
- res.writeHead(200, { 'Content-Type': 'application/json' })
499
- res.end(JSON.stringify(Array.isArray(body) ? results : results[0]))
500
- })
501
- return
502
- }
237
+ if (req.method === 'POST' && req.url === '/upload-model') { handleUploadModel(req, res, uploadHandler); return }
238
+ if (req.method === 'POST' && req.url === '/debug-log') { handleDebugLog(req, res); return }
239
+ if (req.method === 'POST' && req.url === '/client-error') { handleClientError(req, res); return }
240
+ if (req.method === 'GET' && req.url === '/debug/server') { handleDebugServer(req, res, ctx); return }
241
+ if (req.method === 'GET' && req.url === '/metrics') { handleMetrics(req, res, ctx); return }
242
+ if (req.method === 'GET' && req.url === '/benchmark') { handleBenchmark(req, res, ctx); return }
243
+ if (req.method === 'POST' && req.url === '/freddie/viz') { handleFreddieViz(req, res, appRuntime); return }
503
244
  if (staticHandler) {
504
245
  Promise.resolve(staticHandler(req, res)).catch(e => {
505
246
  console.error('[static] handler error:', e?.message || e)
@@ -0,0 +1,279 @@
1
+ // HTTP route handlers for ServerAPI.js's start(): /upload-model, /debug-log, /client-error,
2
+ // /debug/server, /metrics, /benchmark, /freddie/viz. Split out because start()'s httpHandler
3
+ // closure was the single largest contiguous block in ServerAPI.js -- each handler here is a pure
4
+ // function of (req, res, ctx-derived state), no shared closure with the rest of ServerAPI.js beyond
5
+ // what's passed in explicitly.
6
+
7
+ import { timingSafeTokenEqual } from './authCompare.js'
8
+ import { renderMetrics } from './Metrics.js'
9
+ import { collectBenchmark } from './PublicBenchmark.js'
10
+ import { validateMessage, KIND_PLACE, KIND_UPDATE, KIND_REMOVE, KIND_CLEAR } from './FreddieBridge.js'
11
+
12
+ // Per-IP token bucket for /debug-log: caps sustained log-line volume from any single origin even after
13
+ // the loopback/EDITOR_TOKEN gate passes, so a single misbehaving/malicious client on an allowed origin
14
+ // can't still spam the server console / consume CPU by hammering the endpoint at wire speed.
15
+ const DEBUG_LOG_BUCKET_CAPACITY = 20 // burst allowance, lines
16
+ const DEBUG_LOG_BUCKET_REFILL_PER_SEC = 5 // steady-state cap, lines/sec
17
+ const _debugLogBuckets = new Map() // ip -> { tokens, lastRefillMs }
18
+
19
+ function debugLogRateLimited(ip) {
20
+ const now = Date.now()
21
+ let b = _debugLogBuckets.get(ip)
22
+ if (!b) { b = { tokens: DEBUG_LOG_BUCKET_CAPACITY, lastRefillMs: now }; _debugLogBuckets.set(ip, b) }
23
+ const elapsedSec = (now - b.lastRefillMs) / 1000
24
+ if (elapsedSec > 0) {
25
+ b.tokens = Math.min(DEBUG_LOG_BUCKET_CAPACITY, b.tokens + elapsedSec * DEBUG_LOG_BUCKET_REFILL_PER_SEC)
26
+ b.lastRefillMs = now
27
+ }
28
+ if (b.tokens < 1) return true // no tokens left -> rate limited
29
+ b.tokens -= 1
30
+ return false
31
+ }
32
+
33
+ // Per-IP token bucket for /client-error: same shape as debugLogRateLimited above, but this
34
+ // endpoint is PUBLIC (real deployed players, not loopback-only dev tooling) so the bucket is the
35
+ // only defense against a hostile or buggy client flooding the server with crash reports -- tighter
36
+ // than the debug-log bucket since a real crash storm (e.g. every connected player hitting the same
37
+ // bug at once) should still log a representative sample, not every single occurrence.
38
+ const CLIENT_ERROR_BUCKET_CAPACITY = 5
39
+ const CLIENT_ERROR_BUCKET_REFILL_PER_SEC = 0.2 // 1 report per 5s steady-state per IP
40
+ const _clientErrorBuckets = new Map() // ip -> { tokens, lastRefillMs }
41
+
42
+ function clientErrorRateLimited(ip) {
43
+ const now = Date.now()
44
+ let b = _clientErrorBuckets.get(ip)
45
+ if (!b) { b = { tokens: CLIENT_ERROR_BUCKET_CAPACITY, lastRefillMs: now }; _clientErrorBuckets.set(ip, b) }
46
+ const elapsedSec = (now - b.lastRefillMs) / 1000
47
+ if (elapsedSec > 0) {
48
+ b.tokens = Math.min(CLIENT_ERROR_BUCKET_CAPACITY, b.tokens + elapsedSec * CLIENT_ERROR_BUCKET_REFILL_PER_SEC)
49
+ b.lastRefillMs = now
50
+ }
51
+ if (b.tokens < 1) return true
52
+ b.tokens -= 1
53
+ return false
54
+ }
55
+
56
+ export function handleUploadModel(req, res, uploadHandler) {
57
+ const _tok = process.env.EDITOR_TOKEN
58
+ if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
59
+ uploadHandler(req, res)
60
+ }
61
+
62
+ export function handleDebugLog(req, res) {
63
+ // gated: loopback origin is always allowed (local dev console passthrough); a non-loopback
64
+ // origin must present a valid X-Editor-Token when EDITOR_TOKEN is configured, and is refused
65
+ // outright when it isn't (an unset EDITOR_TOKEN must not leave this endpoint open to the world).
66
+ const _remote = req.socket?.remoteAddress || ''
67
+ const _isLoopback = _remote === '127.0.0.1' || _remote === '::1' || _remote === '::ffff:127.0.0.1'
68
+ if (!_isLoopback) {
69
+ const _tok = process.env.EDITOR_TOKEN
70
+ if (!_tok || !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
71
+ }
72
+ // token-bucket rate limit per-IP: caps sustained lines/sec even from an already-authorized origin
73
+ if (debugLogRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
74
+ // size-capped: unbounded body buffering here let any origin exhaust server memory
75
+ const _DEBUG_LOG_MAX = 256 * 1024
76
+ let _len = 0, _over = false
77
+ const chunks = []
78
+ req.on('data', d => {
79
+ if (_over) return
80
+ _len += d.length
81
+ if (_len > _DEBUG_LOG_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
82
+ chunks.push(d)
83
+ })
84
+ req.on('end', () => { if (_over) return; try { const d = JSON.parse(Buffer.concat(chunks).toString()); console.log('[browser]', ...d) } catch(_) {}; res.writeHead(200); res.end() })
85
+ }
86
+
87
+ export function handleClientError(req, res) {
88
+ // PUBLIC, opt-in-only-on-the-CLIENT-side endpoint (client/core/ErrorTelemetry.js) --
89
+ // unlike /debug-log and /upload-model above, this is intentionally reachable from any
90
+ // real deployed player, not loopback/EDITOR_TOKEN-gated, since the whole point is to
91
+ // hear from crashes on machines the operator has no console access to. The gate here is
92
+ // purely anti-abuse (rate limit + size cap), not an identity/auth check -- the payload
93
+ // itself carries no PII by construction (see ErrorTelemetry.js's schema comment).
94
+ const _remote = req.socket?.remoteAddress || ''
95
+ if (clientErrorRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
96
+ const _CLIENT_ERROR_MAX = 16 * 1024 // payload is a small structured JSON object, not a log dump
97
+ let _len = 0, _over = false
98
+ const chunks = []
99
+ req.on('data', d => {
100
+ if (_over) return
101
+ _len += d.length
102
+ if (_len > _CLIENT_ERROR_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
103
+ chunks.push(d)
104
+ })
105
+ req.on('end', () => {
106
+ if (_over) return
107
+ try {
108
+ const report = JSON.parse(Buffer.concat(chunks).toString())
109
+ // Structured, one-line-per-report console surface (an operator greps/aggregates
110
+ // this today; a real dashboard/store is explicitly out of scope for this first
111
+ // slice -- see the sibling PRD row filed for that). kind/message/stack/url/ua/ts
112
+ // are the ErrorTelemetry.js schema fields; renderControls/deviceTier are attached
113
+ // objects, logged inline so `console.log`'s default object formatting keeps them
114
+ // inspectable rather than flattened into an unreadable string.
115
+ console.error(`[client-error] ${report.kind || 'error'}: ${String(report.message || '').slice(0, 500)}`,
116
+ { url: report.url, ua: report.ua, stack: String(report.stack || '').slice(0, 2000), renderControls: report.renderControls, deviceTier: report.deviceTier, remote: _remote })
117
+ } catch (_) { /* malformed payload from a hostile/buggy client -- drop silently, still 200 so sendBeacon doesn't retry-storm */ }
118
+ res.writeHead(200); res.end()
119
+ })
120
+ }
121
+
122
+ export function handleDebugServer(req, res, ctx) {
123
+ // loopback-only: leaks tick/player/entity/session counts + process memory internals
124
+ const remote = req.socket?.remoteAddress || ''
125
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
126
+ const { tickSystem, playerManager, appRuntime, connections, sessions } = ctx
127
+ const data = JSON.stringify({
128
+ tick: tickSystem.currentTick,
129
+ tickRate: ctx.tickRate,
130
+ players: playerManager.getPlayerCount(),
131
+ entities: appRuntime.entities.size,
132
+ connections: connections.getAllStats(),
133
+ sessions: sessions.getActiveCount(),
134
+ heap: process.memoryUsage()
135
+ })
136
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(data)
137
+ }
138
+
139
+ export function handleMetrics(req, res, ctx) {
140
+ // server-scale-prometheus-metrics-endpoint-dashboard: same loopback-only gate as /debug/server
141
+ // immediately above -- this leaks the identical class of operational internals (tick/player/
142
+ // entity counts, process memory), just reformatted for Prometheus scrape instead of a one-shot
143
+ // JSON GET. A Prometheus server itself is expected to run co-located (or reached via an
144
+ // operator-controlled reverse-proxy/tunnel that terminates on loopback), matching how every
145
+ // other loopback-gated route in this file is already meant to be consumed.
146
+ const remote = req.socket?.remoteAddress || ''
147
+ if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
148
+ const { tickSystem, playerManager, appRuntime, sessions } = ctx
149
+ const body = renderMetrics({
150
+ tick: tickSystem.currentTick,
151
+ tickRate: ctx.tickRate,
152
+ players: playerManager.getPlayerCount(),
153
+ entities: appRuntime.entities.size,
154
+ sessionCount: sessions.getActiveCount(),
155
+ uptimeSec: process.uptime(),
156
+ memoryUsage: () => process.memoryUsage(),
157
+ // TickHandler.js's onTick.getMetrics() -- see ctx.tickHandlerFn (server.js/WorkerEntry.js
158
+ // setTickHandler), a stable alias reload-swappable handlerState.fn is mirrored onto so this
159
+ // route never reaches into reload-internal plumbing directly. Absent (fresh boot before the
160
+ // first tick, or a handler build that predates this alias) degrades to no tickTiming section
161
+ // rather than throwing -- /metrics must stay a safe, always-200 operational surface.
162
+ tickTiming: typeof ctx.tickHandlerFn?.getMetrics === 'function' ? ctx.tickHandlerFn.getMetrics() : null,
163
+ // RoomDirectory (src/sdk/RoomDirectory.js) is a standalone, opt-in multi-room primitive not
164
+ // constructed by every boot path -- its own getStatus() doc comment already names this route
165
+ // as its intended consumer, so a caller that DOES wire one up onto ctx.roomDirectory gets
166
+ // per-room rows for free with zero further ServerAPI.js changes; every other boot path simply
167
+ // omits the rooms section (Array.isArray guard in renderMetrics).
168
+ rooms: typeof ctx.roomDirectory?.getStatus === 'function' ? ctx.roomDirectory.getStatus() : undefined,
169
+ })
170
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); res.end(body)
171
+ }
172
+
173
+ export function handleBenchmark(req, res, ctx) {
174
+ // Public benchmark endpoint (see PRD rows ugc-platform + ugc-public-benchmark-dashboard):
175
+ // exposes standardized server performance data as JSON with CORS headers so a static HTML
176
+ // dashboard page (client/benchmark.html) can consume it from any origin. Deliberately UN-gated
177
+ // (no loopback/EDITOR_TOKEN check) -- this is a public brag surface, not an operational secret.
178
+ // The data shape is deliberately high-level (tick stats, player counts, memory, build info) and
179
+ // carries zero PII, internal IPs, auth tokens, or player-identifying data.
180
+ try {
181
+ const data = collectBenchmark(ctx)
182
+ const json = JSON.stringify(data)
183
+ res.writeHead(200, {
184
+ 'Content-Type': 'application/json',
185
+ 'Cache-Control': 'no-cache',
186
+ 'Access-Control-Allow-Origin': '*',
187
+ })
188
+ res.end(json)
189
+ } catch (err) {
190
+ res.writeHead(500, { 'Content-Type': 'application/json' })
191
+ res.end(JSON.stringify({ error: 'benchmark collection failed', detail: err.message }))
192
+ }
193
+ }
194
+
195
+ export function handleFreddieViz(req, res, appRuntime) {
196
+ // FreddieBridge viz endpoint: accepts FreddieBridge messages (JSON), validates them,
197
+ // and creates/updates/destroys entities in the live world. EDITOR_TOKEN-gated when
198
+ // configured (same discipline as /upload-model above); an unset EDITOR_TOKEN leaves
199
+ // this endpoint open (dev default). Rate-limited by body size for safety.
200
+ const _tok = process.env.EDITOR_TOKEN
201
+ if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
202
+ const _FREDDIE_MAX = 256 * 1024
203
+ let _len = 0, _over = false
204
+ const chunks = []
205
+ req.on('data', d => {
206
+ if (_over) return
207
+ _len += d.length
208
+ if (_len > _FREDDIE_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
209
+ chunks.push(d)
210
+ })
211
+ req.on('end', () => {
212
+ if (_over) return
213
+ let body
214
+ try { body = JSON.parse(Buffer.concat(chunks).toString()) } catch (_) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON' })); return }
215
+ // Accept a single message or an array of messages
216
+ const messages = Array.isArray(body) ? body : [body]
217
+ const results = []
218
+ for (const msg of messages) {
219
+ const v = validateMessage(msg)
220
+ if (!v.valid) { results.push({ id: msg.id, ok: false, error: 'validation failed', detail: v.errors }); continue }
221
+ try {
222
+ if (msg.kind === KIND_PLACE) {
223
+ const p = msg.payload
224
+ const entityId = p.entityId
225
+ // Remove existing entity with same id if present (idempotent place)
226
+ if (appRuntime.entities.has(entityId)) appRuntime.destroyEntity(entityId)
227
+ const cfg = {
228
+ position: p.position || [0, 0, 0],
229
+ scale: p.scale || [1, 1, 1],
230
+ custom: {
231
+ mesh: p.primitive || 'box',
232
+ color: p.color ?? 0xffffff,
233
+ emissive: p.emissive ?? 0x000000,
234
+ opacity: p.opacity ?? 1,
235
+ label: p.label || null,
236
+ _freddieSource: msg.source,
237
+ _freddieId: entityId,
238
+ },
239
+ config: {},
240
+ }
241
+ if (p.primitive === 'model' && p.model) cfg.model = p.model
242
+ appRuntime.spawnEntity(entityId, cfg)
243
+ results.push({ id: msg.id, ok: true, entityId })
244
+ } else if (msg.kind === KIND_UPDATE) {
245
+ const p = msg.payload
246
+ const e = appRuntime.entities.get(p.entityId)
247
+ if (!e) { results.push({ id: msg.id, ok: false, error: 'entity not found', entityId: p.entityId }); continue }
248
+ if (p.position) e.position = [...p.position]
249
+ if (p.scale) e.scale = [...p.scale]
250
+ if (e.custom) {
251
+ if (p.color !== undefined) e.custom.color = p.color
252
+ if (p.emissive !== undefined) e.custom.emissive = p.emissive
253
+ if (p.opacity !== undefined) e.custom.opacity = p.opacity
254
+ if (p.label !== undefined) e.custom.label = p.label
255
+ }
256
+ results.push({ id: msg.id, ok: true, entityId: p.entityId })
257
+ } else if (msg.kind === KIND_REMOVE) {
258
+ appRuntime.destroyEntity(msg.payload.entityId)
259
+ results.push({ id: msg.id, ok: true, entityId: msg.payload.entityId })
260
+ } else if (msg.kind === KIND_CLEAR) {
261
+ // Remove all entities created by this source
262
+ const source = msg.source
263
+ const toRemove = []
264
+ for (const [id, e] of appRuntime.entities) {
265
+ if (e.custom?._freddieSource === source) toRemove.push(id)
266
+ }
267
+ for (const id of toRemove) appRuntime.destroyEntity(id)
268
+ results.push({ id: msg.id, ok: true, removed: toRemove.length })
269
+ } else {
270
+ results.push({ id: msg.id, ok: false, error: `unhandled kind: ${msg.kind}` })
271
+ }
272
+ } catch (e) {
273
+ results.push({ id: msg.id, ok: false, error: e.message })
274
+ }
275
+ }
276
+ res.writeHead(200, { 'Content-Type': 'application/json' })
277
+ res.end(JSON.stringify(Array.isArray(body) ? results : results[0]))
278
+ })
279
+ }