Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/apps/AppEval.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const BLOCKED_PATTERNS = ['process.exit', 'child_process', 'require(', '__proto__', 'Object.prototype', 'globalThis', 'eval(', 'import(']

let _nm = null
export async function getNodeModules() {
if (_nm) return _nm
const [fsp, fsSync, path, url] = await Promise.all([import('node:fs/promises'), import('node:fs'), import('node:path'), import('node:url')])
_nm = { readdir: fsp.readdir, readFile: fsp.readFile, watch: fsp.watch, access: fsp.access, existsSync: fsSync.existsSync, join: path.join, basename: path.basename, extname: path.extname, resolve: path.resolve, pathToFileURL: url.pathToFileURL }
return _nm
}

export function validateAppSource(source, name) {
for (const pattern of BLOCKED_PATTERNS) {
if (source.includes(pattern)) { console.error(`[AppLoader] blocked pattern "${pattern}" in ${name}`); return false }
}
return true
}

export async function evaluateAppModule(filePath) {
const nm = await getNodeModules()
try {
const absPath = nm.resolve(filePath), url = nm.pathToFileURL(absPath).href + `?t=${Date.now()}`
const mod = await import(url); return mod.default || mod
} catch (e) {
console.error(`[AppLoader] syntax/eval error in "${filePath}": ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`)
return null
}
}
192 changes: 28 additions & 164 deletions src/apps/AppLoader.js
Original file line number Diff line number Diff line change
@@ -1,205 +1,69 @@
const BLOCKED_PATTERNS = [
'process.exit', 'child_process', 'require(', '__proto__',
'Object.prototype', 'globalThis', 'eval(', 'import('
]

let _nm = null
async function _nodeModules() {
if (_nm) return _nm
const [fsp, fsSync, path, url] = await Promise.all([
import('node:fs/promises'),
import('node:fs'),
import('node:path'),
import('node:url')
])
_nm = {
readdir: fsp.readdir, readFile: fsp.readFile, watch: fsp.watch, access: fsp.access,
existsSync: fsSync.existsSync, join: path.join, basename: path.basename,
extname: path.extname, resolve: path.resolve, pathToFileURL: url.pathToFileURL
}
return _nm
}
import { validateAppSource, evaluateAppModule, getNodeModules } from './AppEval.js'

export class AppLoader {
constructor(runtime, config = {}) {
this._runtime = runtime
this._dirs = config.dirs || [config.dir || './apps']
this._watchers = new Map()
this._loaded = new Map()
this._onReloadCallback = null
this._runtime = runtime; this._dirs = config.dirs || [config.dir || './apps']
this._watchers = new Map(); this._loaded = new Map(); this._onReloadCallback = null
}

async _resolvePath(name) {
const { join, access } = await _nodeModules()
const { join, access } = await getNodeModules()
for (const dir of this._dirs) {
const flat = join(dir, `${name}.js`)
try { await access(flat); return flat } catch {}
const folder = join(dir, name, 'index.js')
try { await access(folder); return folder } catch {}
const flat = join(dir, `${name}.js`); try { await access(flat); return flat } catch {}
const folder = join(dir, name, 'index.js'); try { await access(folder); return folder } catch {}
}
return null
}

async loadAll() {
const { readdir, access, join, basename, extname } = await _nodeModules()
const seen = new Set()
const results = []
const { readdir, access, join, basename, extname } = await getNodeModules()
const seen = new Set(), results = []
for (const dir of this._dirs) {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
let name = null
if (entry.isFile() && entry.name.endsWith('.js')) {
name = basename(entry.name, extname(entry.name))
} else if (entry.isDirectory()) {
try { await access(join(dir, entry.name, 'index.js')); name = entry.name } catch {}
}
if (name && !seen.has(name)) {
seen.add(name)
const loaded = await this.loadApp(name)
if (loaded) results.push(name)
}
if (entry.isFile() && entry.name.endsWith('.js')) name = basename(entry.name, extname(entry.name))
else if (entry.isDirectory()) try { await access(join(dir, entry.name, 'index.js')); name = entry.name } catch {}
if (name && !seen.has(name)) { seen.add(name); if (await this.loadApp(name)) results.push(name) }
}
}
return results
}

