@pluv/crdt-yjs 2.0.2 → 2.2.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.
@@ -0,0 +1,220 @@
1
+ import type {
2
+ CrdtType,
3
+ IOLike,
4
+ OthersSubscriptionEvent,
5
+ PluvRouterEventConfig,
6
+ RoomLike,
7
+ UserInfo,
8
+ } from "@pluv/types";
9
+ import { ObservableV2 } from "lib0/observable";
10
+ import type { Doc as YDoc } from "yjs";
11
+ import { PLUV_PRESENCE_META_KEY, PLUV_PRESENCE_Y_ID_KEY } from "../constants";
12
+ import type { AwarenessPresence, MetaClientState, YjsAwarenessUpdate } from "../types";
13
+
14
+ export interface PluvYjsAwarenessParams<
15
+ TIO extends IOLike<any>,
16
+ TPresence extends Record<string, any>,
17
+ TStorage extends Record<string, CrdtType<any, any>>,
18
+ TEvents extends PluvRouterEventConfig,
19
+ TField extends keyof TPresence | null = null,
20
+ > {
21
+ field?: TField;
22
+ room: RoomLike<TIO, YDoc, TPresence, TStorage, TEvents>;
23
+ }
24
+
25
+ export class PluvYjsAwareness<
26
+ TIO extends IOLike,
27
+ TPresence extends Record<string, any>,
28
+ TStorage extends Record<string, CrdtType<any, any>>,
29
+ TEvents extends PluvRouterEventConfig,
30
+ TField extends keyof TPresence | null = null,
31
+ > extends ObservableV2<{
32
+ change: (updates: YjsAwarenessUpdate, kind: "presence") => void;
33
+ destroy: (awareness: PluvYjsAwareness<TIO, TPresence, TStorage, TEvents, TField>) => void;
34
+ update: (updates: YjsAwarenessUpdate, kind: "local" | "presence") => void;
35
+ }> {
36
+ public readonly doc: YDoc;
37
+
38
+ private readonly _field: TField = null as TField;
39
+ private readonly _idMap = new Map<[connectionId: string][0], [clientID: number][0]>();
40
+ private readonly _room: RoomLike<TIO, YDoc, TPresence, TStorage, TEvents>;
41
+ private readonly _unsubscribe: () => void;
42
+
43
+ public get clientID(): number {
44
+ return this.doc.clientID;
45
+ }
46
+
47
+ public get states(): Map<number, TPresence> {
48
+ return this.getStates();
49
+ }
50
+
51
+ constructor(params: PluvYjsAwarenessParams<TIO, TPresence, TStorage, TEvents, TField>) {
52
+ super();
53
+
54
+ const { field, room } = params;
55
+
56
+ this.doc = room.getDoc().value;
57
+ this._room = room;
58
+
59
+ if (!!field) this._field = field;
60
+
61
+ const myPresence: any = this._room.getMyPresence();
62
+ const myMeta = myPresence?.[PLUV_PRESENCE_META_KEY] || {};
63
+
64
+ this._room.updateMyPresence({
65
+ [PLUV_PRESENCE_META_KEY]: {
66
+ ...myMeta,
67
+ [PLUV_PRESENCE_Y_ID_KEY]: this.clientID,
68
+ },
69
+ } as any);
70
+
71
+ this._unsubscribe = this._room.subscribe.others((others, event) => {
72
+ const updates = this._deriveUpdates(others, event);
73
+
74
+ if (!updates) return;
75
+
76
+ this.emit("change", [updates, "presence"]);
77
+ this.emit("update", [updates, "presence"]);
78
+ });
79
+ }
80
+
81
+ public destroy(): void {
82
+ this.emit("destroy", [this]);
83
+ this._unsubscribe();
84
+ super.destroy();
85
+ }
86
+
87
+ public getLocalState(): AwarenessPresence<TPresence, TField> {
88
+ return this._adaptPresence(this._room.getMyPresence());
89
+ }
90
+
91
+ public setLocalState(state: AwarenessPresence<TPresence, TField>): void {
92
+ const patch = (!!this._field
93
+ ? { [this._field]: state }
94
+ : state) as unknown as Partial<TPresence>;
95
+
96
+ this._room.updateMyPresence(patch);
97
+
98
+ this.emit("update", [{ added: [], updated: [this.clientID], removed: [] }, "local"]);
99
+ }
100
+
101
+ public setLocalStateField<TLocalField extends keyof AwarenessPresence<TPresence, TField>>(
102
+ field: TLocalField,
103
+ value: AwarenessPresence<TPresence, TField>[TLocalField],
104
+ ): void {
105
+ /**
106
+ * !HACK
107
+ * @description Cast to unknown first, because field loses its type of TField when set in
108
+ * an object.
109
+ * @date May 14, 2025
110
+ */
111
+ const patch = (!!this._field
112
+ ? { [this._field]: { ...this.getLocalState(), [field]: value } }
113
+ : { [field]: value }) as unknown as Partial<TPresence>;
114
+
115
+ this._room.updateMyPresence(patch);
116
+ }
117
+
118
+ /**
119
+ * !HACK
120
+ * @description We're building the awareness presences from the states stored on the room's
121
+ * presense instead of using the map defined in the awareness interface.
122
+ * @see https://github.com/yjs/y-protocols/blob/master/awareness.js
123
+ * @date May 13, 2025
124
+ */
125
+ public getStates(): Map<number, AwarenessPresence<TPresence, TField>> {
126
+ const others = this._room.getOthers();
127
+
128
+ const states = others.reduce((map, other) => {
129
+ const presence = this._adaptPresence(other.presence);
130
+ const meta = other.presence?.[PLUV_PRESENCE_META_KEY] || {};
131
+ const clientID: number | undefined = meta[PLUV_PRESENCE_Y_ID_KEY];
132
+
133
+ return typeof presence !== "undefined" && typeof clientID !== "undefined"
134
+ ? map.set(clientID, presence)
135
+ : map;
136
+ }, new Map<number, AwarenessPresence<TPresence, TField>>());
137
+
138
+ const myPresence = this.getLocalState();
139
+
140
+ if (typeof myPresence !== "undefined") states.set(this.clientID, myPresence);
141
+
142
+ return states;
143
+ }
144
+
145
+ private _adaptPresence(presence: TPresence): AwarenessPresence<TPresence, TField> {
146
+ return (!!this._field ? presence[this._field] : presence) as AwarenessPresence<
147
+ TPresence,
148
+ TField
149
+ >;
150
+ }
151
+
152
+ private _deriveUpdates(
153
+ others: readonly UserInfo<any, any>[],
154
+ event: OthersSubscriptionEvent<any, any>,
155
+ ): YjsAwarenessUpdate | null {
156
+ switch (event.kind) {
157
+ case "clear": {
158
+ this._idMap.clear();
159
+ return null;
160
+ }
161
+ case "enter": {
162
+ const clientID = this._resetIdMap(others).get(event.user.connectionId);
163
+
164
+ return {
165
+ added: typeof clientID !== "undefined" ? [clientID] : [],
166
+ updated: [],
167
+ removed: [],
168
+ };
169
+ }
170
+ case "leave": {
171
+ const clientID = this._idMap.get(event.user.connectionId);
172
+
173
+ this._resetIdMap(others);
174
+
175
+ return {
176
+ added: [],
177
+ updated: [],
178
+ removed: typeof clientID !== "undefined" ? [clientID] : [],
179
+ };
180
+ }
181
+ case "sync": {
182
+ this._resetIdMap(others);
183
+ return null;
184
+ }
185
+ case "update": {
186
+ const clientID = this._resetIdMap(others).get(event.user.connectionId);
187
+
188
+ return {
189
+ added: [],
190
+ updated: typeof clientID !== "undefined" ? [clientID] : [],
191
+ removed: [],
192
+ };
193
+ }
194
+ default:
195
+ return null;
196
+ }
197
+ }
198
+
199
+ private _resetIdMap(others: readonly UserInfo<any, any>[]): Map<string, number> {
200
+ this._idMap.clear();
201
+
202
+ return others.reduce((map, other) => {
203
+ const clientID = other.presence[PLUV_PRESENCE_META_KEY]?.[PLUV_PRESENCE_Y_ID_KEY];
204
+
205
+ if (typeof clientID === "undefined") return map;
206
+
207
+ return map.set(other.connectionId, clientID);
208
+ }, this._idMap);
209
+ }
210
+
211
+ /**
212
+ * !HACK
213
+ * @description We dgaf about these. We're just mirroring the fields that exist in the
214
+ * og awareness to make sure everything aligns.
215
+ * @see https://github.com/yjs/y-protocols/blob/master/awareness.js
216
+ * @date May 13, 2025
217
+ */
218
+ public readonly meta = new Map<string, MetaClientState>();
219
+ public readonly _checkInterval: number = 0;
220
+ }
@@ -0,0 +1,15 @@
1
+ import type { CrdtType, IOLike, PluvRouterEventConfig } from "@pluv/types";
2
+ import type { PluvYjsAwarenessParams } from "./PluvYjsAwareness";
3
+ import { PluvYjsAwareness } from "./PluvYjsAwareness";
4
+
5
+ export const awareness = <
6
+ TIO extends IOLike<any>,
7
+ TPresence extends Record<string, any>,
8
+ TStorage extends Record<string, CrdtType<any, any>>,
9
+ TEvents extends PluvRouterEventConfig,
10
+ TField extends keyof TPresence | null = null,
11
+ >(
12
+ params: PluvYjsAwarenessParams<TIO, TPresence, TStorage, TEvents, TField>,
13
+ ): PluvYjsAwareness<TIO, TPresence, TStorage, TEvents, TField> => {
14
+ return new PluvYjsAwareness(params);
15
+ };
@@ -0,0 +1,2 @@
1
+ export { awareness } from "./awareness";
2
+ export type { PluvYjsAwareness } from "./PluvYjsAwareness";
@@ -0,0 +1,2 @@
1
+ export const PLUV_PRESENCE_META_KEY = "__pluv";
2
+ export const PLUV_PRESENCE_Y_ID_KEY = "__y_clientid";
@@ -39,7 +39,7 @@ export type CrdtYjsDocParams<TStorage extends Record<string, YjsType<any, any>>>
39
39
  ) => TStorage;
