spoint 0.1.643 → 0.1.644

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.643",
3
+ "version": "0.1.644",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,261 @@
1
+ import { join, dirname, resolve, relative, extname } from 'node:path'
2
+ import { fileURLToPath, pathToFileURL } from 'node:url'
3
+ import { existsSync, readdirSync, statSync } from 'node:fs'
4
+ import { prewarm } from '../static/GLBTransformer.js'
5
+ import { prewarmCompression } from './StaticHandler.js'
6
+ import { prewarmProgressive, ensureProgressive } from '../static/ProgressiveBake.js'
7
+ import { createServer } from './server.js'
8
+ import { logServerIdentity } from './ServerIdentity.js'
9
+ import { createServerPresence } from './ServerPresence.js'
10
+
11
+ export function buildUniquePathList(paths) {
12
+ const out = [], seen = new Set()
13
+ for (const p of paths) { const rp = resolve(p); if (!seen.has(rp)) { seen.add(rp); out.push(rp) } }
14
+ return out
15
+ }
16
+
17
+ // Recursively collects every .js/.mjs file under `dir` (skipping node_modules/.git-style
18
+ // junk and the pre-compressed .br/.gz sidecar copies StaticHandler's prewarm leaves next to
19
+ // each source file -- those are build artifacts, not watch targets, and would otherwise
20
+ // double-fire a reload for every real edit). Used by server.js's setupSDKWatchers to DERIVE the
21
+ // hot-reload file list from the actual directory tree instead of a hand-maintained array,
22
+ // so a new file dropped into a watched directory is picked up automatically.
23
+ const WATCH_SKIP_DIRS = new Set(['node_modules', '.git', '.gm', 'dist'])
24
+ export function collectWatchableFiles(dir, out = []) {
25
+ let entries
26
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
27
+ for (const entry of entries) {
28
+ if (entry.isDirectory()) {
29
+ if (!WATCH_SKIP_DIRS.has(entry.name)) collectWatchableFiles(join(dir, entry.name), out)
30
+ continue
31
+ }
32
+ const ext = extname(entry.name)
33
+ if (ext !== '.js' && ext !== '.mjs') continue
34
+ if (/\.test\.js$/.test(entry.name)) continue
35
+ out.push(join(dir, entry.name))
36
+ }
37
+ return out
38
+ }
39
+
40
+ // A dist/client/app.bundle.js produced by `npm run build:client` (esbuild, see
41
+ // scripts/bundle-client.mjs) is served in place of the raw client/app.js ONLY when
42
+ // it actually exists on disk -- dev/hot-reload workflow (raw ESM straight from disk,
43
+ // StaticHandler's no-cache headers) is otherwise completely unaffected. The bundle
44
+ // mount is listed BEFORE the raw '/' -> client/ mount so StaticHandler's first-match
45
+ // wins; everything else (node_modules, apps, src, worker) still resolves exactly as
46
+ // before since the bundle only replaces the single /app.js file it produces.
47
+ export function buildStaticDirs(sdkRoot, project, appsDirs) {
48
+ const dirs = [
49
+ { prefix: '/src/', dir: join(sdkRoot, 'src') },
50
+ ...appsDirs.map(dir => ({ prefix: '/apps/', dir })),
51
+ { prefix: '/node_modules/', dir: join(sdkRoot, 'node_modules') },
52
+ { prefix: '/data/', dir: resolve(project, 'data') }
53
+ ]
54
+ // StaticHandler tries mounts in order and falls through to the next one whenever
55
+ // the requested file doesn't exist in the current mount's dir (see the `continue`
56
+ // on a missing file in createStaticHandler) -- so mounting the bundle output dir at
57
+ // the SAME '/' prefix, listed first, transparently overrides only /app.js (the one
58
+ // file that exists there) while every other request (/, /index.html, /core/*.js,
59
+ // /hud/*.js, ...) falls through unchanged to the raw client/ mount below it. This
60
+ // is what gives the "serve bundled output when available, else raw ESM in dev"
61
+ // behavior with zero runtime branching and zero index.html edits.
62
+ const bundleDir = join(sdkRoot, 'dist', 'client')
63
+ const bundlePath = join(bundleDir, 'app.js')
64
+ const rawEntryPath = join(sdkRoot, 'client', 'app.js')
65
+ if (existsSync(bundlePath)) {
66
+ // Freshness check: a bundle built before the raw entry file was last edited is stale and would
67
+ // silently mask live source edits from every dev-server/witness session with zero warning --
68
+ // discovered live burning real debugging time on exactly this (see AGENTS.md stale-bundle-masks-
69
+ // dev-edits). This mtime compare is a cheap proxy (the entry file only, not every transitive
70
+ // import) that catches the common case; it will not catch an edit to a file app.js imports
71
+ // without also touching app.js itself, but that's a strictly smaller, rarer miss than the
72
+ // silent-forever masking this replaces.
73
+ const bundleMtime = statSync(bundlePath).mtimeMs
74
+ const rawMtime = existsSync(rawEntryPath) ? statSync(rawEntryPath).mtimeMs : 0
75
+ if (bundleMtime >= rawMtime) {
76
+ console.log(`[server] serving PREBUILT BUNDLE from dist/client/app.js (built ${new Date(bundleMtime).toISOString()})`)
77
+ dirs.push({ prefix: '/', dir: bundleDir })
78
+ } else {
79
+ console.log(`[server] dist/client/app.js is STALE (built ${new Date(bundleMtime).toISOString()}, client/app.js edited ${new Date(rawMtime).toISOString()}) -- falling through to raw ESM`)
80
+ }
81
+ } else {
82
+ console.log('[server] serving raw ESM from client/ (no dist/client/app.js bundle present)')
83
+ }
84
+ dirs.push({ prefix: '/', dir: join(sdkRoot, 'client') })
85
+ return dirs
86
+ }
87
+
88
+ // Cheapest, most specific preflight for the "this worktree's node_modules was never linked at all"
89
+ // failure mode -- distinct from scripts/worktree-setup.mjs's own torn/mid-install detection (which
90
+ // only runs when node_modules ALREADY EXISTS and checks .package-lock.json + every declared dep).
91
+ // This check is a single existsSync() against SDK_ROOT/node_modules itself: a fresh `git worktree add`
92
+ // starts with genuinely ZERO node_modules (never created, not torn), which the torn-install guard
93
+ // never sees since it only fires on the already-exists branch. Left unchecked, boot() proceeds,
94
+ // binds the port, and reports "listening" successfully -- every client static asset (three.js,
95
+ // webjsx, app.js's importmap-resolved deps) then 404s silently underneath a working-looking HTML
96
+ // shell (StaticHandler's mount-miss just falls through to a plain 404, no diagnostic), and unrelated
97
+ // subsystems (e.g. GLBDraco's `@gltf-transform/core` import) throw confusing, unattributed
98
+ // "Cannot find package" errors buried mid-boot-log instead of naming the real, single root cause.
99
+ // Live-reproduced: a genuinely node_modules-less worktree serves index.html/app.js as 200 while
100
+ // /node_modules/three/build/three.module.js 404s, with zero mention of node_modules anywhere in the
101
+ // response or an obvious top-of-log signal.
102
+ // Fails FAST and LOUD here instead: a totally missing node_modules is always wrong to boot against
103
+ // (unlike a torn one, there's no "maybe it's fine" case), so this throws synchronously before any
104
+ // port bind, world load, or GLB prewarm work happens.
105
+ export function assertNodeModulesLinked(sdkRoot) {
106
+ const nodeModulesDir = join(sdkRoot, 'node_modules')
107
+ if (existsSync(nodeModulesDir)) return
108
+ const msg = `[boot] FATAL: ${nodeModulesDir} does not exist -- this checkout/worktree's node_modules was never linked.\n` +
109
+ ` Every client static asset (three.js, webjsx, app.js's importmap deps) would 404 silently once the server\n` +
110
+ ` reports "listening", presenting as a confusing 404 cascade / boot stuck past "Click to play" instead of\n` +
111
+ ` this clear error. Fix: run "node scripts/worktree-setup.mjs" from this worktree (links node_modules as a\n` +
112
+ ` junction/symlink to the main checkout), or "npm install" here directly for a fully worktree-local install.`
113
+ console.error(msg)
114
+ const err = new Error(`node_modules missing at ${nodeModulesDir} -- run scripts/worktree-setup.mjs`)
115
+ err.spointNodeModulesMissing = true
116
+ throw err
117
+ }
118
+
119
+ export async function boot(overrides = {}) {
120
+ const { ensurePacked } = await import('../protocol/msgpack.js')
121
+ await ensurePacked
122
+ const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
123
+ assertNodeModulesLinked(SDK_ROOT)
124
+ const PROJECT = process.cwd()
125
+ const worldName = process.env.WORLD || 'tps-game'
126
+ const localWorld = resolve(PROJECT, `apps/world/${worldName}.js`)
127
+ const fallbackLocal = resolve(PROJECT, 'apps/world/index.js')
128
+ const worldPath = existsSync(localWorld) ? localWorld : existsSync(fallbackLocal) ? fallbackLocal : resolve(SDK_ROOT, 'apps/world/index.js')
129
+ if (worldName !== 'index') console.log(`[boot] using world: ${worldName}`)
130
+ if (!existsSync(worldPath)) console.log('[boot] no world found, using bundled SDK defaults')
131
+ const worldDef = (await import(pathToFileURL(worldPath).href + `?t=${Date.now()}`)).default || {}
132
+ const localApps = resolve(PROJECT, 'apps'), sdkApps = join(SDK_ROOT, 'apps')
133
+ const appsDirs = buildUniquePathList(existsSync(localApps) ? [localApps, sdkApps] : [sdkApps])
134
+ console.debug(`[boot] loading from: ${appsDirs.join(', ')}`)
135
+ const config = {
136
+ port: parseInt(process.env.PORT || String(worldDef.port || 3000), 10),
137
+ tickRate: worldDef.tickRate || 60, appsDirs, sdkRoot: SDK_ROOT,
138
+ gravity: worldDef.gravity, movement: worldDef.movement, playerConfig: worldDef.player,
139
+ physicsRadius: worldDef.physicsRadius || 0, physicsBodyBudget: worldDef.physicsBodyBudget || 0, entityTickRate: worldDef.entityTickRate,
140
+ staticDirs: buildStaticDirs(SDK_ROOT, PROJECT, appsDirs),
141
+ ...overrides
142
+ }
143
+ // Off the boot-serving critical path (unlike the GLB prewarm below, which env models need
144
+ // ready before first load): pre-populate .br/.gz disk siblings for JS/CSS/HTML/JSON so the
145
+ // first real request already hits a warm sibling instead of paying compression inline.
146
+ setImmediate(() => {
147
+ prewarmCompression(config.staticDirs)
148
+ .then(n => { if (n) console.log(`[static] precompressed ${n} asset(s)`) })
149
+ .catch(e => console.error('[static] prewarm error:', e.message))
150
+ })
151
+ const server = await createServer(config)
152
+ await server.loadWorld(worldDef)
153
+ // Dev-iteration escape hatch: prewarm() synchronously scans + transforms EVERY .glb/.vrm across
154
+ // the WHOLE apps/ tree (both PROJECT/apps and the bundled SDK apps/) before boot() returns,
155
+ // regardless of which single WORLD is actually being iterated on. On a cold .glb-cache this can
156
+ // take minutes wall-clock for a world that needs zero map GLBs (e.g. a terrain-only world) --
157
+ // live-measured 500+s on 2026-07-21. SPOINT_SKIP_PREWARM=1 skips this call entirely for fast dev
158
+ // boot; unset (default) keeps full prewarm, matching prod/CI (where every asset should already be
159
+ // warm/served correctly on first real request, not lazily transformed on first hit).
160
+ if (process.env.SPOINT_SKIP_PREWARM) {
161
+ console.log('[prewarm] SPOINT_SKIP_PREWARM set -- skipping full apps/-tree GLB/VRM prewarm (assets will transform lazily on first request instead)')
162
+ } else {
163
+ await prewarm(appsDirs).catch(e => console.error('[prewarm] error:', e))
164
+ }
165
+ // custom._interior models are awaited before serving: ModelPool needs the bake ready or a cold-cache first load shows no map until a manual refresh
166
+ try {
167
+ const envModels = new Set((worldDef.entities || []).filter(e => e.model && e.custom?._interior).map(e => e.model))
168
+ const allModels = (worldDef.entities || []).filter(e => e.model).map(e => e.model)
169
+ const resolveModel = m => {
170
+ const rel = m.startsWith('./') ? m.slice(2) : m.startsWith('/') ? m.slice(1) : m
171
+ for (const dir of [PROJECT, SDK_ROOT]) { const fp = resolve(dir, rel); if (existsSync(fp)) return fp }
172
+ return null
173
+ }
174
+ const envResolved = [...envModels].map(resolveModel).filter(Boolean)
175
+ const restResolved = allModels.filter(m => !envModels.has(m)).map(resolveModel).filter(Boolean)
176
+ if (restResolved.length) prewarmProgressive(restResolved)
177
+ if (envResolved.length) {
178
+ console.log(`[progressive] awaiting ${envResolved.length} environment bake(s) before serving`)
179
+ await Promise.all(envResolved.map(fp => ensureProgressive(fp).catch(e => console.warn('[progressive] env bake failed:', e?.message))))
180
+ }
181
+ } catch (e) { console.error('[progressive] prewarm error:', e.message) }
182
+ // ServerAPI.start() always binds 0.0.0.0 (all interfaces, never loopback-restricted) -- so an unset
183
+ // EDITOR_TOKEN means every editor-gated surface (AUTH_EDITOR, /upload-model non-loopback callers,
184
+ // /debug-log non-loopback callers) is reachable from any network peer that can route to this host,
185
+ // not just localhost. Warn loudly at boot rather than silently defaulting to "open".
186
+ if (!process.env.EDITOR_TOKEN) {
187
+ console.warn('[server] EDITOR_TOKEN is not set and this server binds 0.0.0.0 (all interfaces, not loopback-only) -- editor auth and non-loopback debug/upload endpoints are OPEN to any network peer that can reach this host. Set EDITOR_TOKEN before exposing this server beyond localhost.')
188
+ }
189
+ const info = await server.start()
190
+ console.log(`[server] http://localhost:${info.port} @ ${info.tickRate} TPS`)
191
+ logServerIdentity()
192
+
193
+ // Nostr-published server presence (see PRD row nostr-server-presence-publisher): opt-in via
194
+ // worldDef.presence.enabled OR env SPOINT_PRESENCE=1 (env wins as an operator-level override so a
195
+ // world def doesn't need editing just to test presence locally), off by default -- publishing to
196
+ // public nostr relays is an outbound-network side effect a server operator should choose, not one
197
+ // every booted server does unprompted. worldDef.presence.relays lets a world pin its own relay set
198
+ // (e.g. a private/self-hosted relay for a closed community server) instead of wireweave's public
199
+ // relay-pool defaults.
200
+ const presenceCfg = worldDef.presence || {}
201
+ const presenceEnabled = process.env.SPOINT_PRESENCE === '1' || process.env.SPOINT_PRESENCE === 'true' || !!presenceCfg.enabled
202
+ const presence = await createServerPresence({
203
+ enabled: presenceEnabled,
204
+ // SPOINT_PRESENCE_RELAYS is a comma-separated override for local/CI testing against a mock relay
205
+ // without editing a real worldDef -- production deployments should prefer worldDef.presence.relays
206
+ // (or omit both to fall through to wireweave's public relay-pool defaults).
207
+ relays: process.env.SPOINT_PRESENCE_RELAYS ? process.env.SPOINT_PRESENCE_RELAYS.split(',').map(s => s.trim()).filter(Boolean) : (presenceCfg.relays || null),
208
+ namespace: presenceCfg.namespace || 'spoint',
209
+ host: process.env.SPOINT_PRESENCE_HOST || presenceCfg.host || 'localhost',
210
+ port: info.port,
211
+ worldName,
212
+ tickRate: info.tickRate,
213
+ getPlayerCount: () => server.playerManager.getConnectedPlayers().length,
214
+ maxPlayers: presenceCfg.maxPlayers ?? null,
215
+ mode: presenceCfg.mode || worldName,
216
+ }).catch(e => { console.error('[presence] init failed:', e.message); return { publish: async () => {}, stop: async () => {}, pubkey: null, enabled: false } })
217
+ if (presence.enabled) {
218
+ console.log(`[presence] publishing as ${presence.pubkey.slice(0, 12)}... (namespace=${presenceCfg.namespace || 'spoint'})`)
219
+ // Player-count freshness: a join/leave republishes immediately rather than waiting out the
220
+ // heartbeat's ~30s cadence, so a server browser's player count doesn't lag a real join by up to
221
+ // 30s right when a player most wants to see it update (the exact moment they'd be watching it).
222
+ server.on('playerJoin', () => { presence.publish('heartbeat').catch(() => {}) })
223
+ server.on('playerLeave', () => { presence.publish('heartbeat').catch(() => {}) })
224
+ }
225
+
226
+ installGracefulShutdown(server, presence)
227
+ return server
228
+ }
229
+
230
+ // Graceful shutdown: on SIGINT (Ctrl+C) / SIGTERM (kill, systemd, docker stop), flush every pending
231
+ // debounced write BEFORE the process exits, then release sockets/watchers/physics via server.stop().
232
+ // Without this, a write still sitting inside its debounce window (ctx.placedModelStorage.persist's
233
+ // 500ms trailing timer, or an app-registered one like apps/tps-game/server.js's scheduleScoreboardPersist)
234
+ // is silently lost on a process kill -- both functions were already real and already documented "used on
235
+ // graceful shutdown" in their own comments, but nothing ever called them. server.flushAll() (ServerAPI.js)
236
+ // drains both the engine-owned placedModelStorage flush and every app-registered ctx.onShutdown hook
237
+ // (AppRuntime.js runShutdownHooks -- tps-game's flushScoreboard among them) via Promise.allSettled, so
238
+ // one hanging/throwing flush never blocks the other.
239
+ export function installGracefulShutdown(server, presence = null) {
240
+ let shuttingDown = false
241
+ const SHUTDOWN_TIMEOUT_MS = 5000
242
+ const handleSignal = (signal) => {
243
+ if (shuttingDown) return // second SIGINT/SIGTERM while a shutdown is already in flight: no-op, let the first one finish
244
+ shuttingDown = true
245
+ console.log(`[server] received ${signal}, flushing pending writes before exit...`)
246
+ // Bounded: a stalled filesystem must not hang the process forever on a signal every orchestrator
247
+ // (systemd, docker, ctrl-c) expects a prompt exit from -- log and proceed to stop()/exit either way.
248
+ const timeout = new Promise(resolve => setTimeout(() => { console.warn(`[server] shutdown flush exceeded ${SHUTDOWN_TIMEOUT_MS}ms, proceeding anyway`); resolve() }, SHUTDOWN_TIMEOUT_MS))
249
+ // Presence 'offline' publish races the same bounded timeout as the flush -- a laggy/dead relay
250
+ // connection must never hold up process exit on a signal every orchestrator expects a prompt
251
+ // response from.
252
+ Promise.race([Promise.allSettled([server.flushAll(), presence ? presence.stop() : Promise.resolve()]), timeout]).then(() => {
253
+ console.log('[server] flush complete, stopping server...')
254
+ try { server.stop() } catch (e) { console.error('[server] stop() error:', e.message) }
255
+ console.log('[server] shutdown complete')
256
+ process.exit(0)
257
+ })
258
+ }
259
+ process.on('SIGINT', () => handleSignal('SIGINT'))
260
+ process.on('SIGTERM', () => handleSignal('SIGTERM'))
261
+ }
package/src/sdk/server.js CHANGED
@@ -1,12 +1,8 @@
1
- import { join, dirname, resolve, relative, extname } from 'node:path'
1
+ import { join, dirname, resolve, relative } from 'node:path'
2
2
  import { fileURLToPath, pathToFileURL } from 'node:url'
