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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@
"optionalDependencies": {
"@fails-components/webtransport": "^1.5.3",
"@fails-components/webtransport-transport-http3-quiche": "^1.5.3",
"wireweave": "^0.3.0",
"nostr-tools": "^2.7.0"
"nostr-tools": "^2.7.0",
"wireweave": "^0.3.0"
},
"devDependencies": {
"@gltf-transform/cli": "^4.3.0",
Expand Down
52 changes: 52 additions & 0 deletions src/apps/AppCollisionTick.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
function getColR(c) {
if (!c) return 0
if (c._cachedRadius !== undefined) return c._cachedRadius
let r = 1
if (c.type === 'sphere') r = c.radius || 1
else if (c.type === 'capsule') r = Math.max(c.radius || 0.5, (c.height || 1) / 2)
else if (c.type === 'box') { const s = c.size, h = c.halfExtents; r = Array.isArray(s) ? Math.max(...s) : typeof s === 'number' ? s : Array.isArray(h) ? Math.max(...h) : 1 }
c._cachedRadius = r; return r
}

export function tickCollisions(runtime, c) {
if (c.length === 0) return
for (let i = 0; i < c.length; i++) c[i]._cachedColR = getColR(c[i].collider)
if (c.length < 100) tickCollisionsBrute(runtime, c); else tickCollisionsGrid(runtime, c)
}

function tickCollisionsBrute(runtime, c) {
for (let i = 0; i < c.length; i++) {
const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2]
for (let j = i + 1; j < c.length; j++) {
const b = c[j], dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az, rr = ar + b._cachedColR
if (dx*dx+dy*dy+dz*dz < rr*rr) {
runtime.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity })
runtime.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity })
}
}
}
}

const _COL_CELL_SZ = 4
function tickCollisionsGrid(runtime, c) {
const grid = runtime._colGrid, cells = runtime._colGridCells
grid.clear()
if ((++runtime._colPruneTick & 63) === 0 || cells.size > c.length * 4) { for (const k of cells.keys()) if (!grid.has(k)) cells.delete(k) }
for (let i = 0; i < c.length; i++) {
const e = c[i], key = Math.floor(e.position[0] / _COL_CELL_SZ) * 65536 + Math.floor(e.position[2] / _COL_CELL_SZ)
let cell = grid.get(key); if (!cell) { cell = cells.get(key); if (!cell) { cell = []; cells.set(key, cell) } else cell.length = 0; grid.set(key, cell) }
cell.push(e)
}
for (let i = 0; i < c.length; i++) {
const a = c[i], ar = a._cachedColR, ax = a.position[0], ay = a.position[1], az = a.position[2], acx = Math.floor(ax / _COL_CELL_SZ), acz = Math.floor(az / _COL_CELL_SZ)
for (let ddx = -1; ddx <= 1; ddx++) for (let ddz = -1; ddz <= 1; ddz++) {
const cell = grid.get((acx + ddx) * 65536 + (acz + ddz))
if (!cell) continue
for (const b of cell) {
if (b.id <= a.id) continue
const dx = b.position[0]-ax, dy = b.position[1]-ay, dz = b.position[2]-az, rr = ar + b._cachedColR
if (dx*dx+dy*dy+dz*dz < rr*rr) { runtime.fireEvent(a.id, 'onCollision', { id: b.id, position: b.position, velocity: b.velocity }); runtime.fireEvent(b.id, 'onCollision', { id: a.id, position: a.position, velocity: a.velocity }) }
}
}
}
}
183 changes: 39 additions & 144 deletions src/apps/AppLoader.js
Original file line number Diff line number Diff line change
@@ -1,205 +1,100 @@
const BLOCKED_PATTERNS = [
'process.exit', 'child_process', 'require(', '__proto__',
'Object.prototype', 'globalThis', 'eval(', 'import('
]
import { nodeModules } from './NodeModules.js'

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
}
const BLOCKED_PATTERNS = ['process.exit', 'child_process', 'require(', '__proto__', 'Object.prototype', 'globalThis', 'eval(', 'import(']

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 nodeModules()
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 nodeModules(), 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); const loaded = await this.loadApp(name); if (loaded) 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 nodeModules()
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
}
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}`); 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
}
}
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
}
const { resolve, pathToFileURL } = await nodeModules()
try { const absPath = resolve(filePath); const mod = await import(pathToFileURL(absPath).href + `?t=${Date.now()}`); return mod.default || mod }
catch (e) { console.error(`[AppLoader] eval error in "${filePath}": ${e.message}`); return null }
}

async watchAll() {
const { existsSync, watch, join, basename, extname } = await _nodeModules()
const { existsSync, watch, basename, extname } = await nodeModules()
for (const dir of this._dirs) {
if (!existsSync(dir)) {
console.debug(`[AppLoader] skipping watch for missing directory: ${dir}`)
continue
}
try {
const ac = new AbortController()
const 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)
}
}
})()
} catch (e) {
console.error(`[AppLoader] watchAll error:`, e.message)
}
if (!existsSync(dir)) continue
const ac = new AbortController(); const 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('/'), 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) }
})()
}
}

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()
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); console.log(`[AppLoader] queued hot reload ${name}`) }
}

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
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)
}
const url = URL.createObjectURL(new Blob([deps ? this._rewriteDeps(source, deps, revokes) : source], { type: 'application/javascript' })); 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
const sub = typeof entry === 'string' ? { source: entry, deps: {} } : entry, subSource = this._rewriteDeps(sub.source, sub.deps || {}, revokes), blob = new Blob([subSource], { type: 'application/javascript' }), 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
)
return source.replace(/((?:from|import)\s*)(['"])(\.[^'"]+)\2/g, (m, pre, q, spec) => urlMap[spec] ? `${pre}${q}${urlMap[spec]}${q}` : m)
}
}
Loading
Loading