@sanity/sdk 2.11.0 → 2.12.0

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 (57) hide show
  1. package/dist/_chunks-dts/utils.d.ts +171 -19
  2. package/dist/_chunks-es/_internal.js +41 -26
  3. package/dist/_chunks-es/_internal.js.map +1 -1
  4. package/dist/_chunks-es/createGroqSearchFilter.js +25 -9
  5. package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
  6. package/dist/_chunks-es/telemetryManager.js +25 -19
  7. package/dist/_chunks-es/telemetryManager.js.map +1 -1
  8. package/dist/_chunks-es/version.js +1 -1
  9. package/dist/_exports/_internal.d.ts +27 -11
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +723 -418
  12. package/dist/index.js.map +1 -1
  13. package/package.json +16 -16
  14. package/src/_exports/index.ts +23 -2
  15. package/src/auth/refreshStampedToken.test.ts +2 -2
  16. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +116 -0
  17. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +27 -9
  18. package/src/config/sanityConfig.ts +12 -0
  19. package/src/document/actions.test.ts +112 -1
  20. package/src/document/actions.ts +148 -1
  21. package/src/document/applyDocumentActions.ts +4 -3
  22. package/src/document/documentStore.ts +7 -6
  23. package/src/document/events.test.ts +57 -2
  24. package/src/document/events.ts +43 -24
  25. package/src/document/permissions.ts +1 -1
  26. package/src/document/processActions/create.ts +135 -0
  27. package/src/document/processActions/delete.ts +100 -0
  28. package/src/document/processActions/discard.ts +63 -0
  29. package/src/document/processActions/edit.ts +141 -0
  30. package/src/document/processActions/processActions.ts +209 -0
  31. package/src/document/processActions/publish.ts +120 -0
  32. package/src/document/processActions/releaseArchive.ts +77 -0
  33. package/src/document/processActions/releaseCreate.ts +59 -0
  34. package/src/document/processActions/releaseDelete.ts +65 -0
  35. package/src/document/processActions/releaseEdit.ts +36 -0
  36. package/src/document/processActions/releasePublish.ts +45 -0
  37. package/src/document/processActions/releaseSchedule.ts +87 -0
  38. package/src/document/processActions/releaseUtil.ts +31 -0
  39. package/src/document/processActions/shared.ts +139 -0
  40. package/src/document/processActions/unpublish.ts +85 -0
  41. package/src/document/processActions.test.ts +424 -2
  42. package/src/document/reducers.ts +41 -6
  43. package/src/releases/getPerspectiveState.test.ts +1 -1
  44. package/src/releases/releasesStore.test.ts +50 -1
  45. package/src/releases/releasesStore.ts +41 -18
  46. package/src/releases/utils/sortReleases.test.ts +2 -2
  47. package/src/releases/utils/sortReleases.ts +1 -1
  48. package/src/telemetry/environment.test.ts +119 -0
  49. package/src/telemetry/environment.ts +92 -0
  50. package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
  51. package/src/telemetry/initTelemetry.test.ts +240 -16
  52. package/src/telemetry/initTelemetry.ts +39 -16
  53. package/src/telemetry/telemetryManager.test.ts +129 -65
  54. package/src/telemetry/telemetryManager.ts +41 -29
  55. package/src/document/processActions.ts +0 -735
  56. package/src/telemetry/devMode.test.ts +0 -60
  57. package/src/telemetry/devMode.ts +0 -41
@@ -7,11 +7,12 @@ import {type StoreContext} from '../store/defineStore'
7
7
  import {insecureRandomId} from '../utils/ids'
8
8
  import {omitProperty} from '../utils/object'
9
9
  import {setCleanupTimeout} from '../utils/setCleanupTimeout'
10
- import {type DocumentAction} from './actions'
10
+ import {type Action} from './actions'
11
11
  import {DOCUMENT_STATE_CLEAR_DELAY} from './documentConstants'
12
12
  import {type DocumentState, type DocumentStoreState} from './documentStore'
13
13
  import {type RemoteDocument} from './listen'
14
- import {ActionError, processActions} from './processActions'
14
+ import {ActionError, processActions} from './processActions/processActions'
15
+ import {getReleaseDocumentId, isReleaseAction} from './processActions/releaseUtil'
15
16
  import {type DocumentSet} from './processMutations'
