topazcube 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "topazcube",
3
+ "version": "0.0.1",
4
+ "description": "TopazCube is a real-time collaborative document editing, and multiplayer game library.",
5
+ "author": "László Matuska @BitOfGold",
6
+ "license": "Apache-2.0",
7
+ "homepage": "https://topazcube.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/BitOfGold/topazcube.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/BitOfGold/topazcube/issues"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "test",
18
+ "LICENSE",
19
+ "README.md",
20
+ "package.json"
21
+ ],
22
+ "keywords": [
23
+ "multiplayer",
24
+ "multiplayer game",
25
+ "multiplayer server",
26
+ "multiplayer client",
27
+ "multiplayer document",
28
+ "collaborative document",
29
+ "multiplayer editor",
30
+ "collaborative editor",
31
+ "multiplayer game",
32
+ "collaborative game"
33
+ ],
34
+ "sideEffects": false,
35
+ "exports": {
36
+ "import": "./dist/topazcube.mjs",
37
+ "require": "./dist/topazcube.js"
38
+ },
39
+ "type": "module",
40
+ "scripts": {
41
+ "dev": "reset && vite --host 0.0.0.0 --port 8800",
42
+ "build": "vite build",
43
+ "preview": "vite preview --host 0.0.0.0 --port 8800"
44
+ },
45
+ "devDependencies": {
46
+ "vite": "^6.2.0",
47
+ "vite-plugin-glsl": "^1.3.3"
48
+ },
49
+ "dependencies": {
50
+ "@msgpack/msgpack": "^3.1.0",
51
+ "fast-json-patch": "^3.1.1",
52
+ "mongodb": "^6.15.0",
53
+ "ws": "^8.18.1"
54
+ }
55
+ }
package/src/client.js ADDED
@@ -0,0 +1,251 @@
1
+ import { applyOperation, applyPatch } from "fast-json-patch"
2
+ import { encode, decode } from "@msgpack/msgpack"
3
+ import { reactive, opmsg, msgop } from "./utils.js"
4
+
5
+ export default class TopazCubeClient {
6
+ CYCLE = 100 // update/patch rate in ms
7
+ url = ""
8
+ documents = {}
9
+ autoReconnect = true
10
+ allowSync = true
11
+ isConnected = false
12
+ isConnecting = false
13
+ isPatched = false
14
+ stats = {
15
+ send: 0,
16
+ rec: 0,
17
+ sendBps: 0,
18
+ recBps: 0,
19
+ ping: 0,
20
+ stdiff: 0, // server time difference
21
+ }
22
+ lastFullState = 0
23
+ lastPatch = 0
24
+ le = true // Server is little endian
25
+ _documentChanges = {}
26
+ constructor({
27
+ url, // server url
28
+ }) {
29
+ this.url = url
30
+ this.socket = null
31
+ this._startLoop()
32
+ }
33
+
34
+ /*= UPDATE ===================================================================*/
35
+
36
+ _startLoop() {
37
+ if (this._loopiv) {
38
+ clearInterval(this._loopiv)
39
+ }
40
+ this._loopiv = setInterval(() => {
41
+ this._loop()
42
+ }, this.CYCLE)
43
+ }
44
+
45
+ _loop() {
46
+ if (!this.isConnected) {
47
+ return
48
+ }
49
+ this._sendPatches()
50
+ }
51
+
52
+ _countLoop() {
53
+ this._stats.sendBps = this._stats.send
54
+ this._stats.recBps = this._stats.rec
55
+ this._stats.send = 0
56
+ this._stats.rec = 0
57
+ }
58
+
59
+ /*= CONNECTION ===============================================================*/
60
+
61
+ subscribe(name) {
62
+ this.documents[name] = {}
63
+ this.send({ c: "sub", n: name })
64
+ }
65
+
66
+ unsubscribe(name) {
67
+ this.send({ c: "unsub", n: name })
68
+ delete this.documents[name]
69
+ }
70
+
71
+ connect() {
72
+ if (this.isConnecting) {
73
+ return
74
+ }
75
+ this.isConnecting = true
76
+ this._clear()
77
+ console.log("connecting...")
78
+
79
+ this.socket = new WebSocket(this.url)
80
+
81
+ // message received
82
+ this.socket.onmessage = async (event) => {
83
+ let buffer = await event.data.arrayBuffer()
84
+ this.stats.rec += buffer.byteLength
85
+ let message = decode(buffer)
86
+ let time = Date.now()
87
+ if (message.c == "full") {
88
+ let name = message.n
89
+ let doc = message.doc
90
+ this.documents[name] = doc
91
+ this.isPatched = false
92
+ this.document = reactive(name, this.documents[name], this._onDocumentChange.bind(this))
93
+ this.isPatched = false
94
+ this.lastFullState = message.t
95
+ this.le = message.le
96
+ this.onChange(name)
97
+ } else if (message.c == "patch") {
98
+ // patch
99
+ this.lastPatch = message.t
100
+ let name = message.n
101
+ if (message.doc) {
102
+ this.isPatched = true
103
+ for (let op of message.doc) {
104
+ let dop = msgop(op)
105
+ applyOperation(this.documents[name], dop)
106
+ }
107
+ this.isPatched = false
108
+ }
109
+ this.onChange(name)
110
+ } else if (message.c == "hello") {
111
+ time = Date.now()
112
+ let lastct = message.ct
113
+ let ping = time - lastct
114
+ let stime = message.t
115
+ this.stats.stdiff = stime + ping / 2 - time
116
+ this.stats.ping = ping
117
+ console.log("ping", ping, "ms", "stdiff", this.stats.stdiff, "ms")
118
+ }
119
+ }
120
+
121
+ // connection closed
122
+ this.socket.onclose = (event) => {
123
+ this.isConnected = false
124
+ this.isConnecting = false
125
+ this.lastFullState = 0
126
+ this.socket = null
127
+ this.onDisconnect()
128
+ if (this.autoReconnect) {
129
+ setTimeout(() => {
130
+ this._reconnect()
131
+ }, 500 + Math.random() * 500)
132
+ }
133
+ }
134
+
135
+ this.socket.onerror = (event) => {
136
+ this.isConnected = false
137
+ this.isConnecting = false
138
+ this.lastFullState = 0
139
+ this.socket = null
140
+ this.onDisconnect()
141
+
142
+ if (this.autoReconnect) {
143
+ setTimeout(() => {
144
+ this._reconnect()
145
+ }, 500 + Math.random() * 500)
146
+ }
147
+ }
148
+
149
+ this.socket.onopen = (event) => {
150
+ this.isConnecting = false
151
+ this.isConnected = true
152
+ this.lastFullState = 0
153
+ this._ping()
154
+ this.onConnect()
155
+ }
156
+ }
157
+
158
+ disconnect() {
159
+ this.isConnected = false
160
+ this.isConnecting = false
161
+ this.lastFullState = 0
162
+ this.socket.close()
163
+ this.socket = null
164
+ }
165
+
166
+ destroy() {
167
+ this.autoReconnect = false
168
+ this.disconnect()
169
+ this.socket = null
170
+ }
171
+
172
+ onConnect() {}
173
+
174
+ onDisconnect() {}
175
+
176
+ _clear() {
177
+ this.stats.sendBps = 0
178
+ this.stats.recBps = 0
179
+ this.stats.send = 0
180
+ this.stats.rec = 0
181
+ this.documents = {}
182
+ this._documentChanges = {}
183
+ this.lastFullState = 0
184
+ this.lastPatch = 0
185
+ this.isPatched = false
186
+ this.le = true
187
+ }
188
+
189
+ _reconnect() {
190
+ if (!this.isConnected) {
191
+ if (!this.isConnecting) {
192
+ this.connect()
193
+ }
194
+ }
195
+ }
196
+
197
+ _ping() {
198
+ if (this.isConnected) {
199
+ this.send({ c: "hello", ct: Date.now() })
200
+ }
201
+ }
202
+
203
+ /*= MESSAGES =================================================================*/
204
+
205
+ onChange(name) {}
206
+
207
+ send(operation) {
208
+ try {
209
+ let enc = encode(operation)
210
+ this.stats.send += enc.byteLength
211
+ this.socket.send(enc)
212
+ } catch (e) {
213
+ console.error("send failed", e)
214
+ }
215
+ }
216
+
217
+ _onDocumentChange(name, op, target, path, value) {
218
+ if (this.isPatched || !this.allowSync) {
219
+ return
220
+ }
221
+ if (path.indexOf("/_") >= 0) {
222
+ return
223
+ }
224
+ if (!this._documentChanges[name]) {
225
+ this._documentChanges[name] = []
226
+ }
227
+ this._documentChanges[name].push(opmsg(op, target, path, value))
228
+ }
229
+
230
+ _sendPatches() {
231
+ for (let name in this._documentChanges) {
232
+ let dc = this._documentChanges[name]
233
+ if (dc.length == 0) {
234
+ continue
235
+ }
236
+ let record = {
237
+ n: name,
238
+ c: "sync",
239
+ ct: Date.now(),
240
+ }
241
+
242
+ if (dc.length > 0) {
243
+ record.p = dc
244
+ }
245
+ this.send(record)
246
+ this._documentChanges[name].length = 0
247
+ this.onChange(name)
248
+ }
249
+ }
250
+
251
+ }
package/src/server.js ADDED
@@ -0,0 +1,335 @@
1
+ import https from "https"
2
+ import fs from "fs"
3
+ import { reactive, clonewo_, opmsg, msgop, getUID } from "./utils.js"
4
+ import fastjsonpatch from "fast-json-patch"
5
+ import { encode, decode } from "@msgpack/msgpack"
6
+ import WebSocket, { WebSocketServer } from "ws"
7
+ import { MongoClient } from "mongodb"
8
+
9
+ const { applyPatch, applyOperation, observe } = fastjsonpatch
10
+
11
+ const LITTLE_ENDIAN = (() => {
12
+ const buffer = new ArrayBuffer(2)
13
+ new DataView(buffer).setInt16(0, 256, true)
14
+ return new Int16Array(buffer)[0] === 256
15
+ })()
16
+
17
+ export default class TopazCubeServer {
18
+ allowSync = true // allow clients to sync their changes (no server authorization)
19
+ CYCLE = 100 // update/patch rate in ms
20
+ clients = new Set()
21
+ _documentChanges = {}
22
+ documents = {}
23
+ isLoading = {}
24
+
25
+ constructor({ port = 8799, https = false }) {
26
+ this.port = port
27
+ if (https) {
28
+ const httpsServer = https.createServer({
29
+ key: fs.readFileSync("./.cert/privkey.pem"),
30
+ cert: fs.readFileSync("./.cert/fullchain.pem"),
31
+ })
32
+ httpsServer.listen(this.port)
33
+ this.wss = new WebSocketServer({ server: httpsServer })
34
+ console.log("TopazCubeServer running on HTTPS port " + this.port)
35
+ } else {
36
+ this.wss = new WebSocketServer({ port: this.port })
37
+ console.log("TopazCubeServer running on port " + this.port)
38
+ }
39
+ this.wss.on("connection", (client) => {
40
+ this._onConnected(client)
41
+ })
42
+
43
+ this._initDB()
44
+ this._exited = false
45
+ process.stdin.resume()
46
+ process.on("SIGINT", () => {
47
+ this._exitSignal("SIGINT")
48
+ })
49
+ process.on("SIGQUIT", () => {
50
+ this._exitSignal("SIGQUIT")
51
+ })
52
+ process.on("SIGTERM", () => {
53
+ this._exitSignal("SIGTERM")
54
+ })
55
+ process.on("SIGUSR2", () => {
56
+ this._exitSignal("SIGUSR2")
57
+ })
58
+ this._startLoop()
59
+ }
60
+
61
+ canCreate(client, name) {
62
+ return true;
63
+ }
64
+
65
+ onCreate(name) {
66
+ return {
67
+ data: {}
68
+ }
69
+ }
70
+
71
+ // to be redefined, to be called when a new document is hydrated
72
+ // (created, or loaded from db)
73
+ onHydrate(name, doc) {
74
+ }
75
+
76
+ _makeReactive(name) {
77
+ //console.log(`Making document '${name}' reactive`, this.documents[name])
78
+ this.documents[name] = reactive(name, this.documents[name], this._onDocumentChange.bind(this))
79
+ }
80
+
81
+ _createEmptyDocument(name) {
82
+ let doc = this.onCreate(name)
83
+ if (!doc) {
84
+ return
85
+ }
86
+ this.documents[name] = doc
87
+ }
88
+
89
+ async _waitLoad(name) {
90
+ if (this.isLoading[name]) {
91
+ while (this.isLoading[name]) {
92
+ await new Promise(resolve => setTimeout(resolve, 50));
93
+ }
94
+ }
95
+ }
96
+
97
+ async _checkDocument(name, client) {
98
+ await this._waitLoad(name)
99
+ if (!this.documents[name]) {
100
+ this.isLoading[name] = true;
101
+ await this._loadDocument(name);
102
+ if (!this.documents[name] && this.canCreate(client, name)) {
103
+ this._createEmptyDocument(name)
104
+ }
105
+ if (this.documents[name]) {
106
+ if (!this._documentChanges[name]) {
107
+ this._documentChanges[name] = []
108
+ }
109
+ this._makeReactive(name)
110
+ this.onHydrate(name, this.documents[name])
111
+ }
112
+ this.isLoading[name] = false
113
+ }
114
+ }
115
+
116
+ /*= UPDATE =================================================================*/
117
+
118
+ // to be redefined. called every 1/20s
119
+ onUpdate(name, doc, dt) {}
120
+
121
+ _startLoop() {
122
+ this.lastUpdate = Date.now()
123
+ if (this._loopiv) {
124
+ clearInterval(this._loopiv)
125
+ }
126
+ this._loopiv = setInterval(() => {
127
+ this._loop()
128
+ }, this.CYCLE)
129
+ }
130
+
131
+ _loop() {
132
+ let dt = Date.now() - this.lastUpdate
133
+ for (let name in this.documents) {
134
+ this.onUpdate(name, this.documents[name], dt)
135
+ }
136
+ this.lastUpdate = Date.now()
137
+ this._sendPatches()
138
+ }
139
+
140
+ /*= MESSAGES ===============================================================*/
141
+
142
+ // to be redefined. Called on message (operation) from client
143
+ onMessage(client, message) {}
144
+
145
+ // to be redefined. Called when a client connects
146
+ onConnect(client) {}
147
+
148
+ // to be redefined. Called when a client disconnects
149
+ onDisconnect(client) {}
150
+
151
+ _onConnected(client) {
152
+ client.ID = getUID()
153
+ client.subscribed = {}
154
+ console.log("client connected", client.ID)
155
+ this.clients.add(client)
156
+ client.on("error", () => {
157
+ this._onError(client, arguments)
158
+ })
159
+ client.on("message", (message) => {
160
+ let dec = decode(message)
161
+ this._onMessage(client, dec)
162
+ })
163
+ client.on("close", (message) => {
164
+ this._onDisconnected(client)
165
+ this.onDisconnect(client)
166
+ })
167
+ this.onConnect(client)
168
+ }
169
+
170
+ async _onMessage(client, message) {
171
+ if (message.c == "hello") {
172
+ //console.log('client hello')
173
+ this.send(client, { c: "hello", t: Date.now(), ct: message.ct })
174
+ } else if (message.c == "sync" && this.allowSync && client.subscribed[message.n] && this.documents[message.n]) {
175
+ let name = message.n
176
+ if (!this._documentChanges[name]) {
177
+ this._documentChanges[name] = []
178
+ }
179
+ for (let op of message.p) {
180
+ this._documentChanges[name].push(op)
181
+ let dop = msgop(op)
182
+ applyOperation(this.documents[name], dop)
183
+ }
184
+ } else if (message.c == "sub") {
185
+ await this._checkDocument(message.n, client)
186
+ if (!this.documents[message.n]) {
187
+ this.send(client, { c: "error", t: Date.now(), message: "Document not found" })
188
+ return
189
+ }
190
+ client.subscribed[message.n] = true
191
+ this._sendFullState(message.n, client)
192
+ } else if (message.c == "unsub") {
193
+ client.subscribed[message.n] = false
194
+ } else {
195
+ this.onMessage(client, message)
196
+ }
197
+ }
198
+
199
+ _onError(client, args) {
200
+ console.error("onError:", args)
201
+ }
202
+
203
+ _onDisconnected(client) {
204
+ console.log("client disconnected")
205
+ }
206
+
207
+ send(client, message) {
208
+ let enc = encode(message)
209
+ client.send(enc)
210
+ }
211
+
212
+ broadcast(message) {
213
+ let enc = encode(message)
214
+ for (let client of this.clients) {
215
+ client.send(enc)
216
+ }
217
+ }
218
+
219
+ async _sendFullState(name, client) {
220
+ await this._waitLoad(name)
221
+ let fullState = {
222
+ c: "full",
223
+ le: LITTLE_ENDIAN,
224
+ t: Date.now(),
225
+ n: name,
226
+ doc: clonewo_(this.documents[name]),
227
+ }
228
+ this.send(client, fullState)
229
+ }
230
+
231
+ _sendPatches() {
232
+ let now = Date.now()
233
+
234
+ for (let name in this._documentChanges) {
235
+ let dc = this._documentChanges[name];
236
+ if (dc.length > 0) {
237
+ let record = {
238
+ c: "patch",
239
+ t: now, // server time
240
+ n: name,
241
+ doc: dc
242
+ }
243
+ for (let client of this.clients) {
244
+ if (client.subscribed[name]) {
245
+ this.send(client, record)
246
+ }
247
+ }
248
+ this._documentChanges[name] = []
249
+ }
250
+ }
251
+ }
252
+
253
+ _onDocumentChange(name, op, target, path, value) {
254
+ if (path.indexOf("/_") >= 0) {
255
+ return
256
+ }
257
+ this._documentChanges[name].push(opmsg(op, target, path, value))
258
+ }
259
+
260
+ async _initDB() {
261
+ await this._connectDB()
262
+ }
263
+
264
+ async _connectDB() {
265
+ this.mongoClient = new MongoClient("mongodb://localhost:27017")
266
+ try {
267
+ await this.mongoClient.connect()
268
+ console.log("Connected to MongoDB")
269
+ const db = this.mongoClient.db("topazcube")
270
+ this._DB = db.collection("documents")
271
+ } catch (error) {
272
+ console.error("Error connecting to MongoDB:", error)
273
+ this.mongoClient = null
274
+ }
275
+ }
276
+
277
+ async _loadDocument(name) {
278
+ if (this._DB) {
279
+ try {
280
+ const doc = await this._DB.findOne({ name: name })
281
+ if (doc) {
282
+ delete doc._id
283
+ this.documents[name] = doc
284
+ }
285
+ } catch (error) {
286
+ console.error("Error loading document from MongoDB:", error)
287
+ }
288
+ } else {
289
+ console.warn("MongoDB client not initialized. Document not loaded.")
290
+ }
291
+ }
292
+
293
+ async _saveDocument(name) {
294
+ if (this._DB) {
295
+ try {
296
+ const doc = this.documents[name]
297
+ let newdoc = clonewo_(doc, "__")
298
+ console.log(`Saving document '${name}' to MongoDB`)
299
+ await this._DB.updateOne({ name: name }, { $set: newdoc }, { upsert: true })
300
+ console.log("Document saved to MongoDB")
301
+ } catch (error) {
302
+ console.error("Error saving document to MongoDB:", error)
303
+ }
304
+ } else {
305
+ console.warn("MongoDB client not initialized. Document not saved.")
306
+ }
307
+ }
308
+
309
+ async _saveAllDocuments() {
310
+ for (let name in this.documents) {
311
+ await this._saveDocument(name)
312
+ }
313
+ }
314
+
315
+ async onHydrate(name, document) {
316
+ document._hydrated = true
317
+ }
318
+
319
+ _exitSignal(signal) {
320
+ if (!this._exited) {
321
+ console.log("\nEXIT: Caught interrupt signal " + signal)
322
+ this._exited = true
323
+ clearInterval(this._loopiv)
324
+ this.onBeforeExit()
325
+ this.broadcast({ server: "Going down" })
326
+ this._saveAllDocuments()
327
+ this.wss.close()
328
+ setTimeout(() => process.exit(0), 1000)
329
+ }
330
+ }
331
+
332
+ // Called on program exit
333
+
334
+ onBeforeExit() {}
335
+ }
@@ -0,0 +1,3 @@
1
+ import TopazCubeClient from "./client.js"
2
+ import TopazCubeServer from "./server.js"
3
+ export { TopazCubeClient, TopazCubeServer }
package/src/utils.js ADDED
@@ -0,0 +1,216 @@
1
+ export function reactive(name, object, callback, path = "", ID = null) {
2
+ if (object === null || typeof object !== "object") {
3
+ return object
4
+ }
5
+ for (const property in object) {
6
+ object[property] = reactive(name, object[property], callback, path + "/" + property, ID)
7
+ }
8
+
9
+ return new Proxy(object, {
10
+ get(target, property) {
11
+ return Reflect.get(...arguments)
12
+ },
13
+ set(target, property, value) {
14
+ let pn = path + "/" + property
15
+ let newvalue = reactive(name, value, callback, pn, ID)
16
+ callback(name, "replace", target, pn, newvalue, ID)
17
+ return Reflect.set(target, property, newvalue)
18
+ },
19
+ deleteProperty(target, property) {
20
+ let pn = path + "/" + property
21
+ delete target[property]
22
+ callback(name, "delete", target, pn, null, ID)
23
+ return true
24
+ },
25
+ })
26
+ }
27
+
28
+ export function deepGet(obj, path) {
29
+ let paths = ("" + path).split("/")
30
+ let len = paths.length
31
+ for (let i = 0; i < len; i++) {
32
+ if (obj[paths[i]] == undefined) {
33
+ return undefined
34
+ } else {
35
+ obj = obj[paths[i]]
36
+ }
37
+ }
38
+ return obj
39
+ }
40
+
41
+ export function deepSet(obj, path, value) {
42
+ let paths = ("" + path).split("/")
43
+ let len = paths.length
44
+ let i
45
+ for (i = 0; i < len - 1; i++) {
46
+ obj = obj[paths[i]]
47
+ }
48
+ obj[paths[i]] = value
49
+ }
50
+
51
+ // recursive clone oject, without properties that starts with _ (or __)
52
+
53
+ export function clonewo_(obj, excludeStart = "_") {
54
+ if (obj === null || typeof obj !== "object") {
55
+ return obj
56
+ }
57
+
58
+ if (obj instanceof Map) {
59
+ const mapClone = new Map()
60
+ for (let [key, value] of obj) {
61
+ mapClone.set(clonewo_(key, excludeStart), clonewo_(value, excludeStart))
62
+ }
63
+ return mapClone
64
+ }
65
+
66
+ let clone
67
+ if (Array.isArray(obj)) {
68
+ clone = []
69
+ for (let i = 0; i < obj.length; i++) {
70
+ clone[i] = clonewo_(obj[i], excludeStart)
71
+ }
72
+ } else {
73
+ clone = {}
74
+ for (let key in obj) {
75
+ if (obj.hasOwnProperty(key) && !key.startsWith(excludeStart)) {
76
+ if (typeof obj[key] === "object") {
77
+ clone[key] = clonewo_(obj[key], excludeStart)
78
+ } else {
79
+ clone[key] = obj[key]
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return clone
86
+ }
87
+
88
+ export function msgop(op) {
89
+ let nop = {}
90
+ if (!op.o) {
91
+ nop.op = "replace"
92
+ } else {
93
+ nop.op = {
94
+ a: "add",
95
+ r: "remove",
96
+ d: "delete",
97
+ t: "test",
98
+ }[op.o]
99
+ }
100
+ nop.path = op.p
101
+ nop.value = op.v
102
+ return nop
103
+ }
104
+
105
+ export function opmsg(op, target, path, value) {
106
+ let c = { p: path, v: value }
107
+ if (op != "replace") {
108
+ c.o = {
109
+ add: "a",
110
+ remove: "r",
111
+ delete: "d",
112
+ test: "t",
113
+ }[op]
114
+ }
115
+ return c
116
+ }
117
+
118
+ var _lastUID = 1
119
+
120
+ export function sdate() {
121
+ return (Date.now() / 1000 - 1715000000) | 0
122
+ }
123
+
124
+ export function getUID() {
125
+ let uid = sdate()
126
+ if (uid <= _lastUID) {
127
+ uid = _lastUID + 1
128
+ }
129
+ _lastUID = uid
130
+ return uid
131
+ }
132
+
133
+ // - Fixed point encoding/decoding functions
134
+ export function encode_uint32(uint, byteArray, offset = 0) {
135
+ if (!byteArray) {
136
+ byteArray = new Uint8Array(4)
137
+ }
138
+ let p = offset + 3
139
+ byteArray[p--] = uint & 0xff
140
+ uint >>= 8
141
+ byteArray[p--] = uint & 0xff
142
+ uint >>= 8
143
+ byteArray[p--] = uint & 0xff
144
+ uint >>= 8
145
+ byteArray[p] = uint
146
+ return byteArray
147
+ }
148
+
149
+ export function decode_uint32(byteArray, offset = 0) {
150
+ let p = offset
151
+ return ((byteArray[p++] & 0x7f) << 24) | (byteArray[p++] << 16) | (byteArray[p++] << 8) | byteArray[p]
152
+ }
153
+
154
+ export function encode_uint16(uint, byteArray, offset = 0) {
155
+ if (!byteArray) {
156
+ byteArray = new Uint8Array(2)
157
+ }
158
+ let p = offset + 1
159
+ byteArray[p--] = uint & 0xff
160
+ uint >>= 8
161
+ byteArray[p] = uint
162
+ return byteArray
163
+ }
164
+
165
+ export function decode_uint16(byteArray, offset = 0) {
166
+ let p = offset
167
+ return (byteArray[p++] << 8) | byteArray[p]
168
+ }
169
+
170
+ export function encode_fp248(float, byteArray, offset = 0) {
171
+ const fp = Math.round(Math.abs(float) * 256)
172
+ const enc = encode_uint32(fp, byteArray, offset)
173
+ if (float < 0) {
174
+ enc[offset] |= 0x80
175
+ }
176
+ return enc
177
+ }
178
+
179
+ export function decode_fp248(byteArray, offset = 0) {
180
+ const divider = (byteArray[offset] & 0x80) === 0x80 ? -256 : 256
181
+ byteArray[offset] &= 0x7f
182
+ const fp = decode_uint32(byteArray, offset)
183
+ return fp / divider
184
+ }
185
+
186
+ export function encode_fp1616(float, byteArray, offset = 0) {
187
+ const fp = Math.round(Math.abs(float) * 65536)
188
+ const enc = encode_uint32(fp, byteArray, offset)
189
+ if (float < 0) {
190
+ enc[offset] |= 0x80
191
+ }
192
+ return enc
193
+ }
194
+
195
+ export function decode_fp1616(byteArray, offset = 0) {
196
+ const divider = (byteArray[offset] & 0x80) === 0x80 ? -65536 : 65536
197
+ byteArray[offset] &= 0x7f
198
+ const fp = decode_uint32(byteArray, offset)
199
+ return fp / divider
200
+ }
201
+
202
+ export function encode_fp88(float, byteArray, offset = 0) {
203
+ const fp = Math.round(Math.abs(float) * 256)
204
+ const enc = encode_uint16(fp, byteArray, offset)
205
+ if (float < 0) {
206
+ enc[offset] |= 0x80
207
+ }
208
+ return enc
209
+ }
210
+
211
+ export function decode_fp88(byteArray, offset = 0) {
212
+ const divider = (byteArray[offset] & 0x80) === 0x80 ? -256 : 256
213
+ byteArray[offset] &= 0x7f
214
+ const fp = decode_uint16(byteArray, offset)
215
+ return fp / divider
216
+ }
@@ -0,0 +1,224 @@
1
+ import React, { useEffect, useState } from "react"
2
+ import TopazCubeClient from "../../src/client.js"
3
+ import "./index.css"
4
+ import { GripHorizontal, Trash2 } from "lucide-react"
5
+
6
+ function App() {
7
+ // Client instance
8
+ let [client, setClient] = useState({})
9
+ const [isConnected, setIsConnected] = useState(false)
10
+
11
+ // Document state (list of todos)
12
+ let [doc, setDoc] = useState({})
13
+ // Sorted todos by order field
14
+ const sortedTodos = doc.todos ? [...doc.todos].filter((todo) => !todo.deleted).sort((a, b) => a.order - b.order) : []
15
+
16
+ // New todo input
17
+ const [newTodo, setNewTodo] = useState("")
18
+
19
+ // For drag and drop reordering
20
+ const [draggedItem, setDraggedItem] = useState(null)
21
+ const [movingUp, setMovingUp] = useState(false)
22
+ const [dragOverIndex, setDragOverIndex] = useState(null)
23
+
24
+ // Initialize client
25
+ useEffect(() => {
26
+ class TodoClient extends TopazCubeClient {
27
+ constructor() {
28
+ super({
29
+ url: "ws://192.168.0.200:4799",
30
+ })
31
+ }
32
+
33
+ onConnect() {
34
+ setIsConnected(true)
35
+ this.subscribe("todos")
36
+ }
37
+
38
+ onDisconnect() {
39
+ setIsConnected(false)
40
+ }
41
+
42
+ onMessage(message) {
43
+ console.log("orig onMessage", message)
44
+ }
45
+
46
+ onChange(name) {
47
+ console.log('onChange ez', this.document)
48
+ setDoc({ ...this.document })
49
+ }
50
+ }
51
+
52
+ let nc = new TodoClient()
53
+ setClient(nc)
54
+ nc.connect()
55
+
56
+ return () => {
57
+ nc.destroy()
58
+ }
59
+ }, [])
60
+
61
+ // Add new todo
62
+ const addTodo = () => {
63
+ if (newTodo) {
64
+ doc.todos.push({
65
+ title: newTodo,
66
+ completed: false,
67
+ order: doc.todos.length,
68
+ })
69
+ setNewTodo("")
70
+ }
71
+ }
72
+
73
+ // Drag and drop handlers
74
+
75
+ const handleDragStart = (e, todo, index) => {
76
+ setDraggedItem(todo)
77
+ e.dataTransfer.effectAllowed = "move"
78
+ // Required for Firefox
79
+ e.dataTransfer.setData("text/plain", index)
80
+ }
81
+
82
+ const handleDragOver = (e, overTodo, overIndex) => {
83
+ e.preventDefault()
84
+ if (!draggedItem || draggedItem === overTodo) return
85
+ setDragOverIndex(overIndex)
86
+ if (draggedItem.order <= overTodo.order) {
87
+ setMovingUp(false)
88
+ } else {
89
+ setMovingUp(true)
90
+ }
91
+ }
92
+
93
+ const handleDragEnd = () => {
94
+ setDraggedItem(null)
95
+ setDragOverIndex(null)
96
+ }
97
+
98
+ const handleDrop = (e, dropTodo) => {
99
+ e.preventDefault()
100
+ if (!draggedItem || draggedItem === dropTodo) return
101
+
102
+ // Get current orders
103
+ const currentOrders = sortedTodos.map((todo) => todo.order)
104
+
105
+ // Find the current order of dragged and drop items
106
+ const draggedOrder = draggedItem.order
107
+ const dropOrder = dropTodo.order
108
+
109
+ // Reorder the items
110
+ if (draggedOrder < dropOrder) {
111
+ // Moving down
112
+ doc.todos.forEach((todo) => {
113
+ if (todo === draggedItem) {
114
+ todo.order = dropOrder
115
+ } else if (todo.order > draggedOrder && todo.order <= dropOrder) {
116
+ todo.order--
117
+ }
118
+ })
119
+ } else {
120
+ // Moving up
121
+ doc.todos.forEach((todo) => {
122
+ if (todo === draggedItem) {
123
+ todo.order = dropOrder
124
+ } else if (todo.order >= dropOrder && todo.order < draggedOrder) {
125
+ todo.order++
126
+ }
127
+ })
128
+ }
129
+
130
+ setDoc({ ...doc })
131
+ setDraggedItem(null)
132
+ setDragOverIndex(null)
133
+ }
134
+
135
+ return (
136
+ <>
137
+ <div className="mx-auto w-full md:max-w-2xl p-2">
138
+ <h1>TopazCube Client Test</h1>
139
+ <h2>Todo list: '{doc.title}'</h2>
140
+ {sortedTodos.length > 0 ? (
141
+ <div>
142
+ <ul>
143
+ {sortedTodos.map(
144
+ (todo, index) =>
145
+ !todo.deleted && (
146
+ <li
147
+ key={index}
148
+ className={`flex items-center gap-2 py-1
149
+ ${index % 2 == 0 ? "bg-gray-200" : ""} ${
150
+ dragOverIndex === index ? "border-" + (movingUp ? "t" : "b") + "-3 border-blue-500" : ""
151
+ }`}
152
+ draggable={true}
153
+ onDragStart={(e) => handleDragStart(e, todo)}
154
+ onDragOver={(e) => handleDragOver(e, todo, index)}
155
+ onDragEnd={handleDragEnd}
156
+ onDrop={(e) => handleDrop(e, todo)}>
157
+ <button className="!cursor-move">
158
+ <GripHorizontal />
159
+ </button>
160
+ <input
161
+ type="checkbox"
162
+ className="cursor-pointer w-5 h-5"
163
+ checked={todo.completed}
164
+ onChange={() => {
165
+ todo.completed = !todo.completed
166
+ }}
167
+ />
168
+ <span className="flex-grow ml-2 cursor-move">
169
+ <span className={`${todo.completed ? "line-through opacity-30" : "font-bold"} cursor-text`}>
170
+ {todo.title}
171
+ </span>
172
+ </span>
173
+ <button
174
+ onClick={() => {
175
+ todo.deleted = true
176
+ }}>
177
+ <Trash2 />
178
+ </button>
179
+ </li>
180
+ )
181
+ )}
182
+ </ul>
183
+ </div>
184
+ ) : (
185
+ <p>No todos yet. Add some!</p>
186
+ )}
187
+ <br />
188
+ <div className="flex gap-2">
189
+ <input
190
+ type="text"
191
+ className="flex-grow border-2 border-gray-400 px-2 py-1 rounded-md"
192
+ placeholder="New Todo"
193
+ value={newTodo}
194
+ onChange={(e) => setNewTodo(e.target.value)}
195
+ />
196
+ <button
197
+ className={`bg-gray-300 ${
198
+ newTodo ? "text-black" : "text-gray-400 opacity-50 !cursor-not-allowed"
199
+ } font-bold border-2 border-gray-400 px-4 py-2 rounded-md`}
200
+ onClick={addTodo}
201
+ onKeyDown={(e) => {
202
+ if (e.key === "Enter") {
203
+ addTodo()
204
+ }
205
+ }}>
206
+ Add Todo
207
+ </button>
208
+ </div>
209
+ <br />
210
+ <pre>{JSON.stringify(doc, null, 2)}</pre>
211
+ <br />
212
+ </div>
213
+ {!isConnected ? (
214
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
215
+ <h2 className="text-white text-xl font-bold">Connecting...</h2>
216
+ </div>
217
+ ) : (
218
+ <></>
219
+ )}
220
+ </>
221
+ )
222
+ }
223
+
224
+ export default App
@@ -0,0 +1,13 @@
1
+ @import "tailwindcss";
2
+
3
+ h1 {
4
+ @apply text-2xl font-bold pb-4;
5
+ }
6
+
7
+ h2 {
8
+ @apply text-xl font-bold pb-2;
9
+ }
10
+
11
+ button {
12
+ @apply transition-all duration-300 font-bold px-2 py-1 rounded-md hover:opacity-50 hover:scale-115 cursor-pointer;
13
+ }
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>TopazCube Client Test</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="main.jsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,7 @@
1
+ import React from 'react'
2
+ import { createRoot } from 'react-dom/client';
3
+ import App from './App'
4
+
5
+ const root = createRoot(document.getElementById('root'))
6
+ root.render(<App />)
7
+
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "topazcube-test-client",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite --host 0.0.0.0 --port 4800"
8
+ },
9
+ "devDependencies": {
10
+ "vite": "^6.2.0"
11
+ },
12
+ "dependencies": {
13
+ "@msgpack/msgpack": "^3.1.0",
14
+ "@tailwindcss/vite": "^4.0.9",
15
+ "@vitejs/plugin-react": "^4.3.4",
16
+ "collections": "^5.1.13",
17
+ "fast-json-patch": "^3.1.1",
18
+ "lucide-react": "^0.476.0",
19
+ "react": "^19.0.0",
20
+ "react-dom": "^19.0.0",
21
+ "tailwindcss": "^4.0.9",
22
+ "ws": "^8.18.1"
23
+ }
24
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vite'
2
+ import tailwindcss from '@tailwindcss/vite'
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig({
6
+ plugins: [
7
+ react(),
8
+ tailwindcss(),
9
+ ],
10
+ })
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "topazcube-test-server",
3
+ "private": true,
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "reset && nodemon todoserver.js"
8
+ },
9
+ "devDependencies": {
10
+ "vite": "^6.2.0"
11
+ },
12
+ "dependencies": {
13
+ "@msgpack/msgpack": "^3.1.0",
14
+ "collections": "^5.1.13",
15
+ "fast-json-patch": "^3.1.1",
16
+ "mongodb": "^6.15.0",
17
+ "ws": "^8.18.1"
18
+ }
19
+ }
@@ -0,0 +1,33 @@
1
+ import TopazCubeServer from "../../src/server.js"
2
+
3
+ class TodoServer extends TopazCubeServer {
4
+ constructor() {
5
+ super({
6
+ port: 4799,
7
+ })
8
+ }
9
+
10
+ canCreate(client, name) {
11
+ return true;
12
+ }
13
+
14
+ onCreate(name) {
15
+ return {
16
+ todos: [],
17
+ }
18
+ }
19
+
20
+ canUpdate(client, name) {
21
+ return true;
22
+ }
23
+
24
+ onHydrate(name, doc) {
25
+ doc.random = Math.random()
26
+ }
27
+ onUpdate(name, doc, dt) {}
28
+ onMessage(client, message) {}
29
+
30
+
31
+ }
32
+
33
+ let server = new TodoServer()