@atproto/bsky 0.0.215 → 0.0.217

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 (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/api/app/bsky/feed/searchPosts.d.ts.map +1 -1
  3. package/dist/api/app/bsky/feed/searchPosts.js +6 -4
  4. package/dist/api/app/bsky/feed/searchPosts.js.map +1 -1
  5. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js +2 -0
  6. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
  7. package/dist/api/app/bsky/unspecced/getPostThreadV2.js +1 -1
  8. package/dist/api/app/bsky/unspecced/getPostThreadV2.js.map +1 -1
  9. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.d.ts.map +1 -1
  10. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js +10 -3
  11. package/dist/api/app/bsky/unspecced/getSuggestedOnboardingUsers.js.map +1 -1
  12. package/dist/api/app/bsky/unspecced/getSuggestedUsers.d.ts.map +1 -1
  13. package/dist/api/app/bsky/unspecced/getSuggestedUsers.js +9 -2
  14. package/dist/api/app/bsky/unspecced/getSuggestedUsers.js.map +1 -1
  15. package/dist/config.d.ts +2 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +5 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/context.d.ts +3 -3
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +2 -2
  22. package/dist/context.js.map +1 -1
  23. package/dist/feature-gates/gates.d.ts +5 -0
  24. package/dist/feature-gates/gates.d.ts.map +1 -0
  25. package/dist/feature-gates/gates.js +6 -0
  26. package/dist/feature-gates/gates.js.map +1 -0
  27. package/dist/feature-gates/index.d.ts +24 -0
  28. package/dist/feature-gates/index.d.ts.map +1 -0
  29. package/dist/feature-gates/index.js +135 -0
  30. package/dist/feature-gates/index.js.map +1 -0
  31. package/dist/feature-gates/metrics.d.ts +32 -0
  32. package/dist/feature-gates/metrics.d.ts.map +1 -0
  33. package/dist/feature-gates/metrics.js +100 -0
  34. package/dist/feature-gates/metrics.js.map +1 -0
  35. package/dist/feature-gates/metrics.test.d.ts +2 -0
  36. package/dist/feature-gates/metrics.test.d.ts.map +1 -0
  37. package/dist/feature-gates/metrics.test.js +152 -0
  38. package/dist/feature-gates/metrics.test.js.map +1 -0
  39. package/dist/feature-gates/types.d.ts +49 -0
  40. package/dist/feature-gates/types.d.ts.map +1 -0
  41. package/dist/feature-gates/types.js +3 -0
  42. package/dist/feature-gates/types.js.map +1 -0
  43. package/dist/feature-gates/utils.d.ts +21 -0
  44. package/dist/feature-gates/utils.d.ts.map +1 -0
  45. package/dist/feature-gates/utils.js +85 -0
  46. package/dist/feature-gates/utils.js.map +1 -0
  47. package/dist/hydration/hydrator.d.ts +8 -3
  48. package/dist/hydration/hydrator.d.ts.map +1 -1
  49. package/dist/hydration/hydrator.js +9 -5
  50. package/dist/hydration/hydrator.js.map +1 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +7 -6
  53. package/dist/index.js.map +1 -1
  54. package/dist/lexicon/index.d.ts +2 -2
  55. package/dist/lexicon/index.d.ts.map +1 -1
  56. package/dist/lexicon/index.js +4 -4
  57. package/dist/lexicon/index.js.map +1 -1
  58. package/dist/lexicon/lexicons.d.ts +116 -100
  59. package/dist/lexicon/lexicons.d.ts.map +1 -1
  60. package/dist/lexicon/lexicons.js +59 -51
  61. package/dist/lexicon/lexicons.js.map +1 -1
  62. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts +3 -1
  63. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.d.ts.map +1 -1
  64. package/dist/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
  65. package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.d.ts → getOnboardingSuggestedUsersSkeleton.d.ts} +1 -1
  66. package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.d.ts.map → getOnboardingSuggestedUsersSkeleton.d.ts.map} +1 -1
  67. package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.js → getOnboardingSuggestedUsersSkeleton.js} +2 -2
  68. package/dist/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.js.map → getOnboardingSuggestedUsersSkeleton.js.map} +1 -1
  69. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts +3 -1
  70. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.d.ts.map +1 -1
  71. package/dist/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.js.map +1 -1
  72. package/dist/views/index.d.ts.map +1 -1
  73. package/dist/views/index.js +3 -4
  74. package/dist/views/index.js.map +1 -1
  75. package/package.json +9 -9
  76. package/src/api/app/bsky/feed/searchPosts.ts +10 -8
  77. package/src/api/app/bsky/graph/getSuggestedFollowsByActor.ts +3 -1
  78. package/src/api/app/bsky/unspecced/getPostThreadV2.ts +3 -3
  79. package/src/api/app/bsky/unspecced/getSuggestedOnboardingUsers.ts +14 -7
  80. package/src/api/app/bsky/unspecced/getSuggestedUsers.ts +13 -6
  81. package/src/config.ts +8 -0
  82. package/src/context.ts +4 -4
  83. package/src/feature-gates/README.md +47 -0
  84. package/src/feature-gates/gates.ts +9 -0
  85. package/src/feature-gates/index.ts +146 -0
  86. package/src/feature-gates/metrics.test.ts +196 -0
  87. package/src/feature-gates/metrics.ts +107 -0
  88. package/src/feature-gates/types.ts +52 -0
  89. package/src/feature-gates/utils.ts +90 -0
  90. package/src/hydration/hydrator.ts +12 -6
  91. package/src/index.ts +8 -7
  92. package/src/lexicon/index.ts +13 -13
  93. package/src/lexicon/lexicons.ts +63 -55
  94. package/src/lexicon/types/app/bsky/graph/getSuggestedFollowsByActor.ts +3 -1
  95. package/src/lexicon/types/app/bsky/unspecced/{getSuggestedOnboardingUsersSkeleton.ts → getOnboardingSuggestedUsersSkeleton.ts} +1 -1
  96. package/src/lexicon/types/app/bsky/unspecced/getSuggestionsSkeleton.ts +3 -1
  97. package/src/views/index.ts +5 -8
  98. package/tests/views/get-suggested-onboarding-users.test.ts +1 -1
  99. package/tests/views/thread.test.ts +2 -0
  100. package/tsconfig.build.tsbuildinfo +1 -1
  101. package/dist/feature-gates.d.ts +0 -44
  102. package/dist/feature-gates.d.ts.map +0 -1
  103. package/dist/feature-gates.js +0 -133
  104. package/dist/feature-gates.js.map +0 -1
  105. package/src/feature-gates.ts +0 -136