16
17
 
17
18
  const EMPTY_REVISIONS: NonNullable<Required<DocumentState['unverifiedRevisions']>> = {}
@@ -33,6 +34,14 @@ type ActionMap = {
33
34
  delete: 'sanity.action.document.delete'
34
35
  edit: 'sanity.action.document.edit'
35
36
  publish: 'sanity.action.document.publish'
37
+ releaseCreate: 'sanity.action.release.create'
38
+ releaseEdit: 'sanity.action.release.edit'
39
+ releasePublish: 'sanity.action.release.publish'
40
+ releaseSchedule: 'sanity.action.release.schedule'
41
+ releaseUnschedule: 'sanity.action.release.unschedule'
42
+ releaseArchive: 'sanity.action.release.archive'
43
+ releaseUnarchive: 'sanity.action.release.unarchive'
44
+ releaseDelete: 'sanity.action.release.delete'
36
45
  }
37
46
 
38
47
  type OptimisticLock = {
@@ -40,6 +49,14 @@ type OptimisticLock = {
40
49
  ifPublishedRevisionId?: string
41
50
  }
42
51
 
52
+ interface ReleaseMetadataPayload {
53
+ title?: string
54
+ description?: string
55
+ intendedPublishAt?: string
56
+ releaseType?: 'asap' | 'scheduled' | 'undecided'
57
+ cardinality?: 'one' | 'many'
58
+ }
59
+
43
60
  export type HttpAction =
44
61
  | {actionType: ActionMap['create']; publishedId: string; attributes: SanityDocumentLike}
45
62
  | {actionType: ActionMap['discard']; versionId: string; purge?: boolean}
@@ -47,6 +64,18 @@ export type HttpAction =
47
64
  | {actionType: ActionMap['delete']; publishedId: string; includeDrafts?: string[]}
48
65
  | {actionType: ActionMap['edit']; draftId: string; publishedId: string; patch: PatchOperations}
49
66
  | ({actionType: ActionMap['publish']; draftId: string; publishedId: string} & OptimisticLock)
67
+ | {
68
+ actionType: ActionMap['releaseCreate']
69
+ releaseId: string
70
+ metadata?: ReleaseMetadataPayload
71
+ }
72
+ | {actionType: ActionMap['releaseEdit']; releaseId: string; patch: PatchOperations}
73
+ | {actionType: ActionMap['releasePublish']; releaseId: string}
74
+ | {actionType: ActionMap['releaseSchedule']; releaseId: string; publishAt: string}
75
+ | {actionType: ActionMap['releaseUnschedule']; releaseId: string}
76
+ | {actionType: ActionMap['releaseArchive']; releaseId: string}
77
+ | {actionType: ActionMap['releaseUnarchive']; releaseId: string}
78
+ | {actionType: ActionMap['releaseDelete']; releaseId: string}
50
79
 
51
80
  /**
52
81
  * Represents a transaction that is queued to be applied but has not yet been
@@ -63,7 +92,7 @@ export interface QueuedTransaction {
63
92
  * actions don't mention draft IDs and is meant to abstract away the draft
64
93
  * model from users.
65
94
  */
66
- actions: DocumentAction[]
95
+ actions: Action[]
67
96
  /**
68
97
  * An optional flag set to disable this transaction from being batched with
69
98
  * other transactions.
@@ -272,7 +301,9 @@ export function batchAppliedTransactions([curr, ...rest]: AppliedTransaction[]):
272
301
  if (next.disableBatching) return editAction
273
302
 
274
303
  // Don't batch a liveEdit edit with a non-liveEdit edit — they route to different APIs
275
- if (!!action.liveEdit !== !!next.actions[0]?.liveEdit) return editAction
304
+ const nextFirst = next.actions[0]
305
+ const nextLiveEdit = nextFirst && 'liveEdit' in nextFirst ? nextFirst.liveEdit : false
306
+ if (!!action.liveEdit !== !!nextLiveEdit) return editAction
276
307
 
277
308
  return {
278
309
  disableBatching: false,
@@ -586,9 +617,13 @@ export function manageSubscriberIds(
586
617
 
587
618
  // document handles are passed in via the public facing API, but we also need to
588
619
  // pull the correct document ids from action bodies, which have similar but not
589
- // identical shapes to the document handles.
590
- function getDocumentIdsFromHandleLikes(handles: DocumentHandleLike[]): string[] {
620
+ // identical shapes to the document handles. release actions also flow through
621
+ // here, and resolve to the underlying release document id.
622
+ function getDocumentIdsFromHandleLikes(handles: (DocumentHandleLike | Action)[]): string[] {
591
623
  return handles.flatMap((handle) => {
624
+ if ('type' in handle && isReleaseAction(handle)) {
625
+ return [getReleaseDocumentId(handle.releaseId)]
626
+ }
592
627
  const idsForDocument = []
593
628
  if (!handle.documentId) return []
594
629
  if (handle.liveEdit) {
@@ -1,3 +1,4 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {filter, firstValueFrom, of, Subject, take} from 'rxjs'
2
3
  import {describe, expect, it, vi} from 'vitest'
3
4
 
@@ -6,7 +7,6 @@ import {getQueryState} from '../query/queryStore'
6
7
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
7
8
  import {type StateSource} from '../store/createStateSourceAction'
8
9
  import {getPerspectiveState} from './getPerspectiveState'
9
- import {type ReleaseDocument} from './releasesStore'
10
10
 
11
11
  vi.mock('../query/queryStore')
12
12
 
@@ -1,10 +1,11 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {NEVER, Observable, type Observer, of, Subject} from 'rxjs'
2
3
  import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
3
4
 
4
5
  import {getQueryState, resolveQuery} from '../query/queryStore'
5
6
  import {createSanityInstance, type SanityInstance} from '../store/createSanityInstance'
6
7
  import {type StateSource} from '../store/createStateSourceAction'
7
- import {getActiveReleasesState, type ReleaseDocument} from './releasesStore'
8
+ import {getActiveReleasesState, getAllReleasesState} from './releasesStore'
8
9
 
9
10
  // Mock dependencies
10
11
  vi.mock('../query/queryStore')
@@ -167,6 +168,54 @@ describe('releasesStore', () => {
167
168
  expect(state.getCurrent()).toEqual([])
168
169
  })
169
170
 
171
+ it('exposes archived/published releases through getAllReleasesState but filters them out of getActiveReleasesState', async () => {
172
+ const subject = new Subject<ReleaseDocument[]>()
173
+ vi.mocked(getQueryState).mockReturnValue({
174
+ subscribe: () => () => {},
175
+ getCurrent: () => undefined,
176
+ observable: subject.asObservable(),
177
+ } as StateSource<ReleaseDocument[] | undefined>)
178
+
179
+ const active = getActiveReleasesState(instance, {
180
+ resource: {projectId: 'test', dataset: 'test'},
181
+ })
182
+ const all = getAllReleasesState(instance, {resource: {projectId: 'test', dataset: 'test'}})
183
+
184
+ const releases: ReleaseDocument[] = [
185
+ {
186
+ _id: 'r-active',
187
+ _type: 'system.release',
188
+ name: 'r-active',
189
+ state: 'active',
190
+ metadata: {releaseType: 'asap'},
191
+ } as ReleaseDocument,
192
+ {
193
+ _id: 'r-archived',
194
+ _type: 'system.release',
195
+ name: 'r-archived',
196
+ state: 'archived',
197
+ metadata: {releaseType: 'asap'},
198
+ } as ReleaseDocument,
199
+ {
200
+ _id: 'r-published',
201
+ _type: 'system.release',
202
+ name: 'r-published',
203
+ state: 'published',
204
+ metadata: {releaseType: 'asap'},
205
+ } as ReleaseDocument,
206
+ ]
207
+
208
+ subject.next(releases)
209
+ await new Promise((resolve) => setTimeout(resolve, 0))
210
+
211
+ const activeNames = active.getCurrent()?.map((r) => r.name) ?? []
212
+ const allNames = all.getCurrent()?.map((r) => r.name) ?? []
213
+
214
+ expect(activeNames).toEqual(['r-active'])
215
+ expect(allNames).toEqual(expect.arrayContaining(['r-active', 'r-archived', 'r-published']))
216
+ expect(allNames).toHaveLength(3)
217
+ })
218
+
170
219
  it('should not crash when the releases query errors', async () => {
171
220
  const subject = new Subject<ReleaseDocument[]>()
172
221
  vi.mocked(getQueryState).mockReturnValue({
@@ -1,4 +1,4 @@
1
- import {type SanityDocument} from '@sanity/types'
1
+ import {type ReleaseDocument} from '@sanity/client'
2
2
  import {map} from 'rxjs'
3
3
 
4
4
  import {type DocumentResource} from '../config/sanityConfig'
@@ -21,23 +21,23 @@ const ARCHIVED_RELEASE_STATES = ['archived', 'published']
21
21
  const STABLE_EMPTY_RELEASES: ReleaseDocument[] = []
22
22
 
23
23
  /**
24
- * Represents a document in a Sanity dataset that represents release options.
25
- * @internal
24
+ * Lifecycle states a release document can be in. Mirrors the server's
25
+ * `ReleaseState`.
26
+ * @beta
26
27
  */
27
- export type ReleaseDocument = SanityDocument & {
28
- name: string
29
- publishAt?: string
30
- state: 'active' | 'scheduled'
31
- metadata: {
32
- title: string
33
- releaseType: 'asap' | 'scheduled' | 'undecided'
34
- intendedPublishAt?: string
35
- description?: string
36
- }
37
- }
28
+ export type ReleaseState =
29
+ | 'active'
30
+ | 'archiving'
31
+ | 'unarchiving'
32
+ | 'archived'
33
+ | 'published'
34
+ | 'publishing'
35
+ | 'scheduled'
36
+ | 'scheduling'
38
37
 
39
38
  export interface ReleasesStoreState {
40
39
  activeReleases?: ReleaseDocument[]
40
+ allReleases?: ReleaseDocument[]
41
41
  error?: unknown
42
42
  }
43
43
 
@@ -45,6 +45,7 @@ export const releasesStore = defineStore<ReleasesStoreState, BoundResourceKey>({
45
45
  name: 'Releases',
46
46
  getInitialState: (): ReleasesStoreState => ({
47
47
  activeReleases: undefined,
48
+ allReleases: undefined,
48
49
  }),
49
50
  initialize: (context) => {
50
51
  const subscription = subscribeToReleases(context)
@@ -74,6 +75,26 @@ export const getActiveReleasesState = (
74
75
  // bindActionByResource keyFn destructures { resource } from the first param, so pass {} when no options
75
76
  _getActiveReleasesState(instance, options ?? {})
76
77
 
78
+ /**
79
+ * Get every release in the store, including archived and published.
80
+ * @internal
81
+ */
82
+ const _getAllReleasesState = bindActionByResource(
83
+ releasesStore,
84
+ createStateSourceAction({
85
+ selector: ({state}, _?) => state.allReleases,
86
+ }),
87
+ )
88
+
89
+ /**
90
+ * Get every release in the store, including archived and published.
91
+ * @internal
92
+ */
93
+ export const getAllReleasesState = (
94
+ instance: SanityInstance,
95
+ options?: {resource?: DocumentResource},
96
+ ): StateSource<ReleaseDocument[] | undefined> => _getAllReleasesState(instance, options ?? {})
97
+
77
98
  const RELEASES_QUERY = 'releases::all()'
78
99
 
79
100
  const subscribeToReleases = ({
@@ -92,10 +113,12 @@ const subscribeToReleases = ({
92
113
  map((releases) => {
93
114
  // logic here mirrors that of studio:
94
115
  // https://github.com/sanity-io/sanity/blob/156e8fa482703d99219f08da7bacb384517f1513/packages/sanity/src/core/releases/store/useActiveReleases.ts#L29
95
- state.set('setActiveReleases', {
96
- activeReleases: sortReleases(releases ?? STABLE_EMPTY_RELEASES)
97
- .filter((release) => !ARCHIVED_RELEASE_STATES.includes(release.state))
98
- .reverse(),
116
+ const sorted = sortReleases(releases ?? STABLE_EMPTY_RELEASES).reverse()
117
+ state.set('setReleases', {
118
+ allReleases: sorted,
119
+ activeReleases: sorted.filter(
120
+ (release) => !ARCHIVED_RELEASE_STATES.includes(release.state),
121
+ ),
99
122
  })
100
123
  }),
101
124
  )
@@ -1,6 +1,6 @@
1
+ import {type ReleaseDocument} from '@sanity/client'
1
2
  import {describe, expect, it} from 'vitest'
2
3
 
3
- import {type ReleaseDocument} from '../releasesStore'
4
4
  import {sortReleases} from './sortReleases'
5
5
 
6
6
  // Mock function to create a release document
@@ -15,7 +15,7 @@ function createReleaseMock(
15
15
  return {
16
16
  _id: id,
17
17
  _rev: 'rev',
18
- _type: 'release',
18
+ _type: 'system.release',
19
19
  _createdAt: new Date().toISOString(),
20
20
  _updatedAt: new Date().toISOString(),
21
21
  state: 'active',
@@ -1,4 +1,4 @@
1
- import {type ReleaseDocument} from '../releasesStore'
1
+ import {type ReleaseDocument} from '@sanity/client'
2
2
 
3
3
  // mirrors the order of the releases in the releases list in Studio
4
4
  // https://github.com/sanity-io/sanity/blob/main/packages/sanity/src/core/releases/hooks/utils.ts
@@ -0,0 +1,119 @@
1
+ import {afterEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {getTelemetryEnvironment, isTelemetryEnabled} from './environment'
4
+
5
+ describe('getTelemetryEnvironment', () => {
6
+ afterEach(() => {
7
+ vi.unstubAllEnvs()
8
+ vi.unstubAllGlobals()
9
+ })
10
+
11
+ describe('browser', () => {
12
+ it('returns "development" on localhost', () => {
13
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
14
+ expect(getTelemetryEnvironment()).toBe('development')
15
+ })
16
+
17
+ it('returns "development" on 127.0.0.1', () => {
18
+ vi.stubGlobal('window', {location: {hostname: '127.0.0.1'}})
19
+ expect(getTelemetryEnvironment()).toBe('development')
20
+ })
21
+
22
+ it('returns "production" on *.sanity.studio', () => {
23
+ vi.stubGlobal('window', {location: {hostname: 'myapp.sanity.studio'}})
24
+ expect(getTelemetryEnvironment()).toBe('production')
25
+ })
26
+
27
+ it('returns "production" on www.sanity.io (dashboard)', () => {
28
+ vi.stubGlobal('window', {location: {hostname: 'www.sanity.io'}})
29
+ expect(getTelemetryEnvironment()).toBe('production')
30
+ })
31
+
32
+ it('returns null on *.sanity.work (staging is intentionally not allowlisted)', () => {
33
+ vi.stubGlobal('window', {location: {hostname: 'www.sanity.work'}})
34
+ expect(getTelemetryEnvironment()).toBeNull()
35
+ })
36
+
37
+ it('returns null on *.sanity.dev (preview hosts are intentionally not allowlisted)', () => {
38
+ vi.stubGlobal('window', {location: {hostname: 'preview-123.sanity.dev'}})
39
+ expect(getTelemetryEnvironment()).toBeNull()
40
+ })
41
+
42
+ it('is case-insensitive on hostname', () => {
43
+ vi.stubGlobal('window', {location: {hostname: 'MyApp.Sanity.Studio'}})
44
+ expect(getTelemetryEnvironment()).toBe('production')
45
+ })
46
+
47
+ it('returns null on a customer-controlled domain', () => {
48
+ vi.stubGlobal('window', {location: {hostname: 'myapp.customer.com'}})
49
+ expect(getTelemetryEnvironment()).toBeNull()
50
+ })
51
+
52
+ it('returns null on a lookalike subdomain (suffix match requires a leading dot)', () => {
53
+ // `evilsanity.studio` ends in `sanity.studio` but not `.sanity.studio`,
54
+ // so the suffix check rejects it.
55
+ vi.stubGlobal('window', {location: {hostname: 'evilsanity.studio'}})
56
+ expect(getTelemetryEnvironment()).toBeNull()
57
+ })
58
+
59
+ it('returns null on the bare apex hostname (sanity.studio with no subdomain)', () => {
60
+ // The allowlist intentionally only matches subdomains (the leading
61
+ // `.` in `.sanity.studio` means the apex `sanity.studio` is excluded).
62
+ vi.stubGlobal('window', {location: {hostname: 'sanity.studio'}})
63
+ expect(getTelemetryEnvironment()).toBeNull()
64
+ })
65
+
66
+ it('returns null when hostname is missing', () => {
67
+ vi.stubGlobal('window', {location: {}})
68
+ expect(getTelemetryEnvironment()).toBeNull()
69
+ })
70
+
71
+ it('returns "development" on localhost even when NODE_ENV is production', () => {
72
+ vi.stubEnv('NODE_ENV', 'production')
73
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
74
+ expect(getTelemetryEnvironment()).toBe('development')
75
+ })
76
+ })
77
+
78
+ describe('node', () => {
79
+ it('returns "development" when NODE_ENV=development and no window', () => {
80
+ vi.stubEnv('NODE_ENV', 'development')
81
+ vi.stubGlobal('window', undefined)
82
+ expect(getTelemetryEnvironment()).toBe('development')
83
+ })
84
+
85
+ it('returns null when NODE_ENV=production and no window', () => {
86
+ vi.stubEnv('NODE_ENV', 'production')
87
+ vi.stubGlobal('window', undefined)
88
+ expect(getTelemetryEnvironment()).toBeNull()
89
+ })
90
+
91
+ it('returns null when NODE_ENV=test and no window', () => {
92
+ vi.stubEnv('NODE_ENV', 'test')
93
+ vi.stubGlobal('window', undefined)
94
+ expect(getTelemetryEnvironment()).toBeNull()
95
+ })
96
+ })
97
+ })
98
+
99
+ describe('isTelemetryEnabled', () => {
100
+ afterEach(() => {
101
+ vi.unstubAllEnvs()
102
+ vi.unstubAllGlobals()
103
+ })
104
+
105
+ it('returns true on a Sanity-controlled domain', () => {
106
+ vi.stubGlobal('window', {location: {hostname: 'app.sanity.studio'}})
107
+ expect(isTelemetryEnabled()).toBe(true)
108
+ })
109
+
110
+ it('returns true on localhost', () => {
111
+ vi.stubGlobal('window', {location: {hostname: 'localhost'}})
112
+ expect(isTelemetryEnabled()).toBe(true)
113
+ })
114
+
115
+ it('returns false on a customer domain', () => {
116
+ vi.stubGlobal('window', {location: {hostname: 'myapp.example.com'}})
117
+ expect(isTelemetryEnabled()).toBe(false)
118
+ })
119
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Telemetry environment classification.
3
+ *
4
+ * - `'development'` — the SDK is running on the local developer's machine
5
+ * (`localhost` / `127.0.0.1` in the browser, or `NODE_ENV=development`
6
+ * in Node). These are the original dev-only telemetry sessions.
7
+ *
8
+ * - `'production'` — the SDK is running on a production Sanity-controlled
9
+ * domain (Studio deployments on `*.sanity.studio`, the dashboard on
10
+ * `*.sanity.io`) where end users are authenticated Sanity users with
11
+ * Populus consent records. Production telemetry is gated to this
12
+ * allowlist; SDK apps deployed to customer-controlled domains do not
13
+ * emit telemetry. Staging (`*.sanity.work`) and preview
14
+ * (`*.sanity.dev`) hosts are deliberately excluded.
15
+ *
16
+ * @internal
17
+ */
18
+ export type TelemetryEnvironment = 'development' | 'production'
19
+
20
+ /**
21
+ * Hostname suffixes that count as Sanity-controlled for the purposes of
22
+ * production telemetry. A user reaching one of these hosts in the
23
+ * browser is authenticated against Sanity and has a Populus consent
24
+ * record, so we apply the same telemetry rules as the Studio's
25
+ * `telemetry-sink`.
26
+ *
27
+ * The leading `.` is required: it ensures suffix matches hit a real
28
+ * subdomain boundary, so apex matches (`sanity.io`, `sanity.studio`)
29
+ * and lookalikes (`evilsanity.studio`) are excluded. Only production
30
+ * hosts are listed; staging (`*.sanity.work`) and preview
31
+ * (`*.sanity.dev`) hosts are excluded by omission.
32
+ *
33
+ * @internal
34
+ */
35
+ const SANITY_CONTROLLED_HOST_SUFFIXES = ['.sanity.studio', '.sanity.io'] as const
36
+
37
+ function getBrowserHostname(win: Window): string | null {
38
+ const hostname = win.location?.hostname
39
+ return typeof hostname === 'string' && hostname.length > 0 ? hostname.toLowerCase() : null
40
+ }
41
+
42
+ function isLocalHostname(hostname: string): boolean {
43
+ return hostname === 'localhost' || hostname === '127.0.0.1'
44
+ }
45
+
46
+ function isSanityControlledHostname(hostname: string): boolean {
47
+ return SANITY_CONTROLLED_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))
48
+ }
49
+
50
+ /**
51
+ * Returns the telemetry environment for the current runtime, or `null`
52
+ * when telemetry should not run at all.
53
+ *
54
+ * Browser:
55
+ * - `localhost` / `127.0.0.1` → `'development'`
56
+ * - host matches the Sanity-controlled allowlist → `'production'`
57
+ * - anything else (customer domains, unknown contexts) → `null`
58
+ *
59
+ * Node (scripts, SSR, tests):
60
+ * - `NODE_ENV=development` → `'development'`
61
+ * - otherwise → `null`. Production-side server runtimes don't carry the
62
+ * browser-authenticated user/consent assumption, so we don't enable
63
+ * them under the production gate.
64
+ *
65
+ * Bracket-notation `process.env['NODE_ENV']` is used to avoid bundler
66
+ * dead-code replacement.
67
+ *
68
+ * @internal
69
+ */
70
+ export function getTelemetryEnvironment(): TelemetryEnvironment | null {
71
+ if (typeof window !== 'undefined') {
72
+ const hostname = getBrowserHostname(window)
73
+ if (!hostname) return null
74
+ if (isLocalHostname(hostname)) return 'development'
75
+ if (isSanityControlledHostname(hostname)) return 'production'
76
+ return null
77
+ }
78
+
79
+ if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development') {
80
+ return 'development'
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Convenience predicate for "telemetry can run in this environment".
87
+ *
88
+ * @internal
89
+ */
90
+ export function isTelemetryEnabled(): boolean {
91
+ return getTelemetryEnvironment() !== null
92
+ }
@@ -1,15 +1,15 @@
1
1
  import {defineEvent} from '@sanity/telemetry'
2
2
 
3
3
  /** @internal */
4
- export const SDKDevSessionStarted = defineEvent<{
4
+ export const SDKSessionStarted = defineEvent<{
5
5
  version: string
6
6
  projectId: string
7
7
  perspective: string
8
8
  authMethod: string
9
9
  }>({
10
- name: 'SDK Dev Session Started',
10
+ name: 'SDK Session Started',
11
11
  version: 1,
12
- description: 'SDK instance created in development mode',
12
+ description: 'SDK instance created (environment is recorded in the event context)',
13
13
  })
14
14
 
15
15
  /** @internal */
@@ -22,21 +22,21 @@ export const SDKHookMounted = defineEvent<{
22
22
  })
23
23
 
24
24
  /** @internal */
25
- export const SDKDevSessionEnded = defineEvent<{
25
+ export const SDKSessionEnded = defineEvent<{
26
26
  durationSeconds: number
27
27
  hooksUsed: string[]
28
28
  }>({
29
- name: 'SDK Dev Session Ended',
29
+ name: 'SDK Session Ended',
30
30
  version: 1,
31
- description: 'SDK instance disposed in development mode',
31
+ description: 'SDK instance disposed (environment is recorded in the event context)',
32
32
  })
33
33
 
34
34
  /** @internal */
35
- export const SDKDevError = defineEvent<{
35
+ export const SDKError = defineEvent<{
36
36
  errorType: string
37
37
  hookName: string
38
38
  }>({
39
- name: 'SDK Dev Error',
39
+ name: 'SDK Error',
40
40
  version: 1,
41
- description: 'Runtime error caught during SDK development',
41
+ description: 'Runtime error caught in the SDK',
42
42
  })