Skip to content

Commit 9c4f3ab

Browse files
committed
Merge upstream/dev with improved storage and DB support
- Unified clean-world script with conditional local/S3 storage support - Added flexible DB configuration (SQLite/PostgreSQL) via DB_TYPE/DB_URL - Resolved conflicts maintaining storage and DB enhancements - Integrated upstream changes: new avatar system, assets, and world improvements - Updated clean-world script to work with both storage types without duplication
1 parent fa33fd5 commit 9c4f3ab

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+1854
-472
lines changed

CHANGELOG.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
## [v0.14.0]
17+
18+
### Added
19+
- core: ambient occlusion
20+
- core: new scene app format
21+
- core: touch device joystick UI
22+
- core: new camera-facing character controls
23+
- core: first-person support
24+
- apps: ability to read/write browser url params
25+
- apps: ability to make avatar nodes invisible (.visible)
26+
27+
### Changed
28+
- core: apps list updates when others add/remove apps
29+
- core: reduced reticle size
30+
- core: fog is now radial distance based
31+
- core: don't preload apps that are disabled
32+
- core: sleeker sidebar UI
33+
34+
### Fixed
35+
- core: shift clicking file fields to download
36+
- core: fix weird transparency ordering issues
37+
- core: improve touch device chat UX
38+
1639
## [v0.13.0]
1740

1841
### Added
@@ -413,7 +436,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
413436
- Basic project structure
414437
- Core functionality from original project
415438

416-
[Unreleased]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.13.0...HEAD
439+
[Unreleased]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.14.0...HEAD
440+
[0.14.0]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.13.0...v0.14.0
417441
[0.13.0]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.12.0...v0.13.0
418442
[0.12.0]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.11.0...v0.12.0
419443
[0.11.0]: https://github.com/hyperfy-xyz/hyperfy/compare/v0.10.0...v0.11.0

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Hyperfy is an open-source framework for building interactive 3D virtual worlds.
2121
- **WebXR support** - Experience worlds in VR
2222
- **Extensible architecture** - Highly customizable for various use cases
2323

24+
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hyperfy-xyz/hyperfy)
25+
2426
## 🚀 Quick Start
2527

2628
### Prerequisites
@@ -59,7 +61,7 @@ For containerized deployment, check [DOCKER.md](DOCKER.md) for detailed instruct
5961

6062
## 📚 Documentation & Resources
6163

