diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 00000000..ee55a6cf --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,30 @@ +name: Publish Node.js Client to NPM + +on: + release: + types: [created] + +jobs: + publish-npm: + runs-on: ubuntu-latest + permissions: + contents: read # Needed to check out the repository + # id-token: write # Potentially needed for provenance if publishing with that feature, but not strictly required for basic NPM publish + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' # .nvmrc exists and should specify the correct Node version (e.g., 22.x) + registry-url: 'https://registry.npmjs.org/' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build and publish to NPM + run: npm run publish:node + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 9585e722..7df65d16 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,75 @@ For containerized deployment, check [DOCKER.md](DOCKER.md) for detailed instruct - **Educational Experiences** - Develop interactive learning spaces - **Creative Showcases** - Display 3D art and interactive installations +## 📦 Using Hyperfy as a Node.js Module + +Beyond running a full Hyperfy server, you can integrate Hyperfy's core functionalities into your own Node.js applications by using the official NPM package. This allows you to create and manage Hyperfy worlds, entities, and systems programmatically. + +### Installation + +To install the Hyperfy Node.js client package, use npm: + +```bash +npm install hyperfy +``` +*(Note: Ensure the package name `hyperfy` matches the name intended for NPM publication.)* + +### Example Usage + +Here's a basic example of how to import and use components from the `hyperfy` package: + +```javascript +// example.js +import { createNodeClientWorld, System, Node, World } from 'hyperfy'; + +async function main() { + // The createNodeClientWorld function initializes a world suitable for server-side/headless operations. + // It may require specific options depending on your setup (e.g., for PhysX, assets). + const world = await createNodeClientWorld({ + // Example options (refer to documentation for details): + // physxWorker: new Worker(new URL('./physx-worker.js', import.meta.url)), // If PhysX is needed + // assetsDir: './assets', // Path to your assets directory + }); + + console.log('Hyperfy World instance created:', world); + + // Example: Define a simple system + class MySystem extends System { + start() { + console.log('MySystem started!'); + } + + update(delta) { + // Called every frame + } + } + + // Register the system with the world + world.register('mySystem', MySystem); + + // Example: Create and add a node + const myNode = new Node({ id: 'myCube', name: 'My Cube' }); + myNode.position.set(0, 1, -2); // Set position (Vector3) + // world.addNode(myNode); // How nodes are added might depend on specific world setup + + // Initialize and start the world (if not auto-started by createNodeClientWorld) + // await world.init({}); // Pass necessary initialization options + // world.start(); + + console.log('Node created:', myNode.id, myNode.name, myNode.position.x, myNode.position.y, myNode.position.z); + + // To run the world's simulation loop (if applicable for your use case): + // function gameLoop() { + // world.tick(performance.now()); + // setImmediate(gameLoop); // or requestAnimationFrame if in a context that supports it + // } + // gameLoop(); +} + +main().catch(console.error); +``` +This example demonstrates basic setup. Refer to the core module documentation for more in-depth usage of `World`, `Node`, `System`, and other components. + ## 📚 Documentation & Resources - **[Community Documentation](https://hyperfy.how)** - Comprehensive guides and reference @@ -111,6 +180,36 @@ Contributions are welcome! Please check out our [contributing guidelines](CONTRI 4. Push to the branch: `git push origin feature/amazing-feature` 5. Open a pull request +### Publishing the Node.js Client Package + +The Hyperfy Node.js client is packaged for NPM distribution. Publishing is handled both automatically via GitHub Actions and can be done manually. + +#### Automated Publishing (GitHub Releases) + +- **Trigger**: New versions of the `hyperfy` NPM package are automatically built and published when a new release is created on the GitHub repository. +- **Workflow**: This process is managed by the `.github/workflows/npm-publish.yml` GitHub Actions workflow. +- **Requirements**: For automated publishing to succeed, the `NPM_TOKEN` secret must be configured in the GitHub repository settings. This token grants the workflow permission to publish to NPM. + +#### Manual Publishing + +To publish the package manually, follow these steps: + +1. **Ensure Version is Updated**: Before publishing, verify that the `version` field in the main `package.json` is updated to the new version number you intend to release. The build script (`scripts/build-node-client.mjs`) uses this version for the package generated in `dist/npm/`. +2. **Authenticate with NPM**: You must be logged into NPM on your local machine with an account that has publish permissions for the `hyperfy` package. If you are not logged in, run: + ```bash + npm login + ``` +3. **Run Publish Script**: Execute the following command from the root of the repository: + ```bash + npm run publish:node + ``` + This script will: + * Build the Node.js client package into the `dist/npm/` directory. + * Change the current directory to `dist/npm/`. + * Run `npm publish` from within `dist/npm/`. + +**Important**: Always ensure that the package is thoroughly tested and the version number is correct before publishing, whether manually or by creating a GitHub release. + ## 🌱 Project Status This project is still in alpha as we transition all of our [reference platform](https://github.com/hyperfy-xyz/hyperfy-ref) code into fully self hostable worlds. diff --git a/package-lock.json b/package-lock.json index 3c0f7bb4..030909b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,12 +47,14 @@ "devDependencies": { "@babel/eslint-parser": "^7.23.10", "@babel/preset-react": "^7.23.10", + "dts-bundle-generator": "^9.0.0", "esbuild": "^0.24.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^3.4.2" + "prettier": "^3.4.2", + "typescript": "^5.0.0" }, "engines": { "node": "22.11.0", @@ -2271,6 +2273,100 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2978,6 +3074,23 @@ "node": ">= 12.0.0" } }, + "node_modules/dts-bundle-generator": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", + "integrity": "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "typescript": ">=5.0.2", + "yargs": "^17.6.0" + }, + "bin": { + "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4103,6 +4216,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", @@ -6302,6 +6425,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7431,6 +7564,20 @@ "rxjs": "*" } }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -7782,6 +7929,16 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7790,6 +7947,80 @@ "license": "ISC", "peer": true }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", diff --git a/package.json b/package.json index 99ef7db7..2f4b7a7c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "hyperfy", "version": "0.13.0", "type": "module", - "main": "index.js", + "main": "build/index.js", + "types": "build/index.d.ts", "homepage": "https://github.com/hyperfy-xyz/hyperfy#readme", "bugs": { "url": "https://github.com/hyperfy-xyz/hyperfy/issues" @@ -31,8 +32,14 @@ "lint": "eslint . --ext .js,.jsx", "lint:fix": "eslint . --ext .js,.jsx --fix", "format": "prettier --write .", - "check": "npm run lint && npm run format" + "check": "npm run lint && npm run format", + "publish:node": "node scripts/build-node-client.mjs && npm publish" }, + "files": [ + "build/", + "README.md", + "LICENSE" + ], "dependencies": { "@fastify/compress": "^8.0.1", "@fastify/cors": "^10.0.1", @@ -77,7 +84,9 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-react": "^7.34.0", "eslint-plugin-react-hooks": "^4.6.0", - "prettier": "^3.4.2" + "prettier": "^3.4.2", + "typescript": "^5.0.0", + "dts-bundle-generator": "^9.0.0" }, "engines": { "npm": ">=10.0.0", diff --git a/scripts/build-node-client.mjs b/scripts/build-node-client.mjs index b4755ac4..6b11a26f 100644 --- a/scripts/build-node-client.mjs +++ b/scripts/build-node-client.mjs @@ -9,9 +9,11 @@ import { polyfillNode } from 'esbuild-plugin-polyfill-node' const dev = process.argv.includes('--dev') const dirname = path.dirname(fileURLToPath(import.meta.url)) const rootDir = path.join(dirname, '../') -const buildDir = path.join(rootDir, 'build') +const buildDir = path.join(rootDir, 'build') // This can remain for other potential build outputs or be removed if not used elsewhere +const npmPackageDir = buildDir -// await fs.emptyDir(buildDir) +// await fs.emptyDir(buildDir) // Keep if buildDir is still used for other things +// await fs.emptyDir(npmPackageDir) // Ensure the new package directory is clean /** * Build Node Client @@ -23,47 +25,339 @@ const buildDir = path.join(rootDir, 'build') let spawn -{ - const nodeClientCtx = await esbuild.context({ - entryPoints: ['src/node-client/index.js'], - outfile: 'build/world-node-client.js', - platform: 'node', - format: 'esm', - bundle: true, - treeShaking: true, - minify: false, - sourcemap: true, - packages: 'external', - loader: {}, - plugins: [ - { - name: 'server-finalize-plugin', - setup(build) { - build.onEnd(async result => { - // copy over physx js - const physxIdlSrc = path.join(rootDir, 'src/core/physx-js-webidl.js') - const physxIdlDest = path.join(rootDir, 'build/physx-js-webidl.js') - await fs.copy(physxIdlSrc, physxIdlDest) - // copy over physx wasm - const physxWasmSrc = path.join(rootDir, 'src/core/physx-js-webidl.wasm') - const physxWasmDest = path.join(rootDir, 'build/physx-js-webidl.wasm') - await fs.copy(physxWasmSrc, physxWasmDest) - // start the server or stop here - if (dev) { - // (re)start server - spawn?.kill('SIGTERM') - spawn = fork(path.join(rootDir, 'build/world-node-client.js')) - } else { - process.exit(0) - } - }) +async function buildNodeClient() { + try { + // Ensure the NPM package directory is clean and exists + await fs.emptyDir(npmPackageDir) + await fs.ensureDir(npmPackageDir) + + console.log(`Building Node.js client for ${dev ? 'development' : 'production'}...`) + + // Read root package.json for details like version, license, dependencies + const rootPackageJson = await fs.readJson(path.join(rootDir, 'package.json')) + + const nodeClientCtx = await esbuild.context({ + entryPoints: [path.join(rootDir, 'src/node-client/index.js')], + outfile: path.join(npmPackageDir, 'index.js'), + platform: 'node', + format: 'esm', + bundle: true, + treeShaking: true, + minify: !dev, + keepNames: true, + sourcemap: dev ? 'inline' : true, + packages: 'external', // Externalize dependencies to be handled by package.json + // loader: {}, // Add if specific loaders are needed + // plugins: [polyfillNode()], // Consider if specific Node polyfills are absolutely required, but 'external' handles most cases + plugins: [ + { + name: 'node-client-finalize-plugin', + setup(build) { + build.onEnd(async result => { + if (result.errors.length > 0) { + console.error('Build failed with errors:', result.errors) + if (!dev) process.exit(1) + return + } + + console.log('Build successful. Finalizing package...') + + const physxFiles = ['physx-js-webidl.js', 'physx-js-webidl.wasm'] + for (const file of physxFiles) { + const src = path.join(rootDir, 'src/core', file) + const dest = path.join(npmPackageDir, file) + if (await fs.pathExists(src)) { + await fs.copy(src, dest) + console.log(`Copied ${file} to ${dest}`) + } else { + console.warn(`PhysX asset ${src} not found. Skipping.`) + } + } + + // 2. Generate package.json for the NPM package + const packageJson = { + name: rootPackageJson.name || 'hyperfy', // Or your chosen scoped package name + version: rootPackageJson.version, // Must be synced + description: rootPackageJson.description || 'Node.js client for Hyperfy virtual worlds', + main: 'index.js', + types: 'index.d.ts', + type: 'module', + files: [ + 'index.js', + 'index.d.ts', + 'vendor/', + 'README.md', + 'LICENSE', + ], + // Dependencies that are truly bundled or very core could be here + // For 'three' and 'eventemitter3', peerDependencies are better. + dependencies: { + // 'ses': rootPackageJson.dependencies?.ses, // If needed and not externalized correctly + }, + peerDependencies: { + 'three': rootPackageJson.dependencies?.three || '>=0.173.0 <0.174.0', + 'eventemitter3': rootPackageJson.dependencies?.eventemitter3 || '^5.0.0', + 'lodash-es': rootPackageJson.dependencies?.['lodash-es'] || '^4.17.0', // if used by the client bundle directly + }, + peerDependenciesMeta: { + 'three': { optional: false }, + 'eventemitter3': { optional: false }, + 'lodash-es': { optional: true }, // Make optional if not strictly required + }, + engines: { + node: rootPackageJson.engines?.node || '>=18.0.0', + }, + homepage: rootPackageJson.homepage, + repository: { + ...rootPackageJson.repository, + directory: 'build', // Point to the package subdirectory + }, + bugs: rootPackageJson.bugs, + keywords: rootPackageJson.keywords, + license: rootPackageJson.license, + } + await fs.writeJson(path.join(npmPackageDir, 'package.json'), packageJson, { spaces: 2 }) + console.log('Generated package.json in', npmPackageDir) + + // 3. Copy README.md and LICENSE + const rootFilesToCopy = ['README.md', 'LICENSE'] + for (const file of rootFilesToCopy) { + const src = path.join(rootDir, file) + const dest = path.join(npmPackageDir, file) + if (await fs.pathExists(src)) { + await fs.copy(src, dest) + console.log(`Copied ${file} to ${npmPackageDir}`) + } else { + console.warn(`Root file ${src} not found. Skipping.`) + } + } + + // 4. Generate index.d.ts + const tsconfigPath = path.join(rootDir, 'tsconfig.dts.json') + const inputFileForDts = path.join(rootDir, 'src/node-client/index.js') // Relative to CWD for the command + const outputFileDts = path.join(npmPackageDir, 'index.d.ts') + + try { + if (!fs.existsSync(tsconfigPath)) { + throw new Error(`tsconfig.dts.json not found at ${tsconfigPath}. Please create it or ensure it's correctly named.`) + } + console.log(`Attempting to generate index.d.ts using dts-bundle-generator with tsconfig: ${tsconfigPath}`) + execSync(`npx dts-bundle-generator --project "${tsconfigPath}" -o "${outputFileDts}" "${inputFileForDts}"`, { + stdio: 'inherit', + cwd: rootDir, // Important: run from project root + }) + console.log('index.d.ts generated successfully using dts-bundle-generator.') + } catch (error) { + console.error('Error generating index.d.ts with dts-bundle-generator:', error.message) + // console.error('stdout:', error.stdout?.toString()) // dts-bundle-generator might not populate these well on error + // console.error('stderr:', error.stderr?.toString()) + console.warn('Falling back to manually specified index.d.ts content.') + + // --- Start of comprehensive fallback index.d.ts --- + const fallbackDtsContent = ` +// Type definitions for hyperfy Node.js client (fallback) +// Project: ${rootPackageJson.homepage || 'https://github.com/hyperfy-xyz/hyperfy'} +// Definitions by: Hyperfy Team (Update with actual author) + +import { Vector3, Quaternion, Euler, Matrix4, Object3D, PerspectiveCamera } from 'three' +import OriginalEventEmitter from 'eventemitter3' + +export interface IStorage { + get(key: string, defaultValue?: T): T | null + set(key: string, value: T): void + remove(key: string): void +} + +export interface PointerEvent { [key: string]: any } + +export interface NodeData { + id?: string + name?: string + active?: boolean + position?: [number, number, number] | Vector3 + quaternion?: [number, number, number, number] | Quaternion + scale?: [number, number, number] | Vector3 + onPointerEnter?: (event: PointerEvent) => void + onPointerLeave?: (event: PointerEvent) => void + onPointerDown?: (event: PointerEvent) => void + onPointerUp?: (event: PointerEvent) => void + cursor?: string + [key: string]: any +} + +export interface WorldInitOptions { + storage?: IStorage + assetsDir?: string + assetsUrl?: string +} + +export declare class System extends OriginalEventEmitter { + constructor(world: World) + world: World + init?(options?: WorldInitOptions): Promise | void + start?(): void + preTick?(): void + preFixedUpdate?(willFixedStep?: boolean): void + fixedUpdate?(delta: number): void + postFixedUpdate?(delta?: number): void + preUpdate?(alpha?: number): void + update?(delta: number, alpha?: number): void + postUpdate?(delta?: number): void + lateUpdate?(delta?: number, alpha?: number): void + postLateUpdate?(delta?: number): void + commit?(): void + postTick?(): void + destroy?(): void +} + +export declare class Node { + constructor(data?: NodeData) + id: string + name: string + parent: Node | null + children: Node[] + ctx: any + position: Vector3 + quaternion: Quaternion + rotation: Euler + scale: Vector3 + matrix: Matrix4 + matrixWorld: Matrix4 + mounted: boolean + isDirty: boolean + isTransformed: boolean + protected _active: boolean + get active(): boolean + set active(value: boolean) + onPointerEnter?: (event: PointerEvent) => void + onPointerLeave?: (event: PointerEvent) => void + onPointerDown?: (event: PointerEvent) => void + onPointerUp?: (event: PointerEvent) => void + cursor?: string + activate(ctx?: any): void + deactivate(): void + add(node: Node): this + remove(node: Node): this + setTransformed(): void + setDirty(): void + clean(): void + mount(): void + commit(didTransform: boolean): void + unmount(): void + updateTransform(): void + traverse(callback: (node: Node) => void): void + clone(recursive?: boolean): this + copy(source: this, recursive?: boolean): this + get(id: string): Node | null + getWorldPosition(target?: Vector3): Vector3 + getWorldMatrix(target?: Matrix4): Matrix4 + getStats(recursive?: boolean, stats?: any): any + applyStats(stats: any): void + getProxy(): any +} + +export declare class World extends OriginalEventEmitter { + constructor() + maxDeltaTime: number + fixedDeltaTime: number + frame: number + time: number + accumulator: number + systems: System[] + networkRate: number + assetsUrl: string | null + assetsDir: string | null + hot: Set + rig: Object3D + camera: PerspectiveCamera + storage?: IStorage + settings: System + collections: System + apps: System + anchors: System + events: System + scripts: System + chat: System + blueprints: System + entities: System + physics: System + stage: System + client: NodeClient + controls: ClientControls + network: ClientNetwork + loader: ServerLoader + environment: NodeEnvironment + register(key: string, systemClass: new (world: World) => T): T + init(options: WorldInitOptions): Promise + start(): void + tick(time: number): void + preTick(): void + preFixedUpdate(willFixedStep: boolean): void + fixedUpdate(delta: number): void + postFixedUpdate(delta: number): void + preUpdate(alpha: number): void + update(delta: number, alpha?: number): void + postUpdate(delta: number): void + lateUpdate(delta: number, alpha?: number): void + postLateUpdate(delta: number): void + commit(): void + postTick(): void + setupMaterial?(material: any): void + setHot(item: any, hot: boolean): void + resolveURL(url: string, allowLocal?: boolean): string + inject?(runtime: any): void + destroy(): void +} + +export declare class NodeClient extends System { /* Actual API for NodeClient */ } +export declare class ClientControls extends System { isMac: boolean; /* Actual API for ClientControls */ } +export declare class ClientNetwork extends System { /* Actual API for ClientNetwork */ } +export declare class ServerLoader extends System { /* Actual API for ServerLoader */ } +export declare class NodeEnvironment extends System { /* Actual API for NodeEnvironment */ } + +// Define specific types for default systems if available, e.g.: +// export declare class SettingsSystem extends System { /* ... */ } +// export declare class EntitiesSystem extends System { /* ... */ } + +export declare function createNodeClientWorld(): World +export declare const storage: IStorage +export declare function getPhysXAssetPath(assetName: string): string +` + // --- End of comprehensive fallback index.d.ts --- + await fs.writeFile(outputFileDts, fallbackDtsContent.trim()) + console.log('Written fallback index.d.ts to', outputFileDts) + } + + if (dev) { + spawn?.kill('SIGTERM') + spawn = fork(path.join(npmPackageDir, 'index.js')) // Runs the newly built package entry point + console.log('Development server (re)started with new build.') + } else { + console.log('NPM package built successfully to:', npmPackageDir) + // For non-dev builds, we let the calling script (e.g., publish:node) handle process exit or next steps. + } + }) + }, }, - }, - ], - }) - if (dev) { - await nodeClientCtx.watch() - } else { - await nodeClientCtx.rebuild() + ], + }) + + if (dev) { + await nodeClientCtx.watch() + console.log('Watching for changes...') + } else { + await nodeClientCtx.rebuild() + await nodeClientCtx.dispose() // Dispose context after build for non-watch mode + console.log('Build complete.') + } + } catch (error) { + console.error('Unhandled error during build process:', error) + if (!dev) process.exit(1) } } + +// Execute the build +buildNodeClient().catch(err => { + console.error('Failed to execute buildNodeClient:', err) + if (!dev) process.exit(1) +}) diff --git a/src/client/utils.js b/src/client/utils.js index 520b99ef..94a90688 100644 --- a/src/client/utils.js +++ b/src/client/utils.js @@ -16,7 +16,7 @@ export function cls(...args) { // export const isTouch = !!navigator.userAgent.match(/OculusBrowser|iPhone|iPad|iPod|Android/i) // if at least two indicators point to touch, consider it primarily touch-based: -const coarse = window.matchMedia('(pointer: coarse)').matches -const noHover = window.matchMedia('(hover: none)').matches -const hasTouch = navigator.maxTouchPoints > 0 -export const isTouch = (coarse && hasTouch) || (noHover && hasTouch) +const coarse = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(pointer: coarse)').matches : false; +const noHover = typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(hover: none)').matches : false; +const hasTouch = typeof navigator !== 'undefined' && typeof navigator.maxTouchPoints === 'number' ? navigator.maxTouchPoints > 0 : false; +export const isTouch = (coarse && hasTouch) || (noHover && hasTouch); diff --git a/src/core/createNodeClientWorld.js b/src/core/createNodeClientWorld.js index 35cc35b9..dda370fa 100644 --- a/src/core/createNodeClientWorld.js +++ b/src/core/createNodeClientWorld.js @@ -1,9 +1,9 @@ import { World } from './World' import { NodeClient } from './systems/NodeClient' -import { ClientControls } from './systems/ClientControls' +// import { ClientControls } from './systems/ClientControls' import { ClientNetwork } from './systems/ClientNetwork' -import { ServerLoader } from './systems/ServerLoader' +// import { ServerLoader } from './systems/ServerLoader' import { NodeEnvironment } from './systems/NodeEnvironment' // import { ClientActions } from './systems/ClientActions' // import { LODs } from './systems/LODs' @@ -12,9 +12,9 @@ import { NodeEnvironment } from './systems/NodeEnvironment' export function createNodeClientWorld() { const world = new World() world.register('client', NodeClient) - world.register('controls', ClientControls) + // world.register('controls', ClientControls) world.register('network', ClientNetwork) - world.register('loader', ServerLoader) // TODO: ClientLoader should be named BrowserLoader and ServerLoader should be called NodeLoader + // world.register('loader', ServerLoader) // TODO: ClientLoader should be named BrowserLoader and ServerLoader should be called NodeLoader world.register('environment', NodeEnvironment) // world.register('actions', ClientActions) // world.register('lods', LODs) diff --git a/src/core/extras/simpleCamLerp.js b/src/core/extras/simpleCamLerp.js index d49e1b74..2061ae42 100644 --- a/src/core/extras/simpleCamLerp.js +++ b/src/core/extras/simpleCamLerp.js @@ -4,6 +4,7 @@ import { Layers } from './Layers' const BACKWARD = new THREE.Vector3(0, 0, 1) const v1 = new THREE.Vector3() +const v2= new THREE.Vector3() let sweepGeometry @@ -25,7 +26,7 @@ export function simpleCamLerp(world, camera, target, delta) { // } // EXPERIMENTAL: snap camera position instead - camera.position.copy(target.position) + camera.position = v2.copy(target.position) // raycast backward to check for zoom collision if (!sweepGeometry) sweepGeometry = new PHYSX.PxSphereGeometry(0.2) diff --git a/src/core/systems/ClientControls.js b/src/core/systems/ClientControls.js index 424e96ab..2ff13e06 100644 --- a/src/core/systems/ClientControls.js +++ b/src/core/systems/ClientControls.js @@ -50,7 +50,7 @@ export class ClientControls extends System { this.actions = [] this.buttonsDown = new Set() this.isUserGesture = false - this.isMac = /Mac/.test(navigator.platform) + this.isMac = typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false; this.pointer = { locked: false, shouldLock: false, diff --git a/src/core/systems/ClientPrefs.js b/src/core/systems/ClientPrefs.js index b0de689f..1438a047 100644 --- a/src/core/systems/ClientPrefs.js +++ b/src/core/systems/ClientPrefs.js @@ -12,7 +12,7 @@ export class ClientPrefs extends System { constructor(world) { super(world) - const isQuest = /OculusBrowser/.test(navigator.userAgent) + const isQuest = typeof navigator !== 'undefined' && navigator.userAgent ? /OculusBrowser/.test(navigator.userAgent) : false; const data = storage.get('prefs', {}) diff --git a/src/core/systems/XR.js b/src/core/systems/XR.js index 51f11f7c..4161eda5 100644 --- a/src/core/systems/XR.js +++ b/src/core/systems/XR.js @@ -27,12 +27,21 @@ export class XR extends System { } async init() { - this.supportsVR = await navigator.xr?.isSessionSupported('immersive-vr') - this.supportsAR = await navigator.xr?.isSessionSupported('immersive-ar') + if (typeof navigator !== 'undefined' && navigator.xr) { + this.supportsVR = await navigator.xr.isSessionSupported?.('immersive-vr') + this.supportsAR = await navigator.xr.isSessionSupported?.('immersive-ar') + } else { + this.supportsVR = false + this.supportsAR = false + } } async enter() { - const session = await navigator.xr?.requestSession('immersive-vr', { + if (typeof navigator === 'undefined' || !navigator.xr) { + console.warn('XR.enter() called in an environment without navigator.xr support.') + return + } + const session = await navigator.xr.requestSession?.('immersive-vr', { requiredFeatures: ['local-floor'], }) try { diff --git a/src/node-client/index.js b/src/node-client/index.js index 7d9b7a09..d7ee17ed 100644 --- a/src/node-client/index.js +++ b/src/node-client/index.js @@ -6,6 +6,44 @@ import { fileURLToPath } from 'url' // support `__dirname` in ESM globalThis.__dirname = path.dirname(fileURLToPath(import.meta.url)) + +export * as THREE from 'three'; + export { createNodeClientWorld } from '../core/createNodeClientWorld' -export { System } from '../core/systems/System' export { storage } from '../core/storage' +export { World } from '../core/World.js' +export { loadPhysX } from '../core/loadPhysX.js' +export { uuid } from '../core/utils.js' + +export { System } from '../core/systems/System' +export { NodeClient } from '../core/systems/NodeClient.js' +export { ClientControls } from '../core/systems/ClientControls.js' +export { ClientNetwork } from '../core/systems/ClientNetwork.js' +export { ServerLoader } from '../core/systems/ServerLoader.js' +export { NodeEnvironment } from '../core/systems/NodeEnvironment.js' + +export { Node } from '../core/nodes/Node.js' + +export { Emotes } from '../core/extras/playerEmotes.js' +export { createEmoteFactory } from '../core/extras/createEmoteFactory.js' +export { createNode } from '../core/extras/createNode.js' +export { glbToNodes } from '../core/extras/glbToNodes.js' +export { Vector3Enhanced } from '../core/extras/Vector3Enhanced.js' + +export { GLTFLoader } from '../core/libs/gltfloader/GLTFLoader.js' +export { CSM } from '../core/libs/csm/CSM' + +/** + * Returns the absolute path to a PhysX asset within the packaged 'vendor' directory. + * This assumes that the 'vendor' directory is at the root of the installed package. + * @param assetName The name of the PhysX asset (e.g., 'physx.wasm'). + */ +export function getPhysXAssetPath(assetName) { + // In ESM, __dirname is not available directly like in CJS. + // This constructs a path relative to the current module file. + // Assumes index.js is at the root of the dist/npm package. + // If index.js is nested, this path needs adjustment (e.g., path.join(__dirname, '../vendor', assetName)) + const currentModulePath = fileURLToPath(import.meta.url); + const packageRootPath = path.dirname(currentModulePath); + return path.join(packageRootPath, 'vendor', assetName); +} diff --git a/tsconfig.dts.json b/tsconfig.dts.json new file mode 100644 index 00000000..44f2e77b --- /dev/null +++ b/tsconfig.dts.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "allowJs": true, + "checkJs": false, // Optional: set to true to type-check JS files (can be noisy) + "strict": false, // dts-bundle-generator might work better with strict mode off for JS projects + "noEmit": true, // We don't want tsc to emit .js files, only dts-bundle-generator uses this for type info + "baseUrl": "./", + "paths": { + "*": ["node_modules/*", "src/*"] + }, + // Include source files that dts-bundle-generator should analyze + // This should at least include the entry point and related core files. + // Be more specific if possible to speed up generation and reduce noise. + "include": [ + "src/node-client/**/*.js", + "src/core/**/*.js" + // Add other paths if necessary for types used by the node-client + ], + "exclude": [ + "node_modules", + "build", + ] + } +}