@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.
- package/dist/_chunks-dts/utils.d.ts +171 -19
- package/dist/_chunks-es/_internal.js +41 -26
- package/dist/_chunks-es/_internal.js.map +1 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +25 -9
- package/dist/_chunks-es/createGroqSearchFilter.js.map +1 -1
- package/dist/_chunks-es/telemetryManager.js +25 -19
- package/dist/_chunks-es/telemetryManager.js.map +1 -1
- package/dist/_chunks-es/version.js +1 -1
- package/dist/_exports/_internal.d.ts +27 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +723 -418
- package/dist/index.js.map +1 -1
- package/package.json +16 -16
- package/src/_exports/index.ts +23 -2
- package/src/auth/refreshStampedToken.test.ts +2 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +116 -0
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +27 -9
- package/src/config/sanityConfig.ts +12 -0
- package/src/document/actions.test.ts +112 -1
- package/src/document/actions.ts +148 -1
- package/src/document/applyDocumentActions.ts +4 -3
- package/src/document/documentStore.ts +7 -6
- package/src/document/events.test.ts +57 -2
- package/src/document/events.ts +43 -24
- package/src/document/permissions.ts +1 -1
- package/src/document/processActions/create.ts +135 -0
- package/src/document/processActions/delete.ts +100 -0
- package/src/document/processActions/discard.ts +63 -0
- package/src/document/processActions/edit.ts +141 -0
- package/src/document/processActions/processActions.ts +209 -0
- package/src/document/processActions/publish.ts +120 -0
- package/src/document/processActions/releaseArchive.ts +77 -0
- package/src/document/processActions/releaseCreate.ts +59 -0
- package/src/document/processActions/releaseDelete.ts +65 -0
- package/src/document/processActions/releaseEdit.ts +36 -0
- package/src/document/processActions/releasePublish.ts +45 -0
- package/src/document/processActions/releaseSchedule.ts +87 -0
- package/src/document/processActions/releaseUtil.ts +31 -0
- package/src/document/processActions/shared.ts +139 -0
- package/src/document/processActions/unpublish.ts +85 -0
- package/src/document/processActions.test.ts +424 -2
- package/src/document/reducers.ts +41 -6
- package/src/releases/getPerspectiveState.test.ts +1 -1
- package/src/releases/releasesStore.test.ts +50 -1
- package/src/releases/releasesStore.ts +41 -18
- package/src/releases/utils/sortReleases.test.ts +2 -2
- package/src/releases/utils/sortReleases.ts +1 -1
- package/src/telemetry/environment.test.ts +119 -0
- package/src/telemetry/environment.ts +92 -0
- package/src/telemetry/{__telemetry__/sdk.telemetry.ts → events.ts} +9 -9
- package/src/telemetry/initTelemetry.test.ts +240 -16
- package/src/telemetry/initTelemetry.ts +39 -16
- package/src/telemetry/telemetryManager.test.ts +129 -65
- package/src/telemetry/telemetryManager.ts +41 -29
- package/src/document/processActions.ts +0 -735
- package/src/telemetry/devMode.test.ts +0 -60
- package/src/telemetry/devMode.ts +0 -41
package/src/document/reducers.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Lifecycle states a release document can be in. Mirrors the server's
|
|
25
|
+
* `ReleaseState`.
|
|
26
|
+
* @beta
|
|
26
27
|
*/
|
|
27
|
-
export type
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 '
|
|
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
|
|
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
|
|
10
|
+
name: 'SDK Session Started',
|
|
11
11
|
version: 1,
|
|
12
|
-
description: 'SDK instance created in
|
|
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
|
|
25
|
+
export const SDKSessionEnded = defineEvent<{
|
|
26
26
|
durationSeconds: number
|
|
27
27
|
hooksUsed: string[]
|
|
28
28
|
}>({
|
|
29
|
-
name: 'SDK
|
|
29
|
+
name: 'SDK Session Ended',
|
|
30
30
|
version: 1,
|
|
31
|
-
description: 'SDK instance disposed in
|
|
31
|
+
description: 'SDK instance disposed (environment is recorded in the event context)',
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
/** @internal */
|
|
35
|
-
export const
|
|
35
|
+
export const SDKError = defineEvent<{
|
|
36
36
|
errorType: string
|
|
37
37
|
hookName: string
|
|
38
38
|
}>({
|
|
39
|
-
name: 'SDK
|
|
39
|
+
name: 'SDK Error',
|
|
40
40
|
version: 1,
|
|
41
|
-
description: 'Runtime error caught
|
|
41
|
+
description: 'Runtime error caught in the SDK',
|
|
42
42
|
})
|