@palantir/pack.state.foundry-event 0.1.2 → 0.2.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.
@@ -17,12 +17,12 @@
17
17
  import type { Logger } from "@osdk/api";
18
18
  import type {
19
19
  ActivityCollaborativeUpdate,
20
- ClientId,
21
20
  DocumentEditDescription,
22
21
  DocumentPublishMessage,
23
22
  DocumentUpdateMessage,
24
23
  DocumentUpdateSubscriptionRequest,
25
- UserId,
24
+ PresenceCollaborativeUpdate,
25
+ PresencePublishMessage,
26
26
  } from "@osdk/foundry.pack";
27
27
  import { getAuthModule } from "@palantir/pack.auth";
28
28
  import { generateId, justOnce, type PackAppInternal } from "@palantir/pack.core";
@@ -33,10 +33,9 @@ import {
33
33
  hasMetadata,
34
34
  } from "@palantir/pack.document-schema.model-types";
35
35
  import { DocumentLoadStatus, type DocumentSyncStatus } from "@palantir/pack.state.core";
36
- import type { CometD } from "cometd";
37
36
  import { Base64 } from "js-base64";
38
37
  import * as y from "yjs";
39
- import { EventServiceCometD } from "./cometd/EventServiceCometD.js";
38
+ import { type CometDLoader, EventServiceCometD } from "./cometd/EventServiceCometD.js";
40
39
  import type {
41
40
  EventService,
42
41
  SubscriptionId,
@@ -49,36 +48,6 @@ export interface PresenceSubscriptionOptions {
49
48
  readonly ignoreSelfUpdates?: boolean;
50
49
  }
51
50
 
52
- export interface PresencePublishMessageCustom {
53
- readonly type: "custom";
54
- readonly custom: {
55
- userId: UserId;
56
- clientId: ClientId;
57
- eventData: any;
58
- };
59
- }
60
-
61
- export type PresencePublishMessage = PresencePublishMessageCustom;
62
-
63
- export interface DocumentPresenceChangeEvent {
64
- readonly type: "presenceChangeEvent";
65
- readonly presenceChangeEvent: {
66
- readonly userId: UserId;
67
- readonly status: "PRESENT" | "NOT_PRESENT";
68
- };
69
- }
70
-
71
- export interface CustomPresenceEvent {
72
- readonly type: "customPresenceEvent";
73
- readonly customPresenceEvent: {
74
- readonly userId: UserId;
75
- readonly clientId: ClientId;
76
- readonly eventData: any;
77
- };
78
- }
79
-
80
- export type PresenceCollaborativeUpdate = DocumentPresenceChangeEvent | CustomPresenceEvent;
81
-
82
51
  // TODO: presence api should have an eventType so we don't need extra wrapper here.
83
52
  interface PresencePublishMessageData {
84
53
  readonly eventType: string; // model name
@@ -130,16 +99,43 @@ interface SyncSessionInternal extends SyncSession {
130
99
  * This manages event subscriptions and publishing of document related events via
131
100
  * our PACK Foundry backend's cometd service.
132
101
  */
133
- export class FoundryEventService {
102
+ export interface FoundryEventService {
103
+ publishCustomPresence(
104
+ documentId: DocumentId,
105
+ eventType: string,
106
+ eventData: unknown,
107
+ ): Promise<void>;
108
+
109
+ startDocumentSync(
110
+ documentId: DocumentId,
111
+ yDoc: y.Doc,
112
+ onStatusChange: (status: Partial<DocumentSyncStatus>) => void,
113
+ ): SyncSession;
114
+
115
+ stopDocumentSync(session: SyncSession): void;
116
+
117
+ subscribeToActivityUpdates(
118
+ documentId: DocumentId,
119
+ callback: (event: ActivityCollaborativeUpdate) => void,
120
+ ): Promise<SubscriptionId>;
121
+
122
+ subscribeToPresenceUpdates(
123
+ documentId: DocumentId,
124
+ callback: (update: PresenceCollaborativeUpdate) => void,
125
+ options?: PresenceSubscriptionOptions,
126
+ ): Promise<SubscriptionId>;
127
+ }
128
+
129
+ class FoundryEventServiceImpl implements FoundryEventService {
134
130
  private readonly eventService: EventService;
135
131
  private readonly logger: Logger;
136
132
  private readonly sessions = new Map<string, SyncSessionInternal>();
137
133
 
138
134
  constructor(
139
135
  private readonly app: PackAppInternal,
140
- cometd?: CometD,
136
+ cometdLoader?: CometDLoader,
141
137
  ) {
142
- this.eventService = new EventServiceCometD(app, cometd);
138
+ this.eventService = new EventServiceCometD(app, cometdLoader);
143
139
  this.logger = app.config.logger.child({}, {
144
140
  level: "debug",
145
141
  msgPrefix: "FoundryEventService",
@@ -311,12 +307,12 @@ export class FoundryEventService {
311
307
  if (ignoreSelfUpdates && localUserId != null) {
312
308
  switch (update.type) {
313
309
  case "presenceChangeEvent":
314
- if (update.presenceChangeEvent.userId === localUserId) {
310
+ if (update.userId === localUserId) {
315
311
  return;
316
312
  }
317
313
  break;
318
314
  case "customPresenceEvent":
319
- if (update.customPresenceEvent.userId === localUserId) {
315
+ if (update.userId === localUserId) {
320
316
  return;
321
317
  }
322
318
  break;
@@ -363,12 +359,10 @@ export class FoundryEventService {
363
359
  }
364
360
 
365
361
  return this.eventService.publish(channelId, {
366
- custom: {
367
- clientId: session.clientId,
368
- eventData: messageData,
369
- // FIXME: why do we have to send this, we are authenticated
370
- userId,
371
- },
362
+ clientId: session.clientId,
363
+ eventData: messageData,
364
+ // FIXME: why do we have to send this, we are authenticated
365
+ userId,
372
366
  type: "custom",
373
367
  }).catch((error: unknown) => {
374
368
  this.logger.error("Failed to publish custom presence", error, {
@@ -492,9 +486,10 @@ export class FoundryEventService {
492
486
 
493
487
  export function createFoundryEventService(
494
488
  app: PackAppInternal,
495
- cometd?: CometD,
489
+ /** @internal */
490
+ cometdLoader?: CometDLoader,
496
491
  ): FoundryEventService {
497
- return new FoundryEventService(app, cometd);
492
+ return new FoundryEventServiceImpl(app, cometdLoader);
498
493
  }
499
494
 
500
495
  function isEditDescription(obj: unknown): obj is EditDescription {
@@ -19,6 +19,7 @@ import type { Callback, CometD, ListenerHandle, SubscriptionHandle } from "comet
19
19
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
20
  import type { MockProxy } from "vitest-mock-extended";
21
21
  import { mock } from "vitest-mock-extended";
22
+ import type { CometDLoader } from "../cometd/EventServiceCometD.js";
22
23
  import { EventServiceCometD } from "../cometd/EventServiceCometD.js";
23
24
  import type { TypedReceiveChannelId } from "../types/EventService.js";
24
25
 
@@ -88,7 +89,20 @@ describe("EventServiceCometD Reconnection Handling", () => {
88
89
  callback();
89
90
  });
90
91
 
91
- service = new EventServiceCometD(mockApp, mockCometD);
92
+ const mockLoader = () => {
93
+ class MockAckExtension {}
94
+ class MockCometD {
95
+ constructor() {
96
+ return mockCometD;
97
+ }
98
+ }
99
+ return Promise.resolve({
100
+ AckExtension: MockAckExtension,
101
+ CometD: MockCometD,
102
+ });
103
+ };
104
+
105
+ service = new EventServiceCometD(mockApp, mockLoader as CometDLoader);
92
106
  });
93
107
 
94
108
  afterEach(() => {
@@ -17,8 +17,7 @@
17
17
  import type { Logger } from "@osdk/api";
18
18
  import { getAuthModule } from "@palantir/pack.auth";
19
19
  import type { AppConfig, PackAppInternal } from "@palantir/pack.core";
20
- import type { Message, SubscriptionHandle } from "cometd";
21
- import { AckExtension, CometD } from "cometd";
20
+ import type { AckExtension, CometD, Message, SubscriptionHandle } from "cometd";
22
21
  import type {
23
22
  EventService,
24
23
  EventServiceLogLevel,
@@ -26,9 +25,7 @@ import type {
26
25
  TypedPublishChannelId,
27
26
  TypedReceiveChannelId,
28
27
  } from "../types/EventService.js";
29
-
30
- // side-effect shenanigans imports the class impl to cometd module
31
- import "cometd/AckExtension.js";
28
+ import { lazyLoadCometD } from "./lazyLoadCometD.js";
32
29
 
33
30
  const BEARER_TOKEN_FIELD = "bearer-token";
34
31
  const EXTENSION_ACK = "AckExtension";
@@ -42,24 +39,26 @@ interface Subscription {
42
39
  readonly getSubscriptionRequest?: () => object;
43
40
  }
44
41
 
42
+ export interface CometDLoader {
43
+ (): Promise<{
44
+ AckExtension: new() => AckExtension;
45
+ CometD: new() => CometD;
46
+ }>;
47
+ }
48
+
45
49
  export class EventServiceCometD implements EventService {
46
50
  private readonly logger: Logger;
47
51
  private initializePromise: Promise<void> | undefined;
48
52
  private readonly subscriptionById: Map<SubscriptionId, Subscription> = new Map();
49
53
  private readonly tokenExtension = new TokenExtension();
50
54
  private nextSubscriptionHandle = 0;
55
+ private cometd?: CometD;
51
56
 
52
57
  constructor(
53
58
  private readonly app: PackAppInternal,
54
- private readonly cometd: CometD = new CometD(),
59
+ private readonly cometdLoader: CometDLoader = lazyLoadCometD,
55
60
  ) {
56
61
  this.logger = app.config.logger.child({}, { msgPrefix: "EventServiceCometD" });
57
- this.configureCometd();
58
- this.cometd.registerExtension(EXTENSION_ACK, new AckExtension());
59
- this.cometd.registerExtension(EXTENSION_HANDSHAKE_TOKEN, this.tokenExtension);
60
-
61
- // TODO: Support binary messages
62
- // this.cometd.registerExtension(BINARY_EXTENSION_NAME, new BinaryExtension());
63
62
 
64
63
  // Any time the token changes, update the extension so reconnection requests use the new token.
65
64
  // This will also be called on initial token set when the auth module is initialized.
@@ -68,44 +67,58 @@ export class EventServiceCometD implements EventService {
68
67
  });
69
68
  }
70
69
 
71
- private initialize(): Promise<void> {
70
+ private async initialize(): Promise<void> {
72
71
  if (this.initializePromise != null) {
73
72
  return this.initializePromise;
74
73
  }
75
74
 
76
- this.initializePromise = new Promise<void>(resolve => {
77
- this.cometd.addListener(
78
- META_CHANNEL_HANDSHAKE,
79
- ({ clientId, connectionType, error, successful }) => {
80
- if (successful) {
81
- this.logger.info("CometD handshake successful", { clientId, connectionType });
82
- resolve();
83
-
84
- this.cometd.batch(() => {
85
- this.subscriptionById.forEach((subscription, subscriptionId) => {
86
- this.logger.debug("Resubscribing to channel", {
87
- channel: subscription.eventChannel,
88
- subscriptionId,
89
- });
90
- const subscribeProps = subscription.getSubscriptionRequest?.();
91
- subscription.handle = this.cometd.resubscribe(subscription.handle, {
92
- ext: subscribeProps,
75
+ this.initializePromise = (async () => {
76
+ if (this.cometd == null) {
77
+ const { AckExtension, CometD } = await this.cometdLoader();
78
+ this.cometd = new CometD();
79
+ this.configureCometd();
80
+
81
+ this.cometd.registerExtension(EXTENSION_ACK, new AckExtension());
82
+ this.cometd.registerExtension(EXTENSION_HANDSHAKE_TOKEN, this.tokenExtension);
83
+
84
+ // TODO: Support binary messages
85
+ // this.cometd.registerExtension(BINARY_EXTENSION_NAME, new BinaryExtension());
86
+ }
87
+
88
+ await new Promise<void>(resolve => {
89
+ this.cometd!.addListener(
90
+ META_CHANNEL_HANDSHAKE,
91
+ ({ clientId, connectionType, error, successful }) => {
92
+ if (successful) {
93
+ this.logger.info("CometD handshake successful", { clientId, connectionType });
94
+ resolve();
95
+
96
+ this.cometd!.batch(() => {
97
+ this.subscriptionById.forEach((subscription, subscriptionId) => {
98
+ this.logger.debug("Resubscribing to channel", {
99
+ channel: subscription.eventChannel,
100
+ subscriptionId,
101
+ });
102
+ const subscribeProps = subscription.getSubscriptionRequest?.();
103
+ subscription.handle = this.cometd!.resubscribe(subscription.handle, {
104
+ ext: subscribeProps,
105
+ });
93
106
  });
94
107
  });
95
- });
96
- } else {
97
- this.logger.warn("CometD handshake failed", { clientId, error });
98
- }
99
- },
100
- );
101
-
102
- const authModule = getAuthModule(this.app);
103
- void authModule.getToken().then(token => {
104
- this.logger.info("Initializing CometD with token");
105
- this.tokenExtension.setToken(token);
106
- this.cometd.handshake();
108
+ } else {
109
+ this.logger.warn("CometD handshake failed", { clientId, error });
110
+ }
111
+ },
112
+ );
113
+
114
+ const authModule = getAuthModule(this.app);
115
+ void authModule.getToken().then(token => {
116
+ this.logger.info("Initializing CometD with token");
117
+ this.tokenExtension.setToken(token);
118
+ this.cometd!.handshake();
119
+ });
107
120
  });
108
- });
121
+ })();
109
122
 
110
123
  return this.initializePromise;
111
124
  }
@@ -116,6 +129,11 @@ export class EventServiceCometD implements EventService {
116
129
  getSubscriptionRequest?: () => object,
117
130
  ): Promise<SubscriptionId> {
118
131
  await this.initialize();
132
+
133
+ if (this.cometd == null) {
134
+ throw new Error("CometD not initialized");
135
+ }
136
+
119
137
  const subscriptionId = (this.nextSubscriptionHandle++).toString(10) as SubscriptionId;
120
138
 
121
139
  return new Promise<SubscriptionId>(
@@ -172,13 +190,13 @@ export class EventServiceCometD implements EventService {
172
190
  const ext = getSubscriptionRequest?.();
173
191
 
174
192
  const subscriptionHandle = ext != null
175
- ? this.cometd.subscribe(
193
+ ? this.cometd!.subscribe(
176
194
  channel,
177
195
  messageHandler,
178
196
  { ext },
179
197
  subscribeCallback,
180
198
  )
181
- : this.cometd.subscribe(
199
+ : this.cometd!.subscribe(
182
200
  channel,
183
201
  messageHandler,
184
202
  subscribeCallback,
@@ -208,6 +226,11 @@ export class EventServiceCometD implements EventService {
208
226
 
209
227
  this.subscriptionById.delete(subscriptionId);
210
228
 
229
+ if (this.cometd == null) {
230
+ this.logger.warn("CometD not initialized, cannot unsubscribe", { subscriptionId });
231
+ return;
232
+ }
233
+
211
234
  this.cometd.unsubscribe(
212
235
  handle,
213
236
  message => {
@@ -227,8 +250,13 @@ export class EventServiceCometD implements EventService {
227
250
  content: T,
228
251
  ): Promise<void> {
229
252
  await this.initialize();
253
+
254
+ if (this.cometd == null) {
255
+ throw new Error("CometD not initialized");
256
+ }
257
+
230
258
  return new Promise<void>((resolve, reject) => {
231
- this.cometd.publish(channel, content, message => {
259
+ this.cometd!.publish(channel, content, message => {
232
260
  if (message.successful) {
233
261
  this.logger.debug("Successfully published message", channel, { content });
234
262
  resolve();
@@ -249,6 +277,10 @@ export class EventServiceCometD implements EventService {
249
277
  }
250
278
 
251
279
  private configureCometd(logLevel?: EventServiceLogLevel) {
280
+ if (this.cometd == null) {
281
+ return;
282
+ }
283
+
252
284
  const url = getCometDWebsocketUrl(this.app.config);
253
285
  this.logger.info("Configuring cometD ", { url });
254
286
  this.cometd.configure({
@@ -0,0 +1,34 @@
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 * as cometd from "cometd";
18
+
19
+ export async function lazyLoadCometD(): Promise<typeof cometd> {
20
+ const cometdModule = await import("cometd");
21
+ // cometd module does declare the extensions, however they are added to the module as side-effects
22
+ // by importing the submodule extension files. So for any we use we'll need to import them explicitly.
23
+ // Note that this file should be included in package.json "sideEffects" to avoid tree-shaking.
24
+
25
+ // TODO: switch back to dynamic import with side-effect loading when supported by new cometD version. Requires
26
+ // backend version upgrade first.
27
+ // AckExtension.js modifies the cometd module as a side-effect to add AckExtension.
28
+ // We need to capture the returned constructor because with ESM bundlers, the module reference
29
+ // may not reflect the mutation.
30
+ // @ts-expect-error TS2307: No type declarations for cometd/AckExtension.js, but its default export is the AckExtension constructor
31
+ const { default: AckExtension } = await import("cometd/AckExtension.js");
32
+
33
+ return { ...cometdModule, AckExtension };
34
+ }
package/src/index.ts CHANGED
@@ -14,22 +14,9 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- export { EventServiceCometD } from "./cometd/EventServiceCometD.js";
18
- export { createFoundryEventService, FoundryEventService } from "./FoundryEventService.js";
17
+ export { createFoundryEventService } from "./FoundryEventService.js";
19
18
  export type {
20
- CustomPresenceEvent,
21
- DocumentPresenceChangeEvent,
22
- PresenceCollaborativeUpdate,
23
- PresencePublishMessage,
24
- PresencePublishMessageCustom,
19
+ FoundryEventService,
25
20
  PresenceSubscriptionOptions,
26
21
  SyncSession,
27
22
  } from "./FoundryEventService.js";
28
- export type {
29
- ChannelId,
30
- EventService,
31
- EventServiceLogLevel,
32
- SubscriptionId,
33
- TypedPublishChannelId,
34
- TypedReceiveChannelId,
35
- } from "./types/EventService.js";