+ {/* Local Apps */}
+ {localApps.map(app => (
+
+ ))}
+ {/* Collection Apps */}
{collection.blueprints.map(blueprint => (
add(blueprint)}>
+ {blueprint.isLocal && (
+
+ This is a local app. Edit the script at:
+ apps/{blueprint.id}/script.js
+
+ )}
{transforms &&
}
diff --git a/src/core/createServerWorld.js b/src/core/createServerWorld.js
index 6849c01b..b29cb3f1 100644
--- a/src/core/createServerWorld.js
+++ b/src/core/createServerWorld.js
@@ -6,6 +6,8 @@ import { ServerNetwork } from './systems/ServerNetwork'
import { ServerLoader } from './systems/ServerLoader'
import { ServerEnvironment } from './systems/ServerEnvironment'
import { ServerMonitor } from './systems/ServerMonitor'
+import { LocalApps } from './systems/LocalApps'
+
export function createServerWorld() {
const world = new World()
@@ -15,5 +17,6 @@ export function createServerWorld() {
world.register('loader', ServerLoader)
world.register('environment', ServerEnvironment)
world.register('monitor', ServerMonitor)
+ world.register('localApps', LocalApps)
return world
}
diff --git a/src/core/entities/App.js b/src/core/entities/App.js
index 00ba660c..fe3a26c7 100644
--- a/src/core/entities/App.js
+++ b/src/core/entities/App.js
@@ -87,7 +87,12 @@ export class App extends Entity {
if (blueprint.script) {
try {
script = this.world.loader.get('script', blueprint.script)
- if (!script) script = await this.world.loader.load('script', blueprint.script)
+ if (!script) {
+ console.log(`[App] Loading script: ${blueprint.script}`)
+ script = await this.world.loader.load('script', blueprint.script)
+ } else {
+ console.log(`[App] Using cached script: ${blueprint.script}`)
+ }
} catch (err) {
console.error(err)
crashed = true
diff --git a/src/core/packets.js b/src/core/packets.js
index 758260db..d2c39e73 100644
--- a/src/core/packets.js
+++ b/src/core/packets.js
@@ -22,6 +22,7 @@ const names = [
'kick',
'ping',
'pong',
+ 'clearScriptCache',
]
const byName = {}
diff --git a/src/core/systems/Blueprints.js b/src/core/systems/Blueprints.js
index 5c6a5d2d..b4ea4a4e 100644
--- a/src/core/systems/Blueprints.js
+++ b/src/core/systems/Blueprints.js
@@ -33,13 +33,19 @@ export class Blueprints extends System {
}
const changed = !isEqual(blueprint, modified)
if (!changed) return
+ console.log(`[Blueprints] Modifying blueprint ${blueprint.id}, changed:`, changed)
this.items.set(blueprint.id, modified)
+ let rebuiltCount = 0
for (const [_, entity] of this.world.entities.items) {
if (entity.data.blueprint === blueprint.id) {
+ console.log(`[Blueprints] Found entity ${entity.data.id} with matching blueprint`)
entity.data.state = {}
entity.build()
+ rebuiltCount++
}
}
+ console.log(`[Blueprints] Rebuilt ${rebuiltCount} entities`)
+
this.emit('modify', modified)
}
diff --git a/src/core/systems/ClientLoader.js b/src/core/systems/ClientLoader.js
index 791c178a..0e6a9c54 100644
--- a/src/core/systems/ClientLoader.js
+++ b/src/core/systems/ClientLoader.js
@@ -135,6 +135,7 @@ export class ClientLoader extends System {
texture.type = result.type
texture.needsUpdate = true
this.results.set(key, texture)
+ console.log(`[ClientLoader] Cached script with key: ${key}`)
return texture
}
if (type === 'image') {
diff --git a/src/core/systems/ClientNetwork.js b/src/core/systems/ClientNetwork.js
index 578b30ac..ebc5831b 100644
--- a/src/core/systems/ClientNetwork.js
+++ b/src/core/systems/ClientNetwork.js
@@ -162,9 +162,48 @@ export class ClientNetwork extends System {
}
onBlueprintModified = change => {
+ console.log('[ClientNetwork] Blueprint modified:', change)
this.world.blueprints.modify(change)
}
+ onClearScriptCache = data => {
+ console.log(`[ClientNetwork] Clearing script cache for blueprint: ${data.blueprintId}`)
+
+ // Clear using the scriptPath directly
+ const scriptKey = `script/${data.scriptPath}`
+ console.log(`[ClientNetwork] Clearing cache key: ${scriptKey}`)
+
+ if (this.world.loader.results) {
+ this.world.loader.results.delete(scriptKey)
+ }
+ if (this.world.loader.promises) {
+ this.world.loader.promises.delete(scriptKey)
+ }
+
+ // Also clear the file cache
+ if (this.world.loader.files) {
+ this.world.loader.files.delete(data.scriptPath)
+ // Also clear with resolved URL
+ const resolvedUrl = this.world.resolveURL(data.scriptPath)
+ if (resolvedUrl !== data.scriptPath) {
+ this.world.loader.files.delete(resolvedUrl)
+ const resolvedKey = `script/${resolvedUrl}`
+ if (this.world.loader.results) {
+ this.world.loader.results.delete(resolvedKey)
+ }
+ if (this.world.loader.promises) {
+ this.world.loader.promises.delete(resolvedKey)
+ }
+ }
+ }
+
+ console.log('[ClientNetwork] Current cache state after clearing:', {
+ results: this.world.loader.results ? Array.from(this.world.loader.results.keys()).filter(k => k.includes('crash-block')) : [],
+ promises: this.world.loader.promises ? Array.from(this.world.loader.promises.keys()).filter(k => k.includes('crash-block')) : [],
+ files: this.world.loader.files ? Array.from(this.world.loader.files.keys()).filter(k => k.includes('crash-block')) : []
+ })
+ }
+
onEntityAdded = data => {
this.world.entities.add(data)
}
diff --git a/src/core/systems/LocalApps.js b/src/core/systems/LocalApps.js
new file mode 100644
index 00000000..cac49339
--- /dev/null
+++ b/src/core/systems/LocalApps.js
@@ -0,0 +1,192 @@
+import { System } from './System.js'
+import fs from 'fs/promises'
+import { watch } from 'fs'
+import path from 'path'
+
+export class LocalApps extends System {
+ constructor(world) {
+ super(world)
+ this.apps = new Map()
+ this.ready = false
+ this.reloadDebounce = new Map()
+ this.init()
+ }
+
+ async init() {
+ await this.loadApps()
+ this.watchApps()
+ this.ready = true
+ }
+
+ async loadApps() {
+ const rootDir = path.join(process.cwd(), 'apps')
+ try {
+ const appFolders = await fs.readdir(rootDir)
+
+ for (const appFolder of appFolders) {
+ const appDir = path.join(rootDir, appFolder)
+ const manifestPath = path.join(appDir, 'manifest.json')
+
+ try {
+ const stat = await fs.stat(appDir)
+ if (!stat.isDirectory()) continue
+
+ const manifestContent = await fs.readFile(manifestPath, 'utf-8')
+ const manifest = JSON.parse(manifestContent)
+
+ // Create a blueprint-like structure that matches existing format
+ const blueprint = {
+ id: appFolder,
+ name: manifest.name || appFolder,
+ desc: manifest.description || '',
+ model: manifest.model ? `/apps/${appFolder}/${manifest.model}` : null,
+ script: manifest.script ? `/apps/${appFolder}/${manifest.script}` : null,
+ isLocal: true,
+ version: 1,
+ }
+
+ this.apps.set(appFolder, blueprint)
+ console.log(`[LocalApps] Loaded app: ${blueprint.name} (${appFolder})`)
+ } catch (err) {
+ // Skip folders without valid manifest
+ if (err.code !== 'ENOENT') {
+ console.warn(`[LocalApps] Failed to load app ${appFolder}:`, err.message)
+ }
+ }
+ }
+
+ console.log(`[LocalApps] Loaded ${this.apps.size} local apps`)
+ } catch (err) {
+ console.error('[LocalApps] Could not read apps directory:', err)
+ }
+ }
+
+ getAppsList() {
+ return Array.from(this.apps.values())
+ }
+
+ getApp(id) {
+ return this.apps.get(id)
+ }
+
+ async reloadApps() {
+ this.apps.clear()
+ await this.loadApps()
+ }
+
+ watchApps() {
+ const appsDir = path.join(process.cwd(), 'apps')
+ watch(appsDir, { recursive: true }, (eventType, filename) => {
+ if (!filename || !filename.endsWith('.js')) return
+
+ // Extract appId from filename (e.g., 'crash-block/script.js' -> 'crash-block')
+ const pathParts = filename.split(path.sep)
+ if (pathParts.length < 2) return
+
+ const appId = pathParts[0]
+ const scriptFile = pathParts[pathParts.length - 1]
+
+ // Get the app blueprint
+ const blueprint = this.apps.get(appId)
+ if (!blueprint || !blueprint.script) return
+
+ // Check if this is the app's script file
+ const scriptPath = blueprint.script.split('/').pop()
+ if (scriptFile !== scriptPath) return
+
+ // Debounce the reload
+ if (this.reloadDebounce.has(appId)) {
+ clearTimeout(this.reloadDebounce.get(appId))
+ }
+
+ this.reloadDebounce.set(
+ appId,
+ setTimeout(() => {
+ this.reloadDebounce.delete(appId)
+ this.triggerAppReload(appId)
+ }, 100)
+ )
+ })
+ }
+
+ triggerAppReload(appId) {
+ const localBlueprint = this.apps.get(appId)
+ if (!localBlueprint) return
+
+ // Find all blueprints that match this local app's script path
+ const matchingBlueprints = []
+ for (const [id, blueprint] of this.world.blueprints.items) {
+ if (blueprint.script && blueprint.script.includes(`/apps/${appId}/`)) {
+ matchingBlueprints.push({ id, blueprint })
+ }
+ }
+
+ if (matchingBlueprints.length === 0) {
+ console.log(
+ `[LocalApps] App ${appId} is not in blueprints system yet. Hot reload will activate once app is added to world.`
+ )
+ return
+ }
+
+ // Update local blueprint version
+ localBlueprint.version = (localBlueprint.version || 1) + 1
+
+ // For local apps, we need to force script reload by temporarily clearing it
+ for (const { id, blueprint } of matchingBlueprints) {
+ const newVersion = (blueprint.version || 1) + 1
+ const scriptPath = blueprint.script
+
+ // Step 1: Clear the script to force unload
+ this.world.blueprints.modify({
+ id: id,
+ script: null,
+ version: newVersion,
+ })
+ // Broadcast to clients
+ this.world.network.send('blueprintModified', {
+ id: id,
+ script: null,
+ version: newVersion,
+ })
+
+ // Step 2: Clear server-side cache
+ const scriptKey = `script/${scriptPath}`
+ if (this.world.loader.results) {
+ this.world.loader.results.delete(scriptKey)
+ }
+ if (this.world.loader.promises) {
+ this.world.loader.promises.delete(scriptKey)
+ }
+
+ // Step 3: Send blueprint info to clear the correct client cache
+ this.world.network.send('clearScriptCache', {
+ scriptPath,
+ blueprintId: id,
+ currentScript: blueprint.script,
+ })
+
+ // Step 4: Restore the script after a short delay
+ setTimeout(() => {
+ // Force all apps with this blueprint to rebuild
+ for (const [_, entity] of this.world.entities.items) {
+ if (entity.data.blueprint === id && entity.isApp) {
+ entity.data.state = {}
+ }
+ }
+
+ this.world.blueprints.modify({
+ id: id,
+ script: scriptPath,
+ version: newVersion + 1,
+ })
+ // Broadcast to clients
+ this.world.network.send('blueprintModified', {
+ id: id,
+ script: scriptPath,
+ version: newVersion + 1,
+ })
+ console.log(`[LocalApps] Hot-reloaded app: ${appId} (blueprint: ${id})`)
+ }, 100)
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/core/systems/ServerLoader.js b/src/core/systems/ServerLoader.js
index 9633e22e..b54810b3 100644
--- a/src/core/systems/ServerLoader.js
+++ b/src/core/systems/ServerLoader.js
@@ -68,7 +68,13 @@ export class ServerLoader extends System {
const arrayBuffer = await response.arrayBuffer()
return arrayBuffer
} else {
- const buffer = await fs.readFile(url)
+ // Strip query parameters for local files
+ let cleanUrl = url.split('?')[0]
+ // Handle /apps/ paths - make them relative to project root
+ if (cleanUrl.startsWith('/apps/')) {
+ cleanUrl = path.join(process.cwd(), cleanUrl)
+ }
+ const buffer = await fs.readFile(cleanUrl)
const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
return arrayBuffer
}
@@ -81,7 +87,13 @@ export class ServerLoader extends System {
const text = await response.text()
return text
} else {
- const text = await fs.readFile(url, { encoding: 'utf8' })
+ // Strip query parameters for local files
+ let cleanUrl = url.split('?')[0]
+ // Handle /apps/ paths - make them relative to project root
+ if (cleanUrl.startsWith('/apps/')) {
+ cleanUrl = path.join(process.cwd(), cleanUrl)
+ }
+ const text = await fs.readFile(cleanUrl, { encoding: 'utf8' })
return text
}
}
diff --git a/src/server/index.js b/src/server/index.js
index ee8488fb..b84f1fa2 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -85,6 +85,16 @@ fastify.register(statics, {
res.setHeader('Expires', new Date(Date.now() + 31536000000).toUTCString()) // older browsers
},
})
+fastify.register(statics, {
+ root: path.join(rootDir, 'apps'),
+ prefix: '/apps/',
+ decorateReply: false,
+ setHeaders: res => {
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
+ res.setHeader('Pragma', 'no-cache')
+ res.setHeader('Expires', '0')
+ },
+})
fastify.register(multipart, {
limits: {
fileSize: 200 * 1024 * 1024, // 200MB
@@ -182,6 +192,19 @@ fastify.get('/status', async (request, reply) => {
}
})
+fastify.get('/api/apps', async (request, reply) => {
+ try {
+ if (!world.localApps) {
+ return reply.send([])
+ }
+ const apps = world.localApps.getAppsList()
+ return reply.send(apps)
+ } catch (error) {
+ console.error('Error fetching local apps:', error)
+ return reply.send([])
+ }
+})
+
fastify.setErrorHandler((err, req, reply) => {
console.error(err)
reply.status(500).send()