@sanity/sdk 0.0.1 → 0.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -53,7 +53,7 @@
53
53
  "lodash-es": "^4.17.21",
54
54
  "reselect": "^5.1.1",
55
55
  "rxjs": "^7.8.2",
56
- "zustand": "^5.0.3"
56
+ "zustand": "^5.0.4"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@sanity/browserslist-config": "^1.0.5",
@@ -69,8 +69,8 @@
69
69
  "vitest": "^3.1.2",
70
70
  "@repo/config-eslint": "0.0.0",
71
71
  "@repo/config-test": "0.0.1",
72
- "@repo/package.config": "0.0.1",
73
72
  "@repo/package.bundle": "3.82.0",
73
+ "@repo/package.config": "0.0.1",
74
74
  "@repo/tsconfig": "0.0.1"
75
75
  },
76
76
  "engines": {
@@ -32,7 +32,7 @@ export {
32
32
  releaseChannel,
33
33
  } from '../comlink/controller/comlinkControllerStore'
34
34
  export type {ComlinkNodeState} from '../comlink/node/comlinkNodeStore'
35
- export {getOrCreateNode, releaseNode} from '../comlink/node/comlinkNodeStore'
35
+ export {getNodeState, type NodeState} from '../comlink/node/getNodeState'
36
36
  export {type FrameMessage, type WindowMessage} from '../comlink/types'
37
37
  export {type AuthConfig, type AuthProvider} from '../config/authConfig'
38
38
  export {
@@ -12,6 +12,7 @@ vi.mock('@sanity/comlink', () => ({
12
12
  createNode: vi.fn(() => ({
13
13
  start: vi.fn(),
14
14
  stop: vi.fn(),
15
+ onStatus: vi.fn(),
15
16
  })),
16
17
  }))
17
18
 
@@ -32,9 +33,9 @@ describe('getOrCreateNode', () => {
32
33
  }
33
34
 
34
35
  beforeEach(() => {
35
- mockNode = {start: vi.fn(), stop: vi.fn()}
36
+ mockNode = {start: vi.fn(), stop: vi.fn(), onStatus: vi.fn()}
36
37
  vi.mocked(comlink.createNode).mockReturnValue(mockNode as Node<WindowMessage, FrameMessage>)
37
- state = createStoreState<ComlinkNodeState>({nodes: new Map()})
38
+ state = createStoreState<ComlinkNodeState>({nodes: new Map(), subscriptions: new Map()})
38
39
  vi.clearAllMocks()
39
40
  })
40
41
 
@@ -45,7 +46,7 @@ describe('getOrCreateNode', () => {
45
46
  expect(node.start).toHaveBeenCalled()
46
47
  })
47
48
 
48
- it('sshould store the node in nodeStore', () => {
49
+ it('should store the node in nodeStore', () => {
49
50
  const node = getOrCreateNode({state, instance}, nodeConfig)
50
51
 
51
52
  expect(getOrCreateNode({state, instance}, nodeConfig)).toBe(node)
@@ -64,4 +65,42 @@ describe('getOrCreateNode', () => {
64
65
  ),
65
66
  ).toThrow('Node "test-node" already exists with different options')
66
67
  })
68
+
69
+ it('should subscribe to status changes and update state', () => {
70
+ let statusCallback: ((status: string) => void) | undefined
71
+ const statusUnsubMock = vi.fn()
72
+ mockNode.onStatus = vi.fn((cb) => {
73
+ statusCallback = cb
74
+ return statusUnsubMock
75
+ })
76
+
77
+ getOrCreateNode({state, instance}, nodeConfig)
78
+
79
+ expect(mockNode.onStatus).toHaveBeenCalled()
80
+ expect(state.get().nodes.get(nodeConfig.name)?.statusUnsub).toBe(statusUnsubMock)
81
+
82
+ statusCallback?.('connected')
83
+
84
+ expect(state.get().nodes.get(nodeConfig.name)?.status).toBe('connected')
85
+ })
86
+
87
+ it('should not update state if node entry is missing when status changes', () => {
88
+ let statusCallback: ((status: string) => void) | undefined
89
+ const statusUnsubMock = vi.fn()
90
+ mockNode.onStatus = vi.fn((cb) => {
91
+ statusCallback = cb
92
+ return statusUnsubMock
93
+ })
94
+
95
+ getOrCreateNode({state, instance}, nodeConfig)
96
+
97
+ // Remove the node entry before triggering the status callback
98
+ state.get().nodes.delete(nodeConfig.name)
99
+
100
+ // Simulate a status change
101
+ statusCallback?.('connected')
102
+
103
+ // Assert: node entry is still missing, so no update occurred
104
+ expect(state.get().nodes.has(nodeConfig.name)).toBe(false)
105
+ })
67
106
  })
@@ -5,12 +5,6 @@ import {type StoreContext} from '../../../store/defineStore'
5
5
  import {type FrameMessage, type WindowMessage} from '../../types'
6
6
  import {type ComlinkNodeState} from '../comlinkNodeStore'
7
7
 
8
- /**
9
- * Retrieve or create a node to be used for communication between
10
- * an application and the controller -- specifically, a node should
11
- * be created within a frame / window to communicate with the controller.
12
- * @public
13
- */
14
8
  export const getOrCreateNode = (
15
9
  {state}: StoreContext<ComlinkNodeState>,
16
10
  options: NodeInput,
@@ -24,13 +18,6 @@ export const getOrCreateNode = (
24
18
  throw new Error(`Node "${options.name}" already exists with different options`)
25
19
  }
26
20
 
27
- state.set('incrementNodeRefCount', {
28
- nodes: new Map(nodes).set(options.name, {
29
- ...existing,
30
- refCount: existing.refCount + 1,
31
- }),
32
- })
33
-
34
21
  existing.node.start()
35
22
  return existing.node
36
23
  }
@@ -38,7 +25,29 @@ export const getOrCreateNode = (
38
25
  const node: Node<WindowMessage, FrameMessage> = createNode(options)
39
26
  node.start()
40
27
 
41
- nodes.set(options.name, {node, options, refCount: 1})
28
+ // Subscribe to status changes
29
+ const statusUnsub = node.onStatus((status) => {
30
+ const currentNodes = state.get().nodes
31
+ const currentEntry = currentNodes.get(options.name)
32
+ if (!currentEntry) return
33
+ const updatedEntry = {
34
+ ...currentEntry,
35
+ status,
36
+ }
37
+ state.set('updateNodeStatus', {
38
+ nodes: new Map(currentNodes).set(options.name, updatedEntry),
39
+ })
40
+ })
41
+
42
+ // Set up initial entry with status, error, and statusUnsub
43
+ const entry = {
44
+ node,
45
+ options,
46
+ status: 'idle' as const,
47
+ statusUnsub,
48
+ }
49
+
50
+ nodes.set(options.name, entry)
42
51
 
43
52
  state.set('createNode', {nodes})
44
53
 
@@ -4,7 +4,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'
4
4
  import {createSanityInstance, type SanityInstance} from '../../../store/createSanityInstance'
5
5
  import {createStoreState} from '../../../store/createStoreState'
6
6
  import {type FrameMessage, type WindowMessage} from '../../types'
7
- import {type ComlinkNodeState, type NodeEntry} from '../comlinkNodeStore'
7
+ import {type ComlinkNodeState} from '../comlinkNodeStore'
8
8
  import {releaseNode} from './releaseNode'
9
9
 
10
10
  const nodeConfig = {
@@ -18,6 +18,7 @@ describe('releaseNode', () => {
18
18
  let mockNode: Partial<Node<WindowMessage, FrameMessage>> & {
19
19
  start: ReturnType<typeof vi.fn>
20
20
  stop: ReturnType<typeof vi.fn>
21
+ onStatus: ReturnType<typeof vi.fn>
21
22
  }
22
23
 
23
24
  beforeEach(() => {
@@ -25,8 +26,8 @@ describe('releaseNode', () => {
25
26
  projectId: 'test-project-id',
26
27
  dataset: 'test-dataset',
27
28
  })
28
- mockNode = {start: vi.fn(), stop: vi.fn()}
29
- state = createStoreState<ComlinkNodeState>({nodes: new Map()})
29
+ mockNode = {start: vi.fn(), stop: vi.fn(), onStatus: vi.fn()}
30
+ state = createStoreState<ComlinkNodeState>({nodes: new Map(), subscriptions: new Map()})
30
31
  vi.clearAllMocks()
31
32
  })
32
33
 
@@ -40,7 +41,6 @@ describe('releaseNode', () => {
40
41
  nodes.set('test-node', {
41
42
  node: mockNode as Node<WindowMessage, FrameMessage>,
42
43
  options: nodeConfig,
43
- refCount: 1,
44
44
  })
45
45
  state.set('setup', {nodes})
46
46
 
@@ -54,83 +54,18 @@ describe('releaseNode', () => {
54
54
  expect(state.get().nodes.has('test-node')).toBe(false)
55
55
  })
56
56
 
57
- it('should not stop the node if refCount is still above 0', () => {
58
- // Create a node twice to increment refCount
57
+ it('should call statusUnsub if present when releasing node', () => {
58
+ const statusUnsub = vi.fn()
59
59
  const nodes = new Map()
60
60
  nodes.set('test-node', {
61
61
  node: mockNode as Node<WindowMessage, FrameMessage>,
62
62
  options: nodeConfig,
63
- refCount: 2,
63
+ statusUnsub,
64
64
  })
65
65
  state.set('setup', {nodes})
66
66
 
67
- // Release once
68
67
  releaseNode({state, instance}, 'test-node')
69
68
 
70
- // Node should not be stopped
71
- expect(mockNode.stop).not.toHaveBeenCalled()
72
-
73
- // Verify refCount is 1
74
- const nodeEntry = state.get().nodes.get('test-node') as NodeEntry
75
- expect(nodeEntry?.refCount).toBe(1)
76
- })
77
-
78
- it('should handle multiple releases gracefully', () => {
79
- // Set up a node in the state
80
- const nodes = new Map()
81
- nodes.set('test-node', {
82
- node: mockNode as Node<WindowMessage, FrameMessage>,
83
- options: nodeConfig,
84
- refCount: 1,
85
- })
86
- state.set('setup', {nodes})
87
-
88
- // Release multiple times
89
- releaseNode({state, instance}, 'test-node')
90
- releaseNode({state, instance}, 'test-node')
91
- releaseNode({state, instance}, 'test-node')
92
-
93
- // Verify node is removed after first release
94
- expect(state.get().nodes.has('test-node')).toBe(false)
95
- // Stop should be called exactly once
96
- expect(mockNode.stop).toHaveBeenCalledTimes(1)
97
- })
98
-
99
- it('should handle releasing non-existent nodes', () => {
100
- // Should not throw when releasing non-existent node
101
- expect(() => releaseNode({state, instance}, 'non-existent')).not.toThrow()
102
- })
103
-
104
- it('should maintain correct state after complex operations', () => {
105
- // Set up a node with refCount = 3
106
- const nodes = new Map()
107
- nodes.set('test-node', {
108
- node: mockNode as Node<WindowMessage, FrameMessage>,
109
- options: nodeConfig,
110
- refCount: 3,
111
- })
112
- state.set('setup', {nodes})
113
-
114
- // Initial refCount should be 3
115
- let nodeEntry = state.get().nodes.get('test-node') as NodeEntry
116
- expect(nodeEntry?.refCount).toBe(3)
117
-
118
- // Release twice
119
- releaseNode({state, instance}, 'test-node')
120
- releaseNode({state, instance}, 'test-node')
121
-
122
- nodeEntry = state.get().nodes.get('test-node') as NodeEntry
123
- expect(nodeEntry?.refCount).toBe(1)
124
-
125
- // Verify node hasn't been stopped yet
126
- expect(mockNode.stop).not.toHaveBeenCalled()
127
-
128
- // Release final reference
129
- releaseNode({state, instance}, 'test-node')
130
-
131
- // Verify node was stopped
132
- expect(mockNode.stop).toHaveBeenCalled()
133
-
134
- expect(state.get().nodes.has('test-node')).toBe(false)
69
+ expect(statusUnsub).toHaveBeenCalled()
135
70
  })
136
71
  })
@@ -1,30 +1,17 @@
1
1
  import {type StoreContext} from '../../../store/defineStore'
2
2
  import {type ComlinkNodeState} from '../comlinkNodeStore'
3
3
 
4
- /**
5
- * Release a node that was previously created with getOrCreateNode.
6
- * @public
7
- */
8
4
  export const releaseNode = ({state}: StoreContext<ComlinkNodeState>, name: string): void => {
9
5
  const nodes = state.get().nodes
10
6
  const existing = nodes.get(name)
11
7
 
12
8
  if (existing) {
13
- const newRefCount = existing.refCount - 1
14
-
15
- if (newRefCount <= 0) {
16
- existing.node.stop()
17
- nodes.delete(name)
18
- state.set('removeNode', {nodes})
19
- return
9
+ if (existing.statusUnsub) {
10
+ existing.statusUnsub()
20
11
  }
21
-
22
- state.set('decrementNodeRefCount', {
23
- nodes: new Map(nodes).set(name, {
24
- ...existing,
25
- refCount: newRefCount,
26
- }),
27
- })
12
+ existing.node.stop()
13
+ nodes.delete(name)
14
+ state.set('removeNode', {nodes})
28
15
  return
29
16
  }
30
17
  }
@@ -1,4 +1,4 @@
1
- import {type Node, type NodeInput} from '@sanity/comlink'
1
+ import {type Node, type NodeInput, type Status} from '@sanity/comlink'
2
2
 
3
3
  import {bindActionGlobally} from '../../store/createActionBinder'
4
4
  import {defineStore} from '../../store/defineStore'
@@ -14,8 +14,9 @@ export interface NodeEntry {
14
14
  node: Node<WindowMessage, FrameMessage>
15
15
  // we store options to ensure that channels remain as unique / consistent as possible
16
16
  options: NodeInput
17
- // we store refCount to ensure nodes are running only as long as they are in use
18
- refCount: number
17
+ // status of the node connection
18
+ status: Status
19
+ statusUnsub?: () => void
19
20
  }
20
21
 
21
22
  /**
@@ -24,12 +25,15 @@ export interface NodeEntry {
24
25
  */
25
26
  export interface ComlinkNodeState {
26
27
  nodes: Map<string, NodeEntry>
28
+ // Map of node name to set of active subscriber symbols
29
+ subscriptions: Map<string, Set<symbol>>
27
30
  }
28
31
 
29
32
  export const comlinkNodeStore = defineStore<ComlinkNodeState>({
30
33
  name: 'nodeStore',
31
34
  getInitialState: () => ({
32
35
  nodes: new Map(),
36
+ subscriptions: new Map(),
33
37
  }),
34
38
 
35
39
  initialize({state}) {
@@ -0,0 +1,90 @@
1
+ import {type Node} from '@sanity/comlink'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {createSanityInstance} from '../../store/createSanityInstance'
5
+ import {type FrameMessage, type WindowMessage} from '../types'
6
+ import * as comlinkNodeStoreModule from './comlinkNodeStore'
7
+ import {getOrCreateNode} from './comlinkNodeStore'
8
+ import {getNodeState} from './getNodeState'
9
+
10
+ const mockNode: Node<WindowMessage, FrameMessage> = {
11
+ start: vi.fn(),
12
+ stop: vi.fn(),
13
+ onStatus: vi.fn(),
14
+ } as unknown as Node<WindowMessage, FrameMessage>
15
+
16
+ vi.mock('@sanity/comlink', () => ({
17
+ createNode: vi.fn(() => mockNode),
18
+ }))
19
+
20
+ const nodeConfig = {name: 'test-node', connectTo: 'parent'}
21
+
22
+ describe('getNodeState', () => {
23
+ let instance: ReturnType<typeof createSanityInstance>
24
+
25
+ beforeEach(() => {
26
+ instance = createSanityInstance({projectId: 'test', dataset: 'test'})
27
+ })
28
+
29
+ afterEach(() => {
30
+ vi.clearAllMocks()
31
+ instance.dispose()
32
+ })
33
+
34
+ it('returns undefined if node is not present', () => {
35
+ const source = getNodeState(instance, nodeConfig)
36
+ expect(source.getCurrent()).toBeUndefined()
37
+ })
38
+
39
+ it('returns node and status if node is present and connected', async () => {
40
+ let statusCallback: ((status: 'idle' | 'handshaking' | 'connected') => void) | undefined
41
+
42
+ mockNode.onStatus = (cb: (status: 'idle' | 'handshaking' | 'connected') => void) => {
43
+ statusCallback = cb
44
+ return () => {}
45
+ }
46
+
47
+ // Subscribe to the state source first
48
+ const source = getNodeState(instance, nodeConfig)
49
+ source.subscribe(() => {})
50
+
51
+ // Actually create the node
52
+ getOrCreateNode(instance, nodeConfig)
53
+
54
+ // Simulate the node becoming connected
55
+ statusCallback?.('connected')
56
+
57
+ // Await a tick for the selector to pick up the change
58
+ await new Promise((resolve) => setTimeout(resolve, 0))
59
+
60
+ expect(source.getCurrent()).toEqual({node: mockNode, status: 'connected'})
61
+ })
62
+
63
+ it('onSubscribe calls getOrCreateNode', () => {
64
+ const spy = vi.spyOn(comlinkNodeStoreModule, 'getOrCreateNode')
65
+ const source = getNodeState(instance, nodeConfig)
66
+ const unsubscribe = source.subscribe()
67
+ expect(spy).toHaveBeenCalledWith(instance, nodeConfig)
68
+ unsubscribe()
69
+ })
70
+
71
+ it('unsubscribe calls releaseNode', async () => {
72
+ vi.useFakeTimers()
73
+ const spy = vi.spyOn(comlinkNodeStoreModule, 'releaseNode')
74
+ let statusCallback: ((status: 'idle' | 'handshaking' | 'connected') => void) | undefined
75
+ mockNode.onStatus = (cb: (status: 'idle' | 'handshaking' | 'connected') => void) => {
76
+ statusCallback = cb
77
+ return () => {}
78
+ }
79
+ const source = getNodeState(instance, nodeConfig)
80
+ const unsubscribe = source.subscribe(() => {})
81
+
82
+ getOrCreateNode(instance, nodeConfig)
83
+ statusCallback?.('connected')
84
+
85
+ unsubscribe()
86
+ vi.advanceTimersByTime(5000)
87
+ expect(spy).toHaveBeenCalledWith(instance, 'test-node')
88
+ vi.useRealTimers()
89
+ })
90
+ })
@@ -0,0 +1,73 @@
1
+ import {type Node, type NodeInput, type Status} from '@sanity/comlink'
2
+ import {createSelector} from 'reselect'
3
+
4
+ import {bindActionGlobally} from '../../store/createActionBinder'
5
+ import {createStateSourceAction, type SelectorContext} from '../../store/createStateSourceAction'
6
+ import {type FrameMessage, type WindowMessage} from '../types'
7
+ import {
8
+ type ComlinkNodeState,
9
+ comlinkNodeStore,
10
+ getOrCreateNode,
11
+ releaseNode,
12
+ } from './comlinkNodeStore'
13
+
14
+ const NODE_RELEASE_TIME = 5000
15
+
16
+ // Public shape for node state
17
+ /**
18
+ * @public
19
+ */
20
+ export interface NodeState {
21
+ node: Node<WindowMessage, FrameMessage>
22
+ status: Status | undefined
23
+ }
24
+ const selectNode = (context: SelectorContext<ComlinkNodeState>, nodeInput: NodeInput) =>
25
+ context.state.nodes.get(nodeInput.name)
26
+
27
+ /**
28
+ * Provides a subscribable state source for a node by name
29
+ * @param instance - The Sanity instance to get the node state for
30
+ * @param nodeInput - The configuration for the node to get the state for
31
+
32
+ * @returns A subscribable state source for the node
33
+ * @public
34
+ */
35
+ export const getNodeState = bindActionGlobally(
36
+ comlinkNodeStore,
37
+ createStateSourceAction<ComlinkNodeState, [NodeInput], NodeState | undefined>({
38
+ selector: createSelector([selectNode], (nodeEntry) => {
39
+ return nodeEntry?.status === 'connected'
40
+ ? {
41
+ node: nodeEntry.node,
42
+ status: nodeEntry.status,
43
+ }
44
+ : undefined
45
+ }),
46
+ onSubscribe: ({state, instance}, nodeInput) => {
47
+ const nodeName = nodeInput.name
48
+ const subscriberId = Symbol('comlink-node-subscriber')
49
+ getOrCreateNode(instance, nodeInput)
50
+
51
+ // Add subscriber to the set for this node
52
+ let subs = state.get().subscriptions.get(nodeName)
53
+ if (!subs) {
54
+ subs = new Set()
55
+ state.get().subscriptions.set(nodeName, subs)
56
+ }
57
+ subs.add(subscriberId)
58
+
59
+ return () => {
60
+ setTimeout(() => {
61
+ const activeSubs = state.get().subscriptions.get(nodeName)
62
+ if (activeSubs) {
63
+ activeSubs.delete(subscriberId)
64
+ if (activeSubs.size === 0) {
65
+ state.get().subscriptions.delete(nodeName)
66
+ releaseNode(instance, nodeName)
67
+ }
68
+ }
69
+ }, NODE_RELEASE_TIME)
70
+ }
71
+ },
72
+ }),
73
+ )
@@ -7,4 +7,4 @@
7
7
  */
8
8
  export const DOCUMENT_STATE_CLEAR_DELAY = 1000
9
9
  export const INITIAL_OUTGOING_THROTTLE_TIME = 1000
10
- export const API_VERSION = 'vX'
10
+ export const API_VERSION = 'v2025-05-06'
@@ -16,7 +16,7 @@ import {
16
16
  import {getClientState} from '../client/clientStore'
17
17
  import {type SanityInstance} from '../store/createSanityInstance'
18
18
 
19
- const API_VERSION = 'vX'
19
+ const API_VERSION = 'v2025-05-06'
20
20
 
21
21
  export interface SharedListener {
22
22
  events: Observable<ListenEvent<SanityDocument>>