@rcap/realtime 0.1.0
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/README.md +156 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +437 -0
- package/dist/index.d.ts +437 -0
- package/dist/index.js +1 -0
- package/dist/y-indexeddb-TNC4K7G2.js +1 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @rcap/realtime
|
|
2
|
+
|
|
3
|
+
Real-time multiplayer rooms powered by CRDTs. Add collaborative features to any application with minimal code.
|
|
4
|
+
|
|
5
|
+
- **Shared State** - Conflict-free data structures that sync automatically across all clients
|
|
6
|
+
- **Presence** - Track who's connected and their live state (cursors, selections, etc.)
|
|
7
|
+
- **Ephemeral Events** - Fire-and-forget broadcasts for transient actions
|
|
8
|
+
- **Yjs Compatible** - Works with the entire Yjs ecosystem (y-codemirror, y-prosemirror, y-quill, y-monaco)
|
|
9
|
+
- **Auto Reconnect** - Exponential backoff, pending queue, seamless recovery
|
|
10
|
+
- **Persistence** - Optional IndexedDB for offline support
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @rcap/realtime yjs
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { createRoom } from '@rcap/realtime';
|
|
20
|
+
|
|
21
|
+
const room = createRoom({
|
|
22
|
+
apiKey: 'your-api-key',
|
|
23
|
+
roomId: 'my-room',
|
|
24
|
+
user: { id: 'user-1', name: 'Alice' },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
await room.connect();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Shared State
|
|
31
|
+
|
|
32
|
+
State is stored in CRDT data structures that automatically merge across clients - no conflict resolution needed.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// Key-value map
|
|
36
|
+
const settings = room.map('settings');
|
|
37
|
+
settings.set('theme', 'dark');
|
|
38
|
+
settings.get('theme'); // 'dark'
|
|
39
|
+
settings.observe((changes) => {
|
|
40
|
+
console.log('changed keys:', Object.keys(changes.changed));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Ordered list
|
|
44
|
+
const items = room.array('items');
|
|
45
|
+
items.push({ id: 1, text: 'First item' });
|
|
46
|
+
items.observe((changes) => {
|
|
47
|
+
console.log('array updated:', items.toArray());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Text (for editor bindings)
|
|
51
|
+
const text = room.doc.getText('content');
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Presence
|
|
55
|
+
|
|
56
|
+
Track connected users and their live state.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Set your state (visible to all other users)
|
|
60
|
+
room.presence.set({ cursor: { x: 100, y: 200 }, status: 'editing' });
|
|
61
|
+
|
|
62
|
+
// Update specific fields
|
|
63
|
+
room.presence.update({ cursor: { x: 150, y: 250 } });
|
|
64
|
+
|
|
65
|
+
// Watch others
|
|
66
|
+
room.presence.subscribe((others) => {
|
|
67
|
+
for (const user of others) {
|
|
68
|
+
console.log(user.user.name, user.state.cursor);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Ephemeral Events
|
|
74
|
+
|
|
75
|
+
Broadcast events that are delivered to all connected clients but not persisted.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// Send
|
|
79
|
+
room.broadcast('reaction', { emoji: 'thumbsup', from: 'Alice' });
|
|
80
|
+
|
|
81
|
+
// Receive
|
|
82
|
+
room.on('reaction', (data, sender) => {
|
|
83
|
+
console.log(`${sender.name} reacted with ${data.emoji}`);
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Editor Integrations
|
|
88
|
+
|
|
89
|
+
The underlying Y.Doc is exposed directly, so any Yjs editor binding works out of the box.
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
// CodeMirror 6
|
|
93
|
+
import { yCollab } from 'y-codemirror.next';
|
|
94
|
+
const ytext = room.doc.getText('editor');
|
|
95
|
+
// Use yCollab(ytext, awareness) in your extensions
|
|
96
|
+
|
|
97
|
+
// ProseMirror
|
|
98
|
+
import { ySyncPlugin } from 'y-prosemirror';
|
|
99
|
+
const ydoc = room.doc;
|
|
100
|
+
// Use ySyncPlugin(ydoc.getXmlFragment('prosemirror'))
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## API
|
|
104
|
+
|
|
105
|
+
### `createRoom(options)`
|
|
106
|
+
|
|
107
|
+
| Option | Type | Description |
|
|
108
|
+
|--------|------|-------------|
|
|
109
|
+
| `apiKey` | `string` | API key for authentication |
|
|
110
|
+
| `roomId` | `string` | Unique room identifier |
|
|
111
|
+
| `user` | `{ id, name, color?, avatar? }` | Your user info |
|
|
112
|
+
| `baseUrl` | `string` | Server URL (default: `wss://sync.elvenvtt.com`) |
|
|
113
|
+
| `persistence` | `boolean` | Enable IndexedDB persistence (default: `true` in browser) |
|
|
114
|
+
|
|
115
|
+
### Room
|
|
116
|
+
|
|
117
|
+
| Method | Description |
|
|
118
|
+
|--------|-------------|
|
|
119
|
+
| `room.connect()` | Connect to the room (returns promise) |
|
|
120
|
+
| `room.disconnect()` | Disconnect from the room |
|
|
121
|
+
| `room.map(name)` | Get a SharedMap |
|
|
122
|
+
| `room.array(name)` | Get a SharedArray |
|
|
123
|
+
| `room.doc` | Raw Y.Doc for editor bindings |
|
|
124
|
+
| `room.transact(fn)` | Batch mutations in a single update |
|
|
125
|
+
| `room.broadcast(event, data)` | Send ephemeral event |
|
|
126
|
+
| `room.on(event, callback)` | Listen for ephemeral events |
|
|
127
|
+
| `room.presence` | Presence instance |
|
|
128
|
+
| `room.onReconnect(fn)` | Reconnection callback |
|
|
129
|
+
| `room.onDisconnect(fn)` | Disconnection callback |
|
|
130
|
+
| `room.getHealth()` | Connection health info |
|
|
131
|
+
|
|
132
|
+
### SharedMap
|
|
133
|
+
|
|
134
|
+
| Method | Description |
|
|
135
|
+
|--------|-------------|
|
|
136
|
+
| `map.get(key)` | Get value |
|
|
137
|
+
| `map.set(key, value)` | Set value |
|
|
138
|
+
| `map.delete(key)` | Delete key |
|
|
139
|
+
| `map.has(key)` | Check if key exists |
|
|
140
|
+
| `map.toJSON()` | Get all entries as object |
|
|
141
|
+
| `map.observe(callback)` | Watch for changes |
|
|
142
|
+
|
|
143
|
+
### SharedArray
|
|
144
|
+
|
|
145
|
+
| Method | Description |
|
|
146
|
+
|--------|-------------|
|
|
147
|
+
| `array.push(...items)` | Append items |
|
|
148
|
+
| `array.insert(index, ...items)` | Insert at index |
|
|
149
|
+
| `array.delete(index, count?)` | Remove items |
|
|
150
|
+
| `array.get(index)` | Get item at index |
|
|
151
|
+
| `array.toArray()` | Get all items |
|
|
152
|
+
| `array.observe(callback)` | Watch for changes |
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var ke=Object.create;var E=Object.defineProperty;var Pe=Object.getOwnPropertyDescriptor;var Ce=Object.getOwnPropertyNames;var Ne=Object.getPrototypeOf,De=Object.prototype.hasOwnProperty;var h=(s,e)=>()=>(s&&(e=s(s=0)),e);var q=(s,e)=>{for(var t in e)E(s,t,{get:e[t],enumerable:!0})},G=(s,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Ce(e))!De.call(s,r)&&r!==t&&E(s,r,{get:()=>e[r],enumerable:!(n=Pe(e,r))||n.enumerable});return s};var C=(s,e,t)=>(t=s!=null?ke(Ne(s)):{},G(e||!s||!s.__esModule?E(t,"default",{value:s,enumerable:!0}):t,s)),Me=s=>G(E({},"__esModule",{value:!0}),s);var f,Fe,O=h(()=>{"use strict";f=s=>new Promise(s),Fe=Promise.all.bind(Promise)});var ee,te=h(()=>{"use strict";ee=s=>new Error(s)});var p,se,B,ne,m,Y,re,W,oe,U,ie,ze,ae,qe,Ge,$e,ce,de,he=h(()=>{"use strict";O();te();p=s=>f((e,t)=>{s.onerror=n=>t(new Error(n.target.error)),s.onsuccess=n=>e(n.target.result)}),se=(s,e)=>f((t,n)=>{let r=indexedDB.open(s);r.onupgradeneeded=o=>e(o.target.result),r.onerror=o=>n(ee(o.target.error)),r.onsuccess=o=>{let a=o.target.result;a.onversionchange=()=>{a.close()},t(a)}}),B=s=>p(indexedDB.deleteDatabase(s)),ne=(s,e)=>e.forEach(t=>s.createObjectStore.apply(s,t)),m=(s,e,t="readwrite")=>{let n=s.transaction(e,t);return e.map(r=>$e(n,r))},Y=(s,e)=>p(s.count(e)),re=(s,e)=>p(s.get(e)),W=(s,e)=>p(s.delete(e)),oe=(s,e,t)=>p(s.put(e,t)),U=(s,e)=>p(s.add(e)),ie=(s,e,t)=>p(s.getAll(e,t)),ze=(s,e,t)=>{let n=null;return Ge(s,e,r=>(n=r,!1),t).then(()=>n)},ae=(s,e=null)=>ze(s,e,"prev"),qe=(s,e)=>f((t,n)=>{s.onerror=n,s.onsuccess=async r=>{let o=r.target.result;if(o===null||await e(o)===!1)return t();o.continue()}}),Ge=(s,e,t,n="next")=>qe(s.openKeyCursor(e,n),r=>t(r.key)),$e=(s,e)=>s.objectStore(e),ce=(s,e)=>IDBKeyRange.upperBound(s,e),de=(s,e)=>IDBKeyRange.lowerBound(s,e)});var R,le,pe=h(()=>{"use strict";R=()=>new Map,le=(s,e,t)=>{let n=s.get(e);return n===void 0&&s.set(e,n=t()),n}});var ue,ye=h(()=>{"use strict";ue=()=>new Set});var fe,me=h(()=>{"use strict";fe=Array.from});var k,ge=h(()=>{"use strict";pe();ye();me();k=class{constructor(){this._observers=R()}on(e,t){le(this._observers,e,ue).add(t)}once(e,t){let n=(...r)=>{this.off(e,n),t(...r)};this.on(e,n)}off(e,t){let n=this._observers.get(e);n!==void 0&&(n.delete(t),n.size===0&&this._observers.delete(e))}emit(e,t){return fe((this._observers.get(e)||R()).values()).forEach(n=>n(...t))}destroy(){this._observers=R()}}});var Se={};q(Se,{IndexeddbPersistence:()=>H,PREFERRED_TRIM_SIZE:()=>J,clearDocument:()=>tt,fetchUpdates:()=>j,storeState:()=>ve});var u,L,be,J,j,ve,tt,H,we=h(()=>{"use strict";u=C(require("yjs"),1);he();O();ge();L="custom",be="updates",J=500,j=(s,e=()=>{},t=()=>{})=>{let[n]=m(s.db,[be]);return ie(n,de(s._dbref,!1)).then(r=>{s._destroyed||(e(n),u.transact(s.doc,()=>{r.forEach(o=>u.applyUpdate(s.doc,o))},s,!1),t(n))}).then(()=>ae(n).then(r=>{s._dbref=r+1})).then(()=>Y(n).then(r=>{s._dbsize=r})).then(()=>n)},ve=(s,e=!0)=>j(s).then(t=>{(e||s._dbsize>=J)&&U(t,u.encodeStateAsUpdate(s.doc)).then(()=>W(t,ce(s._dbref,!0))).then(()=>Y(t).then(n=>{s._dbsize=n}))}),tt=s=>B(s),H=class extends k{constructor(e,t){super(),this.doc=t,this.name=e,this._dbref=0,this._dbsize=0,this._destroyed=!1,this.db=null,this.synced=!1,this._db=se(e,n=>ne(n,[["updates",{autoIncrement:!0}],["custom"]])),this.whenSynced=f(n=>this.on("synced",()=>n(this))),this._db.then(n=>{this.db=n,j(this,a=>U(a,u.encodeStateAsUpdate(t)),()=>{if(this._destroyed)return this;this.synced=!0,this.emit("synced",[this])})}),this._storeTimeout=1e3,this._storeTimeoutId=null,this._storeUpdate=(n,r)=>{if(this.db&&r!==this){let[o]=m(this.db,[be]);U(o,n),++this._dbsize>=J&&(this._storeTimeoutId!==null&&clearTimeout(this._storeTimeoutId),this._storeTimeoutId=setTimeout(()=>{ve(this,!1),this._storeTimeoutId=null},this._storeTimeout))}},t.on("update",this._storeUpdate),this.destroy=this.destroy.bind(this),t.on("destroy",this.destroy)}destroy(){return this._storeTimeoutId&&clearTimeout(this._storeTimeoutId),this.doc.off("update",this._storeUpdate),this.doc.off("destroy",this.destroy),this._destroyed=!0,this._db.then(e=>{e.close()})}clearData(){return this.destroy().then(()=>{B(this.name)})}get(e){return this._db.then(t=>{let[n]=m(t,[L],"readonly");return re(n,e)})}set(e,t){return this._db.then(n=>{let[r]=m(n,[L]);return oe(r,t,e)})}del(e){return this._db.then(t=>{let[n]=m(t,[L]);return W(n,e)})}}});var at={};q(at,{Connection:()=>y,MessageType:()=>i,Presence:()=>b,Room:()=>T,SharedArray:()=>S,SharedMap:()=>v,createRoom:()=>Re});module.exports=Me(at);var M=C(require("yjs"),1);var i={SYNC_REQUEST:"crdt:sync_request",SYNC_RESPONSE:"crdt:sync_response",UPDATE:"crdt:update",AWARENESS:"crdt:awareness",EPHEMERAL:"ephemeral",PRESENCE_JOIN:"presence:join",PRESENCE_LEAVE:"presence:leave",PRESENCE_LIST:"presence:list",PING:"ping",PONG:"pong",SNAPSHOT_RESTORE:"crdt:snapshot_restore"},A=[83,89,78,67],x=[83,78,65,80];function $(s){return s.length<4?!1:s[0]===A[0]&&s[1]===A[1]&&s[2]===A[2]&&s[3]===A[3]}function Q(s){return s.length<4?!1:s[0]===x[0]&&s[1]===x[1]&&s[2]===x[2]&&s[3]===x[3]}function w(s){let e="";for(let t=0;t<s.byteLength;t++)e+=String.fromCharCode(s[t]);return btoa(e)}function _(s){let e=atob(s),t=new Uint8Array(e.length);for(let n=0;n<e.length;n++)t[n]=e.charCodeAt(n);return t}var N=null;function I(){return N||(N=Math.random().toString(36).substring(2,15)),N}var Oe=15e3,Be=45e3,Ye=1e3,We=1e3,Le=3e4,He=1.5,D=32,Je=100,X=40,je=1e3;function Ke(s){return Math.min(We*Math.pow(He,s),Le)+Math.random()*1e3}var y=class{roomId;url;callbacks;ws=null;_connected=!1;_synced=!1;_destroyed=!1;localPresence=null;pendingUpdates=[];sendTimer=null;lastSendTime=0;recentSendTimes=[];reconnectAttempts=0;reconnectTimer=null;wasConnectedBefore=!1;heartbeatTimer=null;lastPongTime=Date.now();lastConnectedTime=0;constructor(e){this.roomId=e.roomId,this.url=e.url,this.callbacks=e.callbacks}get connected(){return this._connected}get synced(){return this._synced}connect(){this._destroyed||this.connectWebSocket()}sendUpdate(e){if(this._destroyed)return;if(this.pendingUpdates.length>=Ye){let o=Math.floor(this.pendingUpdates.length/2),a=this.pendingUpdates.slice(0,o),P=this.pendingUpdates.slice(o),l=M.mergeUpdates(a);this.pendingUpdates=[l,...P]}if(this.pendingUpdates.push(e),!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let n=Date.now()-this.lastSendTime,r=this.getEffectiveBatchMs();this.sendTimer&&(clearTimeout(this.sendTimer),this.sendTimer=null),n>=r?this.flushUpdates():this.sendTimer=setTimeout(()=>this.flushUpdates(),r-n)}sendEphemeral(e){if(!(!this.ws||this.ws.readyState!==WebSocket.OPEN))try{this.ws.send(JSON.stringify({type:i.EPHEMERAL,...e}))}catch{}}sendAwareness(e){!this.ws||this.ws.readyState!==WebSocket.OPEN||this.ws.send(JSON.stringify({type:i.AWARENESS,roomId:this.roomId,clientId:I(),state:e}))}joinPresence(e){this.localPresence={...e,joinedAt:Date.now()},!(!this.ws||this.ws.readyState!==WebSocket.OPEN)&&this.ws.send(JSON.stringify({type:i.PRESENCE_JOIN,...this.localPresence}))}sendRaw(e){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return this.forceReconnect(),!1;try{return this.ws.send(JSON.stringify(e)),!0}catch{return this.forceReconnect(),!1}}getHealth(){return{connected:this._connected,synced:this._synced,reconnectAttempts:this.reconnectAttempts,lastConnectedTime:this.lastConnectedTime,pendingUpdates:this.pendingUpdates.length,sendRate:this.recentSendTimes.length,currentBatchMs:this.getEffectiveBatchMs()}}forceReconnect(){this._destroyed||(this.reconnectAttempts=0,this.ws&&this.ws.readyState===WebSocket.OPEN?this.ws.close(4001,"Force reconnect"):(!this.ws||this.ws.readyState>WebSocket.OPEN)&&this.connectWebSocket())}probeConnection(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.lastPongTime=Date.now();let e=this.lastPongTime;try{this.ws.send(JSON.stringify({type:i.PING,ts:Date.now()}))}catch{this.forceReconnect();return}setTimeout(()=>{this.lastPongTime<=e&&this.forceReconnect()},5e3)}destroy(){if(this._destroyed=!0,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.sendTimer&&(clearTimeout(this.sendTimer),this.sendTimer=null),this.stopHeartbeat(),this.pendingUpdates.length>0&&this.flushUpdates(),this.localPresence&&this.ws&&this.ws.readyState===WebSocket.OPEN)try{this.ws.send(JSON.stringify({type:i.PRESENCE_LEAVE,instanceId:this.localPresence.instanceId}))}catch{}this.localPresence=null,this.ws&&(this.ws.close(),this.ws=null)}connectWebSocket(){if(this._destroyed)return;let e=new WebSocket(this.url);this.ws=e,e.binaryType="arraybuffer",e.onopen=()=>{let t=this.wasConnectedBefore;this._connected=!0,this.lastConnectedTime=Date.now(),this.reconnectAttempts=0,this.startHeartbeat(),this.requestSync(),t&&(this.localPresence&&this.ws?.send(JSON.stringify({type:i.PRESENCE_JOIN,...this.localPresence})),this.pendingUpdates.length>0&&this.flushUpdates(),this.callbacks.onReconnect()),this.wasConnectedBefore=!0},e.onmessage=t=>this.handleMessage(t.data),e.onclose=()=>{let t=this._connected;if(this._connected=!1,this._synced=!1,this.stopHeartbeat(),t&&this.callbacks.onDisconnect(),!this._destroyed){this.reconnectAttempts++;let n=Ke(this.reconnectAttempts);this.reconnectTimer&&clearTimeout(this.reconnectTimer),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this._destroyed||this.connectWebSocket()},n)}},e.onerror=()=>{}}handleMessage(e){try{if(e instanceof ArrayBuffer){let n=new Uint8Array(e);if(Q(n)){let r=n.slice(4);this.callbacks.onDebugMessage?.("binary:snapshot_restore",{bytes:r.length}),this.callbacks.onSnapshotRestore(r);return}if($(n)){let r=n.slice(4);this.callbacks.onDebugMessage?.("binary:sync",{bytes:r.length}),this.handleSyncResponse(r);return}this.callbacks.onDebugMessage?.("binary:update",{bytes:n.length}),this.callbacks.onUpdate(n);return}let t=JSON.parse(e);switch(this.callbacks.onDebugMessage?.(t.type||"unknown",t),t.type){case i.SYNC_RESPONSE:this.handleJsonSyncResponse(t);break;case i.SNAPSHOT_RESTORE:t.state&&this.callbacks.onSnapshotRestore(_(t.state));break;case i.SYNC_REQUEST:this.handleSyncRequest();break;case i.UPDATE:t.update&&this.callbacks.onUpdate(_(t.update));break;case i.AWARENESS:t.clientId&&t.state&&this.callbacks.onAwareness(t.clientId,t.state);break;case i.PRESENCE_JOIN:t.instanceId&&this.callbacks.onPresenceJoin({instanceId:t.instanceId,playerName:t.playerName,playerColor:t.playerColor,playerAvatar:t.playerAvatar,userId:t.userId,joinedAt:t.joinedAt||Date.now(),state:t.state});break;case i.PRESENCE_LEAVE:t.instanceId&&this.callbacks.onPresenceLeave(t.instanceId);break;case i.PRESENCE_LIST:Array.isArray(t.clients)&&this.callbacks.onPresenceList(t.clients);break;case i.EPHEMERAL:this.callbacks.onEphemeral(t);break;case i.PING:this.ws&&this.ws.readyState===WebSocket.OPEN&&this.ws.send(JSON.stringify({type:i.PONG,ts:t.ts}));break;case i.PONG:this.lastPongTime=Date.now();break;default:t.action&&this.callbacks.onEphemeral(t);break}}catch(t){console.error("[Realtime] Error handling message:",t)}}requestSync(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getStateVector(),t=e.length>0?w(e):null;this.ws.send(JSON.stringify({type:i.SYNC_REQUEST,sessionId:this.roomId,stateVector:t}))}handleSyncResponse(e){e.length>0&&this.callbacks.onSyncResponse(e),this.wasConnectedBefore&&this.sendLocalStateToServer(),this._synced||(this._synced=!0,this.callbacks.onSynced())}handleJsonSyncResponse(e){if(e.state&&e.state.length>0){let t=_(e.state);this.callbacks.onSyncResponse(t)}this.wasConnectedBefore&&this.sendLocalStateToServer(),this._synced||(this._synced=!0,this.callbacks.onSynced())}handleSyncRequest(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getFullState();this.ws.send(JSON.stringify({type:i.SYNC_RESPONSE,sessionId:this.roomId,state:w(e)}))}sendLocalStateToServer(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getFullState();if(e.length!==0)try{this.ws.send(e)}catch{this.ws.send(JSON.stringify({type:i.UPDATE,sessionId:this.roomId,update:w(e)}))}}flushUpdates(){if(this.pendingUpdates.length===0||!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.pendingUpdates.length===1?this.pendingUpdates[0]:M.mergeUpdates(this.pendingUpdates);this.pendingUpdates=[],this.sendTimer=null,this.lastSendTime=Date.now(),this.recordSend();try{this.ws.send(e)}catch{this.ws.send(JSON.stringify({type:i.UPDATE,sessionId:this.roomId,update:w(e)}))}}recordSend(){let e=Date.now();this.recentSendTimes.push(e),this.recentSendTimes=this.recentSendTimes.filter(t=>e-t<je)}getEffectiveBatchMs(){let e=this.recentSendTimes.length;if(e>=X){let t=Math.min((e-X)/20,1);return D+(Je-D)*t}return D}startHeartbeat(){this.stopHeartbeat(),this.lastPongTime=Date.now(),this.heartbeatTimer=setInterval(()=>{if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;if(Date.now()-this.lastPongTime>Be){this.ws.close(4e3,"Heartbeat timeout");return}try{this.ws.send(JSON.stringify({type:i.PING,ts:Date.now()}))}catch{}},Oe)}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}};var d=C(require("yjs"),1),c=new Map,g=null,K=1,F="elvenvtt-realtime-schema-version",st=new Set,nt=new Set;function V(s,e=!1){let t=c.get(s);return t||(t=new d.Doc,c.set(s,t),e&&typeof indexedDB<"u"&&Ie(s,t),t)}function Te(s){let e=c.get(s);e&&(e.destroy(),c.delete(s)),Ue(s)}async function Ee(s,e,t=!1){for(let o of st)try{o(s)}catch(a){console.error("[Realtime] Error in before-replace callback:",a)}let n=c.get(s);n&&n.destroy(),Ue(s);let r=new d.Doc;c.set(s,r),e.length>0&&d.applyUpdate(r,e),t&&typeof indexedDB<"u"&&(await rt(s),Ie(s,r));for(let o of nt)try{o(s)}catch(a){console.error("[Realtime] Error in after-replace callback:",a)}return r}function Ae(s){let e=c.get(s);return e?d.encodeStateVector(e):new Uint8Array(0)}function xe(s){let e=c.get(s);return e?d.encodeStateAsUpdate(e):new Uint8Array(0)}function z(s,e){let t=c.get(s);t&&d.applyUpdate(t,e,"remote")}function _e(s,e){let t=c.get(s);return t?(t.on("update",e),()=>t.off("update",e)):()=>{}}async function Ie(s,e){try{ot();let{IndexeddbPersistence:t}=await Promise.resolve().then(()=>(we(),Se));g||(g=new Map);let n=new t(`elven-realtime-${s}`,e);g.set(s,n)}catch{}}function Ue(s){if(!g)return;let e=g.get(s);e?.destroy&&e.destroy(),g.delete(s)}async function rt(s){if(!(typeof indexedDB>"u"))try{let e=`elven-realtime-${s}`;await new Promise((t,n)=>{let r=indexedDB.deleteDatabase(e);r.onsuccess=()=>t(),r.onerror=()=>n(r.error),r.onblocked=()=>t()})}catch{}}function ot(){if(!(typeof localStorage>"u"))try{let s=localStorage.getItem(F);s&&parseInt(s,10)!==K?localStorage.setItem(F,String(K)):s||localStorage.setItem(F,String(K))}catch{}}var b=class{constructor(e,t){this.instanceId=e;this.user=t}others=new Map;localState={};subscribers=new Set;joinSubscribers=new Set;leaveSubscribers=new Set;sendPresenceFn=null;sendAwarenessFn=null;_bind(e,t){this.sendPresenceFn=e,this.sendAwarenessFn=t}set(e){this.localState={...e},this.sendAwarenessFn?.({...this.localState})}update(e){Object.assign(this.localState,e),this.sendAwarenessFn?.({...this.localState})}getOthers(){return Array.from(this.others.values())}subscribe(e){return this.others.size>0&&e(this.getOthers()),this.subscribers.add(e),()=>this.subscribers.delete(e)}onJoin(e){return this.joinSubscribers.add(e),()=>this.joinSubscribers.delete(e)}onLeave(e){return this.leaveSubscribers.add(e),()=>this.leaveSubscribers.delete(e)}_handleJoin(e){let t={user:{id:e.userId||e.instanceId,name:e.playerName||"Anonymous",color:e.playerColor,avatar:e.playerAvatar},state:e.state||{},instanceId:e.instanceId,joinedAt:e.joinedAt||Date.now()};this.others.set(e.instanceId,t),this.notify(),this.joinSubscribers.forEach(n=>{try{n(t)}catch{}})}_handleLeave(e){this.others.delete(e),this.notify(),this.leaveSubscribers.forEach(t=>{try{t(e)}catch{}})}_handleList(e){this.others.clear();for(let t of e)t.instanceId&&t.instanceId!==this.instanceId&&this.others.set(t.instanceId,{user:{id:t.userId||t.instanceId,name:t.playerName||"Anonymous",color:t.playerColor,avatar:t.playerAvatar},state:t.state||{},instanceId:t.instanceId,joinedAt:t.joinedAt||Date.now()});this.notify()}_handleAwareness(e,t){for(let[,n]of this.others)if(n.user.id===e||n.instanceId===e){n.state=t||{},this.notify();break}}_clear(){this.others.clear(),this.notify()}_destroy(){this.others.clear(),this.subscribers.clear(),this.joinSubscribers.clear(),this.leaveSubscribers.clear(),this.sendPresenceFn=null,this.sendAwarenessFn=null}_getJoinInfo(){return{instanceId:this.instanceId,playerName:this.user.name,playerColor:this.user.color,playerAvatar:this.user.avatar,userId:this.user.id,joinedAt:Date.now(),state:this.localState}}notify(){let e=this.getOthers();this.subscribers.forEach(t=>{try{t(e)}catch{}})}};var v=class{constructor(e,t){this.ymap=e;this.doc=t}get(e){return this.ymap.get(e)}set(e,t){this.ymap.set(e,t)}delete(e){this.ymap.delete(e)}has(e){return this.ymap.has(e)}get size(){return this.ymap.size}entries(){return this.ymap.entries()}keys(){return this.ymap.keys()}values(){return this.ymap.values()}toJSON(){return this.ymap.toJSON()}transact(e){this.doc.transact(e)}observe(e){let t=n=>{let r=new Map,o=new Map,a=new Set;n.changes.keys.forEach((P,l)=>{switch(P.action){case"add":r.set(l,this.ymap.get(l));break;case"update":o.set(l,this.ymap.get(l));break;case"delete":a.add(l);break}}),e({added:r,updated:o,deleted:a})};return this.ymap.observe(t),()=>this.ymap.unobserve(t)}get raw(){return this.ymap}};var S=class{constructor(e,t){this.yarray=e;this.doc=t}push(...e){this.yarray.push(e)}insert(e,...t){this.yarray.insert(e,t)}delete(e,t=1){this.yarray.delete(e,t)}get(e){return this.yarray.get(e)}get length(){return this.yarray.length}toArray(){return this.yarray.toArray()}toJSON(){return this.yarray.toJSON()}transact(e){this.doc.transact(e)}observe(e){return this.yarray.observe(e),()=>this.yarray.unobserve(e)}[Symbol.iterator](){return this.yarray[Symbol.iterator]()}get raw(){return this.yarray}};var it="wss://sync.elvenvtt.com",T=class{id;user;presence;connection;persistence;instanceId;maps=new Map;arrays=new Map;syncedCallbacks=new Set;reconnectCallbacks=new Set;disconnectCallbacks=new Set;errorCallbacks=new Set;ephemeralHandlers=new Map;unsubscribeDocUpdate=null;constructor(e){this.id=e.roomId,this.user=e.user,this.instanceId=`${e.user.id}-${I()}-${Date.now().toString(36)}`,this.persistence=e.persistence!==void 0?e.persistence:typeof indexedDB<"u",this.presence=new b(this.instanceId,this.user);let n=`${e.baseUrl||it}/room/${encodeURIComponent(e.roomId)}?apiKey=${encodeURIComponent(e.apiKey)}`;this.connection=new y({url:n,roomId:e.roomId,callbacks:{onSyncResponse:r=>{z(this.id,r)},onUpdate:r=>{z(this.id,r)},onSnapshotRestore:r=>{Ee(this.id,r,this.persistence)},onPresenceJoin:r=>{this.presence._handleJoin(r)},onPresenceLeave:r=>{this.presence._handleLeave(r)},onPresenceList:r=>{this.presence._handleList(r)},onEphemeral:r=>{this.handleEphemeral(r)},onAwareness:(r,o)=>{this.presence._handleAwareness(r,o)},onSynced:()=>{this.syncedCallbacks.forEach(r=>{try{r()}catch{}})},onReconnect:()=>{this.reconnectCallbacks.forEach(r=>{try{r()}catch{}})},onDisconnect:()=>{this.presence._clear(),this.disconnectCallbacks.forEach(r=>{try{r()}catch{}})},getStateVector:()=>Ae(this.id),getFullState:()=>xe(this.id)}}),this.presence._bind(r=>this.connection.joinPresence(r),r=>this.connection.sendAwareness(r))}get doc(){return V(this.id,this.persistence)}get connected(){return this.connection.connected}get synced(){return this.connection.synced}connect(){return new Promise(e=>{if(V(this.id,this.persistence),this.unsubscribeDocUpdate=_e(this.id,(n,r)=>{r!=="remote"&&this.connection.connected&&this.connection.sendUpdate(n)}),this.connection.synced){e(),this.joinRoom();return}let t=this.onSynced(()=>{t(),e()});this.connection.connect(),this.joinRoom()})}disconnect(){this.unsubscribeDocUpdate&&(this.unsubscribeDocUpdate(),this.unsubscribeDocUpdate=null),this.connection.destroy(),this.presence._destroy(),this.syncedCallbacks.clear(),this.reconnectCallbacks.clear(),this.disconnectCallbacks.clear(),this.errorCallbacks.clear(),this.ephemeralHandlers.clear(),this.maps.clear(),this.arrays.clear(),Te(this.id)}map(e){let t=this.maps.get(e);if(!t){let n=this.doc,r=n.getMap(e);t=new v(r,n),this.maps.set(e,t)}return t}array(e){let t=this.arrays.get(e);if(!t){let n=this.doc,r=n.getArray(e);t=new S(r,n),this.arrays.set(e,t)}return t}text(e){return this.doc.getText(e)}broadcast(e,t={}){this.connection.sendEphemeral({event:e,...t})}on(e,t){let n=this.ephemeralHandlers.get(e);return n||(n=new Set,this.ephemeralHandlers.set(e,n)),n.add(t),()=>n.delete(t)}transact(e){this.doc.transact(e)}getHealth(){return this.connection.getHealth()}forceReconnect(){this.connection.forceReconnect()}probeConnection(){this.connection.probeConnection()}onSynced(e){return this.connection.synced&&e(),this.syncedCallbacks.add(e),()=>this.syncedCallbacks.delete(e)}onReconnect(e){return this.reconnectCallbacks.add(e),()=>this.reconnectCallbacks.delete(e)}onDisconnect(e){return this.disconnectCallbacks.add(e),()=>this.disconnectCallbacks.delete(e)}onError(e){return this.errorCallbacks.add(e),()=>this.errorCallbacks.delete(e)}joinRoom(){this.connection.joinPresence(this.presence._getJoinInfo())}handleEphemeral(e){let t=e.event||e.action;if(!t)return;let n=this.ephemeralHandlers.get(t);if(!n||n.size===0)return;let r={id:e.userId||e.instanceId||"unknown",name:e.playerName||"Unknown"};n.forEach(o=>{try{o(e,r)}catch{}})}};function Re(s){return new T(s)}0&&(module.exports={Connection,MessageType,Presence,Room,SharedArray,SharedMap,createRoom});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public types for @elvenvtt/realtime
|
|
5
|
+
*/
|
|
6
|
+
interface RoomOptions {
|
|
7
|
+
/** API key from your Elven dashboard */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Unique room identifier. Created on first connect if it doesn't exist. */
|
|
10
|
+
roomId: string;
|
|
11
|
+
/** User identity for this connection */
|
|
12
|
+
user: UserInfo;
|
|
13
|
+
/** WebSocket base URL. Defaults to wss://sync.elvenvtt.com */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Enable IndexedDB persistence for offline support. Defaults to true in browser. */
|
|
16
|
+
persistence?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface UserInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
avatar?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface PresenceUser<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
26
|
+
user: UserInfo;
|
|
27
|
+
state: T;
|
|
28
|
+
instanceId: string;
|
|
29
|
+
joinedAt: number;
|
|
30
|
+
}
|
|
31
|
+
interface ConnectionHealth {
|
|
32
|
+
connected: boolean;
|
|
33
|
+
synced: boolean;
|
|
34
|
+
reconnectAttempts: number;
|
|
35
|
+
lastConnectedTime: number;
|
|
36
|
+
pendingUpdates: number;
|
|
37
|
+
sendRate: number;
|
|
38
|
+
currentBatchMs: number;
|
|
39
|
+
}
|
|
40
|
+
interface MapChanges<T = unknown> {
|
|
41
|
+
added: Map<string, T>;
|
|
42
|
+
updated: Map<string, T>;
|
|
43
|
+
deleted: Set<string>;
|
|
44
|
+
}
|
|
45
|
+
interface ArrayChanges<T = unknown> {
|
|
46
|
+
added: Array<{
|
|
47
|
+
index: number;
|
|
48
|
+
values: T[];
|
|
49
|
+
}>;
|
|
50
|
+
deleted: Array<{
|
|
51
|
+
index: number;
|
|
52
|
+
count: number;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
/** Internal presence info as sent over the wire */
|
|
56
|
+
interface WirePresenceInfo {
|
|
57
|
+
instanceId: string;
|
|
58
|
+
playerName?: string;
|
|
59
|
+
playerColor?: string;
|
|
60
|
+
playerAvatar?: string;
|
|
61
|
+
userId?: string;
|
|
62
|
+
joinedAt: number;
|
|
63
|
+
state?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
interface ConnectionCallbacks {
|
|
66
|
+
onSyncResponse: (state: Uint8Array) => void;
|
|
67
|
+
onUpdate: (update: Uint8Array) => void;
|
|
68
|
+
onSnapshotRestore: (state: Uint8Array) => void;
|
|
69
|
+
onPresenceJoin: (info: WirePresenceInfo) => void;
|
|
70
|
+
onPresenceLeave: (instanceId: string) => void;
|
|
71
|
+
onPresenceList: (clients: WirePresenceInfo[]) => void;
|
|
72
|
+
onEphemeral: (event: Record<string, unknown>) => void;
|
|
73
|
+
onAwareness: (clientId: string, state: unknown) => void;
|
|
74
|
+
onSynced: () => void;
|
|
75
|
+
onReconnect: () => void;
|
|
76
|
+
onDisconnect: () => void;
|
|
77
|
+
onDebugMessage?: (type: string, data: unknown) => void;
|
|
78
|
+
getStateVector: () => Uint8Array;
|
|
79
|
+
getFullState: () => Uint8Array;
|
|
80
|
+
}
|
|
81
|
+
interface ConnectionOptions {
|
|
82
|
+
url: string;
|
|
83
|
+
roomId: string;
|
|
84
|
+
callbacks: ConnectionCallbacks;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Presence - Track who's connected and their state.
|
|
89
|
+
*
|
|
90
|
+
* Each connected user has a presence state (arbitrary typed data)
|
|
91
|
+
* that is visible to all other connected users.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
declare class Presence<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
95
|
+
private instanceId;
|
|
96
|
+
private user;
|
|
97
|
+
private others;
|
|
98
|
+
private localState;
|
|
99
|
+
private subscribers;
|
|
100
|
+
private joinSubscribers;
|
|
101
|
+
private leaveSubscribers;
|
|
102
|
+
private sendPresenceFn;
|
|
103
|
+
private sendAwarenessFn;
|
|
104
|
+
constructor(instanceId: string, user: UserInfo);
|
|
105
|
+
/** @internal - called by Room */
|
|
106
|
+
_bind(sendPresence: (info: WirePresenceInfo) => void, sendAwareness: (state: unknown) => void): void;
|
|
107
|
+
/** Set your presence state. Replaces the entire state. */
|
|
108
|
+
set(state: T): void;
|
|
109
|
+
/** Update specific fields in your presence state. */
|
|
110
|
+
update(partial: Partial<T>): void;
|
|
111
|
+
/** Get all other connected users and their presence state. */
|
|
112
|
+
getOthers(): PresenceUser<T>[];
|
|
113
|
+
/** Subscribe to presence changes. Returns an unsubscribe function. */
|
|
114
|
+
subscribe(callback: (others: PresenceUser<T>[]) => void): () => void;
|
|
115
|
+
/** Subscribe to individual join events. */
|
|
116
|
+
onJoin(callback: (user: PresenceUser<T>) => void): () => void;
|
|
117
|
+
/** Subscribe to individual leave events. */
|
|
118
|
+
onLeave(callback: (instanceId: string) => void): () => void;
|
|
119
|
+
/** @internal */
|
|
120
|
+
_handleJoin(info: WirePresenceInfo): void;
|
|
121
|
+
/** @internal */
|
|
122
|
+
_handleLeave(instanceId: string): void;
|
|
123
|
+
/** @internal */
|
|
124
|
+
_handleList(clients: WirePresenceInfo[]): void;
|
|
125
|
+
/** @internal */
|
|
126
|
+
_handleAwareness(clientId: string, state: unknown): void;
|
|
127
|
+
/** @internal */
|
|
128
|
+
_clear(): void;
|
|
129
|
+
/** @internal */
|
|
130
|
+
_destroy(): void;
|
|
131
|
+
/** @internal - get the wire presence info for joining */
|
|
132
|
+
_getJoinInfo(): WirePresenceInfo;
|
|
133
|
+
private notify;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* SharedMap - A synchronized key-value map.
|
|
138
|
+
*
|
|
139
|
+
* Thin wrapper over Y.Map that provides a clean developer-friendly API.
|
|
140
|
+
* Mutations go directly through to Yjs - there is no local caching layer.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
declare class SharedMap<T = unknown> {
|
|
144
|
+
private readonly ymap;
|
|
145
|
+
private readonly doc;
|
|
146
|
+
constructor(ymap: Y.Map<T>, doc: Y.Doc);
|
|
147
|
+
/** Get a value by key. */
|
|
148
|
+
get(key: string): T | undefined;
|
|
149
|
+
/** Set a value. Syncs to all connected clients. */
|
|
150
|
+
set(key: string, value: T): void;
|
|
151
|
+
/** Delete a key. Returns true if the key existed. */
|
|
152
|
+
delete(key: string): void;
|
|
153
|
+
/** Check if a key exists. */
|
|
154
|
+
has(key: string): boolean;
|
|
155
|
+
/** Number of entries. */
|
|
156
|
+
get size(): number;
|
|
157
|
+
/** Iterate over entries. */
|
|
158
|
+
entries(): IterableIterator<[string, T]>;
|
|
159
|
+
/** Iterate over keys. */
|
|
160
|
+
keys(): IterableIterator<string>;
|
|
161
|
+
/** Iterate over values. */
|
|
162
|
+
values(): IterableIterator<T>;
|
|
163
|
+
/** Get a plain JSON snapshot. */
|
|
164
|
+
toJSON(): Record<string, T>;
|
|
165
|
+
/** Batch multiple mutations in a single transaction. */
|
|
166
|
+
transact(fn: () => void): void;
|
|
167
|
+
/**
|
|
168
|
+
* Observe changes from any client (including local).
|
|
169
|
+
* Returns an unsubscribe function.
|
|
170
|
+
*/
|
|
171
|
+
observe(callback: (changes: MapChanges<T>) => void): () => void;
|
|
172
|
+
/** Get the underlying Y.Map for advanced Yjs usage. */
|
|
173
|
+
get raw(): Y.Map<T>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* SharedArray - A synchronized ordered list.
|
|
178
|
+
*
|
|
179
|
+
* Thin wrapper over Y.Array that provides a clean developer-friendly API.
|
|
180
|
+
* Mutations go directly through to Yjs - there is no local caching layer.
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
declare class SharedArray<T = unknown> {
|
|
184
|
+
private readonly yarray;
|
|
185
|
+
private readonly doc;
|
|
186
|
+
constructor(yarray: Y.Array<T>, doc: Y.Doc);
|
|
187
|
+
/** Append items to the end. */
|
|
188
|
+
push(...items: T[]): void;
|
|
189
|
+
/** Insert items at a specific index. */
|
|
190
|
+
insert(index: number, ...items: T[]): void;
|
|
191
|
+
/** Delete items starting at index. */
|
|
192
|
+
delete(index: number, count?: number): void;
|
|
193
|
+
/** Get item at index. */
|
|
194
|
+
get(index: number): T;
|
|
195
|
+
/** Number of items. */
|
|
196
|
+
get length(): number;
|
|
197
|
+
/** Get a plain array snapshot. */
|
|
198
|
+
toArray(): T[];
|
|
199
|
+
/** Get a plain JSON snapshot. */
|
|
200
|
+
toJSON(): T[];
|
|
201
|
+
/** Batch multiple mutations in a single transaction. */
|
|
202
|
+
transact(fn: () => void): void;
|
|
203
|
+
/**
|
|
204
|
+
* Observe changes from any client (including local).
|
|
205
|
+
* Returns an unsubscribe function.
|
|
206
|
+
*/
|
|
207
|
+
observe(callback: (event: Y.YArrayEvent<T>) => void): () => void;
|
|
208
|
+
/** Iterate over items. */
|
|
209
|
+
[Symbol.iterator](): IterableIterator<T>;
|
|
210
|
+
/** Get the underlying Y.Array for advanced Yjs usage. */
|
|
211
|
+
get raw(): Y.Array<T>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Room - The main public API for @elvenvtt/realtime.
|
|
216
|
+
*
|
|
217
|
+
* A Room represents a multiplayer session. All connected clients
|
|
218
|
+
* share a Yjs document that syncs in real time via CRDTs.
|
|
219
|
+
*
|
|
220
|
+
* Usage:
|
|
221
|
+
* const room = createRoom({ apiKey, roomId, user });
|
|
222
|
+
* await room.connect();
|
|
223
|
+
* const state = room.map('appState');
|
|
224
|
+
* state.set('title', 'Hello World');
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
declare class Room {
|
|
228
|
+
readonly id: string;
|
|
229
|
+
readonly user: UserInfo;
|
|
230
|
+
readonly presence: Presence;
|
|
231
|
+
private connection;
|
|
232
|
+
private persistence;
|
|
233
|
+
private instanceId;
|
|
234
|
+
private maps;
|
|
235
|
+
private arrays;
|
|
236
|
+
private syncedCallbacks;
|
|
237
|
+
private reconnectCallbacks;
|
|
238
|
+
private disconnectCallbacks;
|
|
239
|
+
private errorCallbacks;
|
|
240
|
+
private ephemeralHandlers;
|
|
241
|
+
private unsubscribeDocUpdate;
|
|
242
|
+
constructor(options: RoomOptions);
|
|
243
|
+
/** The raw Y.Doc. Use this for direct Yjs access or editor bindings. */
|
|
244
|
+
get doc(): Y.Doc;
|
|
245
|
+
/** Whether the WebSocket is connected. */
|
|
246
|
+
get connected(): boolean;
|
|
247
|
+
/** Whether the initial sync is complete. */
|
|
248
|
+
get synced(): boolean;
|
|
249
|
+
/**
|
|
250
|
+
* Connect to the room.
|
|
251
|
+
* Resolves when the initial sync is complete.
|
|
252
|
+
*/
|
|
253
|
+
connect(): Promise<void>;
|
|
254
|
+
/**
|
|
255
|
+
* Disconnect from the room and clean up all resources.
|
|
256
|
+
*/
|
|
257
|
+
disconnect(): void;
|
|
258
|
+
/**
|
|
259
|
+
* Get or create a synchronized map.
|
|
260
|
+
* The map is lazily created and cached.
|
|
261
|
+
*/
|
|
262
|
+
map<T = unknown>(name: string): SharedMap<T>;
|
|
263
|
+
/**
|
|
264
|
+
* Get or create a synchronized array.
|
|
265
|
+
* The array is lazily created and cached.
|
|
266
|
+
*/
|
|
267
|
+
array<T = unknown>(name: string): SharedArray<T>;
|
|
268
|
+
/**
|
|
269
|
+
* Get or create a synchronized text type.
|
|
270
|
+
* Use this with Yjs editor bindings (y-prosemirror, y-codemirror, y-monaco).
|
|
271
|
+
*/
|
|
272
|
+
text(name: string): Y.Text;
|
|
273
|
+
/**
|
|
274
|
+
* Broadcast an ephemeral event to all other connected clients.
|
|
275
|
+
* Ephemeral events are not persisted - they're fire-and-forget.
|
|
276
|
+
*/
|
|
277
|
+
broadcast(event: string, data?: Record<string, unknown>): void;
|
|
278
|
+
/**
|
|
279
|
+
* Subscribe to ephemeral events from other clients.
|
|
280
|
+
* Returns an unsubscribe function.
|
|
281
|
+
*/
|
|
282
|
+
on(event: string, callback: (data: Record<string, unknown>, sender: UserInfo) => void): () => void;
|
|
283
|
+
/**
|
|
284
|
+
* Batch multiple mutations in a single CRDT transaction.
|
|
285
|
+
* This sends one update instead of many.
|
|
286
|
+
*/
|
|
287
|
+
transact(fn: () => void): void;
|
|
288
|
+
/** Get connection health info. */
|
|
289
|
+
getHealth(): ConnectionHealth;
|
|
290
|
+
/** Force reconnect. */
|
|
291
|
+
forceReconnect(): void;
|
|
292
|
+
/** Probe connection health - pings, force reconnects if no pong. */
|
|
293
|
+
probeConnection(): void;
|
|
294
|
+
/** Subscribe to sync completion. */
|
|
295
|
+
onSynced(callback: () => void): () => void;
|
|
296
|
+
/** Subscribe to reconnection events. */
|
|
297
|
+
onReconnect(callback: () => void): () => void;
|
|
298
|
+
/** Subscribe to disconnection events. */
|
|
299
|
+
onDisconnect(callback: () => void): () => void;
|
|
300
|
+
/** Subscribe to error events. */
|
|
301
|
+
onError(callback: (error: Error) => void): () => void;
|
|
302
|
+
private joinRoom;
|
|
303
|
+
private handleEphemeral;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Create a new Room instance.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```typescript
|
|
310
|
+
* import { createRoom } from '@elvenvtt/realtime';
|
|
311
|
+
*
|
|
312
|
+
* const room = createRoom({
|
|
313
|
+
* apiKey: 'ek_live_abc123',
|
|
314
|
+
* roomId: 'my-room',
|
|
315
|
+
* user: { id: 'user-42', name: 'Alice' },
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* await room.connect();
|
|
319
|
+
*
|
|
320
|
+
* const state = room.map('appState');
|
|
321
|
+
* state.set('title', 'Hello World');
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
declare function createRoom(options: RoomOptions): Room;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* WebSocket Connection Manager
|
|
328
|
+
*
|
|
329
|
+
* Manages WebSocket connection to an Elven Realtime room.
|
|
330
|
+
* Handles CRDT sync, presence, ephemeral events, heartbeat, and reconnection.
|
|
331
|
+
*
|
|
332
|
+
* Extracted from the battle-tested Elven VTT sync provider.
|
|
333
|
+
*/
|
|
334
|
+
|
|
335
|
+
declare class Connection {
|
|
336
|
+
readonly roomId: string;
|
|
337
|
+
private url;
|
|
338
|
+
private callbacks;
|
|
339
|
+
private ws;
|
|
340
|
+
private _connected;
|
|
341
|
+
private _synced;
|
|
342
|
+
private _destroyed;
|
|
343
|
+
private localPresence;
|
|
344
|
+
private pendingUpdates;
|
|
345
|
+
private sendTimer;
|
|
346
|
+
private lastSendTime;
|
|
347
|
+
private recentSendTimes;
|
|
348
|
+
private reconnectAttempts;
|
|
349
|
+
private reconnectTimer;
|
|
350
|
+
private wasConnectedBefore;
|
|
351
|
+
private heartbeatTimer;
|
|
352
|
+
private lastPongTime;
|
|
353
|
+
private lastConnectedTime;
|
|
354
|
+
constructor(options: ConnectionOptions);
|
|
355
|
+
get connected(): boolean;
|
|
356
|
+
get synced(): boolean;
|
|
357
|
+
/**
|
|
358
|
+
* Open the WebSocket connection.
|
|
359
|
+
*/
|
|
360
|
+
connect(): void;
|
|
361
|
+
/**
|
|
362
|
+
* Send a CRDT update to peers.
|
|
363
|
+
* Batches rapid updates and queues during disconnection.
|
|
364
|
+
*/
|
|
365
|
+
sendUpdate(update: Uint8Array): void;
|
|
366
|
+
/**
|
|
367
|
+
* Send an ephemeral event (not persisted).
|
|
368
|
+
*/
|
|
369
|
+
sendEphemeral(event: Record<string, unknown>): void;
|
|
370
|
+
/**
|
|
371
|
+
* Send awareness state (cursors, selections).
|
|
372
|
+
*/
|
|
373
|
+
sendAwareness(state: unknown): void;
|
|
374
|
+
/**
|
|
375
|
+
* Join presence - announce this client to others.
|
|
376
|
+
*/
|
|
377
|
+
joinPresence(info: WirePresenceInfo): void;
|
|
378
|
+
/**
|
|
379
|
+
* Send a raw JSON message.
|
|
380
|
+
*/
|
|
381
|
+
sendRaw(message: unknown): boolean;
|
|
382
|
+
/**
|
|
383
|
+
* Get connection health info.
|
|
384
|
+
*/
|
|
385
|
+
getHealth(): {
|
|
386
|
+
connected: boolean;
|
|
387
|
+
synced: boolean;
|
|
388
|
+
reconnectAttempts: number;
|
|
389
|
+
lastConnectedTime: number;
|
|
390
|
+
pendingUpdates: number;
|
|
391
|
+
sendRate: number;
|
|
392
|
+
currentBatchMs: number;
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* Force close and reconnect.
|
|
396
|
+
*/
|
|
397
|
+
forceReconnect(): void;
|
|
398
|
+
/**
|
|
399
|
+
* Probe connection - send ping, force reconnect if no pong within 5s.
|
|
400
|
+
*/
|
|
401
|
+
probeConnection(): void;
|
|
402
|
+
/**
|
|
403
|
+
* Destroy this connection. Cleans up everything.
|
|
404
|
+
*/
|
|
405
|
+
destroy(): void;
|
|
406
|
+
private connectWebSocket;
|
|
407
|
+
private handleMessage;
|
|
408
|
+
private requestSync;
|
|
409
|
+
private handleSyncResponse;
|
|
410
|
+
private handleJsonSyncResponse;
|
|
411
|
+
private handleSyncRequest;
|
|
412
|
+
private sendLocalStateToServer;
|
|
413
|
+
private flushUpdates;
|
|
414
|
+
private recordSend;
|
|
415
|
+
private getEffectiveBatchMs;
|
|
416
|
+
private startHeartbeat;
|
|
417
|
+
private stopHeartbeat;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Wire protocol constants shared between client and server.
|
|
422
|
+
*/
|
|
423
|
+
declare const MessageType: {
|
|
424
|
+
readonly SYNC_REQUEST: "crdt:sync_request";
|
|
425
|
+
readonly SYNC_RESPONSE: "crdt:sync_response";
|
|
426
|
+
readonly UPDATE: "crdt:update";
|
|
427
|
+
readonly AWARENESS: "crdt:awareness";
|
|
428
|
+
readonly EPHEMERAL: "ephemeral";
|
|
429
|
+
readonly PRESENCE_JOIN: "presence:join";
|
|
430
|
+
readonly PRESENCE_LEAVE: "presence:leave";
|
|
431
|
+
readonly PRESENCE_LIST: "presence:list";
|
|
432
|
+
readonly PING: "ping";
|
|
433
|
+
readonly PONG: "pong";
|
|
434
|
+
readonly SNAPSHOT_RESTORE: "crdt:snapshot_restore";
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
export { type ArrayChanges, Connection, type ConnectionCallbacks, type ConnectionHealth, type ConnectionOptions, type MapChanges, MessageType, Presence, type PresenceUser, Room, type RoomOptions, SharedArray, SharedMap, type UserInfo, type WirePresenceInfo, createRoom };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public types for @elvenvtt/realtime
|
|
5
|
+
*/
|
|
6
|
+
interface RoomOptions {
|
|
7
|
+
/** API key from your Elven dashboard */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Unique room identifier. Created on first connect if it doesn't exist. */
|
|
10
|
+
roomId: string;
|
|
11
|
+
/** User identity for this connection */
|
|
12
|
+
user: UserInfo;
|
|
13
|
+
/** WebSocket base URL. Defaults to wss://sync.elvenvtt.com */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Enable IndexedDB persistence for offline support. Defaults to true in browser. */
|
|
16
|
+
persistence?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface UserInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
avatar?: string;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
interface PresenceUser<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
26
|
+
user: UserInfo;
|
|
27
|
+
state: T;
|
|
28
|
+
instanceId: string;
|
|
29
|
+
joinedAt: number;
|
|
30
|
+
}
|
|
31
|
+
interface ConnectionHealth {
|
|
32
|
+
connected: boolean;
|
|
33
|
+
synced: boolean;
|
|
34
|
+
reconnectAttempts: number;
|
|
35
|
+
lastConnectedTime: number;
|
|
36
|
+
pendingUpdates: number;
|
|
37
|
+
sendRate: number;
|
|
38
|
+
currentBatchMs: number;
|
|
39
|
+
}
|
|
40
|
+
interface MapChanges<T = unknown> {
|
|
41
|
+
added: Map<string, T>;
|
|
42
|
+
updated: Map<string, T>;
|
|
43
|
+
deleted: Set<string>;
|
|
44
|
+
}
|
|
45
|
+
interface ArrayChanges<T = unknown> {
|
|
46
|
+
added: Array<{
|
|
47
|
+
index: number;
|
|
48
|
+
values: T[];
|
|
49
|
+
}>;
|
|
50
|
+
deleted: Array<{
|
|
51
|
+
index: number;
|
|
52
|
+
count: number;
|
|
53
|
+
}>;
|
|
54
|
+
}
|
|
55
|
+
/** Internal presence info as sent over the wire */
|
|
56
|
+
interface WirePresenceInfo {
|
|
57
|
+
instanceId: string;
|
|
58
|
+
playerName?: string;
|
|
59
|
+
playerColor?: string;
|
|
60
|
+
playerAvatar?: string;
|
|
61
|
+
userId?: string;
|
|
62
|
+
joinedAt: number;
|
|
63
|
+
state?: Record<string, unknown>;
|
|
64
|
+
}
|
|
65
|
+
interface ConnectionCallbacks {
|
|
66
|
+
onSyncResponse: (state: Uint8Array) => void;
|
|
67
|
+
onUpdate: (update: Uint8Array) => void;
|
|
68
|
+
onSnapshotRestore: (state: Uint8Array) => void;
|
|
69
|
+
onPresenceJoin: (info: WirePresenceInfo) => void;
|
|
70
|
+
onPresenceLeave: (instanceId: string) => void;
|
|
71
|
+
onPresenceList: (clients: WirePresenceInfo[]) => void;
|
|
72
|
+
onEphemeral: (event: Record<string, unknown>) => void;
|
|
73
|
+
onAwareness: (clientId: string, state: unknown) => void;
|
|
74
|
+
onSynced: () => void;
|
|
75
|
+
onReconnect: () => void;
|
|
76
|
+
onDisconnect: () => void;
|
|
77
|
+
onDebugMessage?: (type: string, data: unknown) => void;
|
|
78
|
+
getStateVector: () => Uint8Array;
|
|
79
|
+
getFullState: () => Uint8Array;
|
|
80
|
+
}
|
|
81
|
+
interface ConnectionOptions {
|
|
82
|
+
url: string;
|
|
83
|
+
roomId: string;
|
|
84
|
+
callbacks: ConnectionCallbacks;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Presence - Track who's connected and their state.
|
|
89
|
+
*
|
|
90
|
+
* Each connected user has a presence state (arbitrary typed data)
|
|
91
|
+
* that is visible to all other connected users.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
declare class Presence<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
95
|
+
private instanceId;
|
|
96
|
+
private user;
|
|
97
|
+
private others;
|
|
98
|
+
private localState;
|
|
99
|
+
private subscribers;
|
|
100
|
+
private joinSubscribers;
|
|
101
|
+
private leaveSubscribers;
|
|
102
|
+
private sendPresenceFn;
|
|
103
|
+
private sendAwarenessFn;
|
|
104
|
+
constructor(instanceId: string, user: UserInfo);
|
|
105
|
+
/** @internal - called by Room */
|
|
106
|
+
_bind(sendPresence: (info: WirePresenceInfo) => void, sendAwareness: (state: unknown) => void): void;
|
|
107
|
+
/** Set your presence state. Replaces the entire state. */
|
|
108
|
+
set(state: T): void;
|
|
109
|
+
/** Update specific fields in your presence state. */
|
|
110
|
+
update(partial: Partial<T>): void;
|
|
111
|
+
/** Get all other connected users and their presence state. */
|
|
112
|
+
getOthers(): PresenceUser<T>[];
|
|
113
|
+
/** Subscribe to presence changes. Returns an unsubscribe function. */
|
|
114
|
+
subscribe(callback: (others: PresenceUser<T>[]) => void): () => void;
|
|
115
|
+
/** Subscribe to individual join events. */
|
|
116
|
+
onJoin(callback: (user: PresenceUser<T>) => void): () => void;
|
|
117
|
+
/** Subscribe to individual leave events. */
|
|
118
|
+
onLeave(callback: (instanceId: string) => void): () => void;
|
|
119
|
+
/** @internal */
|
|
120
|
+
_handleJoin(info: WirePresenceInfo): void;
|
|
121
|
+
/** @internal */
|
|
122
|
+
_handleLeave(instanceId: string): void;
|
|
123
|
+
/** @internal */
|
|
124
|
+
_handleList(clients: WirePresenceInfo[]): void;
|
|
125
|
+
/** @internal */
|
|
126
|
+
_handleAwareness(clientId: string, state: unknown): void;
|
|
127
|
+
/** @internal */
|
|
128
|
+
_clear(): void;
|
|
129
|
+
/** @internal */
|
|
130
|
+
_destroy(): void;
|
|
131
|
+
/** @internal - get the wire presence info for joining */
|
|
132
|
+
_getJoinInfo(): WirePresenceInfo;
|
|
133
|
+
private notify;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* SharedMap - A synchronized key-value map.
|
|
138
|
+
*
|
|
139
|
+
* Thin wrapper over Y.Map that provides a clean developer-friendly API.
|
|
140
|
+
* Mutations go directly through to Yjs - there is no local caching layer.
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
declare class SharedMap<T = unknown> {
|
|
144
|
+
private readonly ymap;
|
|
145
|
+
private readonly doc;
|
|
146
|
+
constructor(ymap: Y.Map<T>, doc: Y.Doc);
|
|
147
|
+
/** Get a value by key. */
|
|
148
|
+
get(key: string): T | undefined;
|
|
149
|
+
/** Set a value. Syncs to all connected clients. */
|
|
150
|
+
set(key: string, value: T): void;
|
|
151
|
+
/** Delete a key. Returns true if the key existed. */
|
|
152
|
+
delete(key: string): void;
|
|
153
|
+
/** Check if a key exists. */
|
|
154
|
+
has(key: string): boolean;
|
|
155
|
+
/** Number of entries. */
|
|
156
|
+
get size(): number;
|
|
157
|
+
/** Iterate over entries. */
|
|
158
|
+
entries(): IterableIterator<[string, T]>;
|
|
159
|
+
/** Iterate over keys. */
|
|
160
|
+
keys(): IterableIterator<string>;
|
|
161
|
+
/** Iterate over values. */
|
|
162
|
+
values(): IterableIterator<T>;
|
|
163
|
+
/** Get a plain JSON snapshot. */
|
|
164
|
+
toJSON(): Record<string, T>;
|
|
165
|
+
/** Batch multiple mutations in a single transaction. */
|
|
166
|
+
transact(fn: () => void): void;
|
|
167
|
+
/**
|
|
168
|
+
* Observe changes from any client (including local).
|
|
169
|
+
* Returns an unsubscribe function.
|
|
170
|
+
*/
|
|
171
|
+
observe(callback: (changes: MapChanges<T>) => void): () => void;
|
|
172
|
+
/** Get the underlying Y.Map for advanced Yjs usage. */
|
|
173
|
+
get raw(): Y.Map<T>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* SharedArray - A synchronized ordered list.
|
|
178
|
+
*
|
|
179
|
+
* Thin wrapper over Y.Array that provides a clean developer-friendly API.
|
|
180
|
+
* Mutations go directly through to Yjs - there is no local caching layer.
|
|
181
|
+
*/
|
|
182
|
+
|
|
183
|
+
declare class SharedArray<T = unknown> {
|
|
184
|
+
private readonly yarray;
|
|
185
|
+
private readonly doc;
|
|
186
|
+
constructor(yarray: Y.Array<T>, doc: Y.Doc);
|
|
187
|
+
/** Append items to the end. */
|
|
188
|
+
push(...items: T[]): void;
|
|
189
|
+
/** Insert items at a specific index. */
|
|
190
|
+
insert(index: number, ...items: T[]): void;
|
|
191
|
+
/** Delete items starting at index. */
|
|
192
|
+
delete(index: number, count?: number): void;
|
|
193
|
+
/** Get item at index. */
|
|
194
|
+
get(index: number): T;
|
|
195
|
+
/** Number of items. */
|
|
196
|
+
get length(): number;
|
|
197
|
+
/** Get a plain array snapshot. */
|
|
198
|
+
toArray(): T[];
|
|
199
|
+
/** Get a plain JSON snapshot. */
|
|
200
|
+
toJSON(): T[];
|
|
201
|
+
/** Batch multiple mutations in a single transaction. */
|
|
202
|
+
transact(fn: () => void): void;
|
|
203
|
+
/**
|
|
204
|
+
* Observe changes from any client (including local).
|
|
205
|
+
* Returns an unsubscribe function.
|
|
206
|
+
*/
|
|
207
|
+
observe(callback: (event: Y.YArrayEvent<T>) => void): () => void;
|
|
208
|
+
/** Iterate over items. */
|
|
209
|
+
[Symbol.iterator](): IterableIterator<T>;
|
|
210
|
+
/** Get the underlying Y.Array for advanced Yjs usage. */
|
|
211
|
+
get raw(): Y.Array<T>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Room - The main public API for @elvenvtt/realtime.
|
|
216
|
+
*
|
|
217
|
+
* A Room represents a multiplayer session. All connected clients
|
|
218
|
+
* share a Yjs document that syncs in real time via CRDTs.
|
|
219
|
+
*
|
|
220
|
+
* Usage:
|
|
221
|
+
* const room = createRoom({ apiKey, roomId, user });
|
|
222
|
+
* await room.connect();
|
|
223
|
+
* const state = room.map('appState');
|
|
224
|
+
* state.set('title', 'Hello World');
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
declare class Room {
|
|
228
|
+
readonly id: string;
|
|
229
|
+
readonly user: UserInfo;
|
|
230
|
+
readonly presence: Presence;
|
|
231
|
+
private connection;
|
|
232
|
+
private persistence;
|
|
233
|
+
private instanceId;
|
|
234
|
+
private maps;
|
|
235
|
+
private arrays;
|
|
236
|
+
private syncedCallbacks;
|
|
237
|
+
private reconnectCallbacks;
|
|
238
|
+
private disconnectCallbacks;
|
|
239
|
+
private errorCallbacks;
|
|
240
|
+
private ephemeralHandlers;
|
|
241
|
+
private unsubscribeDocUpdate;
|
|
242
|
+
constructor(options: RoomOptions);
|
|
243
|
+
/** The raw Y.Doc. Use this for direct Yjs access or editor bindings. */
|
|
244
|
+
get doc(): Y.Doc;
|
|
245
|
+
/** Whether the WebSocket is connected. */
|
|
246
|
+
get connected(): boolean;
|
|
247
|
+
/** Whether the initial sync is complete. */
|
|
248
|
+
get synced(): boolean;
|
|
249
|
+
/**
|
|
250
|
+
* Connect to the room.
|
|
251
|
+
* Resolves when the initial sync is complete.
|
|
252
|
+
*/
|
|
253
|
+
connect(): Promise<void>;
|
|
254
|
+
/**
|
|
255
|
+
* Disconnect from the room and clean up all resources.
|
|
256
|
+
*/
|
|
257
|
+
disconnect(): void;
|
|
258
|
+
/**
|
|
259
|
+
* Get or create a synchronized map.
|
|
260
|
+
* The map is lazily created and cached.
|
|
261
|
+
*/
|
|
262
|
+
map<T = unknown>(name: string): SharedMap<T>;
|
|
263
|
+
/**
|
|
264
|
+
* Get or create a synchronized array.
|
|
265
|
+
* The array is lazily created and cached.
|
|
266
|
+
*/
|
|
267
|
+
array<T = unknown>(name: string): SharedArray<T>;
|
|
268
|
+
/**
|
|
269
|
+
* Get or create a synchronized text type.
|
|
270
|
+
* Use this with Yjs editor bindings (y-prosemirror, y-codemirror, y-monaco).
|
|
271
|
+
*/
|
|
272
|
+
text(name: string): Y.Text;
|
|
273
|
+
/**
|
|
274
|
+
* Broadcast an ephemeral event to all other connected clients.
|
|
275
|
+
* Ephemeral events are not persisted - they're fire-and-forget.
|
|
276
|
+
*/
|
|
277
|
+
broadcast(event: string, data?: Record<string, unknown>): void;
|
|
278
|
+
/**
|
|
279
|
+
* Subscribe to ephemeral events from other clients.
|
|
280
|
+
* Returns an unsubscribe function.
|
|
281
|
+
*/
|
|
282
|
+
on(event: string, callback: (data: Record<string, unknown>, sender: UserInfo) => void): () => void;
|
|
283
|
+
/**
|
|
284
|
+
* Batch multiple mutations in a single CRDT transaction.
|
|
285
|
+
* This sends one update instead of many.
|
|
286
|
+
*/
|
|
287
|
+
transact(fn: () => void): void;
|
|
288
|
+
/** Get connection health info. */
|
|
289
|
+
getHealth(): ConnectionHealth;
|
|
290
|
+
/** Force reconnect. */
|
|
291
|
+
forceReconnect(): void;
|
|
292
|
+
/** Probe connection health - pings, force reconnects if no pong. */
|
|
293
|
+
probeConnection(): void;
|
|
294
|
+
/** Subscribe to sync completion. */
|
|
295
|
+
onSynced(callback: () => void): () => void;
|
|
296
|
+
/** Subscribe to reconnection events. */
|
|
297
|
+
onReconnect(callback: () => void): () => void;
|
|
298
|
+
/** Subscribe to disconnection events. */
|
|
299
|
+
onDisconnect(callback: () => void): () => void;
|
|
300
|
+
/** Subscribe to error events. */
|
|
301
|
+
onError(callback: (error: Error) => void): () => void;
|
|
302
|
+
private joinRoom;
|
|
303
|
+
private handleEphemeral;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Create a new Room instance.
|
|
307
|
+
*
|
|
308
|
+
* @example
|
|
309
|
+
* ```typescript
|
|
310
|
+
* import { createRoom } from '@elvenvtt/realtime';
|
|
311
|
+
*
|
|
312
|
+
* const room = createRoom({
|
|
313
|
+
* apiKey: 'ek_live_abc123',
|
|
314
|
+
* roomId: 'my-room',
|
|
315
|
+
* user: { id: 'user-42', name: 'Alice' },
|
|
316
|
+
* });
|
|
317
|
+
*
|
|
318
|
+
* await room.connect();
|
|
319
|
+
*
|
|
320
|
+
* const state = room.map('appState');
|
|
321
|
+
* state.set('title', 'Hello World');
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
324
|
+
declare function createRoom(options: RoomOptions): Room;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* WebSocket Connection Manager
|
|
328
|
+
*
|
|
329
|
+
* Manages WebSocket connection to an Elven Realtime room.
|
|
330
|
+
* Handles CRDT sync, presence, ephemeral events, heartbeat, and reconnection.
|
|
331
|
+
*
|
|
332
|
+
* Extracted from the battle-tested Elven VTT sync provider.
|
|
333
|
+
*/
|
|
334
|
+
|
|
335
|
+
declare class Connection {
|
|
336
|
+
readonly roomId: string;
|
|
337
|
+
private url;
|
|
338
|
+
private callbacks;
|
|
339
|
+
private ws;
|
|
340
|
+
private _connected;
|
|
341
|
+
private _synced;
|
|
342
|
+
private _destroyed;
|
|
343
|
+
private localPresence;
|
|
344
|
+
private pendingUpdates;
|
|
345
|
+
private sendTimer;
|
|
346
|
+
private lastSendTime;
|
|
347
|
+
private recentSendTimes;
|
|
348
|
+
private reconnectAttempts;
|
|
349
|
+
private reconnectTimer;
|
|
350
|
+
private wasConnectedBefore;
|
|
351
|
+
private heartbeatTimer;
|
|
352
|
+
private lastPongTime;
|
|
353
|
+
private lastConnectedTime;
|
|
354
|
+
constructor(options: ConnectionOptions);
|
|
355
|
+
get connected(): boolean;
|
|
356
|
+
get synced(): boolean;
|
|
357
|
+
/**
|
|
358
|
+
* Open the WebSocket connection.
|
|
359
|
+
*/
|
|
360
|
+
connect(): void;
|
|
361
|
+
/**
|
|
362
|
+
* Send a CRDT update to peers.
|
|
363
|
+
* Batches rapid updates and queues during disconnection.
|
|
364
|
+
*/
|
|
365
|
+
sendUpdate(update: Uint8Array): void;
|
|
366
|
+
/**
|
|
367
|
+
* Send an ephemeral event (not persisted).
|
|
368
|
+
*/
|
|
369
|
+
sendEphemeral(event: Record<string, unknown>): void;
|
|
370
|
+
/**
|
|
371
|
+
* Send awareness state (cursors, selections).
|
|
372
|
+
*/
|
|
373
|
+
sendAwareness(state: unknown): void;
|
|
374
|
+
/**
|
|
375
|
+
* Join presence - announce this client to others.
|
|
376
|
+
*/
|
|
377
|
+
joinPresence(info: WirePresenceInfo): void;
|
|
378
|
+
/**
|
|
379
|
+
* Send a raw JSON message.
|
|
380
|
+
*/
|
|
381
|
+
sendRaw(message: unknown): boolean;
|
|
382
|
+
/**
|
|
383
|
+
* Get connection health info.
|
|
384
|
+
*/
|
|
385
|
+
getHealth(): {
|
|
386
|
+
connected: boolean;
|
|
387
|
+
synced: boolean;
|
|
388
|
+
reconnectAttempts: number;
|
|
389
|
+
lastConnectedTime: number;
|
|
390
|
+
pendingUpdates: number;
|
|
391
|
+
sendRate: number;
|
|
392
|
+
currentBatchMs: number;
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* Force close and reconnect.
|
|
396
|
+
*/
|
|
397
|
+
forceReconnect(): void;
|
|
398
|
+
/**
|
|
399
|
+
* Probe connection - send ping, force reconnect if no pong within 5s.
|
|
400
|
+
*/
|
|
401
|
+
probeConnection(): void;
|
|
402
|
+
/**
|
|
403
|
+
* Destroy this connection. Cleans up everything.
|
|
404
|
+
*/
|
|
405
|
+
destroy(): void;
|
|
406
|
+
private connectWebSocket;
|
|
407
|
+
private handleMessage;
|
|
408
|
+
private requestSync;
|
|
409
|
+
private handleSyncResponse;
|
|
410
|
+
private handleJsonSyncResponse;
|
|
411
|
+
private handleSyncRequest;
|
|
412
|
+
private sendLocalStateToServer;
|
|
413
|
+
private flushUpdates;
|
|
414
|
+
private recordSend;
|
|
415
|
+
private getEffectiveBatchMs;
|
|
416
|
+
private startHeartbeat;
|
|
417
|
+
private stopHeartbeat;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Wire protocol constants shared between client and server.
|
|
422
|
+
*/
|
|
423
|
+
declare const MessageType: {
|
|
424
|
+
readonly SYNC_REQUEST: "crdt:sync_request";
|
|
425
|
+
readonly SYNC_RESPONSE: "crdt:sync_response";
|
|
426
|
+
readonly UPDATE: "crdt:update";
|
|
427
|
+
readonly AWARENESS: "crdt:awareness";
|
|
428
|
+
readonly EPHEMERAL: "ephemeral";
|
|
429
|
+
readonly PRESENCE_JOIN: "presence:join";
|
|
430
|
+
readonly PRESENCE_LEAVE: "presence:leave";
|
|
431
|
+
readonly PRESENCE_LIST: "presence:list";
|
|
432
|
+
readonly PING: "ping";
|
|
433
|
+
readonly PONG: "pong";
|
|
434
|
+
readonly SNAPSHOT_RESTORE: "crdt:snapshot_restore";
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
export { type ArrayChanges, Connection, type ConnectionCallbacks, type ConnectionHealth, type ConnectionOptions, type MapChanges, MessageType, Presence, type PresenceUser, Room, type RoomOptions, SharedArray, SharedMap, type UserInfo, type WirePresenceInfo, createRoom };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as P from"yjs";var i={SYNC_REQUEST:"crdt:sync_request",SYNC_RESPONSE:"crdt:sync_response",UPDATE:"crdt:update",AWARENESS:"crdt:awareness",EPHEMERAL:"ephemeral",PRESENCE_JOIN:"presence:join",PRESENCE_LEAVE:"presence:leave",PRESENCE_LIST:"presence:list",PING:"ping",PONG:"pong",SNAPSHOT_RESTORE:"crdt:snapshot_restore"},S=[83,89,78,67],m=[83,78,65,80];function C(n){return n.length<4?!1:n[0]===S[0]&&n[1]===S[1]&&n[2]===S[2]&&n[3]===S[3]}function N(n){return n.length<4?!1:n[0]===m[0]&&n[1]===m[1]&&n[2]===m[2]&&n[3]===m[3]}function p(n){let e="";for(let t=0;t<n.byteLength;t++)e+=String.fromCharCode(n[t]);return btoa(e)}function b(n){let e=atob(n),t=new Uint8Array(e.length);for(let r=0;r<e.length;r++)t[r]=e.charCodeAt(r);return t}var E=null;function v(){return E||(E=Math.random().toString(36).substring(2,15)),E}var H=15e3,B=45e3,J=1e3,j=1e3,F=3e4,V=1.5,A=32,G=100,_=40,$=1e3;function q(n){return Math.min(j*Math.pow(V,n),F)+Math.random()*1e3}var u=class{roomId;url;callbacks;ws=null;_connected=!1;_synced=!1;_destroyed=!1;localPresence=null;pendingUpdates=[];sendTimer=null;lastSendTime=0;recentSendTimes=[];reconnectAttempts=0;reconnectTimer=null;wasConnectedBefore=!1;heartbeatTimer=null;lastPongTime=Date.now();lastConnectedTime=0;constructor(e){this.roomId=e.roomId,this.url=e.url,this.callbacks=e.callbacks}get connected(){return this._connected}get synced(){return this._synced}connect(){this._destroyed||this.connectWebSocket()}sendUpdate(e){if(this._destroyed)return;if(this.pendingUpdates.length>=J){let o=Math.floor(this.pendingUpdates.length/2),d=this.pendingUpdates.slice(0,o),T=this.pendingUpdates.slice(o),h=P.mergeUpdates(d);this.pendingUpdates=[h,...T]}if(this.pendingUpdates.push(e),!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let r=Date.now()-this.lastSendTime,s=this.getEffectiveBatchMs();this.sendTimer&&(clearTimeout(this.sendTimer),this.sendTimer=null),r>=s?this.flushUpdates():this.sendTimer=setTimeout(()=>this.flushUpdates(),s-r)}sendEphemeral(e){if(!(!this.ws||this.ws.readyState!==WebSocket.OPEN))try{this.ws.send(JSON.stringify({type:i.EPHEMERAL,...e}))}catch{}}sendAwareness(e){!this.ws||this.ws.readyState!==WebSocket.OPEN||this.ws.send(JSON.stringify({type:i.AWARENESS,roomId:this.roomId,clientId:v(),state:e}))}joinPresence(e){this.localPresence={...e,joinedAt:Date.now()},!(!this.ws||this.ws.readyState!==WebSocket.OPEN)&&this.ws.send(JSON.stringify({type:i.PRESENCE_JOIN,...this.localPresence}))}sendRaw(e){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return this.forceReconnect(),!1;try{return this.ws.send(JSON.stringify(e)),!0}catch{return this.forceReconnect(),!1}}getHealth(){return{connected:this._connected,synced:this._synced,reconnectAttempts:this.reconnectAttempts,lastConnectedTime:this.lastConnectedTime,pendingUpdates:this.pendingUpdates.length,sendRate:this.recentSendTimes.length,currentBatchMs:this.getEffectiveBatchMs()}}forceReconnect(){this._destroyed||(this.reconnectAttempts=0,this.ws&&this.ws.readyState===WebSocket.OPEN?this.ws.close(4001,"Force reconnect"):(!this.ws||this.ws.readyState>WebSocket.OPEN)&&this.connectWebSocket())}probeConnection(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.lastPongTime=Date.now();let e=this.lastPongTime;try{this.ws.send(JSON.stringify({type:i.PING,ts:Date.now()}))}catch{this.forceReconnect();return}setTimeout(()=>{this.lastPongTime<=e&&this.forceReconnect()},5e3)}destroy(){if(this._destroyed=!0,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.sendTimer&&(clearTimeout(this.sendTimer),this.sendTimer=null),this.stopHeartbeat(),this.pendingUpdates.length>0&&this.flushUpdates(),this.localPresence&&this.ws&&this.ws.readyState===WebSocket.OPEN)try{this.ws.send(JSON.stringify({type:i.PRESENCE_LEAVE,instanceId:this.localPresence.instanceId}))}catch{}this.localPresence=null,this.ws&&(this.ws.close(),this.ws=null)}connectWebSocket(){if(this._destroyed)return;let e=new WebSocket(this.url);this.ws=e,e.binaryType="arraybuffer",e.onopen=()=>{let t=this.wasConnectedBefore;this._connected=!0,this.lastConnectedTime=Date.now(),this.reconnectAttempts=0,this.startHeartbeat(),this.requestSync(),t&&(this.localPresence&&this.ws?.send(JSON.stringify({type:i.PRESENCE_JOIN,...this.localPresence})),this.pendingUpdates.length>0&&this.flushUpdates(),this.callbacks.onReconnect()),this.wasConnectedBefore=!0},e.onmessage=t=>this.handleMessage(t.data),e.onclose=()=>{let t=this._connected;if(this._connected=!1,this._synced=!1,this.stopHeartbeat(),t&&this.callbacks.onDisconnect(),!this._destroyed){this.reconnectAttempts++;let r=q(this.reconnectAttempts);this.reconnectTimer&&clearTimeout(this.reconnectTimer),this.reconnectTimer=setTimeout(()=>{this.reconnectTimer=null,this._destroyed||this.connectWebSocket()},r)}},e.onerror=()=>{}}handleMessage(e){try{if(e instanceof ArrayBuffer){let r=new Uint8Array(e);if(N(r)){let s=r.slice(4);this.callbacks.onDebugMessage?.("binary:snapshot_restore",{bytes:s.length}),this.callbacks.onSnapshotRestore(s);return}if(C(r)){let s=r.slice(4);this.callbacks.onDebugMessage?.("binary:sync",{bytes:s.length}),this.handleSyncResponse(s);return}this.callbacks.onDebugMessage?.("binary:update",{bytes:r.length}),this.callbacks.onUpdate(r);return}let t=JSON.parse(e);switch(this.callbacks.onDebugMessage?.(t.type||"unknown",t),t.type){case i.SYNC_RESPONSE:this.handleJsonSyncResponse(t);break;case i.SNAPSHOT_RESTORE:t.state&&this.callbacks.onSnapshotRestore(b(t.state));break;case i.SYNC_REQUEST:this.handleSyncRequest();break;case i.UPDATE:t.update&&this.callbacks.onUpdate(b(t.update));break;case i.AWARENESS:t.clientId&&t.state&&this.callbacks.onAwareness(t.clientId,t.state);break;case i.PRESENCE_JOIN:t.instanceId&&this.callbacks.onPresenceJoin({instanceId:t.instanceId,playerName:t.playerName,playerColor:t.playerColor,playerAvatar:t.playerAvatar,userId:t.userId,joinedAt:t.joinedAt||Date.now(),state:t.state});break;case i.PRESENCE_LEAVE:t.instanceId&&this.callbacks.onPresenceLeave(t.instanceId);break;case i.PRESENCE_LIST:Array.isArray(t.clients)&&this.callbacks.onPresenceList(t.clients);break;case i.EPHEMERAL:this.callbacks.onEphemeral(t);break;case i.PING:this.ws&&this.ws.readyState===WebSocket.OPEN&&this.ws.send(JSON.stringify({type:i.PONG,ts:t.ts}));break;case i.PONG:this.lastPongTime=Date.now();break;default:t.action&&this.callbacks.onEphemeral(t);break}}catch(t){console.error("[Realtime] Error handling message:",t)}}requestSync(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getStateVector(),t=e.length>0?p(e):null;this.ws.send(JSON.stringify({type:i.SYNC_REQUEST,sessionId:this.roomId,stateVector:t}))}handleSyncResponse(e){e.length>0&&this.callbacks.onSyncResponse(e),this.wasConnectedBefore&&this.sendLocalStateToServer(),this._synced||(this._synced=!0,this.callbacks.onSynced())}handleJsonSyncResponse(e){if(e.state&&e.state.length>0){let t=b(e.state);this.callbacks.onSyncResponse(t)}this.wasConnectedBefore&&this.sendLocalStateToServer(),this._synced||(this._synced=!0,this.callbacks.onSynced())}handleSyncRequest(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getFullState();this.ws.send(JSON.stringify({type:i.SYNC_RESPONSE,sessionId:this.roomId,state:p(e)}))}sendLocalStateToServer(){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.callbacks.getFullState();if(e.length!==0)try{this.ws.send(e)}catch{this.ws.send(JSON.stringify({type:i.UPDATE,sessionId:this.roomId,update:p(e)}))}}flushUpdates(){if(this.pendingUpdates.length===0||!this.ws||this.ws.readyState!==WebSocket.OPEN)return;let e=this.pendingUpdates.length===1?this.pendingUpdates[0]:P.mergeUpdates(this.pendingUpdates);this.pendingUpdates=[],this.sendTimer=null,this.lastSendTime=Date.now(),this.recordSend();try{this.ws.send(e)}catch{this.ws.send(JSON.stringify({type:i.UPDATE,sessionId:this.roomId,update:p(e)}))}}recordSend(){let e=Date.now();this.recentSendTimes.push(e),this.recentSendTimes=this.recentSendTimes.filter(t=>e-t<$)}getEffectiveBatchMs(){let e=this.recentSendTimes.length;if(e>=_){let t=Math.min((e-_)/20,1);return A+(G-A)*t}return A}startHeartbeat(){this.stopHeartbeat(),this.lastPongTime=Date.now(),this.heartbeatTimer=setInterval(()=>{if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;if(Date.now()-this.lastPongTime>B){this.ws.close(4e3,"Heartbeat timeout");return}try{this.ws.send(JSON.stringify({type:i.PING,ts:Date.now()}))}catch{}},H)}stopHeartbeat(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null)}};import*as c from"yjs";var a=new Map,l=null,I=1,k="elvenvtt-realtime-schema-version",z=new Set,K=new Set;function R(n,e=!1){let t=a.get(n);return t||(t=new c.Doc,a.set(n,t),e&&typeof indexedDB<"u"&&W(n,t),t)}function O(n){let e=a.get(n);e&&(e.destroy(),a.delete(n)),L(n)}async function D(n,e,t=!1){for(let o of z)try{o(n)}catch(d){console.error("[Realtime] Error in before-replace callback:",d)}let r=a.get(n);r&&r.destroy(),L(n);let s=new c.Doc;a.set(n,s),e.length>0&&c.applyUpdate(s,e),t&&typeof indexedDB<"u"&&(await Q(n),W(n,s));for(let o of K)try{o(n)}catch(d){console.error("[Realtime] Error in after-replace callback:",d)}return s}function M(n){let e=a.get(n);return e?c.encodeStateVector(e):new Uint8Array(0)}function x(n){let e=a.get(n);return e?c.encodeStateAsUpdate(e):new Uint8Array(0)}function U(n,e){let t=a.get(n);t&&c.applyUpdate(t,e,"remote")}function Y(n,e){let t=a.get(n);return t?(t.on("update",e),()=>t.off("update",e)):()=>{}}async function W(n,e){try{X();let{IndexeddbPersistence:t}=await import("./y-indexeddb-TNC4K7G2.js");l||(l=new Map);let r=new t(`elven-realtime-${n}`,e);l.set(n,r)}catch{}}function L(n){if(!l)return;let e=l.get(n);e?.destroy&&e.destroy(),l.delete(n)}async function Q(n){if(!(typeof indexedDB>"u"))try{let e=`elven-realtime-${n}`;await new Promise((t,r)=>{let s=indexedDB.deleteDatabase(e);s.onsuccess=()=>t(),s.onerror=()=>r(s.error),s.onblocked=()=>t()})}catch{}}function X(){if(!(typeof localStorage>"u"))try{let n=localStorage.getItem(k);n&&parseInt(n,10)!==I?localStorage.setItem(k,String(I)):n||localStorage.setItem(k,String(I))}catch{}}var y=class{constructor(e,t){this.instanceId=e;this.user=t}others=new Map;localState={};subscribers=new Set;joinSubscribers=new Set;leaveSubscribers=new Set;sendPresenceFn=null;sendAwarenessFn=null;_bind(e,t){this.sendPresenceFn=e,this.sendAwarenessFn=t}set(e){this.localState={...e},this.sendAwarenessFn?.({...this.localState})}update(e){Object.assign(this.localState,e),this.sendAwarenessFn?.({...this.localState})}getOthers(){return Array.from(this.others.values())}subscribe(e){return this.others.size>0&&e(this.getOthers()),this.subscribers.add(e),()=>this.subscribers.delete(e)}onJoin(e){return this.joinSubscribers.add(e),()=>this.joinSubscribers.delete(e)}onLeave(e){return this.leaveSubscribers.add(e),()=>this.leaveSubscribers.delete(e)}_handleJoin(e){let t={user:{id:e.userId||e.instanceId,name:e.playerName||"Anonymous",color:e.playerColor,avatar:e.playerAvatar},state:e.state||{},instanceId:e.instanceId,joinedAt:e.joinedAt||Date.now()};this.others.set(e.instanceId,t),this.notify(),this.joinSubscribers.forEach(r=>{try{r(t)}catch{}})}_handleLeave(e){this.others.delete(e),this.notify(),this.leaveSubscribers.forEach(t=>{try{t(e)}catch{}})}_handleList(e){this.others.clear();for(let t of e)t.instanceId&&t.instanceId!==this.instanceId&&this.others.set(t.instanceId,{user:{id:t.userId||t.instanceId,name:t.playerName||"Anonymous",color:t.playerColor,avatar:t.playerAvatar},state:t.state||{},instanceId:t.instanceId,joinedAt:t.joinedAt||Date.now()});this.notify()}_handleAwareness(e,t){for(let[,r]of this.others)if(r.user.id===e||r.instanceId===e){r.state=t||{},this.notify();break}}_clear(){this.others.clear(),this.notify()}_destroy(){this.others.clear(),this.subscribers.clear(),this.joinSubscribers.clear(),this.leaveSubscribers.clear(),this.sendPresenceFn=null,this.sendAwarenessFn=null}_getJoinInfo(){return{instanceId:this.instanceId,playerName:this.user.name,playerColor:this.user.color,playerAvatar:this.user.avatar,userId:this.user.id,joinedAt:Date.now(),state:this.localState}}notify(){let e=this.getOthers();this.subscribers.forEach(t=>{try{t(e)}catch{}})}};var f=class{constructor(e,t){this.ymap=e;this.doc=t}get(e){return this.ymap.get(e)}set(e,t){this.ymap.set(e,t)}delete(e){this.ymap.delete(e)}has(e){return this.ymap.has(e)}get size(){return this.ymap.size}entries(){return this.ymap.entries()}keys(){return this.ymap.keys()}values(){return this.ymap.values()}toJSON(){return this.ymap.toJSON()}transact(e){this.doc.transact(e)}observe(e){let t=r=>{let s=new Map,o=new Map,d=new Set;r.changes.keys.forEach((T,h)=>{switch(T.action){case"add":s.set(h,this.ymap.get(h));break;case"update":o.set(h,this.ymap.get(h));break;case"delete":d.add(h);break}}),e({added:s,updated:o,deleted:d})};return this.ymap.observe(t),()=>this.ymap.unobserve(t)}get raw(){return this.ymap}};var g=class{constructor(e,t){this.yarray=e;this.doc=t}push(...e){this.yarray.push(e)}insert(e,...t){this.yarray.insert(e,t)}delete(e,t=1){this.yarray.delete(e,t)}get(e){return this.yarray.get(e)}get length(){return this.yarray.length}toArray(){return this.yarray.toArray()}toJSON(){return this.yarray.toJSON()}transact(e){this.doc.transact(e)}observe(e){return this.yarray.observe(e),()=>this.yarray.unobserve(e)}[Symbol.iterator](){return this.yarray[Symbol.iterator]()}get raw(){return this.yarray}};var Z="wss://sync.elvenvtt.com",w=class{id;user;presence;connection;persistence;instanceId;maps=new Map;arrays=new Map;syncedCallbacks=new Set;reconnectCallbacks=new Set;disconnectCallbacks=new Set;errorCallbacks=new Set;ephemeralHandlers=new Map;unsubscribeDocUpdate=null;constructor(e){this.id=e.roomId,this.user=e.user,this.instanceId=`${e.user.id}-${v()}-${Date.now().toString(36)}`,this.persistence=e.persistence!==void 0?e.persistence:typeof indexedDB<"u",this.presence=new y(this.instanceId,this.user);let r=`${e.baseUrl||Z}/room/${encodeURIComponent(e.roomId)}?apiKey=${encodeURIComponent(e.apiKey)}`;this.connection=new u({url:r,roomId:e.roomId,callbacks:{onSyncResponse:s=>{U(this.id,s)},onUpdate:s=>{U(this.id,s)},onSnapshotRestore:s=>{D(this.id,s,this.persistence)},onPresenceJoin:s=>{this.presence._handleJoin(s)},onPresenceLeave:s=>{this.presence._handleLeave(s)},onPresenceList:s=>{this.presence._handleList(s)},onEphemeral:s=>{this.handleEphemeral(s)},onAwareness:(s,o)=>{this.presence._handleAwareness(s,o)},onSynced:()=>{this.syncedCallbacks.forEach(s=>{try{s()}catch{}})},onReconnect:()=>{this.reconnectCallbacks.forEach(s=>{try{s()}catch{}})},onDisconnect:()=>{this.presence._clear(),this.disconnectCallbacks.forEach(s=>{try{s()}catch{}})},getStateVector:()=>M(this.id),getFullState:()=>x(this.id)}}),this.presence._bind(s=>this.connection.joinPresence(s),s=>this.connection.sendAwareness(s))}get doc(){return R(this.id,this.persistence)}get connected(){return this.connection.connected}get synced(){return this.connection.synced}connect(){return new Promise(e=>{if(R(this.id,this.persistence),this.unsubscribeDocUpdate=Y(this.id,(r,s)=>{s!=="remote"&&this.connection.connected&&this.connection.sendUpdate(r)}),this.connection.synced){e(),this.joinRoom();return}let t=this.onSynced(()=>{t(),e()});this.connection.connect(),this.joinRoom()})}disconnect(){this.unsubscribeDocUpdate&&(this.unsubscribeDocUpdate(),this.unsubscribeDocUpdate=null),this.connection.destroy(),this.presence._destroy(),this.syncedCallbacks.clear(),this.reconnectCallbacks.clear(),this.disconnectCallbacks.clear(),this.errorCallbacks.clear(),this.ephemeralHandlers.clear(),this.maps.clear(),this.arrays.clear(),O(this.id)}map(e){let t=this.maps.get(e);if(!t){let r=this.doc,s=r.getMap(e);t=new f(s,r),this.maps.set(e,t)}return t}array(e){let t=this.arrays.get(e);if(!t){let r=this.doc,s=r.getArray(e);t=new g(s,r),this.arrays.set(e,t)}return t}text(e){return this.doc.getText(e)}broadcast(e,t={}){this.connection.sendEphemeral({event:e,...t})}on(e,t){let r=this.ephemeralHandlers.get(e);return r||(r=new Set,this.ephemeralHandlers.set(e,r)),r.add(t),()=>r.delete(t)}transact(e){this.doc.transact(e)}getHealth(){return this.connection.getHealth()}forceReconnect(){this.connection.forceReconnect()}probeConnection(){this.connection.probeConnection()}onSynced(e){return this.connection.synced&&e(),this.syncedCallbacks.add(e),()=>this.syncedCallbacks.delete(e)}onReconnect(e){return this.reconnectCallbacks.add(e),()=>this.reconnectCallbacks.delete(e)}onDisconnect(e){return this.disconnectCallbacks.add(e),()=>this.disconnectCallbacks.delete(e)}onError(e){return this.errorCallbacks.add(e),()=>this.errorCallbacks.delete(e)}joinRoom(){this.connection.joinPresence(this.presence._getJoinInfo())}handleEphemeral(e){let t=e.event||e.action;if(!t)return;let r=this.ephemeralHandlers.get(t);if(!r||r.size===0)return;let s={id:e.userId||e.instanceId||"unknown",name:e.playerName||"Unknown"};r.forEach(o=>{try{o(e,s)}catch{}})}};function ee(n){return new w(n)}export{u as Connection,i as MessageType,y as Presence,w as Room,g as SharedArray,f as SharedMap,ee as createRoom};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as c from"yjs";var i=e=>new Promise(e);var C=Promise.all.bind(Promise);var g=e=>new Error(e);var a=e=>i((t,r)=>{e.onerror=o=>r(new Error(o.target.error)),e.onsuccess=o=>t(o.target.result)}),_=(e,t)=>i((r,o)=>{let s=indexedDB.open(e);s.onupgradeneeded=n=>t(n.target.result),s.onerror=n=>o(g(n.target.error)),s.onsuccess=n=>{let p=n.target.result;p.onversionchange=()=>{p.close()},r(p)}}),f=e=>a(indexedDB.deleteDatabase(e)),v=(e,t)=>t.forEach(r=>e.createObjectStore.apply(e,r)),d=(e,t,r="readwrite")=>{let o=e.transaction(t,r);return t.map(s=>F(o,s))},m=(e,t)=>a(e.count(t)),w=(e,t)=>a(e.get(t)),x=(e,t)=>a(e.delete(t)),A=(e,t,r)=>a(e.put(t,r));var l=(e,t)=>a(e.add(t)),I=(e,t,r)=>a(e.getAll(t,r));var Y=(e,t,r)=>{let o=null;return L(e,t,s=>(o=s,!1),r).then(()=>o)},T=(e,t=null)=>Y(e,t,"prev");var q=(e,t)=>i((r,o)=>{e.onerror=o,e.onsuccess=async s=>{let n=s.target.result;if(n===null||await t(n)===!1)return r();n.continue()}});var L=(e,t,r,o="next")=>q(e.openKeyCursor(t,o),s=>r(s.key)),F=(e,t)=>e.objectStore(t);var B=(e,t)=>IDBKeyRange.upperBound(e,t),U=(e,t)=>IDBKeyRange.lowerBound(e,t);var u=()=>new Map;var K=(e,t,r)=>{let o=e.get(t);return o===void 0&&e.set(t,o=r()),o};var D=()=>new Set;var E=Array.from;var h=class{constructor(){this._observers=u()}on(t,r){K(this._observers,t,D).add(r)}once(t,r){let o=(...s)=>{this.off(t,o),r(...s)};this.on(t,o)}off(t,r){let o=this._observers.get(t);o!==void 0&&(o.delete(r),o.size===0&&this._observers.delete(t))}emit(t,r){return E((this._observers.get(t)||u()).values()).forEach(o=>o(...r))}destroy(){this._observers=u()}};var y="custom",R="updates",k=500,z=(e,t=()=>{},r=()=>{})=>{let[o]=d(e.db,[R]);return I(o,U(e._dbref,!1)).then(s=>{e._destroyed||(t(o),c.transact(e.doc,()=>{s.forEach(n=>c.applyUpdate(e.doc,n))},e,!1),r(o))}).then(()=>T(o).then(s=>{e._dbref=s+1})).then(()=>m(o).then(s=>{e._dbsize=s})).then(()=>o)},W=(e,t=!0)=>z(e).then(r=>{(t||e._dbsize>=k)&&l(r,c.encodeStateAsUpdate(e.doc)).then(()=>x(r,B(e._dbref,!0))).then(()=>m(r).then(o=>{e._dbsize=o}))}),J=e=>f(e),S=class extends h{constructor(t,r){super(),this.doc=r,this.name=t,this._dbref=0,this._dbsize=0,this._destroyed=!1,this.db=null,this.synced=!1,this._db=_(t,o=>v(o,[["updates",{autoIncrement:!0}],["custom"]])),this.whenSynced=i(o=>this.on("synced",()=>o(this))),this._db.then(o=>{this.db=o,z(this,p=>l(p,c.encodeStateAsUpdate(r)),()=>{if(this._destroyed)return this;this.synced=!0,this.emit("synced",[this])})}),this._storeTimeout=1e3,this._storeTimeoutId=null,this._storeUpdate=(o,s)=>{if(this.db&&s!==this){let[n]=d(this.db,[R]);l(n,o),++this._dbsize>=k&&(this._storeTimeoutId!==null&&clearTimeout(this._storeTimeoutId),this._storeTimeoutId=setTimeout(()=>{W(this,!1),this._storeTimeoutId=null},this._storeTimeout))}},r.on("update",this._storeUpdate),this.destroy=this.destroy.bind(this),r.on("destroy",this.destroy)}destroy(){return this._storeTimeoutId&&clearTimeout(this._storeTimeoutId),this.doc.off("update",this._storeUpdate),this.doc.off("destroy",this.destroy),this._destroyed=!0,this._db.then(t=>{t.close()})}clearData(){return this.destroy().then(()=>{f(this.name)})}get(t){return this._db.then(r=>{let[o]=d(r,[y],"readonly");return w(o,t)})}set(t,r){return this._db.then(o=>{let[s]=d(o,[y]);return A(s,r,t)})}del(t){return this._db.then(r=>{let[o]=d(r,[y]);return x(o,t)})}};export{S as IndexeddbPersistence,k as PREFERRED_TRIM_SIZE,J as clearDocument,z as fetchUpdates,W as storeState};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rcap/realtime",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Real-time multiplayer rooms powered by CRDTs. Connect users, sync state, track presence.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": ["dist", "README.md"],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean --minify",
|
|
19
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"yjs": "^13.6.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"tsup": "^8.0.0",
|
|
26
|
+
"typescript": "^5.9.0",
|
|
27
|
+
"yjs": "^13.6.29"
|
|
28
|
+
},
|
|
29
|
+
"optionalDependencies": {
|
|
30
|
+
"y-indexeddb": "^9.0.12"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"keywords": ["realtime", "multiplayer", "crdt", "yjs", "collaboration", "presence", "websocket"],
|
|
34
|
+
"homepage": "https://elvenvtt.com",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/aoatkinson/elven",
|
|
38
|
+
"directory": "packages/realtime"
|
|
39
|
+
}
|
|
40
|
+
}
|