@palantir/pack.state.demo 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-transpileBrowser.log +6 -0
- package/.turbo/turbo-transpileCjs.log +6 -0
- package/.turbo/turbo-transpileEsm.log +6 -0
- package/.turbo/turbo-transpileTypes.log +5 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +39 -0
- package/LICENSE.txt +13 -0
- package/README.md +110 -0
- package/build/browser/index.js +545 -0
- package/build/browser/index.js.map +1 -0
- package/build/cjs/index.cjs +570 -0
- package/build/cjs/index.cjs.map +1 -0
- package/build/cjs/index.d.cts +88 -0
- package/build/esm/index.js +545 -0
- package/build/esm/index.js.map +1 -0
- package/build/types/DemoDocumentService.d.ts +40 -0
- package/build/types/DemoDocumentService.d.ts.map +1 -0
- package/build/types/MetadataStore.d.ts +20 -0
- package/build/types/MetadataStore.d.ts.map +1 -0
- package/build/types/PresenceManager.d.ts +27 -0
- package/build/types/PresenceManager.d.ts.map +1 -0
- package/build/types/__tests__/DemoDocumentService.test.d.ts +1 -0
- package/build/types/__tests__/DemoDocumentService.test.d.ts.map +1 -0
- package/build/types/index.d.ts +7 -0
- package/build/types/index.d.ts.map +1 -0
- package/package.json +74 -0
- package/src/DemoDocumentService.ts +414 -0
- package/src/MetadataStore.ts +100 -0
- package/src/PresenceManager.ts +323 -0
- package/src/__tests__/DemoDocumentService.test.ts +414 -0
- package/src/index.ts +33 -0
- package/tsconfig.json +21 -0
- package/vitest.config.mjs +28 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Palantir Technologies, Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
ActivityEvent,
|
|
19
|
+
DocumentId,
|
|
20
|
+
DocumentSchema,
|
|
21
|
+
Model,
|
|
22
|
+
PresenceEvent,
|
|
23
|
+
PresenceEventDataType,
|
|
24
|
+
UserId,
|
|
25
|
+
} from "@palantir/pack.document-schema.model-types";
|
|
26
|
+
import { getMetadata, hasMetadata } from "@palantir/pack.document-schema.model-types";
|
|
27
|
+
|
|
28
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
29
|
+
const STALE_CLIENT_TIMEOUT_MS = 15000;
|
|
30
|
+
|
|
31
|
+
type SerializableActivityEvent = Omit<ActivityEvent, "eventData"> & {
|
|
32
|
+
readonly eventData:
|
|
33
|
+
| { readonly type: "customEvent"; readonly eventData: unknown; readonly modelName: string }
|
|
34
|
+
| ActivityEvent["eventData"];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type PresenceChannelMessage =
|
|
38
|
+
| { readonly type: "heartbeat"; readonly userId: string; readonly timestamp: number }
|
|
39
|
+
| {
|
|
40
|
+
readonly type: "presence";
|
|
41
|
+
readonly userId: string;
|
|
42
|
+
readonly event: SerializablePresenceEvent;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type ActivityChannelMessage = {
|
|
46
|
+
readonly type: "activity";
|
|
47
|
+
readonly userId: string;
|
|
48
|
+
readonly event: SerializableActivityEvent;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type SerializablePresenceEvent = Omit<PresenceEvent, "eventData"> & {
|
|
52
|
+
readonly eventData:
|
|
53
|
+
| { readonly type: "customEvent"; readonly eventData: unknown; readonly modelName: string }
|
|
54
|
+
| PresenceEvent["eventData"];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export class PresenceManager {
|
|
58
|
+
private readonly activityChannel: BroadcastChannel;
|
|
59
|
+
private readonly presenceChannel: BroadcastChannel;
|
|
60
|
+
private readonly schema?: DocumentSchema;
|
|
61
|
+
private readonly userId: UserId;
|
|
62
|
+
private readonly activeClients = new Map<UserId, number>();
|
|
63
|
+
private heartbeatInterval?: number;
|
|
64
|
+
private staleCheckInterval?: number;
|
|
65
|
+
private presenceCallbacks = new Set<(event: PresenceEvent) => void>();
|
|
66
|
+
private activityCallbacks = new Set<(event: ActivityEvent) => void>();
|
|
67
|
+
|
|
68
|
+
constructor(documentId: DocumentId, userId: string, schema?: DocumentSchema) {
|
|
69
|
+
this.userId = userId as UserId;
|
|
70
|
+
this.schema = schema;
|
|
71
|
+
this.activityChannel = new BroadcastChannel(`pack-demo-activity-${documentId}`);
|
|
72
|
+
this.presenceChannel = new BroadcastChannel(`pack-demo-presence-${documentId}`);
|
|
73
|
+
|
|
74
|
+
this.activityChannel.onmessage = event => {
|
|
75
|
+
this.handleActivityMessage(event.data as ActivityChannelMessage);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.presenceChannel.onmessage = event => {
|
|
79
|
+
this.handlePresenceMessage(event.data as PresenceChannelMessage);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
this.startHeartbeat();
|
|
83
|
+
this.startStaleCheck();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
onPresence(callback: (event: PresenceEvent) => void): () => void {
|
|
87
|
+
this.presenceCallbacks.add(callback);
|
|
88
|
+
return () => {
|
|
89
|
+
this.presenceCallbacks.delete(callback);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onActivity(callback: (event: ActivityEvent) => void): () => void {
|
|
94
|
+
this.activityCallbacks.add(callback);
|
|
95
|
+
return () => {
|
|
96
|
+
this.activityCallbacks.delete(callback);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
broadcastActivity(event: ActivityEvent): void {
|
|
101
|
+
const serializableEvent: SerializableActivityEvent = {
|
|
102
|
+
...event,
|
|
103
|
+
eventData: event.eventData.type === "customEvent"
|
|
104
|
+
? {
|
|
105
|
+
eventData: event.eventData.eventData,
|
|
106
|
+
modelName: getMetadata(event.eventData.model).name,
|
|
107
|
+
type: "customEvent",
|
|
108
|
+
}
|
|
109
|
+
: event.eventData,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const message: ActivityChannelMessage = {
|
|
113
|
+
event: serializableEvent,
|
|
114
|
+
type: "activity",
|
|
115
|
+
userId: this.userId,
|
|
116
|
+
};
|
|
117
|
+
this.activityChannel.postMessage(message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
broadcastPresence(event: PresenceEvent): void {
|
|
121
|
+
const serializableEvent: SerializablePresenceEvent = {
|
|
122
|
+
...event,
|
|
123
|
+
eventData: event.eventData.type === "customEvent"
|
|
124
|
+
? {
|
|
125
|
+
eventData: event.eventData.eventData,
|
|
126
|
+
modelName: getMetadata(event.eventData.model).name,
|
|
127
|
+
type: "customEvent",
|
|
128
|
+
}
|
|
129
|
+
: event.eventData,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const message: PresenceChannelMessage = {
|
|
133
|
+
event: serializableEvent,
|
|
134
|
+
type: "presence",
|
|
135
|
+
userId: this.userId,
|
|
136
|
+
};
|
|
137
|
+
this.presenceChannel.postMessage(message);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
dispose(): void {
|
|
141
|
+
if (this.heartbeatInterval != null) {
|
|
142
|
+
clearInterval(this.heartbeatInterval);
|
|
143
|
+
this.heartbeatInterval = undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (this.staleCheckInterval != null) {
|
|
147
|
+
clearInterval(this.staleCheckInterval);
|
|
148
|
+
this.staleCheckInterval = undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.activityChannel.close();
|
|
152
|
+
this.presenceChannel.close();
|
|
153
|
+
this.presenceCallbacks.clear();
|
|
154
|
+
this.activityCallbacks.clear();
|
|
155
|
+
this.activeClients.clear();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private startHeartbeat(): void {
|
|
159
|
+
const sendHeartbeat = () => {
|
|
160
|
+
const message: PresenceChannelMessage = {
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
type: "heartbeat",
|
|
163
|
+
userId: this.userId,
|
|
164
|
+
};
|
|
165
|
+
this.presenceChannel.postMessage(message);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
sendHeartbeat();
|
|
169
|
+
this.heartbeatInterval = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS) as unknown as number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private startStaleCheck(): void {
|
|
173
|
+
this.staleCheckInterval = setInterval(() => {
|
|
174
|
+
this.checkStaleClients();
|
|
175
|
+
}, HEARTBEAT_INTERVAL_MS) as unknown as number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private handleActivityMessage(message: ActivityChannelMessage): void {
|
|
179
|
+
if (message.type === "activity") {
|
|
180
|
+
this.handleActivity(message.event);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private handlePresenceMessage(message: PresenceChannelMessage): void {
|
|
185
|
+
switch (message.type) {
|
|
186
|
+
case "heartbeat":
|
|
187
|
+
this.handleHeartbeat(message.userId, message.timestamp);
|
|
188
|
+
break;
|
|
189
|
+
case "presence":
|
|
190
|
+
this.handlePresence(message.event);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private handleHeartbeat(userId: UserId, timestamp: number): void {
|
|
196
|
+
const wasActive = this.activeClients.has(userId);
|
|
197
|
+
this.activeClients.set(userId, timestamp);
|
|
198
|
+
|
|
199
|
+
if (!wasActive) {
|
|
200
|
+
const event: PresenceEvent = {
|
|
201
|
+
eventData: {
|
|
202
|
+
type: "presenceArrived" as typeof PresenceEventDataType.ARRIVED,
|
|
203
|
+
},
|
|
204
|
+
userId,
|
|
205
|
+
};
|
|
206
|
+
this.emitPresenceEvent(event);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private handleActivity(event: SerializableActivityEvent): void {
|
|
211
|
+
let reconstructedEvent: ActivityEvent = event as ActivityEvent;
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
event.eventData.type === "customEvent"
|
|
215
|
+
&& "modelName" in event.eventData
|
|
216
|
+
&& this.schema != null
|
|
217
|
+
) {
|
|
218
|
+
const modelName = event.eventData.modelName;
|
|
219
|
+
let model: Model | undefined;
|
|
220
|
+
|
|
221
|
+
for (const key of Object.keys(this.schema)) {
|
|
222
|
+
const candidate = this.schema[key as keyof DocumentSchema];
|
|
223
|
+
if (
|
|
224
|
+
candidate != null
|
|
225
|
+
&& typeof candidate === "object"
|
|
226
|
+
&& hasMetadata(candidate)
|
|
227
|
+
) {
|
|
228
|
+
const metadata = getMetadata(candidate);
|
|
229
|
+
if ("name" in metadata && metadata.name === modelName) {
|
|
230
|
+
model = candidate as Model;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (model != null) {
|
|
237
|
+
reconstructedEvent = {
|
|
238
|
+
...event,
|
|
239
|
+
eventData: {
|
|
240
|
+
eventData: event.eventData.eventData,
|
|
241
|
+
model,
|
|
242
|
+
type: "customEvent",
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const callback of this.activityCallbacks) {
|
|
249
|
+
callback(reconstructedEvent);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private handlePresence(event: SerializablePresenceEvent): void {
|
|
254
|
+
let reconstructedEvent: PresenceEvent = event as PresenceEvent;
|
|
255
|
+
|
|
256
|
+
if (
|
|
257
|
+
event.eventData.type === "customEvent"
|
|
258
|
+
&& "modelName" in event.eventData
|
|
259
|
+
&& this.schema != null
|
|
260
|
+
) {
|
|
261
|
+
const modelName = event.eventData.modelName;
|
|
262
|
+
let model: Model | undefined;
|
|
263
|
+
|
|
264
|
+
for (const key of Object.keys(this.schema)) {
|
|
265
|
+
const candidate = this.schema[key as keyof DocumentSchema];
|
|
266
|
+
if (
|
|
267
|
+
candidate != null
|
|
268
|
+
&& typeof candidate === "object"
|
|
269
|
+
&& hasMetadata(candidate)
|
|
270
|
+
) {
|
|
271
|
+
const metadata = getMetadata(candidate);
|
|
272
|
+
if ("name" in metadata && metadata.name === modelName) {
|
|
273
|
+
model = candidate as Model;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (model != null) {
|
|
280
|
+
reconstructedEvent = {
|
|
281
|
+
...event,
|
|
282
|
+
eventData: {
|
|
283
|
+
eventData: event.eventData.eventData,
|
|
284
|
+
model,
|
|
285
|
+
type: "customEvent",
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const callback of this.presenceCallbacks) {
|
|
292
|
+
callback(reconstructedEvent);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private checkStaleClients(): void {
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const staleClients: UserId[] = [];
|
|
299
|
+
|
|
300
|
+
for (const [userId, lastSeen] of this.activeClients.entries()) {
|
|
301
|
+
if (now - lastSeen > STALE_CLIENT_TIMEOUT_MS) {
|
|
302
|
+
staleClients.push(userId);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
for (const userId of staleClients) {
|
|
307
|
+
this.activeClients.delete(userId);
|
|
308
|
+
const event: PresenceEvent = {
|
|
309
|
+
eventData: {
|
|
310
|
+
type: "presenceDeparted" as typeof PresenceEventDataType.DEPARTED,
|
|
311
|
+
},
|
|
312
|
+
userId,
|
|
313
|
+
};
|
|
314
|
+
this.emitPresenceEvent(event);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private emitPresenceEvent(event: PresenceEvent): void {
|
|
319
|
+
for (const callback of this.presenceCallbacks) {
|
|
320
|
+
callback(event);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|