@knocklabs/client 0.21.1 → 0.21.2
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/CHANGELOG.md +8 -0
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/clients/feed/feed.js +1 -1
- package/dist/cjs/clients/feed/feed.js.map +1 -1
- package/dist/cjs/clients/guide/client.js +1 -1
- package/dist/cjs/clients/guide/client.js.map +1 -1
- package/dist/cjs/clients/guide/helpers.js +1 -1
- package/dist/cjs/clients/guide/helpers.js.map +1 -1
- package/dist/cjs/clients/guide/types.js +2 -0
- package/dist/cjs/clients/guide/types.js.map +1 -0
- package/dist/cjs/helpers.js +1 -1
- package/dist/cjs/helpers.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/knock.js +2 -2
- package/dist/cjs/knock.js.map +1 -1
- package/dist/cjs/pageVisibility.js +2 -0
- package/dist/cjs/pageVisibility.js.map +1 -0
- package/dist/esm/api.mjs +27 -12
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/clients/feed/feed.mjs +60 -87
- package/dist/esm/clients/feed/feed.mjs.map +1 -1
- package/dist/esm/clients/guide/client.mjs +191 -147
- package/dist/esm/clients/guide/client.mjs.map +1 -1
- package/dist/esm/clients/guide/helpers.mjs +34 -44
- package/dist/esm/clients/guide/helpers.mjs.map +1 -1
- package/dist/esm/clients/guide/types.mjs +13 -0
- package/dist/esm/clients/guide/types.mjs.map +1 -0
- package/dist/esm/helpers.mjs +19 -4
- package/dist/esm/helpers.mjs.map +1 -1
- package/dist/esm/index.mjs +14 -12
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/knock.mjs +31 -29
- package/dist/esm/knock.mjs.map +1 -1
- package/dist/esm/pageVisibility.mjs +31 -0
- package/dist/esm/pageVisibility.mjs.map +1 -0
- package/dist/types/api.d.ts +4 -0
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/clients/feed/feed.d.ts +1 -11
- package/dist/types/clients/feed/feed.d.ts.map +1 -1
- package/dist/types/clients/feed/interfaces.d.ts +0 -4
- package/dist/types/clients/feed/interfaces.d.ts.map +1 -1
- package/dist/types/clients/feed/utils.d.ts +0 -2
- package/dist/types/clients/feed/utils.d.ts.map +1 -1
- package/dist/types/clients/guide/client.d.ts +3 -1
- package/dist/types/clients/guide/client.d.ts.map +1 -1
- package/dist/types/clients/guide/helpers.d.ts +1 -7
- package/dist/types/clients/guide/helpers.d.ts.map +1 -1
- package/dist/types/clients/guide/index.d.ts +2 -1
- package/dist/types/clients/guide/index.d.ts.map +1 -1
- package/dist/types/clients/guide/types.d.ts +21 -0
- package/dist/types/clients/guide/types.d.ts.map +1 -1
- package/dist/types/helpers.d.ts +19 -0
- package/dist/types/helpers.d.ts.map +1 -1
- package/dist/types/interfaces.d.ts +2 -0
- package/dist/types/interfaces.d.ts.map +1 -1
- package/dist/types/knock.d.ts +1 -0
- package/dist/types/knock.d.ts.map +1 -1
- package/dist/types/pageVisibility.d.ts +22 -0
- package/dist/types/pageVisibility.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/api.ts +30 -0
- package/src/clients/feed/feed.ts +0 -73
- package/src/clients/feed/interfaces.ts +0 -7
- package/src/clients/guide/client.ts +117 -32
- package/src/clients/guide/helpers.ts +0 -12
- package/src/clients/guide/index.ts +3 -0
- package/src/clients/guide/types.ts +34 -0
- package/src/helpers.ts +39 -0
- package/src/interfaces.ts +2 -0
- package/src/knock.ts +4 -3
- package/src/pageVisibility.ts +70 -0
package/src/api.ts
CHANGED
|
@@ -2,11 +2,16 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios";
|
|
|
2
2
|
import axiosRetry from "axios-retry";
|
|
3
3
|
import { Socket } from "phoenix";
|
|
4
4
|
|
|
5
|
+
import { exponentialBackoffFullJitter } from "./helpers";
|
|
6
|
+
import { PageVisibilityManager } from "./pageVisibility";
|
|
7
|
+
|
|
5
8
|
type ApiClientOptions = {
|
|
6
9
|
host: string;
|
|
7
10
|
apiKey: string;
|
|
8
11
|
userToken: string | undefined;
|
|
9
12
|
branch?: string;
|
|
13
|
+
/** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
|
|
14
|
+
disconnectOnPageHidden?: boolean;
|
|
10
15
|
};
|
|
11
16
|
|
|
12
17
|
export interface ApiResponse {
|
|
@@ -26,6 +31,7 @@ class ApiClient {
|
|
|
26
31
|
private axiosClient: AxiosInstance;
|
|
27
32
|
|
|
28
33
|
public socket: Socket | undefined;
|
|
34
|
+
private pageVisibility: PageVisibilityManager | undefined;
|
|
29
35
|
|
|
30
36
|
constructor(options: ApiClientOptions) {
|
|
31
37
|
this.host = options.host;
|
|
@@ -53,7 +59,23 @@ class ApiClient {
|
|
|
53
59
|
api_key: this.apiKey,
|
|
54
60
|
branch_slug: this.branch,
|
|
55
61
|
},
|
|
62
|
+
reconnectAfterMs: (tries: number) => {
|
|
63
|
+
return exponentialBackoffFullJitter(tries, {
|
|
64
|
+
baseDelayMs: 1000,
|
|
65
|
+
maxDelayMs: 30_000,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
rejoinAfterMs: (tries: number) => {
|
|
69
|
+
return exponentialBackoffFullJitter(tries, {
|
|
70
|
+
baseDelayMs: 1000,
|
|
71
|
+
maxDelayMs: 60_000,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
56
74
|
});
|
|
75
|
+
|
|
76
|
+
if (options.disconnectOnPageHidden !== false) {
|
|
77
|
+
this.pageVisibility = new PageVisibilityManager(this.socket);
|
|
78
|
+
}
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
axiosRetry(this.axiosClient, {
|
|
@@ -87,6 +109,14 @@ class ApiClient {
|
|
|
87
109
|
}
|
|
88
110
|
}
|
|
89
111
|
|
|
112
|
+
teardown() {
|
|
113
|
+
this.pageVisibility?.teardown();
|
|
114
|
+
|
|
115
|
+
if (this.socket?.isConnected()) {
|
|
116
|
+
this.socket.disconnect();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
private canRetryRequest(error: AxiosError) {
|
|
91
121
|
// Retry Network Errors.
|
|
92
122
|
if (axiosRetry.isNetworkError(error)) {
|
package/src/clients/feed/feed.ts
CHANGED
|
@@ -41,8 +41,6 @@ const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
|
|
|
41
41
|
mode: "compact",
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
const DEFAULT_DISCONNECT_DELAY = 2000;
|
|
45
|
-
|
|
46
44
|
const CLIENT_REF_ID_PREFIX = "client_";
|
|
47
45
|
|
|
48
46
|
class Feed {
|
|
@@ -53,10 +51,7 @@ class Feed {
|
|
|
53
51
|
private userFeedId: string;
|
|
54
52
|
private broadcaster: EventEmitter;
|
|
55
53
|
private broadcastChannel!: BroadcastChannel | null;
|
|
56
|
-
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
57
54
|
private hasSubscribedToRealTimeUpdates: boolean = false;
|
|
58
|
-
private visibilityChangeHandler: () => void = () => {};
|
|
59
|
-
private visibilityChangeListenerConnected: boolean = false;
|
|
60
55
|
|
|
61
56
|
// The raw store instance, used for binding in React and other environments
|
|
62
57
|
public store: FeedStore;
|
|
@@ -117,13 +112,6 @@ class Feed {
|
|
|
117
112
|
|
|
118
113
|
this.socketManager?.leave(this);
|
|
119
114
|
|
|
120
|
-
this.tearDownVisibilityListeners();
|
|
121
|
-
|
|
122
|
-
if (this.disconnectTimer) {
|
|
123
|
-
clearTimeout(this.disconnectTimer);
|
|
124
|
-
this.disconnectTimer = null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
115
|
if (this.broadcastChannel) {
|
|
128
116
|
this.broadcastChannel.close();
|
|
129
117
|
}
|
|
@@ -553,8 +541,6 @@ class Feed {
|
|
|
553
541
|
__loadingType: undefined,
|
|
554
542
|
__fetchSource: undefined,
|
|
555
543
|
__experimentalCrossBrowserUpdates: undefined,
|
|
556
|
-
auto_manage_socket_connection: undefined,
|
|
557
|
-
auto_manage_socket_connection_delay: undefined,
|
|
558
544
|
};
|
|
559
545
|
|
|
560
546
|
const result = await this.knock.client().makeRequest({
|
|
@@ -815,10 +801,6 @@ class Feed {
|
|
|
815
801
|
// In server environments we might not have a socket connection
|
|
816
802
|
if (!this.socketManager) return;
|
|
817
803
|
|
|
818
|
-
if (this.defaultOptions.auto_manage_socket_connection) {
|
|
819
|
-
this.setUpVisibilityListeners();
|
|
820
|
-
}
|
|
821
|
-
|
|
822
804
|
// If we're initializing but they have previously opted to listen to real-time updates
|
|
823
805
|
// then we will automatically reconnect on their behalf
|
|
824
806
|
if (this.hasSubscribedToRealTimeUpdates && this.knock.isAuthenticated()) {
|
|
@@ -838,33 +820,6 @@ class Feed {
|
|
|
838
820
|
}
|
|
839
821
|
}
|
|
840
822
|
|
|
841
|
-
/**
|
|
842
|
-
* Listen for changes to document visibility and automatically disconnect
|
|
843
|
-
* or reconnect the socket after a delay
|
|
844
|
-
*/
|
|
845
|
-
private setUpVisibilityListeners() {
|
|
846
|
-
if (
|
|
847
|
-
typeof document === "undefined" ||
|
|
848
|
-
this.visibilityChangeListenerConnected
|
|
849
|
-
) {
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
|
|
854
|
-
this.visibilityChangeListenerConnected = true;
|
|
855
|
-
document.addEventListener("visibilitychange", this.visibilityChangeHandler);
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
private tearDownVisibilityListeners() {
|
|
859
|
-
if (typeof document === "undefined") return;
|
|
860
|
-
|
|
861
|
-
document.removeEventListener(
|
|
862
|
-
"visibilitychange",
|
|
863
|
-
this.visibilityChangeHandler,
|
|
864
|
-
);
|
|
865
|
-
this.visibilityChangeListenerConnected = false;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
823
|
private emitEvent(
|
|
869
824
|
type:
|
|
870
825
|
| MessageEngagementStatus
|
|
@@ -882,34 +837,6 @@ class Feed {
|
|
|
882
837
|
// Internal events only need `items:`
|
|
883
838
|
this.broadcastOverChannel(`items:${type}`, { items });
|
|
884
839
|
}
|
|
885
|
-
|
|
886
|
-
private handleVisibilityChange() {
|
|
887
|
-
const disconnectDelay =
|
|
888
|
-
this.defaultOptions.auto_manage_socket_connection_delay ??
|
|
889
|
-
DEFAULT_DISCONNECT_DELAY;
|
|
890
|
-
|
|
891
|
-
const client = this.knock.client();
|
|
892
|
-
|
|
893
|
-
if (document.visibilityState === "hidden") {
|
|
894
|
-
// When the tab is hidden, clean up the socket connection after a delay
|
|
895
|
-
this.disconnectTimer = setTimeout(() => {
|
|
896
|
-
client.socket?.disconnect();
|
|
897
|
-
this.disconnectTimer = null;
|
|
898
|
-
}, disconnectDelay);
|
|
899
|
-
} else if (document.visibilityState === "visible") {
|
|
900
|
-
// When the tab is visible, clear the disconnect timer if active to cancel disconnecting
|
|
901
|
-
// This handles cases where the tab is only briefly hidden to avoid unnecessary disconnects
|
|
902
|
-
if (this.disconnectTimer) {
|
|
903
|
-
clearTimeout(this.disconnectTimer);
|
|
904
|
-
this.disconnectTimer = null;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// If the socket is not connected, try to reconnect
|
|
908
|
-
if (!client.socket?.isConnected()) {
|
|
909
|
-
client.socket?.connect();
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
840
|
}
|
|
914
841
|
|
|
915
842
|
export default Feed;
|
|
@@ -34,11 +34,6 @@ export interface FeedClientOptions {
|
|
|
34
34
|
trigger_data?: TriggerData;
|
|
35
35
|
// Optionally enable cross browser feed updates for this feed
|
|
36
36
|
__experimentalCrossBrowserUpdates?: boolean;
|
|
37
|
-
// Optionally automatically manage socket connections on changes to tab visibility (defaults to `false`)
|
|
38
|
-
auto_manage_socket_connection?: boolean;
|
|
39
|
-
// Optionally set the delay amount in milliseconds when automatically disconnecting sockets from inactive tabs (defaults to `2000`)
|
|
40
|
-
// Requires `auto_manage_socket_connection` to be `true`
|
|
41
|
-
auto_manage_socket_connection_delay?: number;
|
|
42
37
|
// Optionally scope notifications to a given date range
|
|
43
38
|
inserted_at_date_range?: {
|
|
44
39
|
// Optionally set the start date with a string in ISO 8601 format
|
|
@@ -88,8 +83,6 @@ export type FetchFeedOptionsForRequest = Omit<
|
|
|
88
83
|
__loadingType: undefined;
|
|
89
84
|
__fetchSource: undefined;
|
|
90
85
|
__experimentalCrossBrowserUpdates: undefined;
|
|
91
|
-
auto_manage_socket_connection: undefined;
|
|
92
|
-
auto_manage_socket_connection_delay: undefined;
|
|
93
86
|
};
|
|
94
87
|
|
|
95
88
|
export interface ContentBlockBase {
|
|
@@ -7,7 +7,6 @@ import Knock from "../../knock";
|
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
DEFAULT_GROUP_KEY,
|
|
10
|
-
SelectionResult,
|
|
11
10
|
byKey,
|
|
12
11
|
checkStateIfThrottled,
|
|
13
12
|
findDefaultGroup,
|
|
@@ -46,6 +45,8 @@ import {
|
|
|
46
45
|
SelectFilterParams,
|
|
47
46
|
SelectGuideOpts,
|
|
48
47
|
SelectGuidesOpts,
|
|
48
|
+
SelectQueryLimit,
|
|
49
|
+
SelectionResult,
|
|
49
50
|
StepMessageState,
|
|
50
51
|
StoreState,
|
|
51
52
|
TargetParams,
|
|
@@ -150,7 +151,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => {
|
|
|
150
151
|
}
|
|
151
152
|
};
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
type SelectQueryMetadata = {
|
|
155
|
+
limit: SelectQueryLimit;
|
|
156
|
+
opts: SelectGuideOpts;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const select = (
|
|
160
|
+
state: StoreState,
|
|
161
|
+
filters: SelectFilterParams,
|
|
162
|
+
metadata: SelectQueryMetadata,
|
|
163
|
+
) => {
|
|
154
164
|
// A map of selected guides as values, with its order index as keys.
|
|
155
165
|
const result = new SelectionResult();
|
|
156
166
|
|
|
@@ -175,7 +185,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
|
|
|
175
185
|
result.set(index, guide);
|
|
176
186
|
}
|
|
177
187
|
|
|
178
|
-
result.metadata = { guideGroup: defaultGroup };
|
|
188
|
+
result.metadata = { guideGroup: defaultGroup, filters, ...metadata };
|
|
189
|
+
|
|
179
190
|
return result;
|
|
180
191
|
};
|
|
181
192
|
|
|
@@ -617,14 +628,35 @@ export class KnockGuideClient {
|
|
|
617
628
|
`[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`,
|
|
618
629
|
);
|
|
619
630
|
|
|
620
|
-
|
|
621
|
-
|
|
631
|
+
// 1. First, call selectGuide() using the same filters to ensure we have a
|
|
632
|
+
// group stage open and respect throttling. This isn't the real query, but
|
|
633
|
+
// rather it's a shortcut ahead of handling the actual query result below.
|
|
634
|
+
const selectedGuide = this.selectGuide(state, filters, {
|
|
635
|
+
...opts,
|
|
636
|
+
// Don't record this result, not the actual query result we need.
|
|
637
|
+
recordSelectQuery: false,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// 2. Now make the actual select query with the provided filters and opts,
|
|
641
|
+
// and record the result (as needed). By default, we only record the result
|
|
642
|
+
// while in debugging.
|
|
643
|
+
const { recordSelectQuery = !!state.debug?.debugging } = opts;
|
|
644
|
+
const metadata: SelectQueryMetadata = {
|
|
645
|
+
limit: "all",
|
|
646
|
+
opts: { ...opts, recordSelectQuery },
|
|
647
|
+
};
|
|
648
|
+
const result = select(state, filters, metadata);
|
|
649
|
+
this.maybeRecordSelectResult(result);
|
|
650
|
+
|
|
651
|
+
// 3. Stop if there is not at least one guide to return.
|
|
652
|
+
if (!selectedGuide && !opts.includeThrottled) {
|
|
622
653
|
return [];
|
|
623
654
|
}
|
|
624
655
|
|
|
625
656
|
// There should be at least one guide to return here now.
|
|
626
|
-
const guides = [...
|
|
657
|
+
const guides = [...result.values()];
|
|
627
658
|
|
|
659
|
+
// 4. If throttled, filter out any throttled guides.
|
|
628
660
|
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
|
|
629
661
|
const unthrottledGuides = guides.filter(
|
|
630
662
|
(g) => g.bypass_global_group_limit,
|
|
@@ -657,32 +689,6 @@ export class KnockGuideClient {
|
|
|
657
689
|
return undefined;
|
|
658
690
|
}
|
|
659
691
|
|
|
660
|
-
const result = select(state, filters);
|
|
661
|
-
|
|
662
|
-
if (result.size === 0) {
|
|
663
|
-
this.knock.log("[Guide] Selection found zero result");
|
|
664
|
-
return undefined;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
const [index, guide] = [...result][0]!;
|
|
668
|
-
this.knock.log(
|
|
669
|
-
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
|
|
670
|
-
);
|
|
671
|
-
|
|
672
|
-
// If a guide ignores the group limit, then return immediately to render
|
|
673
|
-
// always.
|
|
674
|
-
if (guide.bypass_global_group_limit) {
|
|
675
|
-
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
|
|
676
|
-
return guide;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Check if inside the throttle window (i.e. throttled) and if so stop and
|
|
680
|
-
// return undefined unless explicitly given the option to include throttled.
|
|
681
|
-
if (!opts.includeThrottled && checkStateIfThrottled(state)) {
|
|
682
|
-
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
|
|
683
|
-
return undefined;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
692
|
// Starting here to the end of this method represents the core logic of how
|
|
687
693
|
// "group stage" works. It provides a mechanism for 1) figuring out which
|
|
688
694
|
// guide components are about to render on a page, 2) determining which
|
|
@@ -716,6 +722,35 @@ export class KnockGuideClient {
|
|
|
716
722
|
this.stage = this.openGroupStage(); // Assign here to make tsc happy
|
|
717
723
|
}
|
|
718
724
|
|
|
725
|
+
// Must come AFTER we ensure a group stage exists above, so we can record
|
|
726
|
+
// select queries. By default, we only record the result while in debugging.
|
|
727
|
+
const { recordSelectQuery = !!state.debug?.debugging } = opts;
|
|
728
|
+
const metadata: SelectQueryMetadata = {
|
|
729
|
+
limit: "one",
|
|
730
|
+
opts: { ...opts, recordSelectQuery },
|
|
731
|
+
};
|
|
732
|
+
const result = select(state, filters, metadata);
|
|
733
|
+
this.maybeRecordSelectResult(result);
|
|
734
|
+
|
|
735
|
+
if (result.size === 0) {
|
|
736
|
+
this.knock.log("[Guide] Selection found zero result");
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const [index, guide] = [...result][0]!;
|
|
741
|
+
this.knock.log(
|
|
742
|
+
`[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// If a guide ignores the group limit, then return immediately to render
|
|
746
|
+
// always.
|
|
747
|
+
if (guide.bypass_global_group_limit) {
|
|
748
|
+
this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`);
|
|
749
|
+
return guide;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const throttled = !opts.includeThrottled && checkStateIfThrottled(state);
|
|
753
|
+
|
|
719
754
|
switch (this.stage.status) {
|
|
720
755
|
case "open": {
|
|
721
756
|
this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`);
|
|
@@ -725,8 +760,16 @@ export class KnockGuideClient {
|
|
|
725
760
|
|
|
726
761
|
case "patch": {
|
|
727
762
|
this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
|
|
763
|
+
// Refresh the ordered queue in the group stage while continuing to
|
|
764
|
+
// render the currently resolved guide while in patch window, so that
|
|
765
|
+
// we can re-resolve when the group stage closes.
|
|
728
766
|
this.stage.ordered[index] = guide.key;
|
|
729
767
|
|
|
768
|
+
if (throttled) {
|
|
769
|
+
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
|
|
770
|
+
return undefined;
|
|
771
|
+
}
|
|
772
|
+
|
|
730
773
|
const ret = this.stage.resolved === guide.key ? guide : undefined;
|
|
731
774
|
this.knock.log(
|
|
732
775
|
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
|
|
@@ -735,6 +778,11 @@ export class KnockGuideClient {
|
|
|
735
778
|
}
|
|
736
779
|
|
|
737
780
|
case "closed": {
|
|
781
|
+
if (throttled) {
|
|
782
|
+
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
|
|
783
|
+
return undefined;
|
|
784
|
+
}
|
|
785
|
+
|
|
738
786
|
const ret = this.stage.resolved === guide.key ? guide : undefined;
|
|
739
787
|
this.knock.log(
|
|
740
788
|
`[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`,
|
|
@@ -744,6 +792,42 @@ export class KnockGuideClient {
|
|
|
744
792
|
}
|
|
745
793
|
}
|
|
746
794
|
|
|
795
|
+
// Record select query results by accumulating them by 1) key or type first,
|
|
796
|
+
// and then 2) "one" or "all".
|
|
797
|
+
private maybeRecordSelectResult(result: SelectionResult) {
|
|
798
|
+
if (!result.metadata) return;
|
|
799
|
+
|
|
800
|
+
const { opts, filters, limit } = result.metadata;
|
|
801
|
+
if (!opts.recordSelectQuery) return;
|
|
802
|
+
if (!filters.key && !filters.type) return;
|
|
803
|
+
if (!this.stage || this.stage.status === "closed") return;
|
|
804
|
+
|
|
805
|
+
// Deep merge to accumulate the results.
|
|
806
|
+
const queriedByKey = this.stage.results.key || {};
|
|
807
|
+
if (filters.key) {
|
|
808
|
+
queriedByKey[filters.key] = {
|
|
809
|
+
...(queriedByKey[filters.key] || {}),
|
|
810
|
+
...{ [limit]: result },
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
const queriedByType = this.stage.results.type || {};
|
|
814
|
+
if (filters.type) {
|
|
815
|
+
queriedByType[filters.type] = {
|
|
816
|
+
...(queriedByType[filters.type] || {}),
|
|
817
|
+
...{ [limit]: result },
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
this.stage = {
|
|
822
|
+
...this.stage,
|
|
823
|
+
results: { key: queriedByKey, type: queriedByType },
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
getStage() {
|
|
828
|
+
return this.stage;
|
|
829
|
+
}
|
|
830
|
+
|
|
747
831
|
private openGroupStage() {
|
|
748
832
|
this.knock.log("[Guide] Opening a new group stage");
|
|
749
833
|
|
|
@@ -759,6 +843,7 @@ export class KnockGuideClient {
|
|
|
759
843
|
this.stage = {
|
|
760
844
|
status: "open",
|
|
761
845
|
ordered: [],
|
|
846
|
+
results: {},
|
|
762
847
|
timeoutId,
|
|
763
848
|
};
|
|
764
849
|
|
|
@@ -3,23 +3,11 @@ import {
|
|
|
3
3
|
GuideActivationUrlRuleData,
|
|
4
4
|
GuideData,
|
|
5
5
|
GuideGroupData,
|
|
6
|
-
KnockGuide,
|
|
7
6
|
KnockGuideActivationUrlPattern,
|
|
8
7
|
SelectFilterParams,
|
|
9
8
|
StoreState,
|
|
10
9
|
} from "./types";
|
|
11
10
|
|
|
12
|
-
// Extends the map class to allow having metadata on it, which is used to record
|
|
13
|
-
// the guide group context for the selection result (though currently only a
|
|
14
|
-
// default global group is supported).
|
|
15
|
-
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
|
|
16
|
-
metadata: { guideGroup: GuideGroupData } | undefined;
|
|
17
|
-
|
|
18
|
-
constructor() {
|
|
19
|
-
super();
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
11
|
export const formatGroupStage = (stage: GroupStage) => {
|
|
24
12
|
return `status=${stage.status}, resolved=${stage.resolved}`;
|
|
25
13
|
};
|
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
DEBUG_QUERY_PARAMS,
|
|
4
4
|
checkActivatable,
|
|
5
5
|
} from "./client";
|
|
6
|
+
export { checkStateIfThrottled } from "./helpers";
|
|
6
7
|
export type {
|
|
7
8
|
KnockGuide,
|
|
8
9
|
KnockGuideStep,
|
|
@@ -12,4 +13,6 @@ export type {
|
|
|
12
13
|
SelectGuideOpts as KnockSelectGuideOpts,
|
|
13
14
|
SelectGuidesOpts as KnockSelectGuidesOpts,
|
|
14
15
|
StoreState as KnockGuideClientStoreState,
|
|
16
|
+
GroupStage as KnockGuideClientGroupStage,
|
|
17
|
+
SelectionResult as KnockGuideSelectionResult,
|
|
15
18
|
} from "./types";
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { GenericData } from "@knocklabs/types";
|
|
2
2
|
|
|
3
|
+
// i.e. useGuide vs useGuides
|
|
4
|
+
export type SelectQueryLimit = "one" | "all";
|
|
5
|
+
|
|
6
|
+
type SelectionResultMetadata = {
|
|
7
|
+
guideGroup: GuideGroupData;
|
|
8
|
+
// Additional info about the underlying select query behind the result.
|
|
9
|
+
filters: SelectFilterParams;
|
|
10
|
+
limit: SelectQueryLimit;
|
|
11
|
+
opts: SelectGuideOpts;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Extends the map class to allow having metadata on it, which is used to record
|
|
15
|
+
// the guide group context for the selection result (though currently only a
|
|
16
|
+
// default global group is supported).
|
|
17
|
+
export class SelectionResult<K = number, V = KnockGuide> extends Map<K, V> {
|
|
18
|
+
metadata: SelectionResultMetadata | undefined;
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
super();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
3
25
|
//
|
|
4
26
|
// Fetch guides API
|
|
5
27
|
//
|
|
@@ -237,6 +259,7 @@ export type SelectFilterParams = {
|
|
|
237
259
|
|
|
238
260
|
export type SelectGuideOpts = {
|
|
239
261
|
includeThrottled?: boolean;
|
|
262
|
+
recordSelectQuery?: boolean;
|
|
240
263
|
};
|
|
241
264
|
|
|
242
265
|
export type SelectGuidesOpts = SelectGuideOpts;
|
|
@@ -253,9 +276,20 @@ export type ConstructorOpts = {
|
|
|
253
276
|
throttleCheckInterval?: number;
|
|
254
277
|
};
|
|
255
278
|
|
|
279
|
+
type SelectionResultByLimit = {
|
|
280
|
+
one?: SelectionResult;
|
|
281
|
+
all?: SelectionResult;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
type RecordedSelectionResults = {
|
|
285
|
+
key?: Record<KnockGuide["key"], SelectionResultByLimit>;
|
|
286
|
+
type?: Record<KnockGuide["type"], SelectionResultByLimit>;
|
|
287
|
+
};
|
|
288
|
+
|
|
256
289
|
export type GroupStage = {
|
|
257
290
|
status: "open" | "closed" | "patch";
|
|
258
291
|
ordered: Array<KnockGuide["key"]>;
|
|
259
292
|
resolved?: KnockGuide["key"];
|
|
260
293
|
timeoutId: ReturnType<typeof setTimeout> | null;
|
|
294
|
+
results: RecordedSelectionResults;
|
|
261
295
|
};
|
package/src/helpers.ts
CHANGED
|
@@ -4,3 +4,42 @@ const uuidRegex =
|
|
|
4
4
|
export function isValidUuid(uuid: string) {
|
|
5
5
|
return uuidRegex.test(uuid);
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Exponential backoff with full jitter and a minimum delay floor.
|
|
10
|
+
*
|
|
11
|
+
* - Uses exponential growth capped at maxDelayMs
|
|
12
|
+
* - Applies full jitter to spread retries uniformly across the window
|
|
13
|
+
* - Enforces a minimum delay to avoid tight retry loops
|
|
14
|
+
*
|
|
15
|
+
* Example (baseDelayMs = 1000):
|
|
16
|
+
* Try 1: 250ms – 1,000ms
|
|
17
|
+
* Try 2: 250ms – 2,000ms
|
|
18
|
+
* Try 3: 250ms – 4,000ms
|
|
19
|
+
* Try 4: 250ms – 8,000ms
|
|
20
|
+
* Try 5+: 250ms – maxDelayMs
|
|
21
|
+
*/
|
|
22
|
+
export function exponentialBackoffFullJitter(
|
|
23
|
+
tries: number,
|
|
24
|
+
{
|
|
25
|
+
baseDelayMs,
|
|
26
|
+
maxDelayMs,
|
|
27
|
+
minDelayMs = 250,
|
|
28
|
+
}: {
|
|
29
|
+
baseDelayMs: number;
|
|
30
|
+
maxDelayMs: number;
|
|
31
|
+
minDelayMs?: number;
|
|
32
|
+
},
|
|
33
|
+
): number {
|
|
34
|
+
const exponentialDelay = Math.min(
|
|
35
|
+
maxDelayMs,
|
|
36
|
+
baseDelayMs * Math.pow(2, Math.max(0, tries - 1)),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (exponentialDelay <= minDelayMs) {
|
|
40
|
+
return minDelayMs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const jitterRange = exponentialDelay - minDelayMs;
|
|
44
|
+
return minDelayMs + Math.floor(Math.random() * jitterRange);
|
|
45
|
+
}
|
package/src/interfaces.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface KnockOptions {
|
|
|
9
9
|
host?: string;
|
|
10
10
|
logLevel?: LogLevel;
|
|
11
11
|
branch?: string;
|
|
12
|
+
/** Automatically disconnect the socket when the page is hidden and reconnect when visible. Defaults to `true`. */
|
|
13
|
+
disconnectOnPageHidden?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export interface KnockObject<T = GenericData> {
|
package/src/knock.ts
CHANGED
|
@@ -26,6 +26,7 @@ class Knock {
|
|
|
26
26
|
public userToken?: string;
|
|
27
27
|
public logLevel?: LogLevel;
|
|
28
28
|
public readonly branch?: string;
|
|
29
|
+
private readonly disconnectOnPageHidden?: boolean;
|
|
29
30
|
private tokenExpirationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
31
|
readonly feeds = new FeedClient(this);
|
|
31
32
|
readonly objects = new ObjectClient(this);
|
|
@@ -42,6 +43,7 @@ class Knock {
|
|
|
42
43
|
this.host = options.host || DEFAULT_HOST;
|
|
43
44
|
this.logLevel = options.logLevel;
|
|
44
45
|
this.branch = options.branch || undefined;
|
|
46
|
+
this.disconnectOnPageHidden = options.disconnectOnPageHidden;
|
|
45
47
|
|
|
46
48
|
this.log("Initialized Knock instance");
|
|
47
49
|
|
|
@@ -170,9 +172,7 @@ class Knock {
|
|
|
170
172
|
if (this.tokenExpirationTimer) {
|
|
171
173
|
clearTimeout(this.tokenExpirationTimer);
|
|
172
174
|
}
|
|
173
|
-
|
|
174
|
-
this.apiClient.socket.disconnect();
|
|
175
|
-
}
|
|
175
|
+
this.apiClient?.teardown();
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
log(message: string, force = false) {
|
|
@@ -190,6 +190,7 @@ class Knock {
|
|
|
190
190
|
host: this.host,
|
|
191
191
|
userToken: this.userToken,
|
|
192
192
|
branch: this.branch,
|
|
193
|
+
disconnectOnPageHidden: this.disconnectOnPageHidden,
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Socket } from "phoenix";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DISCONNECT_DELAY_MS = 30_000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Disconnects the socket after a delay when the page becomes hidden,
|
|
7
|
+
* and reconnects when it becomes visible again. This avoids holding
|
|
8
|
+
* open connections for background tabs that aren't being viewed.
|
|
9
|
+
*
|
|
10
|
+
* The delay prevents unnecessary disconnects during brief tab switches.
|
|
11
|
+
* Phoenix channels automatically rejoin after reconnecting.
|
|
12
|
+
*/
|
|
13
|
+
export class PageVisibilityManager {
|
|
14
|
+
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
15
|
+
private wasConnected = false;
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private socket: Socket,
|
|
19
|
+
private disconnectDelayMs: number = DEFAULT_DISCONNECT_DELAY_MS,
|
|
20
|
+
) {
|
|
21
|
+
if (typeof document !== "undefined") {
|
|
22
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private onVisibilityChange = () => {
|
|
27
|
+
if (document.hidden) {
|
|
28
|
+
this.scheduleDisconnect();
|
|
29
|
+
} else {
|
|
30
|
+
this.reconnect();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
private scheduleDisconnect() {
|
|
35
|
+
this.clearTimer();
|
|
36
|
+
|
|
37
|
+
this.disconnectTimer = setTimeout(() => {
|
|
38
|
+
this.disconnectTimer = null;
|
|
39
|
+
|
|
40
|
+
if (this.socket.isConnected()) {
|
|
41
|
+
this.wasConnected = true;
|
|
42
|
+
this.socket.disconnect();
|
|
43
|
+
}
|
|
44
|
+
}, this.disconnectDelayMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private reconnect() {
|
|
48
|
+
this.clearTimer();
|
|
49
|
+
|
|
50
|
+
if (this.wasConnected) {
|
|
51
|
+
this.wasConnected = false;
|
|
52
|
+
this.socket.connect();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private clearTimer() {
|
|
57
|
+
if (this.disconnectTimer) {
|
|
58
|
+
clearTimeout(this.disconnectTimer);
|
|
59
|
+
this.disconnectTimer = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
teardown() {
|
|
64
|
+
this.clearTimer();
|
|
65
|
+
|
|
66
|
+
if (typeof document !== "undefined") {
|
|
67
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|