async loadApp(name) {
const filePath = await this._resolvePath(name)
if (!filePath) return null
const { readFile } = await _nodeModules()
const filePath = await this._resolvePath(name); if (!filePath) return null
const { readFile } = await getNodeModules()
try {
const source = await readFile(filePath, 'utf-8')
if (!this._validate(source, name)) return null
const appDef = await this._evaluate(source, filePath)
if (!appDef) return null
this._runtime.registerApp(name, appDef)
this._loaded.set(name, { filePath, source, clientCode: source })
return appDef
} catch (e) {
console.error(`[AppLoader] failed to load "${name}": ${e.message}\n file: ${filePath}\n stack: ${e.stack?.split('\n').slice(1, 3).join('\n ') || 'none'}`)
return null
}
}

_validate(source, name) {
for (const pattern of BLOCKED_PATTERNS) {
if (source.includes(pattern)) {
console.error(`[AppLoader] blocked pattern "${pattern}" in ${name}`)
return false
}
}
return true
}

async _evaluate(source, filePath) {
const { resolve, pathToFileURL } = await _nodeModules()
try {
const absPath = resolve(filePath)
const url = pathToFileURL(absPath).href + `?t=${Date.now()}`
const mod = await import(url)
return mod.default || mod
} catch (e) {
console.error(`[AppLoader] syntax/eval error in "${filePath}": ${e.message}\n ${e.stack?.split('\n').slice(1, 3).join('\n ') || ''}`)
return null
}
if (!validateAppSource(source, name)) return null
const appDef = await evaluateAppModule(filePath); if (!appDef) return null
this._runtime.registerApp(name, appDef); this._loaded.set(name, { filePath, source, clientCode: source }); return appDef
} catch (e) { console.error(`[AppLoader] failed to load "${name}": ${e.message}`); return null }
}

async watchAll() {
const { existsSync, watch, join, basename, extname } = await _nodeModules()
const { existsSync, watch, basename, extname } = await getNodeModules()
for (const dir of this._dirs) {
if (!existsSync(dir)) {
console.debug(`[AppLoader] skipping watch for missing directory: ${dir}`)
continue
}
if (!existsSync(dir)) continue
try {
const ac = new AbortController()
const watcher = watch(dir, { recursive: true, signal: ac.signal })
this._watchers.set(dir, ac)
const ac = new AbortController(), watcher = watch(dir, { recursive: true, signal: ac.signal }); this._watchers.set(dir, ac)
;(async () => {
try {
for await (const event of watcher) {
if (!event.filename || !event.filename.endsWith('.js')) continue
const parts = event.filename.replace(/\\/g, '/').split('/')
const name = parts.length > 1
? parts[0]
: basename(event.filename, extname(event.filename))
await this._onFileChange(name)
}
} catch (e) {
if (e.name !== 'AbortError') {
console.error(`[AppLoader] watch error:`, e.message)
const parts = event.filename.replace(/\\/g, '/').split('/'), name = parts.length > 1 ? parts[0] : basename(event.filename, extname(event.filename))
console.log(`[AppLoader] reloading ${name}`)
const appDef = await this.loadApp(name)
if (appDef) this._runtime.queueReload(name, appDef, this._onReloadCallback ? (n, d) => this._onReloadCallback(n, this._loaded.get(n)?.clientCode) : null)
}
}
} catch (e) { if (e.name !== 'AbortError') console.error(`[AppLoader] watch error:`, e.message) }
})()
} catch (e) {
console.error(`[AppLoader] watchAll error:`, e.message)
}
} catch (e) { console.error(`[AppLoader] watchAll error:`, e.message) }
}
}