40
40
 
41
41
  export class CrdtYjsDoc<TStorage extends Record<string, YjsType<any, any>>>
42
- implements CrdtDocLike<TStorage>
42
+ implements CrdtDocLike<YDoc, TStorage>
43
43
  {
44
44
  public value: YDoc = new YDoc();
45
45
 
@@ -285,7 +285,9 @@ export class CrdtYjsDoc<TStorage extends Record<string, YjsType<any, any>>>
285
285
  return this;
286
286
  }
287
287
 
288
- public subscribe(listener: (params: DocSubscribeCallbackParams<TStorage>) => void): () => void {
288
+ public subscribe(
289
+ listener: (params: DocSubscribeCallbackParams<YDoc, TStorage>) => void,
290
+ ): () => void {
289
291
  const fn = (update: Uint8Array, origin: any, doc: YDoc) => {
290
292
  listener({
291
293
  doc: this,
@@ -1,11 +1,12 @@
1
1
  import { AbstractCrdtDocFactory } from "@pluv/crdt";
2
+ import type { Doc as YDoc } from "yjs";
2
3
  import type { YjsType } from "../types";
3
4
  import { CrdtYjsDoc } from "./CrdtYjsDoc";
4
5
  import type { YjsBuilder } from "./builder";
5
6
 
6
7
  export class CrdtYjsDocFactory<
7
8
  TStorage extends Record<string, YjsType<any, any>> = {},
8
- > extends AbstractCrdtDocFactory<TStorage> {
9
+ > extends AbstractCrdtDocFactory<YDoc, TStorage> {
9
10
  public readonly _initialStorage: (builder: YjsBuilder) => TStorage;
10
11
 
11
12
  constructor(initialStorage: (builder: YjsBuilder) => TStorage = () => ({}) as TStorage) {
@@ -0,0 +1,132 @@
1
+ import type { CrdtType, IOLike, PluvRouterEventConfig, RoomLike } from "@pluv/types";
2
+ import { StorageState } from "@pluv/types";
3
+ import { ObservableV2 } from "lib0/observable";
4
+ import type { Doc as YDoc } from "yjs";
5
+ import type { PluvYjsAwareness } from "../awareness";
6
+ import { awareness } from "../awareness";
7
+ import type { YjsProviderStatus } from "../types";
8
+
9
+ export interface PluvYjsProviderParams<
10
+ TIO extends IOLike<any>,
11
+ TPresence extends Record<string, any>,
12
+ TStorage extends Record<string, CrdtType<any, any>>,
13
+ TEvents extends PluvRouterEventConfig,
14
+ TField extends keyof TPresence | null = null,
15
+ > {
16
+ field?: TField;
17
+ room: RoomLike<TIO, YDoc, TPresence, TStorage, TEvents>;
18
+ }
19
+
20
+ /**
21
+ * !HACK
22
+ * @description This doesn't really do anything but emit states in a Yjs.Provider compatible way.
23
+ * Connections and document syncing are handled by the PluvRoom already.
24
+ * @date May 13, 2025
25
+ */
26
+ export class PluvYjsProvider<
27
+ TIO extends IOLike<any>,
28
+ TPresence extends Record<string, any>,
29
+ TStorage extends Record<string, CrdtType<any, any>>,
30
+ TEvents extends PluvRouterEventConfig,
31
+ TField extends keyof TPresence | null = null,
32
+ > extends ObservableV2<{
33
+ "connection-close": (
34
+ event: CloseEvent | null,
35
+ provider: PluvYjsProvider<TIO, TPresence, TStorage, TEvents, TField>,
36
+ ) => void;
37
+ "connection-error": (
38
+ event: Event,
39
+ provider: PluvYjsProvider<TIO, TPresence, TStorage, TEvents, TField>,
40
+ ) => void;
41
+ status: (event: { status: YjsProviderStatus }) => void;
42
+ sync: (state: boolean) => void;
43
+ synced: (state: boolean) => void;
44
+ }> {
45
+ public readonly awareness: PluvYjsAwareness<TIO, TPresence, TStorage, TEvents, TField>;
46
+ public readonly doc: YDoc;
47
+
48
+ private readonly _room: RoomLike<TIO, YDoc, TPresence, TStorage, TEvents>;
49
+ private readonly _unsubscribe: () => void;
50
+
51
+ private _synced: boolean = false;
52
+
53
+ constructor(params: PluvYjsProviderParams<TIO, TPresence, TStorage, TEvents, TField>) {
54
+ super();
55
+
56
+ const { field, room } = params;
57
+
58
+ this.awareness = awareness({ field, room });
59
+ this.doc = room.getDoc().value;
60
+
61
+ this._room = room;
62
+
63
+ const subscriptions = [
64
+ this._room.subscribe.connection(({ storage }) => {
65
+ const status = this._toProviderStatus(storage.state);
66
+
67
+ if (!status) return;
68
+
69
+ this.emit("status", [{ status }]);
70
+ }),
71
+ this._room.addEventListener("close", (event) => {
72
+ this.emit("connection-close", [event, this]);
73
+ }),
74
+ this._room.addEventListener("error", (event) => {
75
+ this.emit("connection-error", [event, this]);
76
+ }),
77
+ ];
78
+
79
+ this._unsubscribe = subscriptions.reduce(
80
+ (fn, unsubscribe) => () => {
81
+ fn();
82
+ unsubscribe();
83
+ },
84
+ () => undefined,
85
+ );
86
+ }
87
+
88
+ public get synced(): boolean {
89
+ return this._synced;
90
+ }
91
+
92
+ public set synced(state: boolean) {
93
+ if (this._synced === state) return;
94
+
95
+ this._synced = state;
96
+ this.emit("sync", [state]);
97
+ this.emit("synced", [state]);
98
+ }
99
+
100
+ public destroy(): void {
101
+ this.awareness.destroy();
102
+ this._unsubscribe();
103
+ super.destroy();
104
+ }
105
+
106
+ private _toProviderStatus(state: StorageState): YjsProviderStatus | null {
107
+ switch (state) {
108
+ case StorageState.Loading: {
109
+ return "connecting";
110
+ }
111
+ case StorageState.Unavailable: {
112
+ return "disconnected";
113
+ }
114
+ case StorageState.Offline:
115
+ case StorageState.Synced: {
116
+ return "connected";
117
+ }
118
+ default:
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * !HACK
125
+ * @description We dgaf about these, since the room manages these already. We're just mirroring
126
+ * the fields that exist in the og provider to make sure everything aligns.
127
+ * @see https://github.com/yjs/y-websocket/blob/master/src/y-websocket.js
128
+ * @date May 13, 2025
129
+ */
130
+ public connect(): void {}
131
+ public disconnect(): void {}
132
+ }
@@ -0,0 +1,2 @@
1
+ export type { PluvYjsProvider } from "./PluvYjsProvider";
2
+ export { provider } from "./provider";
@@ -0,0 +1,15 @@
1
+ import type { CrdtType, IOLike, PluvRouterEventConfig } from "@pluv/types";
2
+ import type { PluvYjsProviderParams } from "./PluvYjsProvider";
3
+ import { PluvYjsProvider } from "./PluvYjsProvider";
4
+
5
+ export const provider = <
6
+ TIO extends IOLike<any>,
7
+ TPresence extends Record<string, any>,
8
+ TStorage extends Record<string, CrdtType<any, any>>,
9
+ TEvents extends PluvRouterEventConfig,
10
+ TField extends keyof TPresence | null = null,
11
+ >(
12
+ params: PluvYjsProviderParams<TIO, TPresence, TStorage, TEvents, TField>,
13
+ ) => {
14
+ return new PluvYjsProvider(params);
15
+ };
package/src/types.ts CHANGED
@@ -7,3 +7,29 @@ export type YjsType<TValue extends unknown, TJson extends unknown = any> = CrdtT
7
7
  initialValue?: any;
8
8
  toJSON: () => any;
9
9
  };
10
+
11
+ /**
12
+ * @description Type internally defined by Yjs.Awareness
13
+ * @see https://github.com/yjs/y-protocols/blob/master/awareness.js
14
+ */
15
+ export interface YjsAwarenessUpdate {
16
+ added: number[];
17
+ updated: number[];
18
+ removed: number[];
19
+ }
20
+
21
+ export type YjsProviderStatus = "connecting" | "connected" | "disconnected";
22
+
23
+ /**
24
+ * @description Type internally defined by Yjs.Awareness
25
+ * @see https://github.com/yjs/y-protocols/blob/master/awareness.js
26
+ */
27
+ export interface MetaClientState {
28
+ clock: number;
29
+ lastUpdated: number;
30
+ }
31
+
32
+ export type AwarenessPresence<
33
+ TPresence extends Record<string, any>,
34
+ TField extends keyof TPresence | null,
35
+ > = TField extends null ? TPresence : TField extends keyof TPresence ? TPresence[TField] : never;
package/src/yjs.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { array } from "./array";
2
+ export { awareness } from "./awareness";
2
3
  export { doc } from "./doc";
3
4
  export type { YjsBuilder } from "./doc";
4
5
  export { map } from "./map";
5
6
  export { object } from "./object";
7
+ export { provider } from "./provider";
6
8
  export { text } from "./text";
7
9
  export type { YjsType } from "./types";
8
10
  export { xmlElement } from "./xmlElement";