@knocklabs/client 0.14.4 → 0.14.6

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cjs/clients/feed/feed.js +1 -1
  3. package/dist/cjs/clients/feed/feed.js.map +1 -1
  4. package/dist/cjs/clients/feed/index.js +1 -1
  5. package/dist/cjs/clients/feed/index.js.map +1 -1
  6. package/dist/cjs/clients/feed/socket-manager.js +2 -0
  7. package/dist/cjs/clients/feed/socket-manager.js.map +1 -0
  8. package/dist/cjs/clients/guide/client.js +1 -1
  9. package/dist/cjs/clients/guide/client.js.map +1 -1
  10. package/dist/esm/clients/feed/feed.mjs +117 -102
  11. package/dist/esm/clients/feed/feed.mjs.map +1 -1
  12. package/dist/esm/clients/feed/index.mjs +31 -15
  13. package/dist/esm/clients/feed/index.mjs.map +1 -1
  14. package/dist/esm/clients/feed/socket-manager.mjs +81 -0
  15. package/dist/esm/clients/feed/socket-manager.mjs.map +1 -0
  16. package/dist/esm/clients/guide/client.mjs +123 -65
  17. package/dist/esm/clients/guide/client.mjs.map +1 -1
  18. package/dist/types/clients/feed/feed.d.ts +11 -6
  19. package/dist/types/clients/feed/feed.d.ts.map +1 -1
  20. package/dist/types/clients/feed/index.d.ts +2 -0
  21. package/dist/types/clients/feed/index.d.ts.map +1 -1
  22. package/dist/types/clients/feed/socket-manager.d.ts +31 -0
  23. package/dist/types/clients/feed/socket-manager.d.ts.map +1 -0
  24. package/dist/types/clients/feed/types.d.ts +4 -3
  25. package/dist/types/clients/feed/types.d.ts.map +1 -1
  26. package/dist/types/clients/guide/client.d.ts +22 -1
  27. package/dist/types/clients/guide/client.d.ts.map +1 -1
  28. package/package.json +5 -3
  29. package/src/clients/feed/feed.ts +51 -41
  30. package/src/clients/feed/index.ts +27 -3
  31. package/src/clients/feed/socket-manager.ts +185 -0
  32. package/src/clients/feed/types.ts +5 -3
  33. package/src/clients/guide/client.ts +146 -2