async _onFileChange(name) {
console.log(`[AppLoader] reloading ${name}`)
const appDef = await this.loadApp(name)
if (appDef) {
const cb = this._onReloadCallback ? (n, d) => {
this._onReloadCallback(n, this._loaded.get(n)?.clientCode)
} : null
this._runtime.queueReload(name, appDef, cb)
console.log(`[AppLoader] queued hot reload ${name}`)
}
}

stopWatching() {
for (const ac of this._watchers.values()) ac.abort()
this._watchers.clear()
}

stopWatching() { for (const ac of this._watchers.values()) ac.abort(); this._watchers.clear() }
getLoaded() { return Array.from(this._loaded.keys()) }

getClientModules() {
const modules = {}
for (const [name, data] of this._loaded) {
if (data.clientCode) modules[name] = data.clientCode
}
return modules
}

getClientModules() { const modules = {}; for (const [name, data] of this._loaded) if (data.clientCode) modules[name] = data.clientCode; return modules }
getClientModule(name) { return this._loaded.get(name)?.clientCode || null }

async loadFromString(name, source, deps = null) {
if (!this._validate(source, name)) return null
const revokes = []
try {
const rewrittenSource = deps ? this._rewriteDeps(source, deps, revokes) : source
const blob = new Blob([rewrittenSource], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
revokes.push(url)
const mod = await import(url)
const appDef = mod.default || mod
this._runtime.registerApp(name, appDef)
this._loaded.set(name, { source, clientCode: source, filePath: null })
return appDef
} catch (e) {
console.error(`[AppLoader] string eval error:`, e.message)
return null
} finally {
for (const u of revokes) URL.revokeObjectURL(u)
}
}

_rewriteDeps(source, deps, revokes) {
const urlMap = {}
for (const [spec, entry] of Object.entries(deps)) {
if (!entry) continue
const sub = typeof entry === 'string' ? { source: entry, deps: {} } : entry
const subSource = this._rewriteDeps(sub.source, sub.deps || {}, revokes)
const blob = new Blob([subSource], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
revokes.push(url)
urlMap[spec] = url
}
return source.replace(/((?:from|import)\s*)(['"])(\.[^'"]+)\2/g, (m, pre, q, spec) =>
urlMap[spec] ? `${pre}${q}${urlMap[spec]}${q}` : m
)
}
}
1 change: 1 addition & 0 deletions src/apps/AppRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class AppRuntime {
this._lastSyncMs = 0; this._lastRespawnMs = 0; this._lastSpatialMs = 0; this._lastCollisionMs = 0; this._lastInteractMs = 0
mixinPhysics(this); mixinTick(this); if (this._physics) this._registerPhysicsCallbacks()
this._hotReload = new HotReloadQueue(this); this._eventBus = c.eventBus || new EventBus()
if (typeof globalThis.__SPOINT_DEBUG__ !== 'undefined') globalThis.__SPOINT_DEBUG__.runtime = this
this._eventLog = c.eventLog||null; this._storage = c.storage||null; this._sdkRoot = c.sdkRoot||null
this._eventBus.on('*', ev => { if (!ev.channel.startsWith('system.')) this._log('bus_event', { channel:ev.channel, data:ev.data }, ev.meta) })
this._eventBus.on('system.handover', ev => { const {targetEntityId,stateData}=ev.data||{}; if (targetEntityId) this.fireEvent(targetEntityId,'onHandover',ev.meta.sourceEntity,stateData) })
Expand Down
59 changes: 59 additions & 0 deletions src/netcode/SnapshotDelta.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const Q1 = 100
const VEL_ZERO = [0, 0, 0]
const SCALE_ONE = [1, 1, 1]
const QSCALE = 511 * Math.SQRT2

export function packQuat(rx, ry, rz, rw) {
const q = [rx, ry, rz, rw]
let maxIdx = 0
for (let i = 1; i < 4; i++) if (Math.abs(q[i]) > Math.abs(q[maxIdx])) maxIdx = i
const sign = q[maxIdx] < 0 ? -1 : 1
const indices = [0, 1, 2, 3].filter(i => i !== maxIdx)
let packed = maxIdx
for (const i of indices) {
const n = Math.max(0, Math.min(1022, Math.round((q[i] * sign + Math.SQRT1_2) * QSCALE)))
packed = (packed << 10) | n
}
return packed >>> 0
}

export function unpackQuat(packed, out) {
const maxIdx = (packed >>> 30) & 0x3
const indices = [0, 1, 2, 3].filter(i => i !== maxIdx)
let sumSq = 0
for (let j = 2; j >= 0; j--) {
const v = (packed & 0x3FF) / QSCALE - Math.SQRT1_2; packed = (packed >>> 10)
out[indices[j]] = v; sumSq += v * v
}
out[maxIdx] = Math.sqrt(Math.max(0, 1 - sumSq)); return out
}

export function encodePlayer(p) {
const [px, py, pz] = p.position, [rx, ry, rz, rw] = p.rotation, [vx, vy, vz] = p.velocity
const pitchN = Math.round(((p.lookPitch || 0) + Math.PI) / (2 * Math.PI) * 15) & 0xF
const yawN = Math.round(((p.lookYaw || 0) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) / (2 * Math.PI) * 15) & 0xF
return [p.id, Math.round(px * Q1) / Q1, Math.round(py * Q1) / Q1, Math.round(pz * Q1) / Q1, packQuat(rx, ry, rz, rw), Math.round(vx * Q1) / Q1, Math.round(vy * Q1) / Q1, Math.round(vz * Q1) / Q1, p.onGround ? 1 : 0, Math.round(p.health || 0), p.inputSequence || 0, p.crouch || 0, (pitchN << 4) | yawN]
}

export function fillEntityEnc(e, enc) {
const pos = e.position, rot = e.rotation, v = e.velocity || VEL_ZERO, s = e.scale || SCALE_ONE
const px = pos[0], py = pos[1], pz = pos[2], rx = rot[0], ry = rot[1], rz = rot[2], rw = rot[3]
enc[0] = e.id; enc[1] = e.model || ''
enc[2] = Math.round(px * Q1) / Q1; enc[3] = Math.round(py * Q1) / Q1; enc[4] = Math.round(pz * Q1) / Q1
enc[5] = packQuat(rx, ry, rz, rw)
enc[6] = Math.round((v[0] || 0) * Q1) / Q1; enc[7] = Math.round((v[1] || 0) * Q1) / Q1; enc[8] = Math.round((v[2] || 0) * Q1) / Q1
enc[9] = e.bodyType || 'static'; enc[10] = e.custom || null
enc[11] = Math.round((s[0] || 1) * Q1) / Q1; enc[12] = Math.round((s[1] || 1) * Q1) / Q1; enc[13] = Math.round((s[2] || 1) * Q1) / Q1
enc[14] = e._dynSleeping ? 1 : 0; return enc
}

export function encodeEntity(e) { return fillEntityEnc(e, new Array(15)) }
export function buildEntityKey(enc, custStr) { return enc[1] + '|' + enc[2] + '|' + enc[3] + '|' + enc[4] + '|' + enc[5] + '|' + enc[6] + '|' + enc[7] + '|' + enc[8] + '|' + enc[9] + '|' + custStr + '|' + enc[11] + '|' + enc[12] + '|' + enc[13] + '|' + enc[14] }
export function custToStr(cust) { return cust != null ? JSON.stringify(cust) : '' }

export function resolveKey(entry) {
if (!entry._dirty) return entry.k
const cust = entry.enc[10]
entry.custStr = entry.cust === cust ? entry.custStr : custToStr(cust)
entry.cust = cust; entry.k = buildEntityKey(entry.enc, entry.custStr); entry._dirty = false; return entry.k
}
Loading
Loading