3
- import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
3
+ import { existsSync, mkdirSync } from 'node:fs'
4
4
  import { writeFile, rename, unlink } from 'node:fs/promises'
5
- import { prewarm } from '../static/GLBTransformer.js'
6
- import { prewarmCompression } from './StaticHandler.js'
7
- import { prewarmProgressive, ensureProgressive } from '../static/ProgressiveBake.js'
8
5
  import { MSG } from '../protocol/MessageTypes.js'
9
- import { ensurePacked } from '../protocol/msgpack.js'
10
6
  import { ConnectionManager } from '../connection/ConnectionManager.js'
11
7
  import { SessionStore } from '../connection/SessionStore.js'
12
8
  import { Inspector } from '../debug/Inspector.js'
@@ -28,86 +24,8 @@ import { ReloadManager } from './ReloadManager.js'
28
24
  import { createReloadHandlers } from './ReloadHandlers.js'
29
25
  import { createServerAPI } from './ServerAPI.js'
30
26
  import { createConnectionHandlers } from './ServerHandlers.js'
31
- import { logServerIdentity } from './ServerIdentity.js'
32
- import { createServerPresence } from './ServerPresence.js'
33
27
  import { saveWorldSnapshot } from './WorldPersistence.js'
34
-
35
- function buildUniquePathList(paths) {
36
- const out = [], seen = new Set()
37
- for (const p of paths) { const rp = resolve(p); if (!seen.has(rp)) { seen.add(rp); out.push(rp) } }
38
- return out
39
- }
40
-
41
- // Recursively collects every .js/.mjs file under `dir` (skipping node_modules/.git-style
42
- // junk and the pre-compressed .br/.gz sidecar copies StaticHandler's prewarm leaves next to
43
- // each source file -- those are build artifacts, not watch targets, and would otherwise
44
- // double-fire a reload for every real edit). Used by setupSDKWatchers below to DERIVE the
45
- // hot-reload file list from the actual directory tree instead of a hand-maintained array,
46
- // so a new file dropped into a watched directory is picked up automatically.
47
- const WATCH_SKIP_DIRS = new Set(['node_modules', '.git', '.gm', 'dist'])
48
- function collectWatchableFiles(dir, out = []) {
49
- let entries
50
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out }
51
- for (const entry of entries) {
52
- if (entry.isDirectory()) {
53
- if (!WATCH_SKIP_DIRS.has(entry.name)) collectWatchableFiles(join(dir, entry.name), out)
54
- continue
55
- }
56
- const ext = extname(entry.name)
57
- if (ext !== '.js' && ext !== '.mjs') continue
58
- if (/\.test\.js$/.test(entry.name)) continue
59
- out.push(join(dir, entry.name))
60
- }
61
- return out
62
- }
63
-
64
- // A dist/client/app.bundle.js produced by `npm run build:client` (esbuild, see
65
- // scripts/bundle-client.mjs) is served in place of the raw client/app.js ONLY when
66
- // it actually exists on disk -- dev/hot-reload workflow (raw ESM straight from disk,
67
- // StaticHandler's no-cache headers) is otherwise completely unaffected. The bundle
68
- // mount is listed BEFORE the raw '/' -> client/ mount so StaticHandler's first-match
69
- // wins; everything else (node_modules, apps, src, worker) still resolves exactly as
70
- // before since the bundle only replaces the single /app.js file it produces.
71
- export function buildStaticDirs(sdkRoot, project, appsDirs) {
72
- const dirs = [
73
- { prefix: '/src/', dir: join(sdkRoot, 'src') },
74
- ...appsDirs.map(dir => ({ prefix: '/apps/', dir })),
75
- { prefix: '/node_modules/', dir: join(sdkRoot, 'node_modules') },
76
- { prefix: '/data/', dir: resolve(project, 'data') }
77
- ]
78
- // StaticHandler tries mounts in order and falls through to the next one whenever
79
- // the requested file doesn't exist in the current mount's dir (see the `continue`
80
- // on a missing file in createStaticHandler) -- so mounting the bundle output dir at
81
- // the SAME '/' prefix, listed first, transparently overrides only /app.js (the one
82
- // file that exists there) while every other request (/, /index.html, /core/*.js,
83
- // /hud/*.js, ...) falls through unchanged to the raw client/ mount below it. This
84
- // is what gives the "serve bundled output when available, else raw ESM in dev"
85
- // behavior with zero runtime branching and zero index.html edits.
86
- const bundleDir = join(sdkRoot, 'dist', 'client')
87
- const bundlePath = join(bundleDir, 'app.js')
88
- const rawEntryPath = join(sdkRoot, 'client', 'app.js')
89
- if (existsSync(bundlePath)) {
90
- // Freshness check: a bundle built before the raw entry file was last edited is stale and would
91
- // silently mask live source edits from every dev-server/witness session with zero warning --
92
- // discovered live burning real debugging time on exactly this (see AGENTS.md stale-bundle-masks-
93
- // dev-edits). This mtime compare is a cheap proxy (the entry file only, not every transitive
94
- // import) that catches the common case; it will not catch an edit to a file app.js imports
95
- // without also touching app.js itself, but that's a strictly smaller, rarer miss than the
96
- // silent-forever masking this replaces.
97
- const bundleMtime = statSync(bundlePath).mtimeMs
98
- const rawMtime = existsSync(rawEntryPath) ? statSync(rawEntryPath).mtimeMs : 0
99
- if (bundleMtime >= rawMtime) {
100
- console.log(`[server] serving PREBUILT BUNDLE from dist/client/app.js (built ${new Date(bundleMtime).toISOString()})`)
101
- dirs.push({ prefix: '/', dir: bundleDir })
102
- } else {
103
- console.log(`[server] dist/client/app.js is STALE (built ${new Date(bundleMtime).toISOString()}, client/app.js edited ${new Date(rawMtime).toISOString()}) -- falling through to raw ESM`)
104
- }
105
- } else {
106
- console.log('[server] serving raw ESM from client/ (no dist/client/app.js bundle present)')
107
- }
108
- dirs.push({ prefix: '/', dir: join(sdkRoot, 'client') })
109
- return dirs
110
- }
28
+ import { buildUniquePathList, collectWatchableFiles } from './ServerBoot.js'
111
29
 