@@ -0,0 +1,196 @@
1
+ /// <reference types="jest" />
2
+ import { featureGatesLogger } from '../logger'
3
+ import { MetricsClient } from './metrics'
4
+
5
+ jest.mock('../logger', () => ({
6
+ featureGatesLogger: {
7
+ error: jest.fn(),
8
+ },
9
+ }))
10
+
11
+ type TestEvents = {
12
+ click: { button: string }
13
+ view: { screen: string }
14
+ }
15
+
16
+ // Helper to flush promises and timers
17
+ const flushPromises = () => new Promise((r) => setImmediate(r))
18
+
19
+ describe('MetricsClient', () => {
20
+ let fetchMock: jest.Mock
21
+ let fetchRequests: { body: any }[]
22
+ let client: MetricsClient<TestEvents>
23
+
24
+ beforeEach(() => {
25
+ jest.useFakeTimers({ doNotFake: ['setImmediate', 'performance'] })
26
+ fetchRequests = []
27
+ fetchMock = jest.fn().mockImplementation(async (_url, options) => {
28
+ const body = JSON.parse(options.body)
29
+ fetchRequests.push({ body })
30
+ return { ok: true, status: 200, text: async () => '' }
31
+ })
32
+ global.fetch = fetchMock
33
+ })
34
+
35
+ afterEach(() => {
36
+ client?.stop()
37
+ jest.useRealTimers()
38
+ jest.clearAllMocks()
39
+ })
40
+
41
+ it('flushes events on interval', async () => {
42
+ client = new MetricsClient<TestEvents>({
43
+ trackingEndpoint: 'https://test.metrics.api',
44
+ })
45
+ client.track('click', { button: 'submit' })
46
+ client.track('view', { screen: 'home' })
47
+
48
+ expect(fetchRequests).toHaveLength(0)
49
+
50
+ // Advance past the 10 second interval
51
+ jest.advanceTimersByTime(10_000)
52
+ await flushPromises()
53
+
54
+ expect(fetchRequests).toHaveLength(1)
55
+ expect(fetchRequests[0].body.events).toHaveLength(2)
56
+ expect(fetchRequests[0].body.events[0].event).toBe('click')
57
+ expect(fetchRequests[0].body.events[1].event).toBe('view')
58
+ })
59
+
60
+ it('flushes when maxBatchSize is exceeded', async () => {
61
+ client = new MetricsClient<TestEvents>({
62
+ trackingEndpoint: 'https://test.metrics.api',
63
+ })
64
+ client.maxBatchSize = 5
65
+
66
+ // Add events up to maxBatchSize (should not flush yet)
67
+ for (let i = 0; i < 5; i++) {
68
+ client.track('click', { button: `btn-${i}` })
69
+ }
70
+
71
+ expect(fetchRequests).toHaveLength(0)
72
+
73
+ // One more event should trigger flush (> maxBatchSize)
74
+ client.track('click', { button: 'btn-trigger' })
75
+ await flushPromises()
76
+
77
+ expect(fetchRequests).toHaveLength(1)
78
+ expect(fetchRequests[0].body.events).toHaveLength(6)
79
+ })
80
+
81
+ it('logs error on failed request', async () => {
82
+ fetchMock.mockImplementation(async () => {
83
+ return {
84
+ ok: false,
85
+ status: 500,
86
+ text: async () => 'Internal Server Error',
87
+ }
88
+ })
89
+
90
+ client = new MetricsClient<TestEvents>({
91
+ trackingEndpoint: 'https://test.metrics.api',
92
+ })
93
+ client.track('click', { button: 'submit' })
94
+
95
+ // Trigger flush via interval
96
+ jest.advanceTimersByTime(10_000)
97
+ await flushPromises()
98
+
99
+ expect(fetchMock).toHaveBeenCalledTimes(1)
100
+ expect(featureGatesLogger.error).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ err: expect.any(Error),
103
+ }),
104
+ 'Failed to send metrics',
105
+ )
106
+ })
107
+
108
+ it('handles fetch text() error gracefully', async () => {
109
+ fetchMock.mockImplementation(async () => {
110
+ return {
111
+ ok: false,
112
+ status: 500,
113
+ text: async () => {
114
+ throw new Error('Failed to read response')
115
+ },
116
+ }
117
+ })
118
+
119
+ client = new MetricsClient<TestEvents>({
120
+ trackingEndpoint: 'https://test.metrics.api',
121
+ })
122
+ client.track('click', { button: 'submit' })
123
+
124
+ // Trigger flush - should not throw
125
+ jest.advanceTimersByTime(10_000)
126
+ await flushPromises()
127
+
128
+ expect(fetchMock).toHaveBeenCalledTimes(1)
129
+ expect(featureGatesLogger.error).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ err: expect.objectContaining({
132
+ message: expect.stringContaining('Unknown error'),
133
+ }),
134
+ }),
135
+ 'Failed to send metrics',
136
+ )
137
+ })
138
+
139
+ it('flushes when stop() is called', async () => {
140
+ client = new MetricsClient<TestEvents>({
141
+ trackingEndpoint: 'https://test.metrics.api',
142
+ })
143
+ client.track('click', { button: 'submit' })
144
+
145
+ expect(fetchRequests).toHaveLength(0)
146
+
147
+ // Stop should flush remaining events
148
+ client.stop()
149
+ await flushPromises()
150
+
151
+ expect(fetchRequests).toHaveLength(1)
152
+ expect(fetchRequests[0].body.events).toHaveLength(1)
153
+ expect(fetchRequests[0].body.events[0].event).toBe('click')
154
+ })
155
+
156
+ it('does not send if trackingEndpoint is not configured', async () => {
157
+ client = new MetricsClient<TestEvents>({})
158
+ client.track('click', { button: 'submit' })
159
+
160
+ // Trigger flush via interval
161
+ jest.advanceTimersByTime(10_000)
162
+ await flushPromises()
163
+
164
+ expect(fetchMock).not.toHaveBeenCalled()
165
+ })
166
+
167
+ it('start() is idempotent', async () => {
168
+ client = new MetricsClient<TestEvents>({
169
+ trackingEndpoint: 'https://test.metrics.api',
170
+ })
171
+
172
+ // track() calls start() internally
173
+ client.track('click', { button: 'submit' })
174
+ client.start()
175
+ client.start()
176
+
177
+ // Advance past interval - should only flush once
178
+ jest.advanceTimersByTime(10_000)
179
+ await flushPromises()
180
+
181
+ expect(fetchRequests).toHaveLength(1)
182
+ })
183
+
184
+ it('does not flush if queue is empty', async () => {
185
+ client = new MetricsClient<TestEvents>({
186
+ trackingEndpoint: 'https://test.metrics.api',
187
+ })
188
+ client.start()
189
+
190
+ // Advance past interval with empty queue
191
+ jest.advanceTimersByTime(10_000)
192
+ await flushPromises()
193
+
194
+ expect(fetchMock).not.toHaveBeenCalled()
195
+ })
196
+ })
@@ -0,0 +1,107 @@
1
+ import { featureGatesLogger } from '../logger'
2
+
3
+ type Events = {
4
+ 'experiment:viewed': {
5
+ experimentId: string
6
+ variationId: string
7
+ }
8
+ 'feature:viewed': {
9
+ featureId: string
10
+ featureResultValue: unknown
11
+ /** Only available if feature has experiment rules applied */
12
+ experimentId?: string
13
+ /** Only available if feature has experiment rules applied */
14
+ variationId?: string
15
+ }
16
+ }
17
+
18
+ type Event<M extends Record<string, any>> = {
19
+ time: number
20
+ event: keyof M
21
+ payload: M[keyof M]
22
+ metadata: Record<string, any>
23
+ }
24
+
25
+ export type Config = {
26
+ trackingEndpoint?: string
27
+ }
28
+
29
+ export class MetricsClient<M extends Record<string, any> = Events> {
30
+ maxBatchSize = 100
31
+
32
+ private started: boolean = false
33
+ private queue: Event<M>[] = []
34
+ private flushInterval: NodeJS.Timeout | null = null
35
+ constructor(private config: Config) {}
36
+
37
+ start() {
38
+ if (this.started) return
39
+ this.started = true
40
+ this.flushInterval = setInterval(() => {
41
+ this.flush()
42
+ }, 10_000)
43
+ }
44
+
45
+ stop() {
46
+ if (this.flushInterval) {
47
+ clearInterval(this.flushInterval)
48
+ this.flushInterval = null
49
+ }
50
+ this.flush()
51
+ }
52
+
53
+ track<E extends keyof M>(
54
+ event: E,
55
+ payload: M[E],
56
+ metadata: Record<string, any> = {},
57
+ ) {
58
+ this.start()
59
+
60
+ const e = {
61
+ source: 'appview',
62
+ time: Date.now(),
63
+ event,
64
+ payload,
65
+ metadata,
66
+ }
67
+ this.queue.push(e)
68
+
69
+ if (this.queue.length > this.maxBatchSize) {
70
+ this.flush()
71
+ }
72
+ }
73
+
74
+ flush() {
75
+ if (!this.queue.length) return
76
+ const events = this.queue.splice(0, this.queue.length)
77
+ this.sendBatch(events)
78
+ }
79
+
80
+ private async sendBatch(events: Event<M>[]) {
81
+ if (!this.config.trackingEndpoint) return
82
+
83
+ try {
84
+ const res = await fetch(this.config.trackingEndpoint, {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ },
89
+ body: JSON.stringify({ events }),
90
+ keepalive: true,
91
+ })
92
+
93
+ if (!res.ok) {
94
+ const errorText = await res.text().catch(() => 'Unknown error')
95
+ featureGatesLogger.error(
96
+ { err: new Error(`${res.status} Failed to fetch - ${errorText}`) },
97
+ 'Failed to send metrics',
98
+ )
99
+ } else {
100
+ // Drain response body to allow connection reuse.
101
+ await res.text().catch(() => {})
102
+ }
103
+ } catch (err) {
104
+ featureGatesLogger.error({ err }, 'Failed to send metrics')
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,52 @@
1
+ import type express from 'express'
2
+ import { FeatureGate } from './gates'
3
+
4
+ /**
5
+ * The user context passed to the feature gates client for evaluation and
6
+ * tracking purposes.
7
+ */
8
+ export type RawUserContext = {
9
+ /**
10
+ * The user's DID
11
+ */
12
+ viewer: string | null
13
+ /**
14
+ * The express request object, used to extract analytics headers for the user context
15
+ */
16
+ req: express.Request
17
+ }
18
+
19
+ /**
20
+ * Extracted values from the `RawUserContext`. These values should match the
21
+ * `attributes` we've configured for GrowthBook in our GB dashboard. We also
22
+ * send these same values as properties in our analytics events, so we want to
23
+ * make sure they are consistent.
24
+ */
25
+ export type ParsedUserContext = {
26
+ did?: string | null
27
+ deviceId: string
28
+ sessionId: string
29
+ }
30
+
31
+ /**
32
+ * This loosely matches the metadata we send from the client for analytics
33
+ * events. We want to make sure we have the same properties in both places so
34
+ * that we can correlate feature gate evaluations with analytics events.
35
+ *
36
+ * @see https://github.com/bluesky-social/social-app/blob/76109a58dc7aafccdfbd07a81cbd9925e065d1c0/src/analytics/metadata.ts
37
+ */
38
+ export type TrackingMetadata = {
39
+ base: {
40
+ deviceId: string
41
+ sessionId: string
42
+ }
43
+ session: {
44
+ did: string | undefined
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Pre-evaluated feature gates map, the result of
50
+ * `ctx.FeatureGatesClient.checkGates()`
51
+ */
52
+ export type CheckedFeatureGatesMap = Map<FeatureGate, boolean>
@@ -0,0 +1,90 @@
1
+ import crypto from 'node:crypto'
2
+ import { type UserContext as GrowthBookUserContext } from '@growthbook/growthbook'
3
+ import { ParsedUserContext, RawUserContext, TrackingMetadata } from './types'
4
+
5
+ /**
6
+ * These need to match what the client sends
7
+ */
8
+ const ANALYTICS_HEADER_DEVICE_ID = 'X-Bsky-Device-Id'
9
+ const ANALYTICS_HEADER_SESSION_ID = 'X-Bsky-Session-Id'
10
+
11
+ /**
12
+ * Parse the `RawUserContext` into a `ParsedUserContext` that is used as
13
+ * GrowthBook `attributes` as well as the metadata payload for our analytics
14
+ * events. This ensures that the same user properties are used for both feature
15
+ * gate targeting and analytics.
16
+ */
17
+ export function parseRawUserContext(
18
+ userContext: RawUserContext,
19
+ ): ParsedUserContext {
20
+ const did = userContext.viewer
21
+
22
+ // prioritize passthrough header
23
+ let deviceId = userContext.req.header(ANALYTICS_HEADER_DEVICE_ID)
24
+ if (!deviceId) {
25
+ if (did) {
26
+ /*
27
+ * If we don't have a device header, fall back to the DID. Our event
28
+ * proxy ensures ordering based on this deviceId (also called a stableId
29
+ * in the proxy), so if we have a DID, we want to use it to ensure client
30
+ * and server events are properly ordered.
31
+ */
32
+ deviceId = did
33
+ } else {
34
+ /*
35
+ * Without any better option for identifying the user, we generate a
36
+ * random deviceId.
37
+ */
38
+ deviceId = `anon-${crypto.randomUUID()}`
39
+ }
40
+ }
41
+
42
+ // prioritize passthrough header
43
+ let sessionId = userContext.req.header(ANALYTICS_HEADER_SESSION_ID)
44
+ if (!sessionId) {
45
+ /*
46
+ * Without any better option for identifying the user, we generate a
47
+ * random deviceId.
48
+ */
49
+ sessionId = `anon-${crypto.randomUUID()}`
50
+ }
51
+
52
+ return {
53
+ did,
54
+ deviceId,
55
+ sessionId,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Extract the `ParsedUserContext` from the GrowthBook `UserContext`, which we
61
+ * passed into `isOn` as `attributes`.
62
+ */
63
+ export function extractParsedUserContextFromGrowthBookUserContext(
64
+ userContext: GrowthBookUserContext,
65
+ ): ParsedUserContext {
66
+ return {
67
+ did: userContext.attributes?.did,
68
+ deviceId: userContext.attributes?.deviceId,
69
+ sessionId: userContext.attributes?.sessionId,
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Convert the `ParsedUserContext` into the `TrackingMetadata` format that we
75
+ * use for our analytics events. This ensures that we have the same user
76
+ * properties as we do for events from our client app.
77
+ */
78
+ export function parsedUserContextToTrackingMetadata(
79
+ parsedUserContext: ParsedUserContext,
80
+ ): TrackingMetadata {
81
+ return {
82
+ base: {
83
+ deviceId: parsedUserContext.deviceId,
84
+ sessionId: parsedUserContext.sessionId,
85
+ },
86
+ session: {
87
+ did: parsedUserContext.did ?? undefined,
88
+ },
89
+ }
90
+ }
@@ -2,7 +2,7 @@ import assert from 'node:assert'
2
2
  import { mapDefined } from '@atproto/common'
3
3
  import { AtUri } from '@atproto/syntax'
4
4
  import { DataPlaneClient } from '../data-plane/client'
5
- import { type CheckedFeatureGatesMap, FeatureGateID } from '../feature-gates'
5
+ import { type CheckedFeatureGatesMap } from '../feature-gates/types'
6
6
  import { ids } from '../lexicon/lexicons'
7
7
  import { Record as ProfileRecord } from '../lexicon/types/app/bsky/actor/profile'
8
8
  import { isMain as isEmbedRecord } from '../lexicon/types/app/bsky/embed/record'
@@ -83,7 +83,13 @@ export class HydrateCtx {
83
83
  overrideIncludeTakedownsForActor = this.vals.overrideIncludeTakedownsForActor
84
84
  include3pBlocks = this.vals.include3pBlocks
85
85
  includeDebugField = this.vals.includeDebugField
86
- featureGates: CheckedFeatureGatesMap = this.vals.featureGates || new Map()
86
+ /**
87
+ * Cache of evaluated feature gates to be used in a given request lifecycle.
88
+ * The actual evaluations happen at the top of the route handler and the
89
+ * results are stored in this map.
90
+ */
91
+ featureGatesMap: CheckedFeatureGatesMap =
92
+ this.vals.featureGatesMap || new Map()
87
93
  constructor(private vals: HydrateCtxVals) {}
88
94
  // Convenience with use with dataplane.getActors cache control
89
95
  get skipCacheForViewer() {
@@ -102,7 +108,7 @@ export type HydrateCtxVals = {
102
108
  overrideIncludeTakedownsForActor?: boolean
103
109
  include3pBlocks?: boolean
104
110
  includeDebugField?: boolean
105
- featureGates?: CheckedFeatureGatesMap
111
+ featureGatesMap?: CheckedFeatureGatesMap
106
112
  }
107
113
 
108
114
  export type HydrationState = {
@@ -746,8 +752,8 @@ export class Hydrator {
746
752
  ctx: HydrateCtx,
747
753
  ): Promise<HydrationState> {
748
754
  const postsState = await this.hydratePosts(refs, ctx, undefined, {
749
- processDynamicTagsForView: ctx.featureGates.get(
750
- FeatureGateID.ThreadsReplyRankingExplorationEnable,
755
+ processDynamicTagsForView: ctx.featureGatesMap.get(
756
+ 'threads:reply_ranking_exploration:enable',
751
757
  )
752
758
  ? 'thread'
753
759
  : undefined,
@@ -1331,7 +1337,7 @@ export class Hydrator {
1331
1337
  includeTakedowns: vals.includeTakedowns,
1332
1338
  include3pBlocks: vals.include3pBlocks,
1333
1339
  includeDebugField,
1334
- featureGates: vals.featureGates,
1340
+ featureGatesMap: vals.featureGatesMap,
1335
1341
  })
1336
1342
  }
1337
1343
 
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  createDataPlaneClient,
24
24
  } from './data-plane/client'
25
25
  import * as error from './error'
26
- import { FeatureGates } from './feature-gates'
26
+ import { FeatureGatesClient } from './feature-gates'
27
27
  import { Hydrator } from './hydration/hydrator'
28
28
  import * as imageServer from './image/server'
29
29
  import { ImageUriBuilder } from './image/uri'
@@ -181,9 +181,10 @@ export class BskyAppView {
181
181
  entrywayJwtPublicKey,
182
182
  })
183
183
 
184
- const featureGates = new FeatureGates({
185
- apiHost: config.growthBookApiHost,
186
- clientKey: config.growthBookClientKey,
184
+ const featureGatesClient = new FeatureGatesClient({
185
+ growthBookApiHost: config.growthBookApiHost,
186
+ growthBookClientKey: config.growthBookClientKey,
187
+ eventProxyTrackingEndpoint: config.eventProxyTrackingEndpoint,
187
188
  })
188
189
 
189
190
  const blobDispatcher = createBlobDispatcher(config)
@@ -205,7 +206,7 @@ export class BskyAppView {
205
206
  courierClient,
206
207
  rolodexClient,
207
208
  authVerifier,
208
- featureGates,
209
+ featureGatesClient,
209
210
  blobDispatcher,
210
211
  kwsClient,
211
212
  })
@@ -241,7 +242,7 @@ export class BskyAppView {
241
242
  if (this.ctx.dataplaneHostList instanceof EtcdHostList) {
242
243
  await this.ctx.dataplaneHostList.connect()
243
244
  }
244
- await this.ctx.featureGates.start()
245
+ this.ctx.featureGatesClient.start() // lazy, no await
245
246
  const server = this.app.listen(this.ctx.cfg.port)
246
247
  this.server = server
247
248
  server.keepAliveTimeout = 90000
@@ -253,7 +254,7 @@ export class BskyAppView {
253
254
  }
254
255
 
255
256
  async destroy(): Promise<void> {
256
- this.ctx.featureGates.destroy()
257
+ this.ctx.featureGatesClient.destroy()
257
258
  await this.terminator?.terminate()
258
259
  await this.ctx.etcd?.close()
259
260
  }
@@ -91,13 +91,13 @@ import * as AppBskyUnspeccedGetAgeAssuranceState from './types/app/bsky/unspecce
91
91
  import * as AppBskyUnspeccedGetConfig from './types/app/bsky/unspecced/getConfig.js'
92
92
  import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacks from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacks.js'
93
93
  import * as AppBskyUnspeccedGetOnboardingSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedStarterPacksSkeleton.js'
94
+ import * as AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton from './types/app/bsky/unspecced/getOnboardingSuggestedUsersSkeleton.js'
94
95
  import * as AppBskyUnspeccedGetPopularFeedGenerators from './types/app/bsky/unspecced/getPopularFeedGenerators.js'
95
96
  import * as AppBskyUnspeccedGetPostThreadOtherV2 from './types/app/bsky/unspecced/getPostThreadOtherV2.js'
96
97
  import * as AppBskyUnspeccedGetPostThreadV2 from './types/app/bsky/unspecced/getPostThreadV2.js'
97
98
  import * as AppBskyUnspeccedGetSuggestedFeeds from './types/app/bsky/unspecced/getSuggestedFeeds.js'
98
99
  import * as AppBskyUnspeccedGetSuggestedFeedsSkeleton from './types/app/bsky/unspecced/getSuggestedFeedsSkeleton.js'
99
100
  import * as AppBskyUnspeccedGetSuggestedOnboardingUsers from './types/app/bsky/unspecced/getSuggestedOnboardingUsers.js'
100
- import * as AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton from './types/app/bsky/unspecced/getSuggestedOnboardingUsersSkeleton.js'
101
101
  import * as AppBskyUnspeccedGetSuggestedStarterPacks from './types/app/bsky/unspecced/getSuggestedStarterPacks.js'
102
102
  import * as AppBskyUnspeccedGetSuggestedStarterPacksSkeleton from './types/app/bsky/unspecced/getSuggestedStarterPacksSkeleton.js'
103
103
  import * as AppBskyUnspeccedGetSuggestedUsers from './types/app/bsky/unspecced/getSuggestedUsers.js'
@@ -1386,6 +1386,18 @@ export class AppBskyUnspeccedNS {
1386
1386
  return this._server.xrpc.method(nsid, cfg)
1387
1387
  }
1388
1388
 
1389
+ getOnboardingSuggestedUsersSkeleton<A extends Auth = void>(
1390
+ cfg: MethodConfigOrHandler<
1391
+ A,
1392
+ AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.QueryParams,
1393
+ AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.HandlerInput,
1394
+ AppBskyUnspeccedGetOnboardingSuggestedUsersSkeleton.HandlerOutput
1395
+ >,
1396
+ ) {
1397
+ const nsid = 'app.bsky.unspecced.getOnboardingSuggestedUsersSkeleton' // @ts-ignore
1398
+ return this._server.xrpc.method(nsid, cfg)
1399
+ }
1400
+
1389
1401
  getPopularFeedGenerators<A extends Auth = void>(
1390
1402
  cfg: MethodConfigOrHandler<
1391
1403
  A,
@@ -1458,18 +1470,6 @@ export class AppBskyUnspeccedNS {
1458
1470
  return this._server.xrpc.method(nsid, cfg)
1459
1471
  }
1460
1472
 
1461
- getSuggestedOnboardingUsersSkeleton<A extends Auth = void>(
1462
- cfg: MethodConfigOrHandler<
1463
- A,
1464
- AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.QueryParams,
1465
- AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.HandlerInput,
1466
- AppBskyUnspeccedGetSuggestedOnboardingUsersSkeleton.HandlerOutput
1467
- >,
1468
- ) {
1469
- const nsid = 'app.bsky.unspecced.getSuggestedOnboardingUsersSkeleton' // @ts-ignore
1470
- return this._server.xrpc.method(nsid, cfg)
1471
- }
1472
-
1473
1473
  getSuggestedStarterPacks<A extends Auth = void>(
1474
1474
  cfg: MethodConfigOrHandler<
1475
1475
  A,