@sanity/sdk 3.0.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/_internal.d.ts +72 -19
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +130 -11
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +7 -7
- package/dist/_exports/agent.d.ts.map +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +9 -9
- package/dist/_exports/comlink.d.ts.map +1 -1
- package/dist/_exports/comlink.js +2 -2
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C0o1NOxW.d.ts → authStore-BO2vch3I.d.ts} +516 -63
- package/dist/authStore-BO2vch3I.d.ts.map +1 -0
- package/dist/{authStore-CtE9bLHW.js → clientStore-BI4D8HhJ.js} +390 -15
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-BQIqyGLX.d.ts} +17 -2
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{createStateSourceAction-BOKtqFNm.js → createStateSourceAction-DdAhEfnZ.js} +7 -3
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-CR3__B2D.js → getNodeState-B4nG0sjm.js} +2 -2
- package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +370 -90
- package/dist/index.js.map +1 -1
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-D0WqhQGF.d.ts +435 -0
- package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
- package/dist/{queryStore-C7yMHPUI.js → version-BcRmCiYi.js} +48 -16
- package/dist/version-BcRmCiYi.js.map +1 -0
- package/package.json +19 -18
- package/src/_exports/_internal.ts +13 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/access/checkPermissions.test.ts +1 -1
- package/src/access/checkPermissions.ts +1 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.test.ts +4 -4
- package/src/applications/applications.ts +43 -18
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.ts +12 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/handleAuthCallback.test.ts +2 -2
- package/src/auth/handleAuthCallback.ts +1 -1
- package/src/auth/logout.test.ts +46 -1
- package/src/auth/logout.ts +2 -1
- package/src/auth/oauth/oauthActions.test.ts +457 -0
- package/src/auth/oauth/oauthActions.ts +389 -0
- package/src/auth/oauth/oauthAuth.test.ts +206 -0
- package/src/auth/oauth/oauthAuth.ts +168 -0
- package/src/auth/oauth/pkce.test.ts +44 -0
- package/src/auth/oauth/pkce.ts +60 -0
- package/src/auth/oauth/types.ts +12 -0
- package/src/auth/refreshStampedToken.ts +1 -1
- package/src/auth/studioModeAuth.test.ts +1 -1
- package/src/auth/studioModeAuth.ts +1 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
- package/src/comments/addonDatasetStore.test.ts +2 -2
- package/src/comments/addonDatasetStore.ts +2 -2
- package/src/comments/commentActions.ts +6 -5
- package/src/comments/commentsStore.ts +4 -4
- package/src/config/authConfig.ts +18 -0
- package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
- package/src/dashboard/messageBus/bus.test.ts +1033 -0
- package/src/dashboard/messageBus/bus.ts +1246 -0
- package/src/dashboard/messageBus/store.test.ts +142 -0
- package/src/dashboard/messageBus/store.ts +74 -0
- package/src/dashboard/messageBus/topicStore.test.ts +172 -0
- package/src/dashboard/messageBus/topicStore.ts +184 -0
- package/src/dashboard/messageBus/topics.test.ts +71 -0
- package/src/dashboard/messageBus/topics.ts +260 -0
- package/src/document/actions.ts +3 -3
- package/src/document/applyDocumentActions.ts +5 -9
- package/src/document/documentStore.concurrency.test.ts +2 -1
- package/src/document/documentStore.test.ts +19 -18
- package/src/document/documentStore.ts +17 -17
- package/src/document/permissions.ts +2 -1
- package/src/document/processActions.test.ts +2 -1
- package/src/document/processMutations.ts +61 -24
- package/src/document/reducers.ts +2 -2
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.test.ts +2 -2
- package/src/installations/installations.ts +4 -21
- package/src/organization/organization.test.ts +2 -2
- package/src/organization/organization.ts +1 -1
- package/src/organizations/organizations.test.ts +2 -2
- package/src/organizations/organizations.ts +1 -1
- package/src/presence/presenceStore.test.ts +15 -22
- package/src/presence/presenceStore.ts +3 -2
- package/src/project/project.test.ts +3 -3
- package/src/project/project.ts +1 -1
- package/src/projection/getProjectionState.test.ts +6 -4
- package/src/projection/getProjectionState.ts +4 -4
- package/src/projection/resolveProjection.ts +2 -2
- package/src/projects/projects.test.ts +2 -2
- package/src/projects/projects.ts +1 -1
- package/src/query/queryStore.ts +6 -6
- package/src/store/createSanityInstance.test.ts +1 -1
- package/src/store/createSanityInstance.ts +4 -4
- package/src/store/createStoreInstance.test.ts +0 -5
- package/src/telemetry/telemetryManager.test.ts +1 -1
- package/src/telemetry/telemetryManager.ts +2 -2
- package/src/typegen/defineProjection.test.ts +17 -0
- package/src/typegen/defineProjection.ts +19 -0
- package/src/typegen/groqCompat.ts +122 -0
- package/src/typegen/resolve.ts +37 -0
- package/src/users/usersStore.test.ts +1 -1
- package/src/users/usersStore.ts +5 -5
- package/src/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/src/utils/ids.test.ts +40 -15
- package/src/utils/ids.ts +47 -2
- package/dist/authStore-C0o1NOxW.d.ts.map +0 -1
- package/dist/authStore-CtE9bLHW.js.map +0 -1
- package/dist/clientStore-BtE2-q2i.js +0 -90
- package/dist/clientStore-BtE2-q2i.js.map +0 -1
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +0 -1
- package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
- package/dist/getEnv-D5RGkQLu.js +0 -8
- package/dist/getEnv-D5RGkQLu.js.map +0 -1
- package/dist/logger-DAtJb5RQ.js +0 -173
- package/dist/logger-DAtJb5RQ.js.map +0 -1
- package/dist/queryStore-C7yMHPUI.js.map +0 -1
- package/dist/rolldown-runtime-vyAXikos.js +0 -9
- package/dist/telemetryManager-UIEiMtC0.js +0 -127
- package/dist/telemetryManager-UIEiMtC0.js.map +0 -1
- package/dist/version-D8RP_Q59.js +0 -9
- package/dist/version-D8RP_Q59.js.map +0 -1
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
import './__fixtures__/test-topics'
|
|
2
|
+
|
|
3
|
+
import {EmptyError, firstValueFrom} from 'rxjs'
|
|
4
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
connectApplicationToMessageBus,
|
|
8
|
+
type ConnectApplicationToMessageBusOptions,
|
|
9
|
+
connectMessageBus,
|
|
10
|
+
createIsolatedMessageBus as createRuntimeMessageBus,
|
|
11
|
+
installMessageBus,
|
|
12
|
+
type MessageBus,
|
|
13
|
+
type MessageBusClient,
|
|
14
|
+
MessageBusError,
|
|
15
|
+
type MessageBusHost,
|
|
16
|
+
registerStateTopics,
|
|
17
|
+
resetMessageBus,
|
|
18
|
+
} from './bus'
|
|
19
|
+
import {type TopicManifest, type TopicMigration} from './topics'
|
|
20
|
+
|
|
21
|
+
const createMessageBus = (appId = 'dashboard') => createRuntimeMessageBus(appId)
|
|
22
|
+
|
|
23
|
+
// Connects an application and returns the host's client handle for it.
|
|
24
|
+
function connect(host: MessageBusHost, options: ConnectApplicationToMessageBusOptions) {
|
|
25
|
+
const app = connectApplicationToMessageBus(host, options)
|
|
26
|
+
let client: MessageBusClient | undefined
|
|
27
|
+
host.connections.subscribe((candidate) => (client = candidate)).unsubscribe()
|
|
28
|
+
if (client?.moduleId !== (options.moduleId ?? options.appId)) {
|
|
29
|
+
throw new Error('Expected the new connection to be the latest client')
|
|
30
|
+
}
|
|
31
|
+
return {app, client}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
|
|
35
|
+
const MESSAGE_BUS_REGISTRY_KEY = Symbol.for('sanity.os.registry')
|
|
36
|
+
const globals = globalThis as Record<symbol, unknown>
|
|
37
|
+
let previousMessageBus: unknown
|
|
38
|
+
|
|
39
|
+
const preserveMessageBusInstallation = () => {
|
|
40
|
+
previousMessageBus = globals[MESSAGE_BUS_KEY]
|
|
41
|
+
delete globals[MESSAGE_BUS_KEY]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const restoreMessageBusInstallation = () => {
|
|
45
|
+
globals[MESSAGE_BUS_KEY] = previousMessageBus
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const getRegistry = (messageBus: MessageBus) =>
|
|
49
|
+
(messageBus as unknown as Record<symbol, {topics: Map<string, TopicManifest[string]>}>)[
|
|
50
|
+
MESSAGE_BUS_REGISTRY_KEY
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
type ProfileV1 = {name: string}
|
|
54
|
+
type ProfileV2 = {name: string; tags: readonly string[]}
|
|
55
|
+
type ProfileV3 = {fullName: string; tags: readonly string[]}
|
|
56
|
+
|
|
57
|
+
const profileMigrations: readonly TopicMigration[] = [
|
|
58
|
+
{
|
|
59
|
+
from: 1,
|
|
60
|
+
to: 2,
|
|
61
|
+
up: (value) => ({name: (value as ProfileV1).name, tags: []}),
|
|
62
|
+
down: (value) => ({name: (value as ProfileV2).name}),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
from: 2,
|
|
66
|
+
to: 3,
|
|
67
|
+
up: (value) => ({
|
|
68
|
+
fullName: (value as ProfileV2).name,
|
|
69
|
+
tags: (value as ProfileV2).tags,
|
|
70
|
+
}),
|
|
71
|
+
down: (value) => ({
|
|
72
|
+
name: (value as ProfileV3).fullName,
|
|
73
|
+
tags: (value as ProfileV3).tags,
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
const greetMigrations: readonly TopicMigration[] = [
|
|
79
|
+
{
|
|
80
|
+
from: 1,
|
|
81
|
+
to: 2,
|
|
82
|
+
up: (value) => ({fullName: (value as {name: string}).name}),
|
|
83
|
+
down: (value) => ({name: (value as {fullName: string}).fullName}),
|
|
84
|
+
reply: {
|
|
85
|
+
up: (value) => ({
|
|
86
|
+
salutation: (value as {greeting: string}).greeting,
|
|
87
|
+
language: 'en',
|
|
88
|
+
}),
|
|
89
|
+
down: (value) => ({
|
|
90
|
+
greeting: (value as {salutation: string}).salutation,
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const latestMigrations = new Map([
|
|
97
|
+
['test.profile', profileMigrations],
|
|
98
|
+
['test.greet', greetMigrations],
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
vi.restoreAllMocks()
|
|
103
|
+
vi.useRealTimers()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('dashboard connection', () => {
|
|
107
|
+
beforeEach(preserveMessageBusInstallation)
|
|
108
|
+
afterEach(restoreMessageBusInstallation)
|
|
109
|
+
|
|
110
|
+
it('returns undefined without installing a message bus', () => {
|
|
111
|
+
expect(connectMessageBus()).toBeUndefined()
|
|
112
|
+
expect(globals[MESSAGE_BUS_KEY]).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('requires an application id when installing', () => {
|
|
116
|
+
expect(() => installMessageBus()).toThrowError(
|
|
117
|
+
expect.objectContaining({code: 'MISSING_APP_ID'}),
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('reuses an existing installation', () => {
|
|
122
|
+
const dashboard = installMessageBus({appId: 'dashboard'})
|
|
123
|
+
const secondInstaller = installMessageBus({appId: 'dashboard'})
|
|
124
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
125
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
126
|
+
|
|
127
|
+
const seen: string[] = []
|
|
128
|
+
secondInstaller.connections.subscribe((client) => seen.push(client.appId)).unsubscribe()
|
|
129
|
+
dashboard.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
130
|
+
|
|
131
|
+
// Both installers share one bus: the second sees every other connection, the first can
|
|
132
|
+
// write to the application.
|
|
133
|
+
expect(seen).toEqual(['dashboard', 'favorites'])
|
|
134
|
+
expect(application.subscribe('auth.token').getCurrent()).toBe('token')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('rejects installing as a different application than the installed host', () => {
|
|
138
|
+
installMessageBus({appId: 'dashboard'})
|
|
139
|
+
|
|
140
|
+
expect(() => installMessageBus({appId: 'favorites'})).toThrowError(
|
|
141
|
+
expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}),
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('replaces a foreign value at the installation key and reports it as an error', () => {
|
|
146
|
+
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
147
|
+
globals[MESSAGE_BUS_KEY] = {foreign: true}
|
|
148
|
+
|
|
149
|
+
const dashboard = installMessageBus({appId: 'dashboard'})
|
|
150
|
+
|
|
151
|
+
expect(dashboard).not.toMatchObject({foreign: true})
|
|
152
|
+
expect(globals[MESSAGE_BUS_KEY]).not.toMatchObject({foreign: true})
|
|
153
|
+
expect(error).toHaveBeenCalledWith(
|
|
154
|
+
'[sanity-sdk:message-bus] overwriting an incompatible message bus already installed',
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('connects applications to the installed message bus', async () => {
|
|
159
|
+
const dashboard = installMessageBus({appId: 'dashboard'})
|
|
160
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
161
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
162
|
+
|
|
163
|
+
let callerId: string | undefined
|
|
164
|
+
dashboard.subscribe('auth.token.refresh', (message) => {
|
|
165
|
+
callerId = message.meta.appId
|
|
166
|
+
message.reply('new-token')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await expect(application.emit('auth.token.refresh')).resolves.toBe('new-token')
|
|
170
|
+
expect(callerId).toBe('favorites')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('writes state to a connected application through its client', () => {
|
|
174
|
+
const dashboard = installMessageBus({appId: 'dashboard'})
|
|
175
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
176
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
177
|
+
|
|
178
|
+
dashboard.connections.subscribe((client) => client.emit('auth.token', 'token'))
|
|
179
|
+
|
|
180
|
+
expect(application.subscribe('auth.token').getCurrent()).toBe('token')
|
|
181
|
+
expect(() => application.emit('auth.token' as never, 'spoofed' as never)).toThrowError(
|
|
182
|
+
expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}),
|
|
183
|
+
)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('reports request failures as MessageBusError values', async () => {
|
|
187
|
+
installMessageBus({appId: 'dashboard'})
|
|
188
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
189
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
190
|
+
|
|
191
|
+
const error = await application.emit('auth.token.refresh').catch((caught) => caught)
|
|
192
|
+
|
|
193
|
+
expect(error).toBeInstanceOf(MessageBusError)
|
|
194
|
+
expect(error).toMatchObject({code: 'NO_RESPONDER'})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('warns and returns undefined without an application id', () => {
|
|
198
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
199
|
+
installMessageBus({appId: 'dashboard'})
|
|
200
|
+
|
|
201
|
+
expect(connectMessageBus()).toBeUndefined()
|
|
202
|
+
expect(warn).toHaveBeenCalledWith(
|
|
203
|
+
'[sanity-sdk:message-bus] cannot connect without an app ID; build with the Sanity CLI or pass appId',
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('returns undefined when the installed protocol is incompatible', () => {
|
|
208
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
209
|
+
installMessageBus({appId: 'dashboard'})
|
|
210
|
+
const protocolKey = Symbol.for('sanity.os.protocol')
|
|
211
|
+
const installedMessageBus = globals[MESSAGE_BUS_KEY] as Record<symbol, unknown>
|
|
212
|
+
installedMessageBus[protocolKey] = 2
|
|
213
|
+
|
|
214
|
+
expect(connectMessageBus({appId: 'favorites'})).toBeUndefined()
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('state topics', () => {
|
|
219
|
+
it('exposes the current value, first value, and subsequent updates', async () => {
|
|
220
|
+
const host = createMessageBus()
|
|
221
|
+
registerStateTopics(host, {'test.count': 0})
|
|
222
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
223
|
+
const source = app.subscribe('test.count')
|
|
224
|
+
const seen: number[] = []
|
|
225
|
+
source.subscribe((value) => seen.push(value))
|
|
226
|
+
const firstValue = source.firstValue
|
|
227
|
+
|
|
228
|
+
client.emit('test.count', 1)
|
|
229
|
+
client.emit('test.count', 2)
|
|
230
|
+
|
|
231
|
+
expect(source.getCurrent()).toBe(2)
|
|
232
|
+
await expect(firstValue).resolves.toBe(0)
|
|
233
|
+
expect(seen).toEqual([0, 1, 2])
|
|
234
|
+
expect(app.subscribe('test.count').getCurrent()).toBe(2)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('waits for the first value of a suspending topic', async () => {
|
|
238
|
+
const host = createMessageBus()
|
|
239
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
240
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
241
|
+
const source = app.subscribe('test.suspending')
|
|
242
|
+
const seen: string[] = []
|
|
243
|
+
source.subscribe((value) => seen.push(value))
|
|
244
|
+
|
|
245
|
+
expect(source.getCurrent()).toBeUndefined()
|
|
246
|
+
const query = app.query('test.suspending')
|
|
247
|
+
queueMicrotask(() => client.emit('test.suspending', 'ready'))
|
|
248
|
+
|
|
249
|
+
await expect(source.firstValue).resolves.toBe('ready')
|
|
250
|
+
await expect(query).resolves.toBe('ready')
|
|
251
|
+
expect(seen).toEqual(['ready'])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('times out while a suspending topic has no value', async () => {
|
|
255
|
+
vi.useFakeTimers()
|
|
256
|
+
const host = createMessageBus()
|
|
257
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
258
|
+
const {app} = connect(host, {appId: 'favorites'})
|
|
259
|
+
|
|
260
|
+
const result = expect(app.query('test.suspending')).rejects.toMatchObject({
|
|
261
|
+
code: 'TIMEOUT',
|
|
262
|
+
})
|
|
263
|
+
await vi.advanceTimersByTimeAsync(5000)
|
|
264
|
+
|
|
265
|
+
await result
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('honours a custom query timeout', async () => {
|
|
269
|
+
vi.useFakeTimers()
|
|
270
|
+
const host = createMessageBus()
|
|
271
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
272
|
+
const {app} = connect(host, {appId: 'favorites'})
|
|
273
|
+
|
|
274
|
+
const result = expect(app.query('test.suspending', {timeout: 1})).rejects.toMatchObject({
|
|
275
|
+
code: 'TIMEOUT',
|
|
276
|
+
})
|
|
277
|
+
// Rejects well before the 5s default, proving the timeout is configurable.
|
|
278
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
279
|
+
|
|
280
|
+
await result
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('never times out when the query timeout is disabled', async () => {
|
|
284
|
+
vi.useFakeTimers()
|
|
285
|
+
const host = createMessageBus()
|
|
286
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
287
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
288
|
+
|
|
289
|
+
const query = app.query('test.suspending', {timeout: null})
|
|
290
|
+
await vi.advanceTimersByTimeAsync(60_000)
|
|
291
|
+
client.emit('test.suspending', 'ready')
|
|
292
|
+
|
|
293
|
+
await expect(query).resolves.toBe('ready')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('aborts a pending query', async () => {
|
|
297
|
+
const host = createMessageBus()
|
|
298
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
299
|
+
const {app} = connect(host, {appId: 'favorites'})
|
|
300
|
+
const controller = new AbortController()
|
|
301
|
+
|
|
302
|
+
const result = expect(
|
|
303
|
+
app.query('test.suspending', {signal: controller.signal}),
|
|
304
|
+
).rejects.toMatchObject({code: 'ABORTED'})
|
|
305
|
+
controller.abort()
|
|
306
|
+
|
|
307
|
+
await result
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('stops a subscription when its signal aborts', () => {
|
|
311
|
+
const host = createMessageBus()
|
|
312
|
+
registerStateTopics(host, {'test.count': 0})
|
|
313
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
314
|
+
const controller = new AbortController()
|
|
315
|
+
const seen: number[] = []
|
|
316
|
+
app.subscribe('test.count', (value) => seen.push(value), {
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
client.emit('test.count', 1)
|
|
321
|
+
controller.abort()
|
|
322
|
+
client.emit('test.count', 2)
|
|
323
|
+
|
|
324
|
+
expect(seen).toEqual([0, 1])
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('skips a repeated value but delivers an equal object with a new reference', () => {
|
|
328
|
+
const host = createMessageBus()
|
|
329
|
+
registerStateTopics(host, {'test.token': null, 'test.profile': undefined})
|
|
330
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
331
|
+
const tokens: (string | null)[] = []
|
|
332
|
+
app.subscribe('test.token', (value) => tokens.push(value))
|
|
333
|
+
const profiles: unknown[] = []
|
|
334
|
+
app.subscribe('test.profile', (value) => profiles.push(value))
|
|
335
|
+
|
|
336
|
+
client.emit('test.token', 'token')
|
|
337
|
+
client.emit('test.token', 'token')
|
|
338
|
+
const profile = {fullName: 'Ada', tags: []}
|
|
339
|
+
client.emit('test.profile', profile)
|
|
340
|
+
client.emit('test.profile', profile)
|
|
341
|
+
// React external-store snapshots compare by reference, so a new object must get through.
|
|
342
|
+
client.emit('test.profile', {...profile})
|
|
343
|
+
|
|
344
|
+
expect(tokens).toEqual([null, 'token'])
|
|
345
|
+
expect(profiles).toHaveLength(2)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('starts each connection from the manifest seed', () => {
|
|
349
|
+
const host = createMessageBus()
|
|
350
|
+
const {app} = connect(host, {appId: 'favorites'})
|
|
351
|
+
|
|
352
|
+
expect(app.subscribe('panels.mode').getCurrent()).toEqual({ok: true, value: null})
|
|
353
|
+
expect(app.subscribe('auth.token').getCurrent()).toBeUndefined()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('applies a re-registered seed to later connections only', () => {
|
|
357
|
+
const host = createMessageBus()
|
|
358
|
+
registerStateTopics(host, {'test.count': 1})
|
|
359
|
+
const {app: earlier} = connect(host, {appId: 'earlier'})
|
|
360
|
+
expect(earlier.subscribe('test.count').getCurrent()).toBe(1)
|
|
361
|
+
|
|
362
|
+
registerStateTopics(host, {'test.count': 2})
|
|
363
|
+
const {app: later} = connect(host, {appId: 'later'})
|
|
364
|
+
|
|
365
|
+
expect(later.subscribe('test.count').getCurrent()).toBe(2)
|
|
366
|
+
expect(earlier.subscribe('test.count').getCurrent()).toBe(1)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('rejects an event registered as state', () => {
|
|
370
|
+
const messageBus = createMessageBus()
|
|
371
|
+
|
|
372
|
+
expect(() =>
|
|
373
|
+
registerStateTopics(messageBus, {
|
|
374
|
+
'test.count': 0,
|
|
375
|
+
'panels.mode.set': undefined,
|
|
376
|
+
} as never),
|
|
377
|
+
).toThrowError(/knows it as "event"/)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('event topics', () => {
|
|
382
|
+
it('delivers fire-and-forget events to current subscribers without replaying them', () => {
|
|
383
|
+
const messageBus = createMessageBus()
|
|
384
|
+
const seen: number[] = []
|
|
385
|
+
messageBus.subscribe('test.ping', (message) => seen.push(message.payload.n))
|
|
386
|
+
messageBus.subscribe('test.ping').subscribe((payload) => seen.push(payload.n * 10))
|
|
387
|
+
|
|
388
|
+
messageBus.emit('test.ping', {n: 1})
|
|
389
|
+
|
|
390
|
+
const late: number[] = []
|
|
391
|
+
messageBus.subscribe('test.ping', (message) => late.push(message.payload.n))
|
|
392
|
+
expect(seen).toEqual([1, 10])
|
|
393
|
+
expect(late).toEqual([])
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('resolves a request with the first reply', async () => {
|
|
397
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
398
|
+
const messageBus = createMessageBus()
|
|
399
|
+
messageBus.subscribe('test.echo', (message) => {
|
|
400
|
+
message.reply({n: message.payload.n + 1})
|
|
401
|
+
message.reply({n: 100})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
await expect(messageBus.emit('test.echo', {n: 1})).resolves.toEqual({n: 2})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
it('rejects a request without a responder', async () => {
|
|
408
|
+
await expect(createMessageBus().emit('test.echo', {n: 1})).rejects.toMatchObject({
|
|
409
|
+
code: 'NO_RESPONDER',
|
|
410
|
+
})
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('times out when a responder never replies', async () => {
|
|
414
|
+
const messageBus = createMessageBus()
|
|
415
|
+
messageBus.subscribe('test.echo', () => {})
|
|
416
|
+
|
|
417
|
+
await expect(messageBus.emit('test.echo', {n: 1}, {timeout: 1})).rejects.toMatchObject({
|
|
418
|
+
code: 'TIMEOUT',
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('aborts the caller and responder together', async () => {
|
|
423
|
+
const messageBus = createMessageBus()
|
|
424
|
+
const caller = new AbortController()
|
|
425
|
+
let responderSignal: AbortSignal | undefined
|
|
426
|
+
messageBus.subscribe('test.echo', (message) => {
|
|
427
|
+
responderSignal = message.signal
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
const result = expect(
|
|
431
|
+
messageBus.emit('test.echo', {n: 1}, {signal: caller.signal, timeout: null}),
|
|
432
|
+
).rejects.toMatchObject({code: 'ABORTED'})
|
|
433
|
+
caller.abort()
|
|
434
|
+
|
|
435
|
+
await result
|
|
436
|
+
expect(responderSignal?.aborted).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it.each([
|
|
440
|
+
[
|
|
441
|
+
'synchronous',
|
|
442
|
+
() => {
|
|
443
|
+
throw new Error('sync failure')
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
['asynchronous', () => Promise.reject(new Error('async failure'))],
|
|
447
|
+
])('reports a %s responder failure', async (_kind, respond) => {
|
|
448
|
+
const messageBus = createMessageBus()
|
|
449
|
+
messageBus.subscribe('test.echo', respond)
|
|
450
|
+
|
|
451
|
+
await expect(messageBus.emit('test.echo', {n: 1})).rejects.toMatchObject({
|
|
452
|
+
code: 'HANDLER_THREW',
|
|
453
|
+
cause: expect.any(Error),
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('supports catch and finally on an emit result', async () => {
|
|
458
|
+
let finalized = false
|
|
459
|
+
const result = createMessageBus().emit('test.echo', {n: 1})
|
|
460
|
+
|
|
461
|
+
const finalizedResult = result
|
|
462
|
+
.finally(() => {
|
|
463
|
+
finalized = true
|
|
464
|
+
})
|
|
465
|
+
.catch(() => undefined)
|
|
466
|
+
const code = await result.catch((error) => (error as {code: string}).code)
|
|
467
|
+
|
|
468
|
+
await finalizedResult
|
|
469
|
+
expect(code).toBe('NO_RESPONDER')
|
|
470
|
+
expect(finalized).toBe(true)
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
describe('application connections', () => {
|
|
475
|
+
it('stamps emitted messages with the connected application id', async () => {
|
|
476
|
+
const installedMessageBus = createMessageBus('dashboard')
|
|
477
|
+
const application = connectApplicationToMessageBus(installedMessageBus, {appId: 'favorites'})
|
|
478
|
+
let applicationId: string | undefined
|
|
479
|
+
installedMessageBus.subscribe('test.echo', (message) => {
|
|
480
|
+
applicationId = message.meta.appId
|
|
481
|
+
message.reply({n: message.payload.n})
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
await application.emit('test.echo', {n: 1})
|
|
485
|
+
|
|
486
|
+
expect(applicationId).toBe('favorites')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('enforces state and responder ownership', () => {
|
|
490
|
+
const host = createMessageBus('dashboard')
|
|
491
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
492
|
+
client.emit('auth.token', 'trusted')
|
|
493
|
+
|
|
494
|
+
expect(() => app.emit('auth.token' as never, 'spoofed' as never)).toThrowError(
|
|
495
|
+
expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}),
|
|
496
|
+
)
|
|
497
|
+
expect(() =>
|
|
498
|
+
app.subscribe('auth.token.refresh', (message) => message.reply('spoofed')),
|
|
499
|
+
).toThrowError(expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}))
|
|
500
|
+
expect(app.subscribe('auth.token').getCurrent()).toBe('trusted')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('rejects state emits from every connection, including the host', () => {
|
|
504
|
+
const host = createMessageBus('dashboard')
|
|
505
|
+
const panel = {
|
|
506
|
+
ok: true as const,
|
|
507
|
+
value: {appId: 'favorites', name: 'list', mode: 'full' as const},
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
expect(() => host.emit('panels.mode' as never, panel as never)).toThrowError(
|
|
511
|
+
expect.objectContaining({code: 'OWNERSHIP_MISMATCH'}),
|
|
512
|
+
)
|
|
513
|
+
expect(host.subscribe('panels.mode').getCurrent()).toEqual({ok: true, value: null})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('returns stable state and event streams', () => {
|
|
517
|
+
const application = connectApplicationToMessageBus(createMessageBus(), {appId: 'favorites'})
|
|
518
|
+
|
|
519
|
+
expect(application.subscribe('panels.mode')).toBe(application.subscribe('panels.mode'))
|
|
520
|
+
expect(application.subscribe('test.ping')).toBe(application.subscribe('test.ping'))
|
|
521
|
+
})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
describe('connection identity and lifetime', () => {
|
|
525
|
+
it('stamps each connection module id on emitted messages', async () => {
|
|
526
|
+
const host = createMessageBus('dashboard')
|
|
527
|
+
const a = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
528
|
+
const b = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-b'})
|
|
529
|
+
const seen: (string | undefined)[] = []
|
|
530
|
+
host.subscribe('test.mint', (message) => {
|
|
531
|
+
seen.push(message.meta.moduleId)
|
|
532
|
+
message.reply('ok')
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
await a.emit('test.mint')
|
|
536
|
+
await b.emit('test.mint')
|
|
537
|
+
|
|
538
|
+
expect(seen).toEqual(['module-a', 'module-b'])
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('defaults the module id to the app id', async () => {
|
|
542
|
+
const host = createMessageBus('dashboard')
|
|
543
|
+
const application = connectApplicationToMessageBus(host, {appId: 'favorites'})
|
|
544
|
+
let meta: {appId: string; moduleId?: string} | undefined
|
|
545
|
+
host.subscribe('test.mint', (message) => {
|
|
546
|
+
meta = message.meta
|
|
547
|
+
message.reply('ok')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
await application.emit('test.mint')
|
|
551
|
+
|
|
552
|
+
expect(meta?.moduleId).toBe('favorites')
|
|
553
|
+
expect(meta?.moduleId).toBe(meta?.appId)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('returns a connection with disconnect from connectMessageBus', () => {
|
|
557
|
+
preserveMessageBusInstallation()
|
|
558
|
+
try {
|
|
559
|
+
installMessageBus({appId: 'dashboard'})
|
|
560
|
+
const connection = connectMessageBus({appId: 'favorites', moduleId: 'module'})
|
|
561
|
+
if (!connection) throw new Error('Expected a dashboard message bus')
|
|
562
|
+
expect(typeof connection.disconnect).toBe('function')
|
|
563
|
+
} finally {
|
|
564
|
+
restoreMessageBusInstallation()
|
|
565
|
+
}
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('tears down one connection while its siblings keep working', async () => {
|
|
569
|
+
const host = createMessageBus('dashboard')
|
|
570
|
+
const a = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
571
|
+
const b = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-b'})
|
|
572
|
+
|
|
573
|
+
host.subscribe('test.echo', () => {})
|
|
574
|
+
const pending = a.emit('test.echo', {n: 1}, {timeout: null})
|
|
575
|
+
pending.catch(() => {})
|
|
576
|
+
|
|
577
|
+
const handlerSeen: number[] = []
|
|
578
|
+
a.subscribe('test.ping', (message) => handlerSeen.push(message.payload.n))
|
|
579
|
+
const streamSeen: number[] = []
|
|
580
|
+
const streamComplete = firstValueFrom(a.subscribe('test.ping')).catch((error) => error)
|
|
581
|
+
a.subscribe('test.ping').subscribe({
|
|
582
|
+
next: (payload) => streamSeen.push(payload.n),
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
a.disconnect()
|
|
586
|
+
|
|
587
|
+
await expect(pending).rejects.toMatchObject({code: 'ABORTED'})
|
|
588
|
+
// A's subscriptions stop; the stream form completes without a value.
|
|
589
|
+
await expect(streamComplete).resolves.toBeInstanceOf(EmptyError)
|
|
590
|
+
|
|
591
|
+
host.emit('test.ping', {n: 5})
|
|
592
|
+
expect(handlerSeen).toEqual([])
|
|
593
|
+
expect(streamSeen).toEqual([])
|
|
594
|
+
|
|
595
|
+
// B is untouched and still delivers events.
|
|
596
|
+
const bSeen: number[] = []
|
|
597
|
+
b.subscribe('test.ping', (message) => bSeen.push(message.payload.n))
|
|
598
|
+
host.emit('test.ping', {n: 7})
|
|
599
|
+
expect(bSeen).toEqual([7])
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('completes a stream subscribed after disconnect() without delivering values', () => {
|
|
603
|
+
const host = createMessageBus('dashboard')
|
|
604
|
+
const a = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
605
|
+
// Obtain the stream before disconnect, then subscribe after: the abort notifier must replay.
|
|
606
|
+
const stream = a.subscribe('test.ping')
|
|
607
|
+
|
|
608
|
+
a.disconnect()
|
|
609
|
+
|
|
610
|
+
const seen: number[] = []
|
|
611
|
+
let completed = false
|
|
612
|
+
stream.subscribe({
|
|
613
|
+
next: (payload) => seen.push(payload.n),
|
|
614
|
+
complete: () => (completed = true),
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
expect(completed).toBe(true)
|
|
618
|
+
host.emit('test.ping', {n: 1})
|
|
619
|
+
expect(seen).toEqual([])
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('fails emit, state emit, query, and subscribe after disconnect()', async () => {
|
|
623
|
+
const host = createMessageBus('dashboard')
|
|
624
|
+
registerStateTopics(host, {'test.count': 0})
|
|
625
|
+
const a = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
626
|
+
|
|
627
|
+
const responderSeen: number[] = []
|
|
628
|
+
host.subscribe('test.ping', (message) => responderSeen.push(message.payload.n))
|
|
629
|
+
|
|
630
|
+
a.disconnect()
|
|
631
|
+
|
|
632
|
+
expect(() => a.emit('test.ping', {n: 1})).toThrowError(
|
|
633
|
+
expect.objectContaining({code: 'ABORTED'}),
|
|
634
|
+
)
|
|
635
|
+
expect(() => a.emit('test.count' as never, 1 as never)).toThrowError(
|
|
636
|
+
expect.objectContaining({code: 'ABORTED'}),
|
|
637
|
+
)
|
|
638
|
+
await expect(a.query('test.count')).rejects.toMatchObject({code: 'ABORTED'})
|
|
639
|
+
expect(() => a.subscribe('test.ping', () => {})).toThrowError(
|
|
640
|
+
expect.objectContaining({code: 'ABORTED'}),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
// The blocked emit never reached a sibling responder, and the state topic was not republished.
|
|
644
|
+
expect(responderSeen).toEqual([])
|
|
645
|
+
expect(host.subscribe('test.count').getCurrent()).toBe(0)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
it('delivers a reply only to the requesting connection', async () => {
|
|
649
|
+
const host = createMessageBus('dashboard')
|
|
650
|
+
const a = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
651
|
+
const b = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-b'})
|
|
652
|
+
host.subscribe('test.mint', (message) => message.reply(`minted-for-${message.meta.moduleId}`))
|
|
653
|
+
|
|
654
|
+
const bReplies: unknown[] = []
|
|
655
|
+
b.subscribe('test.mint').subscribe(() => bReplies.push('b saw a payload'))
|
|
656
|
+
|
|
657
|
+
await expect(a.emit('test.mint')).resolves.toBe('minted-for-module-a')
|
|
658
|
+
// B observes the fire-and-forget payload but never A's private reply value.
|
|
659
|
+
expect(bReplies).not.toContain('minted-for-module-a')
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
describe('per-connection state', () => {
|
|
664
|
+
it('delivers a state value only to the connection it was written to', async () => {
|
|
665
|
+
const host = createMessageBus('dashboard')
|
|
666
|
+
const a = connect(host, {appId: 'app', moduleId: 'module-a'})
|
|
667
|
+
const b = connect(host, {appId: 'app', moduleId: 'module-b'})
|
|
668
|
+
const aHandler: (string | null)[] = []
|
|
669
|
+
const bHandler: (string | null)[] = []
|
|
670
|
+
a.app.subscribe('auth.token', (token) => aHandler.push(token))
|
|
671
|
+
b.app.subscribe('auth.token', (token) => bHandler.push(token))
|
|
672
|
+
|
|
673
|
+
a.client.emit('auth.token', 'tok-a')
|
|
674
|
+
b.client.emit('auth.token', 'tok-b')
|
|
675
|
+
|
|
676
|
+
expect(a.app.subscribe('auth.token').getCurrent()).toBe('tok-a')
|
|
677
|
+
expect(b.app.subscribe('auth.token').getCurrent()).toBe('tok-b')
|
|
678
|
+
expect(aHandler).toEqual(['tok-a'])
|
|
679
|
+
expect(bHandler).toEqual(['tok-b'])
|
|
680
|
+
await expect(a.app.query('auth.token')).resolves.toBe('tok-a')
|
|
681
|
+
await expect(b.app.query('auth.token')).resolves.toBe('tok-b')
|
|
682
|
+
// The host's own copy was never written.
|
|
683
|
+
expect(host.subscribe('auth.token').getCurrent()).toBeUndefined()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('starts a later connection without values written to earlier ones', () => {
|
|
687
|
+
const host = createMessageBus('dashboard')
|
|
688
|
+
const a = connect(host, {appId: 'app', moduleId: 'module-a'})
|
|
689
|
+
a.client.emit('auth.token', 'tok-a')
|
|
690
|
+
|
|
691
|
+
const late = connect(host, {appId: 'app', moduleId: 'module-late'})
|
|
692
|
+
|
|
693
|
+
expect(late.app.subscribe('auth.token').getCurrent()).toBeUndefined()
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
it('lists open connections first, then new ones, with one client object per connection', () => {
|
|
697
|
+
const host = createMessageBus('dashboard')
|
|
698
|
+
const a = connect(host, {appId: 'app', moduleId: 'module-a'})
|
|
699
|
+
const seen: MessageBusClient[] = []
|
|
700
|
+
host.connections.subscribe((client) => seen.push(client))
|
|
701
|
+
|
|
702
|
+
expect(seen.map((client) => client.moduleId)).toEqual(['module-a'])
|
|
703
|
+
expect(seen[0]).toBe(a.client)
|
|
704
|
+
|
|
705
|
+
connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-b'})
|
|
706
|
+
|
|
707
|
+
expect(seen.map((client) => client.moduleId)).toEqual(['module-a', 'module-b'])
|
|
708
|
+
expect(seen[1]).toMatchObject({appId: 'app', moduleId: 'module-b'})
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
it('closes the client and stops writes when its connection disconnects', () => {
|
|
712
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
713
|
+
const host = createMessageBus('dashboard')
|
|
714
|
+
const a = connect(host, {appId: 'app', moduleId: 'module-a'})
|
|
715
|
+
const b = connect(host, {appId: 'app', moduleId: 'module-b'})
|
|
716
|
+
b.client.emit('auth.token', 'tok-b')
|
|
717
|
+
const bStream = b.app.subscribe('auth.token')
|
|
718
|
+
expect(a.client.closed.aborted).toBe(false)
|
|
719
|
+
|
|
720
|
+
a.app.disconnect()
|
|
721
|
+
|
|
722
|
+
expect(a.client.closed.aborted).toBe(true)
|
|
723
|
+
expect(() => a.client.emit('auth.token', 'tok-a')).not.toThrow()
|
|
724
|
+
expect(warn).toHaveBeenCalledWith(
|
|
725
|
+
'[sanity-sdk:message-bus] "auth.token" not written: connection "module-a" has closed',
|
|
726
|
+
)
|
|
727
|
+
// B keeps its value and stream.
|
|
728
|
+
expect(bStream.getCurrent()).toBe('tok-b')
|
|
729
|
+
b.client.emit('auth.token', 'tok-b2')
|
|
730
|
+
expect(bStream.getCurrent()).toBe('tok-b2')
|
|
731
|
+
|
|
732
|
+
const remaining: string[] = []
|
|
733
|
+
host.connections.subscribe((client) => remaining.push(client.moduleId)).unsubscribe()
|
|
734
|
+
expect(remaining).toEqual(['module-b'])
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('ends pending state reads and streams of a disconnected connection', async () => {
|
|
738
|
+
const host = createMessageBus('dashboard')
|
|
739
|
+
const a = connect(host, {appId: 'app', moduleId: 'module-a'})
|
|
740
|
+
const pending = a.app.query('auth.token', {timeout: null})
|
|
741
|
+
pending.catch(() => {})
|
|
742
|
+
let completed = false
|
|
743
|
+
a.app.subscribe('auth.token').subscribe({complete: () => (completed = true)})
|
|
744
|
+
|
|
745
|
+
a.app.disconnect()
|
|
746
|
+
|
|
747
|
+
await expect(pending).rejects.toMatchObject({code: 'ABORTED'})
|
|
748
|
+
expect(completed).toBe(true)
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('completes connections when the host disconnects', () => {
|
|
752
|
+
const host = createMessageBus('dashboard')
|
|
753
|
+
let completed = false
|
|
754
|
+
host.connections.subscribe({complete: () => (completed = true)})
|
|
755
|
+
|
|
756
|
+
host.disconnect()
|
|
757
|
+
|
|
758
|
+
expect(completed).toBe(true)
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
it('lets a host subscriber write to a connection as soon as it is announced', () => {
|
|
762
|
+
const host = createMessageBus('dashboard')
|
|
763
|
+
host.connections.subscribe((client) =>
|
|
764
|
+
client.emit('auth.token', `token-for-${client.moduleId}`),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
const application = connectApplicationToMessageBus(host, {appId: 'app', moduleId: 'module-a'})
|
|
768
|
+
|
|
769
|
+
expect(application.subscribe('auth.token').getCurrent()).toBe('token-for-module-a')
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('writes through the client owner’s topic version', async () => {
|
|
773
|
+
// The registry speaks the latest version; this host copy is older and writes v1 profiles.
|
|
774
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
775
|
+
registerStateTopics(dashboard, {'test.profile': undefined})
|
|
776
|
+
const olderHost = connectApplicationToMessageBus(dashboard, {
|
|
777
|
+
appId: 'dashboard',
|
|
778
|
+
migrations: new Map(),
|
|
779
|
+
}) as MessageBusHost
|
|
780
|
+
const {app, client} = connect(olderHost, {appId: 'favorites', migrations: latestMigrations})
|
|
781
|
+
|
|
782
|
+
client.emit('test.profile', {name: 'Ada'} as never)
|
|
783
|
+
|
|
784
|
+
await expect(app.query('test.profile')).resolves.toEqual({fullName: 'Ada', tags: []})
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
it('lists and writes to a sibling connection that shares the host app id', () => {
|
|
788
|
+
const host = createMessageBus('dashboard')
|
|
789
|
+
const {app, client} = connect(host, {appId: 'dashboard', moduleId: 'dashboard/views/dock'})
|
|
790
|
+
|
|
791
|
+
client.emit('auth.token', 'token')
|
|
792
|
+
|
|
793
|
+
expect(client.appId).toBe('dashboard')
|
|
794
|
+
expect(app.subscribe('auth.token').getCurrent()).toBe('token')
|
|
795
|
+
// Only the host's own connection is left out.
|
|
796
|
+
expect(host.subscribe('auth.token').getCurrent()).toBeUndefined()
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
it('does not expose connections to an application', () => {
|
|
800
|
+
const host = createMessageBus('dashboard')
|
|
801
|
+
const application = connectApplicationToMessageBus(host, {appId: 'favorites'})
|
|
802
|
+
|
|
803
|
+
expect('connections' in application).toBe(false)
|
|
804
|
+
})
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
describe('reset', () => {
|
|
808
|
+
beforeEach(preserveMessageBusInstallation)
|
|
809
|
+
afterEach(restoreMessageBusInstallation)
|
|
810
|
+
|
|
811
|
+
it('does nothing when no message bus is installed', () => {
|
|
812
|
+
expect(() => resetMessageBus()).not.toThrow()
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
it('resets state sources without unhandled rejections', async () => {
|
|
816
|
+
const messageBus = installMessageBus({appId: 'dashboard'})
|
|
817
|
+
messageBus.subscribe('applications.config')
|
|
818
|
+
const pending = messageBus.query('applications.list')
|
|
819
|
+
|
|
820
|
+
resetMessageBus()
|
|
821
|
+
|
|
822
|
+
await expect(pending).rejects.toMatchObject({code: 'ABORTED'})
|
|
823
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
it('aborts pending work and keeps existing application connections reusable', async () => {
|
|
827
|
+
const installedMessageBus = installMessageBus({appId: 'dashboard'})
|
|
828
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
829
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
830
|
+
installedMessageBus.subscribe('test.echo', () => {})
|
|
831
|
+
const pending = application.emit('test.echo', {n: 1}, {timeout: null})
|
|
832
|
+
pending.catch(() => {})
|
|
833
|
+
|
|
834
|
+
resetMessageBus()
|
|
835
|
+
|
|
836
|
+
await expect(pending).rejects.toMatchObject({code: 'ABORTED'})
|
|
837
|
+
const seen: unknown[] = []
|
|
838
|
+
application.subscribe('panels.mode').subscribe((value) => seen.push(value))
|
|
839
|
+
const panel = {
|
|
840
|
+
ok: true as const,
|
|
841
|
+
value: {appId: 'favorites', name: 'list', mode: 'full' as const},
|
|
842
|
+
}
|
|
843
|
+
installedMessageBus.connections.subscribe((client) => client.emit('panels.mode', panel))
|
|
844
|
+
expect(seen).toEqual([{ok: true, value: null}, panel])
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('keeps announcing new connections to a host subscribed before the reset', () => {
|
|
848
|
+
const host = installMessageBus({appId: 'dashboard'})
|
|
849
|
+
const seen: string[] = []
|
|
850
|
+
host.connections.subscribe((client) => seen.push(client.moduleId))
|
|
851
|
+
|
|
852
|
+
resetMessageBus()
|
|
853
|
+
connectMessageBus({appId: 'favorites', moduleId: 'module-late'})
|
|
854
|
+
|
|
855
|
+
expect(seen).toEqual(['module-late'])
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
|
|
859
|
+
describe('compatibility', () => {
|
|
860
|
+
beforeEach(preserveMessageBusInstallation)
|
|
861
|
+
afterEach(() => {
|
|
862
|
+
restoreMessageBusInstallation()
|
|
863
|
+
vi.resetModules()
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('connects independently loaded SDK copies through the shared symbol', async () => {
|
|
867
|
+
const installer = await import('./bus')
|
|
868
|
+
const dashboard = installer.installMessageBus({appId: 'dashboard'})
|
|
869
|
+
vi.resetModules()
|
|
870
|
+
const consumer = await import('./bus')
|
|
871
|
+
const application = consumer.connectMessageBus({appId: 'favorites'})
|
|
872
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
873
|
+
|
|
874
|
+
dashboard.subscribe('test.echo', (message) => message.reply({n: message.payload.n + 1}))
|
|
875
|
+
|
|
876
|
+
await expect(application.emit('test.echo', {n: 1})).resolves.toEqual({n: 2})
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it('rejects incompatible message bus protocol semantics', async () => {
|
|
880
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
881
|
+
const dashboard = createMessageBus('dashboard')
|
|
882
|
+
const protocolKey = Symbol.for('sanity.os.protocol')
|
|
883
|
+
const internalDashboard = dashboard as unknown as Record<symbol, unknown>
|
|
884
|
+
internalDashboard[protocolKey] = 2
|
|
885
|
+
const application = connectApplicationToMessageBus(dashboard, {appId: 'favorites'})
|
|
886
|
+
|
|
887
|
+
// `query` returns a Promise, so a rejected connection must reject rather than throw synchronously.
|
|
888
|
+
await expect(application.query('applications.foreground')).rejects.toMatchObject({
|
|
889
|
+
code: 'PROTOCOL_MISMATCH',
|
|
890
|
+
})
|
|
891
|
+
// `emit`/`subscribe` are not Promise-typed and still surface the failure synchronously.
|
|
892
|
+
expect(() => application.subscribe('applications.foreground')).toThrowError(
|
|
893
|
+
expect.objectContaining({code: 'PROTOCOL_MISMATCH'}),
|
|
894
|
+
)
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
it('adapts state across the full migration chain', async () => {
|
|
898
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
899
|
+
registerStateTopics(dashboard, {
|
|
900
|
+
'test.profile': {fullName: 'Initial', tags: ['one']},
|
|
901
|
+
})
|
|
902
|
+
const {app, client} = connect(dashboard, {appId: 'favorites', migrations: new Map()})
|
|
903
|
+
const source = app.subscribe('test.profile')
|
|
904
|
+
|
|
905
|
+
expect(source.getCurrent() as unknown).toEqual({name: 'Initial'})
|
|
906
|
+
await expect(source.firstValue as Promise<unknown>).resolves.toEqual({name: 'Initial'})
|
|
907
|
+
expect(source.getCurrent()).toBe(source.getCurrent())
|
|
908
|
+
|
|
909
|
+
client.emit('test.profile', {fullName: 'Ada', tags: []})
|
|
910
|
+
|
|
911
|
+
expect((await app.query('test.profile')) as unknown).toEqual({name: 'Ada'})
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('adapts an application newer than the installed message bus', async () => {
|
|
915
|
+
const dashboard = createMessageBus()
|
|
916
|
+
registerStateTopics(dashboard, {'test.profile': undefined})
|
|
917
|
+
const {app, client} = connect(dashboard, {
|
|
918
|
+
appId: 'favorites',
|
|
919
|
+
migrations: new Map([['test.profile', [profileMigrations[1]]]]),
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
client.emit('test.profile', {name: 'Ada', tags: []} as never)
|
|
923
|
+
|
|
924
|
+
await expect(app.query('test.profile')).resolves.toEqual({fullName: 'Ada', tags: []})
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
it('adapts event payloads in both directions', () => {
|
|
928
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
929
|
+
const application = connectApplicationToMessageBus(dashboard, {
|
|
930
|
+
appId: 'favorites',
|
|
931
|
+
migrations: new Map(),
|
|
932
|
+
})
|
|
933
|
+
const dashboardPayloads: unknown[] = []
|
|
934
|
+
dashboard.subscribe('test.greet', (message) => dashboardPayloads.push(message.payload))
|
|
935
|
+
|
|
936
|
+
application.emit('test.greet', {name: 'Ada'} as never)
|
|
937
|
+
|
|
938
|
+
const applicationPayloads: unknown[] = []
|
|
939
|
+
application.subscribe('test.greet', (message) => applicationPayloads.push(message.payload))
|
|
940
|
+
dashboard.emit('test.greet', {fullName: 'Grace'})
|
|
941
|
+
|
|
942
|
+
expect(dashboardPayloads).toEqual([{fullName: 'Ada'}, {fullName: 'Grace'}])
|
|
943
|
+
expect(applicationPayloads).toEqual([{name: 'Grace'}])
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
it('adapts event replies for an older emitter', async () => {
|
|
947
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
948
|
+
const application = connectApplicationToMessageBus(dashboard, {
|
|
949
|
+
appId: 'favorites',
|
|
950
|
+
migrations: new Map(),
|
|
951
|
+
})
|
|
952
|
+
dashboard.subscribe('test.greet', (message) =>
|
|
953
|
+
message.reply({salutation: `Hello ${message.payload.fullName}`, language: 'en'}),
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
await expect(application.emit('test.greet', {name: 'Ada'} as never)).resolves.toEqual({
|
|
957
|
+
greeting: 'Hello Ada',
|
|
958
|
+
})
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
it('adapts event replies from an older responder', async () => {
|
|
962
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
963
|
+
const application = connectApplicationToMessageBus(dashboard, {
|
|
964
|
+
appId: 'favorites',
|
|
965
|
+
migrations: new Map(),
|
|
966
|
+
})
|
|
967
|
+
application.subscribe('test.greet', (message) =>
|
|
968
|
+
message.reply({
|
|
969
|
+
greeting: `Hello ${(message.payload as unknown as {name: string}).name}`,
|
|
970
|
+
} as never),
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
await expect(dashboard.emit('test.greet', {fullName: 'Ada'})).resolves.toEqual({
|
|
974
|
+
salutation: 'Hello Ada',
|
|
975
|
+
language: 'en',
|
|
976
|
+
})
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
it('keeps migrated fire-and-forget replies lazy', () => {
|
|
980
|
+
vi.useFakeTimers()
|
|
981
|
+
const dashboard = createRuntimeMessageBus('dashboard', {migrations: latestMigrations})
|
|
982
|
+
const application = connectApplicationToMessageBus(dashboard, {
|
|
983
|
+
appId: 'favorites',
|
|
984
|
+
migrations: new Map(),
|
|
985
|
+
})
|
|
986
|
+
let responderSignal: AbortSignal | undefined
|
|
987
|
+
dashboard.subscribe('test.greet', (message) => {
|
|
988
|
+
responderSignal = message.signal
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
application.emit('test.greet', {name: 'Ada'} as never, {timeout: 1})
|
|
992
|
+
vi.advanceTimersByTime(1)
|
|
993
|
+
|
|
994
|
+
expect(responderSignal?.aborted).toBe(false)
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
it('returns a failed connection without replacing a conflicting manifest', () => {
|
|
998
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
999
|
+
const dashboard = createMessageBus()
|
|
1000
|
+
const registry = getRegistry(dashboard)
|
|
1001
|
+
registry.topics.set('panels.mode', {
|
|
1002
|
+
kind: 'event',
|
|
1003
|
+
ownership: {type: 'any_app'},
|
|
1004
|
+
})
|
|
1005
|
+
registry.topics.delete('auth.token')
|
|
1006
|
+
|
|
1007
|
+
const application = connectApplicationToMessageBus(dashboard, {appId: 'favorites'})
|
|
1008
|
+
|
|
1009
|
+
expect(() => application.subscribe('panels.mode')).toThrowError(
|
|
1010
|
+
expect.objectContaining({code: 'PROTOCOL_MISMATCH'}),
|
|
1011
|
+
)
|
|
1012
|
+
expect(registry.topics.get('panels.mode')).toEqual({
|
|
1013
|
+
kind: 'event',
|
|
1014
|
+
ownership: {type: 'any_app'},
|
|
1015
|
+
})
|
|
1016
|
+
expect(registry.topics.has('auth.token')).toBe(false)
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('returns a failed connection for an incompatible message bus', () => {
|
|
1020
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
1021
|
+
const foreignMessageBus = {
|
|
1022
|
+
emit() {},
|
|
1023
|
+
query() {},
|
|
1024
|
+
subscribe() {},
|
|
1025
|
+
} as unknown as MessageBus
|
|
1026
|
+
|
|
1027
|
+
const application = connectApplicationToMessageBus(foreignMessageBus, {appId: 'stale'})
|
|
1028
|
+
|
|
1029
|
+
expect(() => application.subscribe('panels.mode')).toThrowError(
|
|
1030
|
+
expect.objectContaining({code: 'PROTOCOL_MISMATCH'}),
|
|
1031
|
+
)
|
|
1032
|
+
})
|
|
1033
|
+
})
|