112
30
  // tickRate: caller (createServer) already resolved config.tickRate||128 and passes it in explicitly --
113
31
  // never re-derive the ||128 default here, or a future edit to only one of the two literals would silently
@@ -327,176 +245,6 @@ export async function createServer(config = {}) {
327
245
  return api
328
246
  }
329
247
 
330
- // Cheapest, most specific preflight for the "this worktree's node_modules was never linked at all"
331
- // failure mode -- distinct from scripts/worktree-setup.mjs's own torn/mid-install detection (which
332
- // only runs when node_modules ALREADY EXISTS and checks .package-lock.json + every declared dep).
333
- // This check is a single existsSync() against SDK_ROOT/node_modules itself: a fresh `git worktree add`
334
- // starts with genuinely ZERO node_modules (never created, not torn), which the torn-install guard
335
- // never sees since it only fires on the already-exists branch. Left unchecked, boot() proceeds,
336
- // binds the port, and reports "listening" successfully -- every client static asset (three.js,
337
- // webjsx, app.js's importmap-resolved deps) then 404s silently underneath a working-looking HTML
338
- // shell (StaticHandler's mount-miss just falls through to a plain 404, no diagnostic), and unrelated
339
- // subsystems (e.g. GLBDraco's `@gltf-transform/core` import) throw confusing, unattributed
340
- // "Cannot find package" errors buried mid-boot-log instead of naming the real, single root cause.
341
- // Live-reproduced: a genuinely node_modules-less worktree serves index.html/app.js as 200 while
342
- // /node_modules/three/build/three.module.js 404s, with zero mention of node_modules anywhere in the
343
- // response or an obvious top-of-log signal.
344
- // Fails FAST and LOUD here instead: a totally missing node_modules is always wrong to boot against
345
- // (unlike a torn one, there's no "maybe it's fine" case), so this throws synchronously before any
346
- // port bind, world load, or GLB prewarm work happens.
347
- export function assertNodeModulesLinked(sdkRoot) {
348
- const nodeModulesDir = join(sdkRoot, 'node_modules')
349
- if (existsSync(nodeModulesDir)) return
350
- const msg = `[boot] FATAL: ${nodeModulesDir} does not exist -- this checkout/worktree's node_modules was never linked.\n` +
351
- ` Every client static asset (three.js, webjsx, app.js's importmap deps) would 404 silently once the server\n` +
352
- ` reports "listening", presenting as a confusing 404 cascade / boot stuck past "Click to play" instead of\n` +
353
- ` this clear error. Fix: run "node scripts/worktree-setup.mjs" from this worktree (links node_modules as a\n` +
354
- ` junction/symlink to the main checkout), or "npm install" here directly for a fully worktree-local install.`
355
- console.error(msg)
356
- const err = new Error(`node_modules missing at ${nodeModulesDir} -- run scripts/worktree-setup.mjs`)
357
- err.spointNodeModulesMissing = true
358
- throw err
359
- }
360
-
361
- export async function boot(overrides = {}) {
362
- await ensurePacked
363
- const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..')
364
- assertNodeModulesLinked(SDK_ROOT)
365
- const PROJECT = process.cwd()
366
- const worldName = process.env.WORLD || 'tps-game'
367
- const localWorld = resolve(PROJECT, `apps/world/${worldName}.js`)
368
- const fallbackLocal = resolve(PROJECT, 'apps/world/index.js')
369
- const worldPath = existsSync(localWorld) ? localWorld : existsSync(fallbackLocal) ? fallbackLocal : resolve(SDK_ROOT, 'apps/world/index.js')
370
- if (worldName !== 'index') console.log(`[boot] using world: ${worldName}`)
371
- if (!existsSync(worldPath)) console.log('[boot] no world found, using bundled SDK defaults')
372
- const worldDef = (await import(pathToFileURL(worldPath).href + `?t=${Date.now()}`)).default || {}
373
- const localApps = resolve(PROJECT, 'apps'), sdkApps = join(SDK_ROOT, 'apps')
374
- const appsDirs = buildUniquePathList(existsSync(localApps) ? [localApps, sdkApps] : [sdkApps])
375
- console.debug(`[boot] loading from: ${appsDirs.join(', ')}`)
376
- const config = {
377
- port: parseInt(process.env.PORT || String(worldDef.port || 3000), 10),
378
- tickRate: worldDef.tickRate || 60, appsDirs, sdkRoot: SDK_ROOT,
379
- gravity: worldDef.gravity, movement: worldDef.movement, playerConfig: worldDef.player,
380
- physicsRadius: worldDef.physicsRadius || 0, physicsBodyBudget: worldDef.physicsBodyBudget || 0, entityTickRate: worldDef.entityTickRate,
381
- staticDirs: buildStaticDirs(SDK_ROOT, PROJECT, appsDirs),
382
- ...overrides
383
- }
384
- // Off the boot-serving critical path (unlike the GLB prewarm below, which env models need
385
- // ready before first load): pre-populate .br/.gz disk siblings for JS/CSS/HTML/JSON so the
386
- // first real request already hits a warm sibling instead of paying compression inline.
387
- setImmediate(() => {
388
- prewarmCompression(config.staticDirs)
389
- .then(n => { if (n) console.log(`[static] precompressed ${n} asset(s)`) })
390
- .catch(e => console.error('[static] prewarm error:', e.message))
391
- })
392
- const server = await createServer(config)
393
- await server.loadWorld(worldDef)
394
- // Dev-iteration escape hatch: prewarm() synchronously scans + transforms EVERY .glb/.vrm across
395
- // the WHOLE apps/ tree (both PROJECT/apps and the bundled SDK apps/) before boot() returns,
396
- // regardless of which single WORLD is actually being iterated on. On a cold .glb-cache this can
397
- // take minutes wall-clock for a world that needs zero map GLBs (e.g. a terrain-only world) --
398
- // live-measured 500+s on 2026-07-21. SPOINT_SKIP_PREWARM=1 skips this call entirely for fast dev
399
- // boot; unset (default) keeps full prewarm, matching prod/CI (where every asset should already be
400
- // warm/served correctly on first real request, not lazily transformed on first hit).
401
- if (process.env.SPOINT_SKIP_PREWARM) {
402
- console.log('[prewarm] SPOINT_SKIP_PREWARM set -- skipping full apps/-tree GLB/VRM prewarm (assets will transform lazily on first request instead)')
403
- } else {
404
- await prewarm(appsDirs).catch(e => console.error('[prewarm] error:', e))
405
- }
406
- // custom._interior models are awaited before serving: ModelPool needs the bake ready or a cold-cache first load shows no map until a manual refresh
407
- try {
408
- const envModels = new Set((worldDef.entities || []).filter(e => e.model && e.custom?._interior).map(e => e.model))
409
- const allModels = (worldDef.entities || []).filter(e => e.model).map(e => e.model)
410
- const resolveModel = m => {
411
- const rel = m.startsWith('./') ? m.slice(2) : m.startsWith('/') ? m.slice(1) : m
412
- for (const dir of [PROJECT, SDK_ROOT]) { const fp = resolve(dir, rel); if (existsSync(fp)) return fp }
413
- return null
414
- }
415
- const envResolved = [...envModels].map(resolveModel).filter(Boolean)
416
- const restResolved = allModels.filter(m => !envModels.has(m)).map(resolveModel).filter(Boolean)
417
- if (restResolved.length) prewarmProgressive(restResolved)
418
- if (envResolved.length) {
419
- console.log(`[progressive] awaiting ${envResolved.length} environment bake(s) before serving`)
420
- await Promise.all(envResolved.map(fp => ensureProgressive(fp).catch(e => console.warn('[progressive] env bake failed:', e?.message))))
421
- }
422
- } catch (e) { console.error('[progressive] prewarm error:', e.message) }
423
- // ServerAPI.start() always binds 0.0.0.0 (all interfaces, never loopback-restricted) -- so an unset
424
- // EDITOR_TOKEN means every editor-gated surface (AUTH_EDITOR, /upload-model non-loopback callers,
425
- // /debug-log non-loopback callers) is reachable from any network peer that can route to this host,
426
- // not just localhost. Warn loudly at boot rather than silently defaulting to "open".
427
- if (!process.env.EDITOR_TOKEN) {
428
- console.warn('[server] EDITOR_TOKEN is not set and this server binds 0.0.0.0 (all interfaces, not loopback-only) -- editor auth and non-loopback debug/upload endpoints are OPEN to any network peer that can reach this host. Set EDITOR_TOKEN before exposing this server beyond localhost.')
429
- }
430
- const info = await server.start()
431
- console.log(`[server] http://localhost:${info.port} @ ${info.tickRate} TPS`)
432
- logServerIdentity()
433
-
434
- // Nostr-published server presence (see PRD row nostr-server-presence-publisher): opt-in via
435
- // worldDef.presence.enabled OR env SPOINT_PRESENCE=1 (env wins as an operator-level override so a
436
- // world def doesn't need editing just to test presence locally), off by default -- publishing to
437
- // public nostr relays is an outbound-network side effect a server operator should choose, not one
438
- // every booted server does unprompted. worldDef.presence.relays lets a world pin its own relay set
439
- // (e.g. a private/self-hosted relay for a closed community server) instead of wireweave's public
440
- // relay-pool defaults.
441
- const presenceCfg = worldDef.presence || {}
442
- const presenceEnabled = process.env.SPOINT_PRESENCE === '1' || process.env.SPOINT_PRESENCE === 'true' || !!presenceCfg.enabled
443
- const presence = await createServerPresence({
444
- enabled: presenceEnabled,
445
- // SPOINT_PRESENCE_RELAYS is a comma-separated override for local/CI testing against a mock relay
446
- // without editing a real worldDef -- production deployments should prefer worldDef.presence.relays
447
- // (or omit both to fall through to wireweave's public relay-pool defaults).
448
- relays: process.env.SPOINT_PRESENCE_RELAYS ? process.env.SPOINT_PRESENCE_RELAYS.split(',').map(s => s.trim()).filter(Boolean) : (presenceCfg.relays || null),
449
- namespace: presenceCfg.namespace || 'spoint',
450
- host: process.env.SPOINT_PRESENCE_HOST || presenceCfg.host || 'localhost',
451
- port: info.port,
452
- worldName,
453
- tickRate: info.tickRate,
454
- getPlayerCount: () => server.playerManager.getConnectedPlayers().length,
455
- maxPlayers: presenceCfg.maxPlayers ?? null,
456
- mode: presenceCfg.mode || worldName,
457
- }).catch(e => { console.error('[presence] init failed:', e.message); return { publish: async () => {}, stop: async () => {}, pubkey: null, enabled: false } })
458
- if (presence.enabled) {
459
- console.log(`[presence] publishing as ${presence.pubkey.slice(0, 12)}... (namespace=${presenceCfg.namespace || 'spoint'})`)
460
- // Player-count freshness: a join/leave republishes immediately rather than waiting out the
461
- // heartbeat's ~30s cadence, so a server browser's player count doesn't lag a real join by up to
462
- // 30s right when a player most wants to see it update (the exact moment they'd be watching it).
463
- server.on('playerJoin', () => { presence.publish('heartbeat').catch(() => {}) })
464
- server.on('playerLeave', () => { presence.publish('heartbeat').catch(() => {}) })
465
- }
466
-
467
- installGracefulShutdown(server, presence)
468
- return server
469
- }
470
-
471
- // Graceful shutdown: on SIGINT (Ctrl+C) / SIGTERM (kill, systemd, docker stop), flush every pending
472
- // debounced write BEFORE the process exits, then release sockets/watchers/physics via server.stop().
473
- // Without this, a write still sitting inside its debounce window (ctx.placedModelStorage.persist's
474
- // 500ms trailing timer, or an app-registered one like apps/tps-game/server.js's scheduleScoreboardPersist)
475
- // is silently lost on a process kill -- both functions were already real and already documented "used on
476
- // graceful shutdown" in their own comments, but nothing ever called them. server.flushAll() (ServerAPI.js)
477
- // drains both the engine-owned placedModelStorage flush and every app-registered ctx.onShutdown hook
478
- // (AppRuntime.js runShutdownHooks -- tps-game's flushScoreboard among them) via Promise.allSettled, so
479
- // one hanging/throwing flush never blocks the other.
480
- function installGracefulShutdown(server, presence = null) {
481
- let shuttingDown = false
482
- const SHUTDOWN_TIMEOUT_MS = 5000
483
- const handleSignal = (signal) => {
484
- if (shuttingDown) return // second SIGINT/SIGTERM while a shutdown is already in flight: no-op, let the first one finish
485
- shuttingDown = true
486
- console.log(`[server] received ${signal}, flushing pending writes before exit...`)
487
- // Bounded: a stalled filesystem must not hang the process forever on a signal every orchestrator
488
- // (systemd, docker, ctrl-c) expects a prompt exit from -- log and proceed to stop()/exit either way.
489
- const timeout = new Promise(resolve => setTimeout(() => { console.warn(`[server] shutdown flush exceeded ${SHUTDOWN_TIMEOUT_MS}ms, proceeding anyway`); resolve() }, SHUTDOWN_TIMEOUT_MS))
490
- // Presence 'offline' publish races the same bounded timeout as the flush -- a laggy/dead relay
491
- // connection must never hold up process exit on a signal every orchestrator expects a prompt
492
- // response from.
493
- Promise.race([Promise.allSettled([server.flushAll(), presence ? presence.stop() : Promise.resolve()]), timeout]).then(() => {
494
- console.log('[server] flush complete, stopping server...')
495
- try { server.stop() } catch (e) { console.error('[server] stop() error:', e.message) }
496
- console.log('[server] shutdown complete')
497
- process.exit(0)
498
- })
499
- }
500
- process.on('SIGINT', () => handleSignal('SIGINT'))
501
- process.on('SIGTERM', () => handleSignal('SIGTERM'))
502
- }
248
+ // Re-exported from ServerBoot.js for backward compatibility -- every existing caller imports
249
+ // boot/buildStaticDirs/assertNodeModulesLinked/installGracefulShutdown from this file's own path.
250
+ export { buildStaticDirs, assertNodeModulesLinked, boot, installGracefulShutdown } from './ServerBoot.js'