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 +55 -0
- package/src/client.js +251 -0
- package/src/server.js +335 -0
- package/src/topazcube.js +3 -0
- package/src/utils.js +216 -0
- package/test/todo/client/App.jsx +224 -0
- package/test/todo/client/index.css +13 -0
- package/test/todo/client/index.html +13 -0
- package/test/todo/client/main.jsx +7 -0
- package/test/todo/client/package.json +24 -0
- package/test/todo/client/vite.config.js +10 -0
- package/test/todo/server/package.json +19 -0
- package/test/todo/server/todoserver.js +33 -0
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
|
+
}
|
package/src/topazcube.js
ADDED
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
|
+
<!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,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,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()
|