@@ -0,0 +1,185 @@
1
+ import { Store } from "@tanstack/store";
2
+ import { Channel, Socket } from "phoenix";
3
+
4
+ import Feed from "./feed";
5
+ import type { FeedClientOptions, FeedMetadata } from "./interfaces";
6
+
7
+ export const SocketEventType = {
8
+ NewMessage: "new-message",
9
+ } as const;
10
+
11
+ const SOCKET_EVENT_TYPES = [SocketEventType.NewMessage];
12
+
13
+ type ClientQueryParams = FeedClientOptions;
14
+
15
+ // e.g. feeds:<channel_id>:<user_id>
16
+ type ChannelTopic = string;
17
+
18
+ // Unique reference id of a feed client
19
+ type ClientReferenceId = string;
20
+
21
+ type NewMessageEventPayload = {
22
+ event: typeof SocketEventType.NewMessage;
23
+ /**
24
+ * @deprecated Top-level feed metadata. Exists for legacy reasons.
25
+ */
26
+ metadata: FeedMetadata;
27
+ /** Feed metadata, keyed by client reference id. */
28
+ data: Record<ClientReferenceId, { metadata: FeedMetadata }>;
29
+ };
30
+
31
+ export type SocketEventPayload = NewMessageEventPayload;
32
+
33
+ // "attn" field contains a list of client reference ids that should be notified
34
+ // of a socket event.
35
+ type WithAttn<P> = P & { attn: ClientReferenceId[] };
36
+
37
+ type FeedSocketInbox = Record<ClientReferenceId, SocketEventPayload>;
38
+
39
+ /*
40
+ * Manages socket subscriptions for feeds, allowing multiple feed clients
41
+ * to listen for real time updates from the socket API via a single socket
42
+ * connection.
43
+ */
44
+ export class FeedSocketManager {
45
+ // Mapping of live channels by topic. Note, there can be one or more feed
46
+ // client(s) that can subscribe.
47
+ private channels: Record<ChannelTopic, Channel>;
48
+
49
+ // Mapping of query params for each feeds client, partitioned by reference id,
50
+ // and grouped by channel topic. It's a double nested object that looks like:
51
+ // {
52
+ // "feeds:<channel_1>:<user_1>": {
53
+ // "ref-1": {
54
+ // "tenant": "foo",
55
+ // },
56
+ // "ref-2": {
57
+ // "tenant": "bar",
58
+ // },
59
+ // },
60
+ // "feeds:<channel_2>:<user_1>": {
61
+ // "ref-3": {
62
+ // "tenant": "baz",
63
+ // },
64
+ // }
65
+ // }
66
+ //
67
+ // Each time a new feed client joins a channel, we send all cumulated
68
+ // params such that the socket API can apply filtering rules and figure out
69
+ // which feed clients should be notified based on reference ids in
70
+ // "attn" field of the event payload when sending out an event.
71
+ private params: Record<
72
+ ChannelTopic,
73
+ Record<ClientReferenceId, ClientQueryParams>
74
+ >;
75
+
76
+ // A reactive store that captures a new socket event, that notifies any feed
77
+ // clients that have subscribed.
78
+ private inbox: Store<
79
+ FeedSocketInbox,
80
+ (cb: FeedSocketInbox) => FeedSocketInbox
81
+ >;
82
+
83
+ constructor(readonly socket: Socket) {
84
+ this.channels = {};
85
+ this.params = {};
86
+ this.inbox = new Store<FeedSocketInbox>({});
87
+ }
88
+
89
+ join(feed: Feed) {
90
+ const topic = feed.socketChannelTopic;
91
+ const referenceId = feed.referenceId;
92
+ const params = feed.defaultOptions;
93
+
94
+ // Ensure a live socket connection if not yet connected.
95
+ if (!this.socket.isConnected()) {
96
+ this.socket.connect();
97
+ }
98
+
99
+ // If a new feed client joins, or has updated query params, then
100
+ // track the updated params and (re)join with the latest query params.
101
+ // Note, each time we send combined params of all feed clients that
102
+ // have subscribed for a given feed channel and user, grouped by
103
+ // client's reference id.
104
+ if (!this.params[topic]) {
105
+ this.params[topic] = {};
106
+ }
107
+
108
+ const maybeParams = this.params[topic][referenceId];
109
+ const hasNewOrUpdatedParams =
110
+ !maybeParams || JSON.stringify(maybeParams) !== JSON.stringify(params);
111
+
112
+ if (hasNewOrUpdatedParams) {
113
+ // Tracks all subscribed clients' params by reference id and by topic.
114
+ this.params[topic] = { ...this.params[topic], [referenceId]: params };
115
+ }
116
+
117
+ if (!this.channels[topic] || hasNewOrUpdatedParams) {
118
+ const newChannel = this.socket.channel(topic, this.params[topic]);
119
+ for (const eventType of SOCKET_EVENT_TYPES) {
120
+ newChannel.on(eventType, (payload) => this.setInbox(payload));
121
+ }
122
+ // Tracks live channels by channel topic.
123
+ this.channels[topic] = newChannel;
124
+ }
125
+
126
+ const channel = this.channels[topic];
127
+
128
+ // Join the channel if not already joined or joining or leaving.
129
+ if (["closed", "errored"].includes(channel.state)) {
130
+ channel.join();
131
+ }
132
+
133
+ // Let the feed client subscribe to the "inbox", so it can be notified
134
+ // when there's a new socket event that is relevant to it
135
+ const unsub = this.inbox.subscribe(() => {
136
+ const payload = this.inbox.state[referenceId];
137
+ if (!payload) return;
138
+
139
+ feed.handleSocketEvent(payload);
140
+ });
141
+
142
+ return unsub;
143
+ }
144
+
145
+ leave(feed: Feed) {
146
+ feed.unsubscribeFromSocketEvents?.();
147
+
148
+ const topic = feed.socketChannelTopic;
149
+ const referenceId = feed.referenceId;
150
+
151
+ const partitionedParams = { ...this.params };
152
+ const paramsForTopic = partitionedParams[topic] || {};
153
+ const paramsForReferenceClient = paramsForTopic[referenceId];
154
+
155
+ if (paramsForReferenceClient) {
156
+ delete paramsForTopic[referenceId];
157
+ }
158
+
159
+ const channels = { ...this.channels };
160
+ const channelForTopic = channels[topic];
161
+ if (channelForTopic && Object.keys(paramsForTopic).length === 0) {
162
+ for (const eventType of SOCKET_EVENT_TYPES) {
163
+ channelForTopic.off(eventType);
164
+ }
165
+ channelForTopic.leave();
166
+ delete channels[topic];
167
+ }
168
+
169
+ this.params = partitionedParams;
170
+ this.channels = channels;
171
+ }
172
+
173
+ private setInbox(payload: WithAttn<SocketEventPayload>) {
174
+ const { attn, ...rest } = payload;
175
+
176
+ // Set the incoming socket event into the inbox, keyed by relevant client
177
+ // reference ids provided by the server (via attn field), so we can notify
178
+ // only the clients that need to be notified.
179
+ this.inbox.setState(() =>
180
+ attn.reduce((acc, referenceId) => {
181
+ return { ...acc, [referenceId]: rest };
182
+ }, {}),
183
+ );
184
+ }
185
+ }
@@ -3,6 +3,7 @@ import { GenericData, PageInfo } from "@knocklabs/types";
3
3
  import { NetworkStatus } from "../../networkStatus";