62-
- **[Community Documentation](https://hyperfy.how)** - Comprehensive guides and reference
64+
- **[Community Documentation](https://docs.hyperfy.xyz)** - Comprehensive guides and reference
6365
- **[Website](https://hyperfy.io/)** - Official Hyperfy website
6466
- **[Sandbox](https://play.hyperfy.xyz/)** - Try Hyperfy in your browser
6567
- **[Twitter/X](https://x.com/hyperfy_io)** - Latest updates and announcements

docs/ref/Avatar.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ An asset url (eg from props) or an absolute URL to a `.vrm` file.
1919
2020
An emote url (eg from props) or an absolute URL to a `.glb` file with an emote animation.
2121
22+
### `.visible`: Boolean
23+
24+
Whether the avatar is visible or not.
25+
2226
## Methods
2327
2428
### `.getHeight()`: Number

docs/ref/World.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,11 @@ Returns a player. If no `playerId` is provided it returns the local player.
5454

5555
Returns an array of all players.
5656

57+
### `.getQueryParam(key)`
58+
59+
Gets a query parameter value from the browsers url
60+
61+
### `.setQueryParam(key, value)`
62+
63+
Sets a query parameter in the browsers url
64+

package-lock.json

Lines changed: 13 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hyperfy",
3-
"version": "0.13.0",
3+
"version": "0.14.0",
44
"type": "module",
55
"main": "index.js",
66
"homepage": "https://github.com/hyperfy-xyz/hyperfy#readme",
@@ -59,6 +59,7 @@
5959
"lucide-react": "^0.469.0",
6060
"moment": "^2.30.1",
6161
"msgpackr": "^1.11.0",
62+
"n8ao": "^1.10.0",
6263
"nanoid": "^5.0.6",
6364
"postprocessing": "^6.36.4",
6465
"react": "^19.1.0",

scripts/clean-world.mjs

Lines changed: 234 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,240 @@
1-
#!/usr/bin/env node
2-
31
import 'dotenv-flow/config'
2+
import fs from 'fs-extra'
3+
import path from 'path'
4+
import Knex from 'knex'
5+
import moment from 'moment'
6+
import { fileURLToPath } from 'url'
7+
import { DeleteObjectCommand, S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'
48

5-
/**
6-
* Clean World Script
7-
* Main entry point for world cleaning operations.
8-
*
9-
* Automatically selects the appropriate cleanup script based on STORAGE_TYPE:
10-
* - STORAGE_TYPE=s3: Uses clean-world-s3.mjs
11-
* - STORAGE_TYPE=local or unset: Uses clean-world-local.mjs
12-
*
13-
* Usage:
14-
* npm run world:clean
15-
*/
16-
9+
const DRY_RUN = false
1710
const storageType = process.env.STORAGE_TYPE || 'local'
11+
const world = process.env.WORLD || 'world'
12+
13+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
14+
const rootDir = path.join(__dirname, '../')
15+
const worldDir = path.join(rootDir, world)
16+
const assetsDir = path.join(worldDir, '/assets')
17+
18+
// Database configuration
19+
const { DB_TYPE = '', DB_URL = '' } = process.env
20+
let dbConfig
21+
22+
if (!DB_TYPE && !DB_URL) {
23+
// Default: sqlite in world folder
24+
dbConfig = {
25+
client: 'better-sqlite3',
26+
connection: { filename: `./${world}/db.sqlite` },
27+
useNullAsDefault: true,
28+
}
29+
} else if (DB_TYPE === 'pg' && DB_URL) {
30+
dbConfig = {
31+
client: 'pg',
32+
connection: DB_URL,
33+
pool: { min: 2, max: 10 },
34+
}
35+
} else {
36+
throw new Error('Unsupported or incomplete DB configuration. Only sqlite (default) and postgres (pg) via DB_TYPE/DB_URL are supported.')
37+
}
38+
39+
const db = Knex(dbConfig)
40+
41+
// S3 configuration if needed
42+
let s3Client, bucketName, assetsPrefix
43+
if (storageType === 's3') {
44+
if (!process.env.S3_BUCKET_NAME) {
45+
console.error('Error: S3_BUCKET_NAME is required when STORAGE_TYPE=s3')
46+
process.exit(1)
47+
}
48+
49+
s3Client = new S3Client({
50+
region: process.env.S3_REGION || 'us-east-1',
51+
credentials: {
52+
accessKeyId: process.env.S3_ACCESS_KEY_ID,
53+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
54+
},
55+
})
56+
bucketName = process.env.S3_BUCKET_NAME
57+
assetsPrefix = process.env.S3_ASSETS_PREFIX || 'assets/'
58+
}
59+
60+
console.log(`Using ${storageType.toUpperCase()} storage`)
61+
62+
// TODO: run any missing migrations first?
63+
64+
let blueprints = new Set()
65+
const blueprintRows = await db('blueprints')
66+
for (const row of blueprintRows) {
67+
const blueprint = JSON.parse(row.data)
68+
blueprints.add(blueprint)
69+
}
70+
71+
const entities = []
72+
const entityRows = await db('entities')
73+
for (const row of entityRows) {
74+
const entity = JSON.parse(row.data)
75+
entities.push(entity)
76+
}
77+
78+
const vrms = new Set()
79+
const userRows = await db('users').select('avatar')
80+
for (const user of userRows) {
81+
if (!user.avatar) continue
82+
const avatar = user.avatar.replace('asset://', '')
83+
vrms.add(avatar)
84+
}
85+
86+
// Get assets from storage (local or S3)
87+
const fileAssets = new Set()
1888

1989
if (storageType === 's3') {
20-
console.log('Running S3 cleanup...')
21-
await import('./clean-world/clean-world-s3.mjs')
90+
// List S3 assets
91+
console.log('Fetching S3 assets...')
92+
let continuationToken = undefined
93+
do {
94+
const command = new ListObjectsV2Command({
95+
Bucket: bucketName,
96+
Prefix: assetsPrefix,
97+
ContinuationToken: continuationToken,
98+
})
99+
100+
const response = await s3Client.send(command)
101+
102+
if (response.Contents) {
103+
for (const object of response.Contents) {
104+
const key = object.Key
105+
const filename = key.replace(assetsPrefix, '')
106+
// Check if it's a hashed asset (64 character hash)
107+
const isAsset = filename.split('.')[0].length === 64
108+
if (isAsset) {
109+
fileAssets.add(filename)
110+
}
111+
}
112+
}
113+
114+
continuationToken = response.NextContinuationToken
115+
} while (continuationToken)
116+
117+
console.log(`Found ${fileAssets.size} S3 assets`)
22118
} else {
23-
console.log('Running local cleanup...')
24-
await import('./clean-world/clean-world-local.mjs')
25-
}
119+
// List local assets
120+
const files = fs.readdirSync(assetsDir)
121+
for (const file of files) {
122+
const filePath = path.join(assetsDir, file)
123+
const isDirectory = fs.statSync(filePath).isDirectory()
124+
if (isDirectory) continue
125+
const relPath = path.relative(assetsDir, filePath)
126+
// HACK: we only want to include uploaded assets (not core/assets/*) so we do a check
127+
// if its filename is a 64 character hash
128+
const isAsset = relPath.split('.')[0].length === 64
129+
if (!isAsset) continue
130+
fileAssets.add(relPath)
131+
}
132+
}
133+
134+
let worldImage
135+
let worldModel
136+
let worldAvatar
137+
let settings = await db('config').where('key', 'settings').first()
138+
if (settings) {
139+
settings = JSON.parse(settings.value)
140+
if (settings.image) worldImage = settings.image.url.replace('asset://', '')
141+
if (settings.model) worldModel = settings.model.url.replace('asset://', '')
142+
if (settings.avatar) worldAvatar = settings.avatar.url.replace('asset://', '')
143+
}
144+
145+
/**
146+
* Phase 1:
147+
* Remove all blueprints that no entities reference any more.
148+
* The world doesn't need them, and we shouldn't be loading them in and sending dead blueprints to all the clients.
149+
*/
150+
151+
const blueprintsToDelete = []
152+
for (const blueprint of blueprints) {
153+
const canDelete = !entities.find(e => e.blueprint === blueprint.id)
154+
if (canDelete) {
155+
blueprintsToDelete.push(blueprint)
156+
}
157+
}
158+
console.log(`deleting ${blueprintsToDelete.length} blueprints`)
159+
for (const blueprint of blueprintsToDelete) {
160+
blueprints.delete(blueprint)
161+
if (!DRY_RUN) {
162+
await db('blueprints').where('id', blueprint.id).delete()
163+
}
164+
console.log('delete blueprint:', blueprint.id)
165+
}
166+
167+
/**
168+
* Phase 2:
169+
* Remove all asset files that are not:
170+
* - referenced by a blueprint
171+
* - used as a player avatar
172+
* - used as the world image
173+
* - used as the world avatar
174+
* - used as the world model
175+
* The world no longer uses/needs them.
176+
*
177+
*/
178+
179+
const blueprintAssets = new Set()
180+
for (const blueprint of blueprints) {
181+
if (blueprint.model && blueprint.model.startsWith('asset://')) {
182+
const asset = blueprint.model.replace('asset://', '')
183+
blueprintAssets.add(asset)
184+
}
185+
if (blueprint.script && blueprint.script.startsWith('asset://')) {
186+
const asset = blueprint.script.replace('asset://', '')
187+
blueprintAssets.add(asset)
188+
}
189+
if (blueprint.image?.url && blueprint.image.url.startsWith('asset://')) {
190+
const asset = blueprint.image.url.replace('asset://', '')
191+
blueprintAssets.add(asset)
192+
}
193+
for (const key in blueprint.props) {
194+
const url = blueprint.props[key]?.url
195+
if (!url) continue
196+
const asset = url.replace('asset://', '')
197+
blueprintAssets.add(asset)
198+
}
199+
}
200+
201+
const filesToDelete = []
202+
for (const fileAsset of fileAssets) {
203+
const isUsedByBlueprint = blueprintAssets.has(fileAsset)
204+
const isUsedByUser = vrms.has(fileAsset)
205+
const isWorldImage = fileAsset === worldImage
206+
const isWorldModel = fileAsset === worldModel
207+
const isWorldAvatar = fileAsset === worldAvatar
208+
if (!isUsedByBlueprint && !isUsedByUser && !isWorldModel && !isWorldAvatar && !isWorldImage) {
209+
filesToDelete.push(fileAsset)
210+
}
211+
}
212+
213+
console.log(`deleting ${filesToDelete.length} assets`)
214+
for (const fileAsset of filesToDelete) {
215+
if (storageType === 's3') {
216+
// Delete from S3
217+
const s3Key = `${assetsPrefix}${fileAsset}`
218+
if (!DRY_RUN) {
219+
const deleteCommand = new DeleteObjectCommand({
220+
Bucket: bucketName,
221+
Key: s3Key,
222+
})
223+
await s3Client.send(deleteCommand)
224+
}
225+
console.log('delete asset:', fileAsset)
226+
} else {
227+
// Delete from local filesystem
228+
const fullPath = path.join(assetsDir, fileAsset)
229+
if (!DRY_RUN) {
230+
fs.removeSync(fullPath)
231+
}
232+
console.log('delete asset:', fileAsset)
233+
}
234+
}
235+
236+
console.log(`${storageType.toUpperCase()} cleanup completed`)
237+
238+
// Close database connection before exiting
239+
await db.destroy()
240+
process.exit()

0 commit comments

Comments
 (0)