@sanity/sdk-react 3.1.0 → 3.2.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/_exports/dashboard-internal.d.ts +2 -0
- package/dist/_exports/dashboard-internal.js +2 -0
- package/dist/_exports/dashboard.d.ts +457 -7
- package/dist/_exports/dashboard.d.ts.map +1 -1
- package/dist/_exports/dashboard.js +623 -2
- package/dist/_exports/dashboard.js.map +1 -1
- package/dist/index.d.ts +90 -87
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/{useStudioWorkspacesByProjectIdDataset-DxUlukmF.js → useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js} +53 -56
- package/dist/useStudioWorkspacesByProjectIdDataset-BZ_Ud887.js.map +1 -0
- package/package.json +34 -32
- package/src/_exports/dashboard-internal.ts +1 -0
- package/src/_exports/dashboard.test-d.ts +64 -0
- package/src/_exports/dashboard.ts +70 -0
- package/src/_exports/index.ts +1 -1
- package/src/components/auth/AuthBoundary.test.tsx +34 -0
- package/src/components/auth/AuthBoundary.tsx +1 -2
- package/src/context/DashboardTokenRefresh.test.tsx +124 -22
- package/src/context/DashboardTokenRefresh.tsx +31 -15
- package/src/dashboard/createRemoteInstance.test.ts +168 -0
- package/src/dashboard/createRemoteInstance.ts +123 -0
- package/src/dashboard/module.ts +39 -0
- package/src/dashboard/remoteClientState.ts +27 -0
- package/src/dashboard/urlFor.test.ts +246 -0
- package/src/dashboard/urlFor.ts +462 -0
- package/src/hooks/applications/useApplication.test-d.ts +1 -1
- package/src/hooks/dashboard/useApplication.test.tsx +59 -0
- package/src/hooks/dashboard/useApplication.ts +22 -0
- package/src/hooks/dashboard/useApplicationConfig.test.tsx +106 -0
- package/src/hooks/dashboard/useApplicationConfig.ts +45 -0
- package/src/hooks/dashboard/useApplicationConfigs.test.tsx +89 -0
- package/src/hooks/dashboard/useApplicationConfigs.ts +26 -0
- package/src/hooks/dashboard/useApplicationForegroundId.test.tsx +54 -0
- package/src/hooks/dashboard/useApplicationForegroundId.ts +22 -0
- package/src/hooks/dashboard/useApplications.test.tsx +249 -0
- package/src/hooks/dashboard/useApplications.ts +128 -0
- package/src/hooks/dashboard/useEmit.test.tsx +124 -0
- package/src/hooks/dashboard/useEmit.ts +78 -0
- package/src/hooks/dashboard/useNavigate.ts +1 -3
- package/src/hooks/dashboard/useRemoteClient.test.tsx +88 -0
- package/src/hooks/dashboard/useRemoteClient.ts +10 -0
- package/src/hooks/dashboard/useTopic.test.tsx +200 -0
- package/src/hooks/dashboard/useTopic.ts +29 -0
- package/src/hooks/dashboard/useUpdateFavorite.test.tsx +8 -1
- package/src/hooks/document/useApplyDocumentActions.ts +3 -4
- package/src/hooks/document/useCreateDocument.ts +2 -3
- package/src/hooks/document/useDocument.ts +11 -6
- package/src/hooks/document/useEditDocument.ts +5 -5
- package/src/hooks/projection/useDocumentProjection.ts +6 -7
- package/src/hooks/query/useQuery.ts +3 -4
- package/dist/useStudioWorkspacesByProjectIdDataset-DxUlukmF.js.map +0 -1
- package/src/context/dashboardToken.ts +0 -63
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import {AuthStateType, setAuthToken} from '@sanity/sdk'
|
|
2
|
+
import {getDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
2
3
|
import {act, render} from '@testing-library/react'
|
|
3
4
|
import {of} from 'rxjs'
|
|
4
5
|
import {afterEach, beforeEach, describe, expect, it, type Mock, vi} from 'vitest'
|
|
5
6
|
|
|
7
|
+
import {getDashboardModuleContext} from '../dashboard/module'
|
|
6
8
|
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
7
|
-
import {
|
|
8
|
-
isDashboardEnvironment,
|
|
9
|
-
observeDashboardToken,
|
|
10
|
-
refreshDashboardToken,
|
|
11
|
-
} from './dashboardToken'
|
|
9
|
+
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
12
10
|
import {DashboardTokenRefreshProvider} from './DashboardTokenRefresh'
|
|
13
11
|
import {ResourceProvider} from './ResourceProvider'
|
|
14
12
|
|
|
13
|
+
const messageBus = vi.hoisted(() => ({
|
|
14
|
+
client: undefined as
|
|
15
|
+
| undefined
|
|
16
|
+
| {emit: ReturnType<typeof vi.fn>; subscribe: ReturnType<typeof vi.fn>},
|
|
17
|
+
emit: vi.fn(),
|
|
18
|
+
subscribe: vi.fn(),
|
|
19
|
+
// A spy so tests can observe the forwarded arguments.
|
|
20
|
+
getDashboardMessageBus: vi.fn(),
|
|
21
|
+
}))
|
|
22
|
+
|
|
15
23
|
vi.mock('@sanity/sdk', async () => {
|
|
16
24
|
const actual = await vi.importActual('@sanity/sdk')
|
|
17
25
|
return {
|
|
@@ -24,17 +32,13 @@ vi.mock('../hooks/auth/useAuthState', () => ({
|
|
|
24
32
|
useAuthState: vi.fn(),
|
|
25
33
|
}))
|
|
26
34
|
|
|
27
|
-
vi.mock('
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
refreshDashboardToken: vi.fn(),
|
|
35
|
+
vi.mock('@sanity/sdk/_internal', async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import('@sanity/sdk/_internal')>()),
|
|
37
|
+
getDashboardMessageBus: messageBus.getDashboardMessageBus,
|
|
31
38
|
}))
|
|
32
39
|
|
|
33
40
|
const mockSetAuthToken = setAuthToken as Mock
|
|
34
41
|
const mockUseAuthState = useAuthState as Mock
|
|
35
|
-
const mockIsDashboardEnvironment = isDashboardEnvironment as Mock
|
|
36
|
-
const mockObserveDashboardToken = observeDashboardToken as Mock
|
|
37
|
-
const mockRefreshDashboardToken = refreshDashboardToken as Mock
|
|
38
42
|
|
|
39
43
|
const renderProvider = () =>
|
|
40
44
|
render(
|
|
@@ -47,6 +51,9 @@ const renderProvider = () =>
|
|
|
47
51
|
|
|
48
52
|
describe('DashboardTokenRefreshProvider', () => {
|
|
49
53
|
beforeEach(() => {
|
|
54
|
+
messageBus.client = undefined
|
|
55
|
+
messageBus.getDashboardMessageBus.mockReset()
|
|
56
|
+
messageBus.getDashboardMessageBus.mockImplementation(() => messageBus.client)
|
|
50
57
|
mockUseAuthState.mockReturnValue({type: AuthStateType.LOGGED_IN})
|
|
51
58
|
})
|
|
52
59
|
|
|
@@ -55,9 +62,7 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
55
62
|
})
|
|
56
63
|
|
|
57
64
|
describe('when not in the dashboard', () => {
|
|
58
|
-
it('does not subscribe to
|
|
59
|
-
mockIsDashboardEnvironment.mockReturnValue(false)
|
|
60
|
-
|
|
65
|
+
it('does not subscribe to a dashboard token', () => {
|
|
61
66
|
act(() => {
|
|
62
67
|
renderProvider()
|
|
63
68
|
})
|
|
@@ -68,12 +73,13 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
68
73
|
|
|
69
74
|
describe('when in the dashboard', () => {
|
|
70
75
|
beforeEach(() => {
|
|
71
|
-
|
|
76
|
+
messageBus.client = messageBus
|
|
77
|
+
messageBus.subscribe.mockReturnValue(of('dashboard-token'))
|
|
78
|
+
// `emit` returns a lazily-awaited reply the provider attaches a `.catch` to.
|
|
79
|
+
messageBus.emit.mockReturnValue(Promise.resolve(undefined))
|
|
72
80
|
})
|
|
73
81
|
|
|
74
|
-
it('mirrors the
|
|
75
|
-
mockObserveDashboardToken.mockReturnValue(of('dashboard-token'))
|
|
76
|
-
|
|
82
|
+
it('mirrors the dashboard token into the auth store', () => {
|
|
77
83
|
act(() => {
|
|
78
84
|
renderProvider()
|
|
79
85
|
})
|
|
@@ -81,9 +87,80 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
81
87
|
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), 'dashboard-token')
|
|
82
88
|
})
|
|
83
89
|
|
|
84
|
-
it('
|
|
85
|
-
|
|
90
|
+
it('renders children bare when no host bus is installed', () => {
|
|
91
|
+
messageBus.client = undefined
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
renderProvider()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(messageBus.getDashboardMessageBus).toHaveBeenCalledTimes(1)
|
|
98
|
+
expect(mockSetAuthToken).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('connects with the module id before any child reads the bus during render', () => {
|
|
102
|
+
// getDashboardMessageBus is first-caller-wins per instance. A hook reading the bus in
|
|
103
|
+
// its render runs before any parent effect, so the provider must connect during render
|
|
104
|
+
// or the connection is pinned to the app id.
|
|
105
|
+
const ModuleContext = getDashboardModuleContext()
|
|
106
|
+
function ReadsBus() {
|
|
107
|
+
getDashboardMessageBus(useSanityInstance())
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
act(() => {
|
|
112
|
+
render(
|
|
113
|
+
<ModuleContext.Provider value="favorites/views/list/panel">
|
|
114
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
115
|
+
<DashboardTokenRefreshProvider>
|
|
116
|
+
<ReadsBus />
|
|
117
|
+
</DashboardTokenRefreshProvider>
|
|
118
|
+
</ResourceProvider>
|
|
119
|
+
</ModuleContext.Provider>,
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
expect(messageBus.getDashboardMessageBus.mock.calls[0]).toEqual([
|
|
124
|
+
expect.anything(),
|
|
125
|
+
'favorites/views/list/panel',
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('forwards the module id from the dashboard module context', () => {
|
|
130
|
+
const ModuleContext = getDashboardModuleContext()
|
|
131
|
+
|
|
132
|
+
act(() => {
|
|
133
|
+
render(
|
|
134
|
+
<ModuleContext.Provider value="favorites/views/list/panel">
|
|
135
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
136
|
+
<DashboardTokenRefreshProvider>
|
|
137
|
+
<div>Test</div>
|
|
138
|
+
</DashboardTokenRefreshProvider>
|
|
139
|
+
</ResourceProvider>
|
|
140
|
+
</ModuleContext.Provider>,
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(messageBus.getDashboardMessageBus).toHaveBeenCalledWith(
|
|
145
|
+
expect.anything(),
|
|
146
|
+
'favorites/views/list/panel',
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('treats subscription failures as a missing token', () => {
|
|
151
|
+
messageBus.subscribe.mockImplementationOnce(() => {
|
|
152
|
+
throw new Error('Incompatible message bus')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(() => {
|
|
156
|
+
act(() => {
|
|
157
|
+
renderProvider()
|
|
158
|
+
})
|
|
159
|
+
}).not.toThrow()
|
|
160
|
+
expect(mockSetAuthToken).toHaveBeenCalledWith(expect.anything(), null)
|
|
161
|
+
})
|
|
86
162
|
|
|
163
|
+
it('asks the message bus to reissue the token on a 401', () => {
|
|
87
164
|
const {rerender} = renderProvider()
|
|
88
165
|
|
|
89
166
|
mockUseAuthState.mockReturnValue({
|
|
@@ -100,7 +177,32 @@ describe('DashboardTokenRefreshProvider', () => {
|
|
|
100
177
|
)
|
|
101
178
|
})
|
|
102
179
|
|
|
103
|
-
expect(
|
|
180
|
+
expect(messageBus.emit).toHaveBeenCalledWith('auth.token.refresh', undefined)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('logs a failed token reissue instead of dropping it silently', async () => {
|
|
184
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
185
|
+
messageBus.emit.mockReturnValueOnce(Promise.reject(new Error('NO_RESPONDER')))
|
|
186
|
+
const {rerender} = renderProvider()
|
|
187
|
+
|
|
188
|
+
mockUseAuthState.mockReturnValue({
|
|
189
|
+
type: AuthStateType.ERROR,
|
|
190
|
+
error: {statusCode: 401, message: 'Unauthorized'},
|
|
191
|
+
})
|
|
192
|
+
await act(async () => {
|
|
193
|
+
rerender(
|
|
194
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
195
|
+
<DashboardTokenRefreshProvider>
|
|
196
|
+
<div>Test</div>
|
|
197
|
+
</DashboardTokenRefreshProvider>
|
|
198
|
+
</ResourceProvider>,
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
expect(warn).toHaveBeenCalledWith(
|
|
203
|
+
'[sanity/sdk] Dashboard token refresh failed:',
|
|
204
|
+
expect.any(Error),
|
|
205
|
+
)
|
|
104
206
|
})
|
|
105
207
|
})
|
|
106
208
|
})
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import {type ClientError} from '@sanity/client'
|
|
2
2
|
import {AuthStateType, setAuthToken} from '@sanity/sdk'
|
|
3
|
-
import
|
|
3
|
+
import {getDashboardMessageBus} from '@sanity/sdk/_internal'
|
|
4
|
+
import {type MessageBus} from '@sanity/sdk/dashboard'
|
|
5
|
+
import React, {type PropsWithChildren, useContext, useEffect, useRef, useState} from 'react'
|
|
6
|
+
import {defer, of} from 'rxjs'
|
|
7
|
+
import {catchError} from 'rxjs/operators'
|
|
4
8
|
|
|
9
|
+
import {getDashboardModuleContext} from '../dashboard/module'
|
|
5
10
|
import {useAuthState} from '../hooks/auth/useAuthState'
|
|
6
11
|
import {useSanityInstance} from '../hooks/context/useSanityInstance'
|
|
7
|
-
import {
|
|
8
|
-
isDashboardEnvironment,
|
|
9
|
-
observeDashboardToken,
|
|
10
|
-
refreshDashboardToken,
|
|
11
|
-
} from './dashboardToken'
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Keeps the SDK auth token in sync with the dashboard "OS".
|
|
@@ -20,17 +20,20 @@ import {
|
|
|
20
20
|
* (the token expired), we ask the OS to reissue rather than tearing the session
|
|
21
21
|
* down; the new token arrives back through the same subscription.
|
|
22
22
|
*/
|
|
23
|
-
function DashboardTokenRefresh({
|
|
23
|
+
function DashboardTokenRefresh({
|
|
24
|
+
children,
|
|
25
|
+
messageBus,
|
|
26
|
+
}: PropsWithChildren<{messageBus: MessageBus}>) {
|
|
24
27
|
const instance = useSanityInstance()
|
|
25
28
|
const authState = useAuthState()
|
|
26
29
|
const processed401ErrorRef = useRef<unknown | null>(null)
|
|
27
30
|
|
|
28
31
|
useEffect(() => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const subscription = defer(() => messageBus.subscribe('auth.token'))
|
|
33
|
+
.pipe(catchError(() => of(null)))
|
|
34
|
+
.subscribe((token) => setAuthToken(instance, token))
|
|
32
35
|
return () => subscription.unsubscribe()
|
|
33
|
-
}, [instance])
|
|
36
|
+
}, [instance, messageBus])
|
|
34
37
|
|
|
35
38
|
useEffect(() => {
|
|
36
39
|
const has401Error =
|
|
@@ -38,11 +41,15 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
|
38
41
|
|
|
39
42
|
if (has401Error && processed401ErrorRef.current !== authState.error) {
|
|
40
43
|
processed401ErrorRef.current = authState.error
|
|
41
|
-
|
|
44
|
+
// Event topics have no replay, so a missing responder or timeout is otherwise dropped silently.
|
|
45
|
+
messageBus.emit('auth.token.refresh', undefined).catch((error) => {
|
|
46
|
+
// eslint-disable-next-line no-console
|
|
47
|
+
console.warn('[sanity/sdk] Dashboard token refresh failed:', error)
|
|
48
|
+
})
|
|
42
49
|
} else if (!has401Error) {
|
|
43
50
|
processed401ErrorRef.current = null
|
|
44
51
|
}
|
|
45
|
-
}, [authState])
|
|
52
|
+
}, [authState, messageBus])
|
|
46
53
|
|
|
47
54
|
return children
|
|
48
55
|
}
|
|
@@ -87,8 +94,17 @@ function DashboardTokenRefresh({children}: PropsWithChildren) {
|
|
|
87
94
|
* @public
|
|
88
95
|
*/
|
|
89
96
|
export const DashboardTokenRefreshProvider: React.FC<PropsWithChildren> = ({children}) => {
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
const instance = useSanityInstance()
|
|
98
|
+
const moduleId = useContext(getDashboardModuleContext())
|
|
99
|
+
// The connection is first-caller-wins per instance, and hooks below read it during their
|
|
100
|
+
// render, before any effect here could run. Connecting in the first render pins the module
|
|
101
|
+
// identity before they do; nothing has subscribed to the store yet, so the write is safe.
|
|
102
|
+
// The module id is read once: the CLI wrapper provides it statically above this tree.
|
|
103
|
+
// No retry: the host installs the bus at module evaluation, before any remote renders, and
|
|
104
|
+
// a standalone app has no host to wait for.
|
|
105
|
+
const [messageBus] = useState(() => getDashboardMessageBus(instance, moduleId))
|
|
106
|
+
if (messageBus) {
|
|
107
|
+
return <DashboardTokenRefresh messageBus={messageBus}>{children}</DashboardTokenRefresh>
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
return children
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ModuleFederationRuntimePlugin,
|
|
3
|
+
type UserOptions,
|
|
4
|
+
} from '@module-federation/runtime/types'
|
|
5
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {createRemoteInstance} from './createRemoteInstance'
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
onDebug,
|
|
11
|
+
mockCreateLogger,
|
|
12
|
+
mockRegisterRemotes,
|
|
13
|
+
mockLoadRemote,
|
|
14
|
+
mockPreloadRemote,
|
|
15
|
+
mockCreateMFInstance,
|
|
16
|
+
} = vi.hoisted(() => {
|
|
17
|
+
const registerRemotes = vi.fn()
|
|
18
|
+
const loadRemote = vi.fn()
|
|
19
|
+
const preloadRemote = vi.fn()
|
|
20
|
+
const debug = vi.fn()
|
|
21
|
+
return {
|
|
22
|
+
onDebug: debug,
|
|
23
|
+
mockCreateLogger: vi.fn(() => ({debug})),
|
|
24
|
+
mockRegisterRemotes: registerRemotes,
|
|
25
|
+
mockLoadRemote: loadRemote,
|
|
26
|
+
mockPreloadRemote: preloadRemote,
|
|
27
|
+
mockCreateMFInstance: vi.fn((_options: UserOptions) => ({
|
|
28
|
+
registerRemotes,
|
|
29
|
+
loadRemote,
|
|
30
|
+
preloadRemote,
|
|
31
|
+
})),
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
vi.mock('@module-federation/runtime', () => ({
|
|
36
|
+
createInstance: mockCreateMFInstance,
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
vi.mock('@sanity/sdk/_internal', () => ({createLogger: mockCreateLogger}))
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('createRemoteInstance', () => {
|
|
46
|
+
it('seeds empty remotes and preserves custom plugins', () => {
|
|
47
|
+
const customPlugin = {name: 'custom-plugin'}
|
|
48
|
+
|
|
49
|
+
createRemoteInstance({name: 'sanity-workbench', plugins: [customPlugin]})
|
|
50
|
+
|
|
51
|
+
expect(mockCreateLogger).toHaveBeenCalledWith('sanity-workbench')
|
|
52
|
+
expect(mockCreateMFInstance).toHaveBeenCalledWith({
|
|
53
|
+
name: 'sanity-workbench',
|
|
54
|
+
remotes: [],
|
|
55
|
+
plugins: [expect.objectContaining({name: 'sanity-logger'}), customPlugin],
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('seeds the underlying instance with the manifest of each remote entry', () => {
|
|
60
|
+
createRemoteInstance({
|
|
61
|
+
name: 'sanity-workbench',
|
|
62
|
+
remotes: [{name: 'studio-1', entry: 'https://example.com/apps/studio-1/'}],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(mockCreateMFInstance).toHaveBeenCalledWith({
|
|
66
|
+
name: 'sanity-workbench',
|
|
67
|
+
plugins: [expect.objectContaining({name: 'sanity-logger'})],
|
|
68
|
+
remotes: [{name: 'studio-1', entry: 'https://example.com/apps/studio-1/mf-manifest.json'}],
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('leaves a version-based remote untouched', () => {
|
|
73
|
+
const remote = {name: 'studio-1', version: '1.0.0'}
|
|
74
|
+
|
|
75
|
+
createRemoteInstance({name: 'sanity-workbench', remotes: [remote]})
|
|
76
|
+
|
|
77
|
+
expect(mockCreateMFInstance).toHaveBeenCalledWith({
|
|
78
|
+
name: 'sanity-workbench',
|
|
79
|
+
plugins: [expect.objectContaining({name: 'sanity-logger'})],
|
|
80
|
+
remotes: [remote],
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('registers a remote origin as its manifest, without forcing re-registration', () => {
|
|
85
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
86
|
+
|
|
87
|
+
instance.registerRemotes([{name: 'studio-1', entry: 'https://example.com'}])
|
|
88
|
+
|
|
89
|
+
expect(mockRegisterRemotes).toHaveBeenCalledWith(
|
|
90
|
+
[{name: 'studio-1', entry: 'https://example.com/mf-manifest.json'}],
|
|
91
|
+
{force: false},
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('leaves an entry that already names the manifest untouched', () => {
|
|
96
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
97
|
+
const remotes = [{name: 'studio-1', entry: 'https://example.com/mf-manifest.json'}]
|
|
98
|
+
|
|
99
|
+
instance.registerRemotes(remotes)
|
|
100
|
+
|
|
101
|
+
expect(mockRegisterRemotes).toHaveBeenCalledWith(remotes, {force: false})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('resolves with the loaded module on success', async () => {
|
|
105
|
+
const module = {render: () => () => {}}
|
|
106
|
+
mockLoadRemote.mockResolvedValue(module)
|
|
107
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
108
|
+
|
|
109
|
+
await expect(instance.loadRemote('studio-1/App')).resolves.toBe(module)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('wraps a load failure with the module id and original cause', async () => {
|
|
113
|
+
const cause = new Error('network down')
|
|
114
|
+
mockLoadRemote.mockRejectedValue(cause)
|
|
115
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
116
|
+
|
|
117
|
+
await expect(instance.loadRemote('studio-1/App')).rejects.toMatchObject({
|
|
118
|
+
message: 'Failed to load remote module "studio-1/App"',
|
|
119
|
+
cause,
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('preloads specific exposes, warming sync and async assets', () => {
|
|
124
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
125
|
+
|
|
126
|
+
instance.preloadRemote('studio-1', ['views/feed/panel'])
|
|
127
|
+
|
|
128
|
+
expect(mockPreloadRemote).toHaveBeenCalledWith([
|
|
129
|
+
{
|
|
130
|
+
nameOrAlias: 'studio-1',
|
|
131
|
+
exposes: ['views/feed/panel'],
|
|
132
|
+
resourceCategory: 'all',
|
|
133
|
+
},
|
|
134
|
+
])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('preloads the whole remote when no exposes are given', () => {
|
|
138
|
+
const instance = createRemoteInstance({name: 'sanity-workbench'})
|
|
139
|
+
|
|
140
|
+
instance.preloadRemote('studio-1')
|
|
141
|
+
|
|
142
|
+
expect(mockPreloadRemote).toHaveBeenCalledWith([
|
|
143
|
+
{nameOrAlias: 'studio-1', exposes: undefined, resourceCategory: 'all'},
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('lifecycle logging', () => {
|
|
149
|
+
const hooks = [
|
|
150
|
+
'beforeInit',
|
|
151
|
+
'beforeRegisterRemote',
|
|
152
|
+
'beforePreloadRemote',
|
|
153
|
+
'beforeRequest',
|
|
154
|
+
'afterResolve',
|
|
155
|
+
'onLoad',
|
|
156
|
+
'loadShare',
|
|
157
|
+
'beforeLoadShare',
|
|
158
|
+
] as const
|
|
159
|
+
|
|
160
|
+
it.each(hooks)('logs %s without the runtime instance', (hook) => {
|
|
161
|
+
createRemoteInstance({name: 'sanity-workbench'})
|
|
162
|
+
const plugin = mockCreateMFInstance.mock.calls[0][0]
|
|
163
|
+
.plugins![0] as ModuleFederationRuntimePlugin
|
|
164
|
+
// Deliberately partial args: only the logger's origin filtering is under test.
|
|
165
|
+
plugin[hook]?.({origin: 'noisy', id: hook} as never)
|
|
166
|
+
expect(onDebug).toHaveBeenCalledWith(`[Lifecycle] ${hook}`, {id: hook, internal: true})
|
|
167
|
+
})
|
|
168
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {createInstance as createMFInstance} from '@module-federation/runtime'
|
|
2
|
+
import {
|
|
3
|
+
type ModuleFederationRuntimePlugin,
|
|
4
|
+
type UserOptions,
|
|
5
|
+
} from '@module-federation/runtime/types'
|
|
6
|
+
import {createLogger} from '@sanity/sdk/_internal'
|
|
7
|
+
|
|
8
|
+
type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export type FederationRemote = {
|
|
14
|
+
name: string
|
|
15
|
+
entry: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MANIFEST_FILE = 'mf-manifest.json'
|
|
19
|
+
|
|
20
|
+
function withManifest<T extends {entry: string}>(remote: T): T {
|
|
21
|
+
if (remote.entry.endsWith(`/${MANIFEST_FILE}`)) return remote
|
|
22
|
+
const base = remote.entry.endsWith('/') ? remote.entry : `${remote.entry}/`
|
|
23
|
+
return {...remote, entry: new URL(MANIFEST_FILE, base).href}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
export type CreateRemoteInstanceOptions = MakeOptional<
|
|
30
|
+
Pick<UserOptions, 'name' | 'remotes' | 'shared' | 'plugins'>,
|
|
31
|
+
'remotes' | 'plugins'
|
|
32
|
+
>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export interface RemoteInstance {
|
|
38
|
+
registerRemotes(remotes: FederationRemote[]): void
|
|
39
|
+
/** Loads and evaluates a remote expose. */
|
|
40
|
+
loadRemote<T>(id: string): Promise<T | null>
|
|
41
|
+
/** Preloads assets without evaluating them; omit exposes to warm the whole remote. */
|
|
42
|
+
preloadRemote(name: string, exposes?: string[]): Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a client for registering, loading, and preloading federated modules.
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export function createRemoteInstance(options: CreateRemoteInstanceOptions): RemoteInstance {
|
|
50
|
+
const logger = createLogger(options.name)
|
|
51
|
+
const instance = createMFInstance({
|
|
52
|
+
...options,
|
|
53
|
+
plugins: [log(logger.debug), ...(options.plugins ?? [])],
|
|
54
|
+
remotes:
|
|
55
|
+
options.remotes?.map((remote) => ('entry' in remote ? withManifest(remote) : remote)) ?? [],
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
registerRemotes: (remotes) =>
|
|
60
|
+
instance.registerRemotes(remotes.map(withManifest), {force: false}),
|
|
61
|
+
loadRemote: async <T>(id: string) => {
|
|
62
|
+
let remoteModule: T | null
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
remoteModule = await instance.loadRemote<T>(id)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new Error(`Failed to load remote module "${id}"`, {
|
|
68
|
+
cause: error,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return remoteModule
|
|
73
|
+
},
|
|
74
|
+
// Include async chunks so lazy code is warm before the remote loads.
|
|
75
|
+
preloadRemote: (name, exposes) =>
|
|
76
|
+
instance.preloadRemote([{nameOrAlias: name, exposes, resourceCategory: 'all'}]),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function log(
|
|
81
|
+
onDebug: (message: string, context?: Record<string, unknown>) => void,
|
|
82
|
+
): ModuleFederationRuntimePlugin {
|
|
83
|
+
const logEvent = (eventName: string, args: object) => {
|
|
84
|
+
// `origin` logs the whole instance, which is noisy
|
|
85
|
+
const {origin: _origin, ...data} = args as Record<string, unknown>
|
|
86
|
+
|
|
87
|
+
onDebug(`[Lifecycle] ${eventName}`, {...data, internal: true})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name: 'sanity-logger',
|
|
92
|
+
beforeInit(args) {
|
|
93
|
+
logEvent('beforeInit', args)
|
|
94
|
+
return args
|
|
95
|
+
},
|
|
96
|
+
beforeRegisterRemote(args) {
|
|
97
|
+
logEvent('beforeRegisterRemote', args)
|
|
98
|
+
return args
|
|
99
|
+
},
|
|
100
|
+
beforePreloadRemote(args) {
|
|
101
|
+
logEvent('beforePreloadRemote', args)
|
|
102
|
+
},
|
|
103
|
+
beforeRequest(args) {
|
|
104
|
+
logEvent('beforeRequest', args)
|
|
105
|
+
return args
|
|
106
|
+
},
|
|
107
|
+
afterResolve(args) {
|
|
108
|
+
logEvent('afterResolve', args)
|
|
109
|
+
return args
|
|
110
|
+
},
|
|
111
|
+
onLoad(args) {
|
|
112
|
+
logEvent('onLoad', args)
|
|
113
|
+
return args
|
|
114
|
+
},
|
|
115
|
+
loadShare(args) {
|
|
116
|
+
logEvent('loadShare', args)
|
|
117
|
+
},
|
|
118
|
+
beforeLoadShare(args) {
|
|
119
|
+
logEvent('beforeLoadShare', args)
|
|
120
|
+
return args
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
const MODULE_SLOT_KEY = Symbol.for('sanity.os.module')
|
|
4
|
+
|
|
5
|
+
type ModuleContext = React.Context<string | undefined>
|
|
6
|
+
type ModuleSlot = WeakMap<typeof React.createContext, ModuleContext>
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the React context that carries the current federation module id.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* This is the slot the CLI-generated wrapper populates with
|
|
13
|
+
* `renderOptions.moduleId`. The context lives in a per-React-copy slot on
|
|
14
|
+
* `globalThis` so the CLI wrapper and the SDK share the same context even
|
|
15
|
+
* across module copies. Each React copy gets its own context, since a context
|
|
16
|
+
* created by one copy is inert in another.
|
|
17
|
+
*
|
|
18
|
+
* The slot is keyed on `React.createContext` rather than the `React` namespace
|
|
19
|
+
* object: `import * as React` and `import React from 'react'` can yield
|
|
20
|
+
* different wrapper objects for the same React copy under bundler interop,
|
|
21
|
+
* whereas the `createContext` function is the same reference under both.
|
|
22
|
+
*
|
|
23
|
+
* The provider side lives in the CLI, which must not import the SDK:
|
|
24
|
+
* `packages/@sanity/workbench-cli/src/actions/build/render-remote.ts` in
|
|
25
|
+
* `sanity-io/cli` reconstructs this accessor (same symbol, same key) and
|
|
26
|
+
* wraps `App` in the context's `Provider`. Nothing in this repo exports it.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export function getDashboardModuleContext(): ModuleContext {
|
|
30
|
+
const globals = globalThis as {[MODULE_SLOT_KEY]?: ModuleSlot}
|
|
31
|
+
const slot = (globals[MODULE_SLOT_KEY] ??= new WeakMap())
|
|
32
|
+
const key = React.createContext
|
|
33
|
+
let context = slot.get(key)
|
|
34
|
+
if (!context) {
|
|
35
|
+
context = React.createContext<string | undefined>(undefined)
|
|
36
|
+
slot.set(key, context)
|
|
37
|
+
}
|
|
38
|
+
return context
|
|
39
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {type SanityInstance, type StateSource} from '@sanity/sdk'
|
|
2
|
+
import {of} from 'rxjs'
|
|
3
|
+
|
|
4
|
+
import {createRemoteInstance, type RemoteInstance} from './createRemoteInstance'
|
|
5
|
+
|
|
6
|
+
const remoteClientStates = new WeakMap<SanityInstance, StateSource<RemoteInstance>>()
|
|
7
|
+
|
|
8
|
+
/** Returns the instance's remote client state, creating it on first use. @internal */
|
|
9
|
+
export function getRemoteClientState(instance: SanityInstance): StateSource<RemoteInstance> {
|
|
10
|
+
if (instance.isDisposed()) {
|
|
11
|
+
throw new Error('Cannot create a remote client for a disposed Sanity instance')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const current = remoteClientStates.get(instance)
|
|
15
|
+
if (current) return current
|
|
16
|
+
|
|
17
|
+
const client = createRemoteInstance({name: `sanity-remote-${instance.instanceId}`})
|
|
18
|
+
const state = {
|
|
19
|
+
getCurrent: () => client,
|
|
20
|
+
observable: of(client),
|
|
21
|
+
subscribe: () => () => {},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
remoteClientStates.set(instance, state)
|
|
25
|
+
instance.onDispose(() => remoteClientStates.delete(instance))
|
|
26
|
+
return state
|
|
27
|
+
}
|