4
4
 
5
5
  import { FeedItem, FeedMetadata, FeedResponse } from "./interfaces";
6
+ import { SocketEventPayload, SocketEventType } from "./socket-manager";
6
7
 
7
8
  export type StoreFeedResultOptions = {
8
9
  shouldSetPage?: boolean;
@@ -22,9 +23,10 @@ export interface FeedStoreState {
22
23
  resetStore: (metadata?: FeedMetadata) => void;
23
24
  }
24
25
 
25
- export interface FeedMessagesReceivedPayload {
26
- metadata: FeedMetadata;
27
- }
26
+ export type FeedMessagesReceivedPayload = Extract<
27
+ SocketEventPayload,
28
+ { event: typeof SocketEventType.NewMessage }
29
+ >;
28
30
 
29
31
  /*
30
32
  Event types:
@@ -1,6 +1,7 @@
1
1
  import { GenericData } from "@knocklabs/types";
2
2
  import { Store } from "@tanstack/store";
3
3
  import { Channel, Socket } from "phoenix";
4
+ import { URLPattern } from "urlpattern-polyfill";
4
5
 
5
6
  import Knock from "../../knock";
6
7
 
@@ -38,6 +39,11 @@ interface GuideStepData {
38
39
  content: any;
39
40
  }
40
41
 
42
+ interface GuideActivationLocationRuleData {
43
+ directive: "allow" | "block";
44
+ pathname: string;
45
+ }
46
+
41
47
  interface GuideData {
42
48
  __typename: "Guide";
43
49
  channel_id: string;
@@ -47,6 +53,7 @@ interface GuideData {
47
53
  type: string;
48
54
  semver: string;
49
55
  steps: GuideStepData[];
56
+ activation_location_rules: GuideActivationLocationRuleData[];
50
57
  inserted_at: string;
51
58
  updated_at: string;
52
59
  }
@@ -57,8 +64,14 @@ export interface KnockGuideStep extends GuideStepData {
57
64
  markAsArchived: () => void;
58
65
  }
59
66
 
67
+ interface KnockGuideActivationLocationRule
68
+ extends GuideActivationLocationRuleData {
69
+ pattern: URLPattern;
70
+ }
71
+
60
72
  export interface KnockGuide extends GuideData {
61
73
  steps: KnockGuideStep[];
74
+ activation_location_rules: KnockGuideActivationLocationRule[];
62
75
  }
63
76
 
64
77
  type GetGuidesQueryParams = {
@@ -133,6 +146,7 @@ type QueryStatus = {
133
146
  type StoreState = {
134
147
  guides: KnockGuide[];
135
148
  queries: Record<QueryKey, QueryStatus>;
149
+ location: string | undefined;
136
150
  };
137
151
 
138
152
  type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
@@ -147,6 +161,10 @@ export type TargetParams = {
147
161
  tenant?: string | undefined;
148
162
  };
149
163
 
164
+ type ConstructorOpts = {
165
+ trackLocationFromWindow?: boolean;
166
+ };
167
+
150
168
  export class KnockGuideClient {
151
169
  public store: Store<StoreState, (state: StoreState) => StoreState>;
152
170
 
@@ -156,14 +174,26 @@ export class KnockGuideClient {
156
174
  private socketChannelTopic: string;
157
175
  private socketEventTypes = ["guide.added", "guide.updated", "guide.removed"];
158
176
 
177
+ // Original history methods to monkey patch, or restore in cleanups.
178
+ private pushStateFn: History["pushState"] | undefined;
179
+ private replaceStateFn: History["replaceState"] | undefined;
180
+
159
181
  constructor(
160
182
  readonly knock: Knock,
161
183
  readonly channelId: string,
162
184
  readonly targetParams: TargetParams = {},
185
+ readonly options: ConstructorOpts = {},
163
186
  ) {
187
+ const { trackLocationFromWindow = true } = options;
188
+
189
+ const location = trackLocationFromWindow
190
+ ? window?.location.href
191
+ : undefined;
192
+
164
193
  this.store = new Store<StoreState>({
165
194
  guides: [],
166
195
  queries: {},
196
+ location,
167
197
  });
168
198
 
169
199
  // In server environments we might not have a socket connection.
@@ -171,9 +201,18 @@ export class KnockGuideClient {
171
201
  this.socket = maybeSocket;
172
202
  this.socketChannelTopic = `guides:${channelId}`;
173
203
 
204
+ if (trackLocationFromWindow) {
205
+ this.listenForLocationChangesFromWindow();
206
+ }
207
+
174
208
  this.knock.log("[Guide] Initialized a guide client");
175
209
  }
176
210
 
211
+ cleanup() {
212
+ this.unsubscribe();
213
+ this.removeEventListeners();
214
+ }
215
+
177
216
  async fetch(opts?: { filters?: QueryFilterParams }) {
178
217
  this.knock.failIfNotAuthenticated();
179
218
  this.knock.log("[Guide] Loading all eligible guides");
@@ -291,8 +330,6 @@ export class KnockGuideClient {
291
330
  //
292
331
 
293
332
  select(state: StoreState, filters: SelectFilterParams = {}) {
294
- // TODO(KNO-7790): Need to evaluate activation rules also.
295
-
296
333
  return state.guides.filter((guide) => {
297
334
  if (filters.type && filters.type !== guide.type) {
298
335
  return false;
@@ -302,6 +339,42 @@ export class KnockGuideClient {
302
339
  return false;
303
340
  }
304
341
 
342
+ const locationRules = guide.activation_location_rules || [];
343
+
344
+ if (locationRules.length > 0 && state.location) {
345
+ const allowed = locationRules.reduce<boolean | undefined>(
346
+ (acc, rule) => {
347
+ // Any matched block rule prevails so no need to evaluate further
348
+ // as soon as there is one.
349
+ if (acc === false) return false;
350
+
351
+ // At this point we either have a matched allow rule (acc is true),
352
+ // or no matched rule found yet (acc is undefined).
353
+
354
+ switch (rule.directive) {
355
+ case "allow": {
356
+ // No need to evaluate more allow rules once we matched one
357
+ // since any matched allowed rule means allow.
358
+ if (acc === true) return true;
359
+
360
+ const matched = rule.pattern.test(state.location);
361
+ return matched ? true : undefined;
362
+ }
363
+
364
+ case "block": {
365
+ // Always test block rules (unless already matched to block)
366
+ // because they'd prevail over matched allow rules.
367
+ const matched = rule.pattern.test(state.location);
368
+ return matched ? false : acc;
369
+ }
370
+ }
371
+ },
372
+ undefined,
373
+ );
374
+
375
+ if (!allowed) return false;
376
+ }
377
+
305
378
  return true;
306
379
  });
307
380
  }
@@ -427,6 +500,14 @@ export class KnockGuideClient {
427
500
  return localStep;
428
501
  });
429
502
 
503
+ localGuide.activation_location_rules =
504
+ remoteGuide.activation_location_rules.map((rule) => {
505
+ return {
506
+ ...rule,
507
+ pattern: new URLPattern({ pathname: rule.pathname }),
508
+ };
509
+ });
510
+
430
511
  return localGuide as KnockGuide;
431
512
  }
432
513
 
@@ -538,4 +619,67 @@ export class KnockGuideClient {
538
619
  return { ...state, guides };
539
620
  });
540
621
  }
622
+
623
+ private handleLocationChange() {
624
+ const href = window.location.href;
625
+ if (this.store.state.location === href) return;
626
+
627
+ this.knock.log(`[Guide] Handle Location change: ${href}`);
628
+
629
+ this.store.setState((state) => ({ ...state, location: href }));
630
+ }
631
+
632
+ private listenForLocationChangesFromWindow() {
633
+ if (window?.history) {
634
+ // 1. Listen for browser back/forward button clicks.
635
+ window.addEventListener("popstate", this.handleLocationChange);
636
+
637
+ // 2. Listen for hash changes in case it's used for routing.
638
+ window.addEventListener("hashchange", this.handleLocationChange);
639
+
640
+ // 3. Monkey-patch history methods to catch programmatic navigation.
641
+ const pushStateFn = window.history.pushState;
642
+ const replaceStateFn = window.history.replaceState;
643
+
644
+ // Use setTimeout to allow the browser state to potentially settle.
645
+ window.history.pushState = new Proxy(pushStateFn, {
646
+ apply: (target, history, args) => {
647
+ Reflect.apply(target, history, args);
648
+ setTimeout(() => {
649
+ this.handleLocationChange();
650
+ }, 0);
651
+ },
652
+ });
653
+ window.history.replaceState = new Proxy(replaceStateFn, {
654
+ apply: (target, history, args) => {
655
+ Reflect.apply(target, history, args);
656
+ setTimeout(() => {
657
+ this.handleLocationChange();
658
+ }, 0);
659
+ },
660
+ });
661
+
662
+ // 4. Keep refs to the original handlers so we can restore during cleanup.
663
+ this.pushStateFn = pushStateFn;
664
+ this.replaceStateFn = replaceStateFn;
665
+ } else {
666
+ this.knock.log(
667
+ "[Guide] Unable to access the `window.history` object to detect location changes",
668
+ );
669
+ }
670
+ }
671
+
672
+ private removeEventListeners() {
673
+ window.removeEventListener("popstate", this.handleLocationChange);
674
+ window.removeEventListener("hashchange", this.handleLocationChange);
675
+
676
+ if (this.pushStateFn) {
677
+ window.history.pushState = this.pushStateFn;
678
+ this.pushStateFn = undefined;
679
+ }
680
+ if (this.replaceStateFn) {
681
+ window.history.replaceState = this.replaceStateFn;
682
+ this.replaceStateFn = undefined;
683
+ }
684
+ }
541
685
  }