Skip to content
Merged
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
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ jobs:
cp -r dist package/
cd package
zip -r ../self-host.zip .
- run: pnpm build-playground
# - run: pnpm build-storybook
- run: pnpm test-unit
- run: pnpm lint
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/next-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ jobs:
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/
# - name: Copy playground files
# run: |
# mkdir -p .vercel/output/static/playground
# pnpm build-playground
# cp -r renderer/dist/* .vercel/output/static/playground/
- name: Deploy Project Artifacts to Vercel
uses: mathiasvr/command-output@v2.0.0
with:
Expand Down
5 changes: 0 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ jobs:
env:
CONFIG_JSON_SOURCE: BUNDLED
LOCAL_CONFIG_FILE: config.mcraft-only.json
- name: Copy playground files
run: |
mkdir -p .vercel/output/static/playground
pnpm build-playground
cp -r renderer/dist/* .vercel/output/static/playground/

# publish to github
- run: cp vercel.json .vercel/output/static/vercel.json
Expand Down
37 changes: 24 additions & 13 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Don't confuse this with [Eaglercraft](https://eagsrc.webmc.xyz) which is a REAL

For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md) (also for comparison with Eaglercraft).

> **Note**: You can deploy it on your own server in less than a minute using a one-liner script from the [Minecraft Everywhere repo](https://github.com/zardoy/minecraft-everywhere).
> **Note**: See [Self-hosting & proxy](#servers--proxy) for Docker, proxy-only setups, and a one-liner VPS script with [Minecraft Everywhere](https://github.com/zardoy/minecraft-everywhere).

### Big Features

Expand Down Expand Up @@ -78,15 +78,9 @@ Whatever offline mode you used (zip, folder, just singleplayer), you can always

### Servers & Proxy

You can play almost on any Java server, vanilla servers are fully supported.
See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for the list of supported versions (should support majority of versions).
There is a built-in proxy, but you can also host your own! Just clone the repo, run `pnpm i` (see CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field. You can also deploy it to a cloud service:
You can play on almost any Java server; vanilla servers are fully supported. See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for supported protocol versions.

[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)

> **Note**: If you want to make **your own** Minecraft server accessible to web clients (without our proxies), you can use [mwc-proxy](https://github.com/zardoy/mwc-proxy) - a lightweight JS WebSocket proxy that runs on the same server as your Minecraft server, allowing players to connect directly via `wss://play.example.com`. `?client_mcraft` is added to the URL, so the proxy will know that it's this client.

Proxy servers are used to connect to Minecraft servers that use the TCP protocol. When you connect to a server with a proxy, a WebSocket connection is created between you (browser client) and the proxy server; then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid additional delays. That said all Minecraft protocol packets are processed by the client, right in your browser.
**How it works:** browsers speak WebSockets; Minecraft Java uses TCP. A **proxy** bridges the two. When you connect with a proxy URL enabled, your browser talks to the proxy over WebSocket **using the proxy's IP**; the proxy opens a TCP connection to the Minecraft server and forwards bytes without deserializing packets. All protocol handling still runs in the browser (Mineflayer in the client).

```mermaid
graph LR
Expand All @@ -101,12 +95,29 @@ So if the server is located in Europe and you are connecting from Europe, you wi

Again, the proxy server is not a part of the client, it is a separate service that you can host yourself.

### Docker Files
#### Self-hosting

| What you want | What to run |
|---------------|-------------|
| **Full stack** (your own full game copy + bundled proxy) | This repo: local dev with `pnpm i` and `pnpm prod-start` (see [CONTRIBUTING.md](./CONTRIBUTING.md)), or **Docker** with the [main Dockerfile](./Dockerfile) below. |
| **Proxy only** (keep using a public game client e.g. [mcraft.fun](https://mcraft.fun)) | Run **[mwc-proxy](https://github.com/zardoy/mwc-proxy)** somewhere that can reach your Minecraft server (often same host/VPC); follow that README for the rest. **Where to host:** your own PC or home network often behaves better than a cloud relay—some servers flag datacenter IPs like “VPN” traffic; for casual play, any reachable host is fine. **URL in the client:** paste your relay’s URL into the **proxy** field; you still have to make it reachable (reverse proxy **with SSL**, **[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)**, temporary URL services like cloudflared are not recommended). **Note:** the Minecraft server sees **your relay’s public IP** on the TCP leg (the proxy → server connection). You do **not** need this web-client repo for proxy-only. |
| **One-liner deploy on a VPS** | [Minecraft Everywhere](https://github.com/zardoy/minecraft-everywhere) provides a **one-liner** script that can deploy these variants (your own full game copy, proxy, or combined setups)—pick what you need from that repo’s instructions. |

Public deployments (e.g. GitHub Pages) ship without your private TCP endpoint; for your own server you still point the client at **your** proxy (e.g. mwc-proxy or a stack you deployed with the script above).

**Koyeb (full app from this repo):**

[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)

> **mwc-proxy vs this repo’s proxy:** [mwc-proxy](https://github.com/zardoy/mwc-proxy) is the lightweight standalone WebSocket→TCP bridge you run next to your world. This repo can embed a compatible proxy in `prod-start` / Docker. For “I only need a relay for the hosted client,” use mwc-proxy.

### Docker (self-hosted)

Use Docker when you want a reproducible install without local Node/pnpm setup (see [CONTRIBUTING.md](./CONTRIBUTING.md) only if you build from source).

- [Main Dockerfile](./Dockerfile) - for production build & offline/private usage. Includes full web-app + proxy server for connecting to Minecraft servers.
- [Proxy Dockerfile](./Dockerfile.proxy) - for proxy server only - that one you would be able to specify in the proxy field on the client (`docker build . -f Dockerfile.proxy -t minecraft-web-proxy`)
**[Dockerfile](./Dockerfile)** — production image: **your own full game copy (web client) + proxy** in one process. Good when you host the full stack yourself.

In case of using docker, you don't have to follow preparation steps from CONTRIBUTING.MD, like installing Node.js, pnpm, etc.
For **proxy only**, use **[mwc-proxy](https://github.com/zardoy/mwc-proxy)** (including its Docker options) instead of maintaining a second image in this repo.

### Rendering

Expand Down
Binary file removed assets/generic_91.png
Binary file not shown.
Binary file removed assets/generic_92.png
Binary file not shown.
Binary file removed assets/generic_93.png
Binary file not shown.
Binary file removed assets/generic_94.png
Binary file not shown.
Binary file removed assets/generic_95.png
Binary file not shown.
15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"watch-mesher": "pnpm build-mesher -w",
"run-playground": "run-p watch-mesher watch-other-workers watch-playground",
"run-all": "run-p start run-playground",
"build-playground": "rsbuild build --config renderer/rsbuild.config.ts",
"watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts",
"update-git-deps": "tsx scripts/updateGitDeps.ts",
"request-data": "tsx scripts/requestData.ts"
Expand Down Expand Up @@ -80,14 +79,15 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.118",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.119",
"framer-motion": "^12.9.2",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.23",
"minecraft-data": "3.103.0",
"minecraft-data": "0.0.0",
"minecraft-inventory": "^0.1.37",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
"mojangson": "^2.0.4",
Expand Down Expand Up @@ -155,9 +155,8 @@
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.72",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer#gen-the-master",
"mineflayer-mouse": "^0.1.26",
"mineflayer": "0.0.0",
"mineflayer-mouse": "0.1.28",
"npm-run-all": "^4.1.5",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
Expand Down Expand Up @@ -205,7 +204,7 @@
"diamond-square": "github:zardoy/diamond-square",
"prismarine-block": "github:zardoy/prismarine-block#next-era",
"prismarine-world": "github:zardoy/prismarine-world#next-era",
"minecraft-data": "3.103.0",
"minecraft-data": "3.106.0",
"prismarine-provider-anvil": "github:zardoy/prismarine-provider-anvil#everything",
"prismarine-physics": "github:zardoy/prismarine-physics",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
Expand All @@ -222,7 +221,7 @@
"patchedDependencies": {
"pixelarticons@1.8.1": "patches/pixelarticons@1.8.1.patch",
"mineflayer-item-map-downloader@1.2.0": "patches/mineflayer-item-map-downloader@1.2.0.patch",
"minecraft-protocol": "patches/minecraft-protocol.patch"
"minecraft-protocol": "patches/minecraft-protocol@1.66.0.patch"
},
"ignoredBuiltDependencies": [
"canvas",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,92 +1,5 @@
diff --git a/src/client.js b/src/client.js
index e369e77..f6c408d 100644
--- a/src/client.js
+++ b/src/client.js
@@ -75,7 +75,35 @@ class Client extends EventEmitter {
}
const deserializerDirection = this.isServer ? 'toServer' : 'toClient'
e.field = [this.protocolState, deserializerDirection].concat(parts).join('.')
- e.message = e.buffer ? `Parse error for ${e.field} (${e.buffer?.length} bytes, ${e.buffer?.toString('hex').slice(0, 6)}...) : ${e.message}` : `Parse error for ${e.field}: ${e.message}`
+ e.message = e.buffer
+ ? `Parse error for ${e.field} (${e.buffer?.length} bytes, ${e.buffer?.toString('hex').slice(0, 6)}...) : ${e.message}`
+ : `Parse error for ${e.field}: ${e.message}`
+
+ // Non-fatal play-state parse error — e.g. Hypixel sending NBT tag IDs outside
+ // the standard 0-20 range (tag 71 = custom component data introduced in 1.21.4+).
+ // The deserializer stream is destroyed by Node.js on error, so we tear it down
+ // fully, rebuild it via setSerializer, and re-pipe it into the chain so packet
+ // processing continues normally. We emit 'warning' instead of 'error' so nothing
+ // upstream treats this as a fatal connection failure.
+ if (this.protocolState === states.PLAY) {
+ console.warn(`[minecraft-protocol] Non-fatal parse error (connection kept alive): ${e.message}`)
+ this.deserializer.removeAllListeners()
+ if (!this.compressor) {
+ this.splitter.unpipe(this.deserializer)
+ } else {
+ this.decompressor.unpipe(this.deserializer)
+ }
+ this.setSerializer(states.PLAY)
+ if (!this.compressor) {
+ this.splitter.pipe(this.deserializer)
+ } else {
+ this.decompressor.pipe(this.deserializer)
+ }
+ this.emit('warning', e)
+ return
+ }
+
+ // All other parse errors are still fatal — re-pipe attempt then propagate.
if (!this.compressor) { this.splitter.pipe(this.deserializer) } else { this.decompressor.pipe(this.deserializer) }
this.emit('error', e)
})
@@ -111,7 +139,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
- emitPacket(parsed)
+ try {
+ emitPacket(parsed)
+ } catch (err) {
+ console.log('Client incorrectly handled packet ' + parsed.metadata.name)
+ console.error(err)
+ // todo investigate why it doesn't close the stream even if unhandled there
+ }
}
})
}
@@ -169,7 +203,10 @@ class Client extends EventEmitter {
}

const onFatalError = (err) => {
- this.emit('error', err)
+ // todo find out what is trying to write after client disconnect
+ if(err.code !== 'ECONNABORTED') {
+ this.emit('error', err)
+ }
endSocket()
}

@@ -198,6 +235,10 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else {
if (this.socket) this.socket.end()
}
@@ -243,6 +284,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

diff --git a/src/client/chat.js b/src/client/chat.js
index 0021870..a53fceb 100644
index 0021870994fc59a82f0ac8aba0a65a8be43ef2f4..a53fceb843105ea2a1d88722b3fc7c3b43cb102a 100644
--- a/src/client/chat.js
+++ b/src/client/chat.js
@@ -116,7 +116,7 @@ module.exports = function (client, options) {
Expand Down Expand Up @@ -144,7 +57,7 @@ index 0021870..a53fceb 100644
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
diff --git a/src/client/encrypt.js b/src/client/encrypt.js
index 63cc2bd..36df57d 100644
index 63cc2bd9615100bd2fd63dfe14c094aa6b8cd1c9..36df57d1196af9761d920fa285ac48f85410eaef 100644
--- a/src/client/encrypt.js
+++ b/src/client/encrypt.js
@@ -25,7 +25,11 @@ module.exports = function (client, options) {
Expand All @@ -161,7 +74,7 @@ index 63cc2bd..36df57d 100644

function onJoinServerResponse (err) {
diff --git a/src/client/play.js b/src/client/play.js
index 71ad739..7130b22 100644
index 71ad739f118306b741908817851b0322db6f17e5..881abc9faf4dcf85127935b320e1a83786894330 100644
--- a/src/client/play.js
+++ b/src/client/play.js
@@ -31,11 +31,37 @@ module.exports = function (client, options) {
Expand Down Expand Up @@ -195,8 +108,8 @@ index 71ad739..7130b22 100644
+ locale: options.clientProfile?.locale ?? pickLocale(),
+ viewDistance: options.clientProfile?.viewDistance ?? 10,
+ mainHand: options.clientProfile?.mainHand !== undefined
+ ? options.clientProfile.mainHand
+ : (Math.random() < 0.75 ? 1 : 0)
+ ? options.clientProfile.mainHand
+ : (Math.random() < 0.75 ? 1 : 0)
+ }
+
if (mcData.supportFeature('hasConfigurationState')) {
Expand Down Expand Up @@ -225,7 +138,7 @@ index 71ad739..7130b22 100644
client.write('select_known_packs', { packs: [] })
})
diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js
index 671eb45..7f69f51 100644
index 671eb452f31e6b5fcd57d715f1009d010160c65f..7f69f511c8fb97d431ec5125c851b49be8e2ab76 100644
--- a/src/client/pluginChannels.js
+++ b/src/client/pluginChannels.js
@@ -57,7 +57,7 @@ module.exports = function (client, options) {
Expand All @@ -237,3 +150,90 @@ index 671eb45..7f69f51 100644
return
}
}
diff --git a/src/client.js b/src/client.js
index e369e77d055ba919e8f9da7b8e8b5dc879c74cf4..5395c9ac988d00a782d4ba98a73dc9d2e3fee666 100644
--- a/src/client.js
+++ b/src/client.js
@@ -75,7 +75,35 @@ class Client extends EventEmitter {
}
const deserializerDirection = this.isServer ? 'toServer' : 'toClient'
e.field = [this.protocolState, deserializerDirection].concat(parts).join('.')
- e.message = e.buffer ? `Parse error for ${e.field} (${e.buffer?.length} bytes, ${e.buffer?.toString('hex').slice(0, 6)}...) : ${e.message}` : `Parse error for ${e.field}: ${e.message}`
+ e.message = e.buffer
+ ? `Parse error for ${e.field} (${e.buffer?.length} bytes, ${e.buffer?.toString('hex').slice(0, 6)}...) : ${e.message}`
+ : `Parse error for ${e.field}: ${e.message}`
+
+ // Non-fatal play-state parse error — e.g. Hypixel sending NBT tag IDs outside
+ // the standard 0-20 range (tag 71 = custom component data introduced in 1.21.4+).
+ // The deserializer stream is destroyed by Node.js on error, so we tear it down
+ // fully, rebuild it via setSerializer, and re-pipe it into the chain so packet
+ // processing continues normally. We emit 'warning' instead of 'error' so nothing
+ // upstream treats this as a fatal connection failure.
+ if (this.protocolState === states.PLAY) {
+ console.warn(`[minecraft-protocol] Non-fatal parse error (connection kept alive): ${e.message}`)
+ this.deserializer.removeAllListeners()
+ if (!this.compressor) {
+ this.splitter.unpipe(this.deserializer)
+ } else {
+ this.decompressor.unpipe(this.deserializer)
+ }
+ this.setSerializer(states.PLAY)
+ if (!this.compressor) {
+ this.splitter.pipe(this.deserializer)
+ } else {
+ this.decompressor.pipe(this.deserializer)
+ }
+ this.emit('warning', e)
+ return
+ }
+
+ // All other parse errors are still fatal — re-pipe attempt then propagate.
if (!this.compressor) { this.splitter.pipe(this.deserializer) } else { this.decompressor.pipe(this.deserializer) }
this.emit('error', e)
})
@@ -111,7 +139,13 @@ class Client extends EventEmitter {
this._hasBundlePacket = false
}
} else {
- emitPacket(parsed)
+ try {
+ emitPacket(parsed)
+ } catch (err) {
+ console.log('Client incorrectly handled packet ' + parsed.metadata.name)
+ console.error(err)
+ // todo investigate why it doesn't close the stream even if unhandled there
+ }
}
})
}
@@ -169,7 +203,10 @@ class Client extends EventEmitter {
}

const onFatalError = (err) => {
- this.emit('error', err)
+ // todo find out what is trying to write after client disconnect
+ if (err.code !== 'ECONNABORTED') {
+ this.emit('error', err)
+ }
endSocket()
}

@@ -198,6 +235,10 @@ class Client extends EventEmitter {
serializer -> framer -> socket -> splitter -> deserializer */
if (this.serializer) {
this.serializer.end()
+ setTimeout(() => {
+ this.socket?.end()
+ this.socket?.emit('end')
+ }, 2000) // allow the serializer to finish writing
} else {
if (this.socket) this.socket.end()
}
@@ -243,6 +284,7 @@ class Client extends EventEmitter {
debug('writing packet ' + this.state + '.' + name)
debug(params)
}
+ this.emit('writePacket', name, params)
this.serializer.write({ name, params })
}

Loading
Loading