@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.
Files changed (132) hide show
  1. package/dist/_exports/_internal.d.ts +72 -19
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +130 -11
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +7 -7
  6. package/dist/_exports/agent.d.ts.map +1 -1
  7. package/dist/_exports/agent.js +1 -1
  8. package/dist/_exports/comlink.d.ts +9 -9
  9. package/dist/_exports/comlink.d.ts.map +1 -1
  10. package/dist/_exports/comlink.js +2 -2
  11. package/dist/_exports/dashboard.d.ts +2 -0
  12. package/dist/_exports/dashboard.js +2 -0
  13. package/dist/{authStore-C0o1NOxW.d.ts → authStore-BO2vch3I.d.ts} +516 -63
  14. package/dist/authStore-BO2vch3I.d.ts.map +1 -0
  15. package/dist/{authStore-CtE9bLHW.js → clientStore-BI4D8HhJ.js} +390 -15
  16. package/dist/clientStore-BI4D8HhJ.js.map +1 -0
  17. package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-BQIqyGLX.d.ts} +17 -2
  18. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  19. package/dist/{createStateSourceAction-BOKtqFNm.js → createStateSourceAction-DdAhEfnZ.js} +7 -3
  20. package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
  21. package/dist/defineProperty-Bho9Ngsi.js +31 -0
  22. package/dist/{getNodeState-CR3__B2D.js → getNodeState-B4nG0sjm.js} +2 -2
  23. package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +370 -90
  26. package/dist/index.js.map +1 -1
  27. package/dist/topicStore-Ct7IFNtY.js +565 -0
  28. package/dist/topicStore-Ct7IFNtY.js.map +1 -0
  29. package/dist/topicStore-D0WqhQGF.d.ts +435 -0
  30. package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
  31. package/dist/{queryStore-C7yMHPUI.js → version-BcRmCiYi.js} +48 -16
  32. package/dist/version-BcRmCiYi.js.map +1 -0
  33. package/package.json +19 -18
  34. package/src/_exports/_internal.ts +13 -0
  35. package/src/_exports/dashboard.ts +36 -0
  36. package/src/_exports/index.ts +17 -1
  37. package/src/access/checkPermissions.test.ts +1 -1
  38. package/src/access/checkPermissions.ts +1 -1
  39. package/src/applications/applications.test-d.ts +28 -4
  40. package/src/applications/applications.test.ts +4 -4
  41. package/src/applications/applications.ts +43 -18
  42. package/src/auth/authMode.test.ts +24 -0
  43. package/src/auth/authMode.ts +8 -3
  44. package/src/auth/authStore.ts +12 -0
  45. package/src/auth/authStrategy.ts +2 -0
  46. package/src/auth/handleAuthCallback.test.ts +2 -2
  47. package/src/auth/handleAuthCallback.ts +1 -1
  48. package/src/auth/logout.test.ts +46 -1
  49. package/src/auth/logout.ts +2 -1
  50. package/src/auth/oauth/oauthActions.test.ts +457 -0
  51. package/src/auth/oauth/oauthActions.ts +389 -0
  52. package/src/auth/oauth/oauthAuth.test.ts +206 -0
  53. package/src/auth/oauth/oauthAuth.ts +168 -0
  54. package/src/auth/oauth/pkce.test.ts +44 -0
  55. package/src/auth/oauth/pkce.ts +60 -0
  56. package/src/auth/oauth/types.ts +12 -0
  57. package/src/auth/refreshStampedToken.ts +1 -1
  58. package/src/auth/studioModeAuth.test.ts +1 -1
  59. package/src/auth/studioModeAuth.ts +1 -1
  60. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
  61. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
  62. package/src/comments/addonDatasetStore.test.ts +2 -2
  63. package/src/comments/addonDatasetStore.ts +2 -2
  64. package/src/comments/commentActions.ts +6 -5
  65. package/src/comments/commentsStore.ts +4 -4
  66. package/src/config/authConfig.ts +18 -0
  67. package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
  68. package/src/dashboard/messageBus/bus.test.ts +1033 -0
  69. package/src/dashboard/messageBus/bus.ts +1246 -0
  70. package/src/dashboard/messageBus/store.test.ts +142 -0
  71. package/src/dashboard/messageBus/store.ts +74 -0
  72. package/src/dashboard/messageBus/topicStore.test.ts +172 -0
  73. package/src/dashboard/messageBus/topicStore.ts +184 -0
  74. package/src/dashboard/messageBus/topics.test.ts +71 -0
  75. package/src/dashboard/messageBus/topics.ts +260 -0
  76. package/src/document/actions.ts +3 -3
  77. package/src/document/applyDocumentActions.ts +5 -9
  78. package/src/document/documentStore.concurrency.test.ts +2 -1
  79. package/src/document/documentStore.test.ts +19 -18
  80. package/src/document/documentStore.ts +17 -17
  81. package/src/document/permissions.ts +2 -1
  82. package/src/document/processActions.test.ts +2 -1
  83. package/src/document/processMutations.ts +61 -24
  84. package/src/document/reducers.ts +2 -2
  85. package/src/installations/installations.test-d.ts +3 -3
  86. package/src/installations/installations.test.ts +2 -2
  87. package/src/installations/installations.ts +4 -21
  88. package/src/organization/organization.test.ts +2 -2
  89. package/src/organization/organization.ts +1 -1
  90. package/src/organizations/organizations.test.ts +2 -2
  91. package/src/organizations/organizations.ts +1 -1
  92. package/src/presence/presenceStore.test.ts +15 -22
  93. package/src/presence/presenceStore.ts +3 -2
  94. package/src/project/project.test.ts +3 -3
  95. package/src/project/project.ts +1 -1
  96. package/src/projection/getProjectionState.test.ts +6 -4
  97. package/src/projection/getProjectionState.ts +4 -4
  98. package/src/projection/resolveProjection.ts +2 -2
  99. package/src/projects/projects.test.ts +2 -2
  100. package/src/projects/projects.ts +1 -1
  101. package/src/query/queryStore.ts +6 -6
  102. package/src/store/createSanityInstance.test.ts +1 -1
  103. package/src/store/createSanityInstance.ts +4 -4
  104. package/src/store/createStoreInstance.test.ts +0 -5
  105. package/src/telemetry/telemetryManager.test.ts +1 -1
  106. package/src/telemetry/telemetryManager.ts +2 -2
  107. package/src/typegen/defineProjection.test.ts +17 -0
  108. package/src/typegen/defineProjection.ts +19 -0
  109. package/src/typegen/groqCompat.ts +122 -0
  110. package/src/typegen/resolve.ts +37 -0
  111. package/src/users/usersStore.test.ts +1 -1
  112. package/src/users/usersStore.ts +5 -5
  113. package/src/utils/createGroqSearchFilter.test.ts +8 -0
  114. package/src/utils/createGroqSearchFilter.ts +6 -3
  115. package/src/utils/ids.test.ts +40 -15
  116. package/src/utils/ids.ts +47 -2
  117. package/dist/authStore-C0o1NOxW.d.ts.map +0 -1
  118. package/dist/authStore-CtE9bLHW.js.map +0 -1
  119. package/dist/clientStore-BtE2-q2i.js +0 -90
  120. package/dist/clientStore-BtE2-q2i.js.map +0 -1
  121. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +0 -1
  122. package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
  123. package/dist/getEnv-D5RGkQLu.js +0 -8
  124. package/dist/getEnv-D5RGkQLu.js.map +0 -1
  125. package/dist/logger-DAtJb5RQ.js +0 -173
  126. package/dist/logger-DAtJb5RQ.js.map +0 -1
  127. package/dist/queryStore-C7yMHPUI.js.map +0 -1
  128. package/dist/rolldown-runtime-vyAXikos.js +0 -9
  129. package/dist/telemetryManager-UIEiMtC0.js +0 -127
  130. package/dist/telemetryManager-UIEiMtC0.js.map +0 -1
  131. package/dist/version-D8RP_Q59.js +0 -9
  132. package/dist/version-D8RP_Q59.js.map +0 -1
@@ -0,0 +1,1246 @@
1
+ /* eslint-disable no-console -- The bus can initialize before the SDK logger exists. */
2
+ import {
3
+ BehaviorSubject,
4
+ concat,
5
+ defer,
6
+ filter,
7
+ firstValueFrom,
8
+ from,
9
+ map,
10
+ type Observable,
11
+ ReplaySubject,
12
+ Subject,
13
+ type Subscription,
14
+ takeUntil,
15
+ } from 'rxjs'
16
+
17
+ import {type Application} from '../../applications/applications'
18
+ import {
19
+ DASHBOARD_TOPIC_MANIFEST,
20
+ type EventTopic,
21
+ type PayloadOf,
22
+ type ReplyOf,
23
+ type StateTopic,
24
+ type TopicManifest,
25
+ type TopicMigration,
26
+ topicMigrations,
27
+ type TopicName,
28
+ type ValueOf,
29
+ } from './topics'
30
+
31
+ /**
32
+ * A message bus protocol error code.
33
+ * @public
34
+ */
35
+ export type MessageBusErrorCode =
36
+ | 'NO_RESPONDER'
37
+ | 'TIMEOUT'
38
+ | 'ABORTED'
39
+ | 'HANDLER_THREW'
40
+ | 'PROTOCOL_MISMATCH'
41
+ | 'OWNERSHIP_MISMATCH'
42
+ | 'MISSING_APP_ID'
43
+
44
+ /**
45
+ * An error raised by the message bus protocol.
46
+ * @public
47
+ */
48
+ export class MessageBusError extends Error {
49
+ /** The machine-readable protocol error code. */
50
+ readonly code: MessageBusErrorCode
51
+
52
+ /** Creates a message bus protocol error. */
53
+ constructor(code: MessageBusErrorCode, message?: string, options?: {cause?: unknown}) {
54
+ super(message ?? code, options)
55
+ this.name = 'MessageBusError'
56
+ this.code = code
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Metadata that travels with each message bus event.
62
+ * @public
63
+ */
64
+ export interface MessageBusMeta {
65
+ /** The application that produced the message. */
66
+ appId: Application['id']
67
+ /**
68
+ * The federation module id of the connection that sent the message (e.g.
69
+ * `favorites/views/list/panel`). Absent from older copies and when the host
70
+ * supplied none, so readers normalise with `message.meta.moduleId ?? message.meta.appId`.
71
+ */
72
+ moduleId?: string
73
+ /** The Unix timestamp in milliseconds when the message was produced. */
74
+ timestamp: number
75
+ }
76
+
77
+ /**
78
+ * A message delivered to an event topic responder.
79
+ * @public
80
+ */
81
+ export interface MessageBusMessage<T, R = never> {
82
+ /** The topic that carries the message. */
83
+ type: TopicName
84
+ /** The event payload. */
85
+ payload: T
86
+ /** The message provenance. */
87
+ meta: MessageBusMeta
88
+ /** Replies to an awaiting sender. */
89
+ reply(value: R): void
90
+ /** Aborts when the sender stops waiting for a reply. */
91
+ readonly signal: AbortSignal
92
+ }
93
+
94
+ /**
95
+ * An observable state topic with access to its current and first values.
96
+ * @public
97
+ */
98
+ export interface MessageBusStateSource<T> extends Observable<T> {
99
+ /** Returns the current value, or `undefined` before the first value is published. */
100
+ getCurrent(): T | undefined
101
+ /** Resolves with the first published value. */
102
+ readonly firstValue: Promise<T>
103
+ }
104
+
105
+ /**
106
+ * Options for aborting a message bus operation.
107
+ * @public
108
+ */
109
+ export interface MessageBusAbortOptions {
110
+ /** Aborts the operation when signaled. */
111
+ signal?: AbortSignal
112
+ }
113
+
114
+ /**
115
+ * Options for reading a state topic.
116
+ * @public
117
+ */
118
+ export interface MessageBusQueryOptions extends MessageBusAbortOptions {
119
+ /** Wait timeout in milliseconds. Defaults to 5 seconds; `null` disables it. */
120
+ timeout?: number | null
121
+ }
122
+
123
+ /**
124
+ * Options for emitting an event topic.
125
+ * @public
126
+ */
127
+ export interface MessageBusEmitOptions extends MessageBusAbortOptions {
128
+ /** Reply timeout in milliseconds. Defaults to 5 seconds; `null` disables it. */
129
+ timeout?: number | null
130
+ }
131
+
132
+ /**
133
+ * A lazily awaited event reply.
134
+ * @public
135
+ */
136
+ export interface MessageBusEmitResult<R> extends PromiseLike<R> {
137
+ /** Handles a rejected event reply. */
138
+ catch<T = never>(onRejected?: (reason: unknown) => T | PromiseLike<T>): Promise<R | T>
139
+ /** Runs after the event reply settles. */
140
+ finally(onFinally?: () => void): Promise<R>
141
+ }
142
+
143
+ /**
144
+ * Sends events and reads typed state and event topics.
145
+ *
146
+ * @remarks
147
+ * State topics are written by the host to each connection separately through
148
+ * {@link MessageBusClient.emit}; a connection only ever reads its own values.
149
+ * @public
150
+ */
151
+ export interface MessageBus {
152
+ /** Emits an event topic and provides its reply when awaited. */
153
+ emit<K extends EventTopic>(
154
+ type: K,
155
+ ...rest: PayloadOf<K> extends void
156
+ ? [payload?: void, options?: MessageBusEmitOptions]
157
+ : [payload: PayloadOf<K>, options?: MessageBusEmitOptions]
158
+ ): MessageBusEmitResult<ReplyOf<K>>
159
+ /** Reads the current or next value of a state topic. */
160
+ query<K extends StateTopic>(type: K, options?: MessageBusQueryOptions): Promise<ValueOf<K>>
161
+ /** Runs a responder for each event until its signal aborts. */
162
+ subscribe<K extends EventTopic>(
163
+ type: K,
164
+ handler: (message: MessageBusMessage<PayloadOf<K>, ReplyOf<K>>) => void,
165
+ options?: MessageBusAbortOptions,
166
+ ): void
167
+ /** Runs a handler for each state value until its signal aborts. */
168
+ subscribe<K extends StateTopic>(
169
+ type: K,
170
+ handler: (value: ValueOf<K>) => void,
171
+ options?: MessageBusAbortOptions,
172
+ ): void
173
+ /** Returns a state topic as a `MessageBusStateSource`. */
174
+ subscribe<K extends StateTopic>(type: K): MessageBusStateSource<ValueOf<K>>
175
+ /** Returns an event topic as an observable of its payloads. */
176
+ subscribe<K extends EventTopic>(type: K): Observable<PayloadOf<K>>
177
+ }
178
+
179
+ /**
180
+ * A message bus connection that can be torn down independently of its siblings.
181
+ * @public
182
+ */
183
+ export interface MessageBusConnection extends MessageBus {
184
+ /**
185
+ * Tears down this connection: pending requests reject `ABORTED`, subscriptions
186
+ * complete, and later `emit`, `query`, and `subscribe` calls fail with `ABORTED`.
187
+ * Siblings and the shared bus are untouched.
188
+ */
189
+ disconnect(): void
190
+ }
191
+
192
+ /**
193
+ * The host's handle to one connection: who it is, when it closes, and where to write its state.
194
+ * @public
195
+ */
196
+ export interface MessageBusClient {
197
+ /** The application the connection belongs to. */
198
+ readonly appId: Application['id']
199
+ /** The federation module id of the connection. Equals `appId` when the host supplied none. */
200
+ readonly moduleId: string
201
+ /** Aborts when the connection disconnects. Drop per-client state here. */
202
+ readonly closed: AbortSignal
203
+ /** Writes a state value to this connection only. Ignored once the connection has closed. */
204
+ emit<K extends StateTopic>(type: K, value: ValueOf<K>): void
205
+ }
206
+
207
+ /**
208
+ * The host's connection to the bus.
209
+ *
210
+ * @remarks
211
+ * State reaches an application only through {@link MessageBusClient.emit}. There is no
212
+ * broadcast: sending the same value to everyone is a loop over `connections`, and giving a
213
+ * new connection the current values is done when it appears there, as an SSE server writes
214
+ * a snapshot to each new client.
215
+ * @public
216
+ */
217
+ export interface MessageBusHost extends MessageBusConnection {
218
+ /**
219
+ * Every open connection other than this one first, then each new one as it joins. The same
220
+ * {@link MessageBusClient} object is handed out for a connection across subscriptions, so it
221
+ * can be kept in a `Set`. A connection stays listed until it calls `disconnect()`; the SDK
222
+ * does that when the owning `SanityInstance` is disposed.
223
+ */
224
+ readonly connections: Observable<MessageBusClient>
225
+ }
226
+
227
+ const MESSAGE_BUS_KEY = Symbol.for('sanity.os.bus')
228
+ const MESSAGE_BUS_PROTOCOL_KEY = Symbol.for('sanity.os.protocol')
229
+ const MESSAGE_BUS_REGISTRY_KEY = Symbol.for('sanity.os.registry')
230
+ const MESSAGE_BUS_PENDING_REPLY_KEY = Symbol.for('sanity.os.request')
231
+
232
+ const MESSAGE_BUS_PROTOCOL = 1
233
+
234
+ const DEFAULT_TIMEOUT_MS = 5000
235
+
236
+ // Distinguishes an unpublished topic from a published `undefined` value.
237
+ const NO_VALUE = Symbol.for('sanity.os.no-value')
238
+
239
+ type ReplyOutcome =
240
+ | {readonly ok: true; readonly value: unknown}
241
+ | {readonly ok: false; readonly error: unknown}
242
+
243
+ // Defers Promise creation until the reply is awaited to preserve fire-and-forget emission.
244
+ interface PendingReply {
245
+ readonly responderAbort: AbortController
246
+ readonly responderSignal: AbortSignal
247
+ settled: boolean
248
+ outcomeBeforeAwait?: ReplyOutcome
249
+ settlePromise?: (outcome: ReplyOutcome) => void
250
+ replyPromise?: Promise<unknown>
251
+ }
252
+
253
+ // One connection: its identity, its own copy of every state topic, and its lifetime.
254
+ interface ConnectionRecord {
255
+ readonly appId: string
256
+ readonly moduleId: string
257
+ readonly stateSubjects: Map<string, BehaviorSubject<unknown>>
258
+ readonly abort: AbortController
259
+ }
260
+
261
+ interface MessageBusRegistry {
262
+ readonly appId: string
263
+ readonly topics: Map<string, TopicManifest[string]>
264
+ readonly connections: Set<ConnectionRecord>
265
+ readonly connected$: Subject<ConnectionRecord>
266
+ readonly eventSubjects: Map<string, Subject<MessageBusMessage<unknown, unknown>>>
267
+ readonly responderCounts: Map<string, number>
268
+ readonly migrations: ReadonlyMap<string, readonly TopicMigration[]>
269
+ resetAbort: AbortController
270
+ generation: number
271
+ }
272
+
273
+ type InternalMessageBus = MessageBus & {
274
+ [MESSAGE_BUS_REGISTRY_KEY]: MessageBusRegistry
275
+ [MESSAGE_BUS_PROTOCOL_KEY]: number
276
+ }
277
+
278
+ // A connection's box for a state topic, created on first use from the topic's manifest seed.
279
+ function resolveStateSubject(
280
+ registry: MessageBusRegistry,
281
+ connection: ConnectionRecord,
282
+ type: string,
283
+ ): BehaviorSubject<unknown> {
284
+ // Callers gate on the connection's lifetime; this keeps a slipped call from recreating a
285
+ // box on a closed connection that nothing would ever complete.
286
+ if (connection.abort.signal.aborted) throw new MessageBusError('ABORTED')
287
+ let subject = connection.stateSubjects.get(type)
288
+ if (!subject) {
289
+ const topic = registry.topics.get(type)
290
+ if (!topic) {
291
+ console.warn(`[sanity-sdk:message-bus] state topic "${type}" is not declared`)
292
+ }
293
+ const seed = topic?.kind === 'state' ? topic.seed : undefined
294
+ subject = new BehaviorSubject<unknown>(seed === undefined ? NO_VALUE : seed)
295
+ connection.stateSubjects.set(type, subject)
296
+ }
297
+ return subject
298
+ }
299
+
300
+ function toStateSource(
301
+ values: Observable<unknown>,
302
+ getCurrent: () => unknown,
303
+ ): MessageBusStateSource<unknown> {
304
+ const source = values as MessageBusStateSource<unknown>
305
+ source.getCurrent = getCurrent
306
+ // Unread sources must not create promises that reject when reset completes them.
307
+ let firstValue: Promise<unknown> | undefined
308
+ Object.defineProperty(source, 'firstValue', {
309
+ get: () => (firstValue ??= firstValueFrom(values)),
310
+ })
311
+ return source
312
+ }
313
+
314
+ function assertCompatibleTopicManifest(
315
+ registry: MessageBusRegistry,
316
+ manifest: TopicManifest,
317
+ ): void {
318
+ for (const [type, entry] of Object.entries(manifest)) {
319
+ const existing = registry.topics.get(type)
320
+ if (existing && existing.kind !== entry.kind) {
321
+ throw new MessageBusError(
322
+ 'PROTOCOL_MISMATCH',
323
+ `topic "${type}" is declared "${entry.kind}" but the installed bus knows it as "${existing.kind}"`,
324
+ )
325
+ }
326
+ if (
327
+ existing?.kind === 'event' &&
328
+ entry.kind === 'event' &&
329
+ existing.ownership.type !== entry.ownership.type
330
+ ) {
331
+ throw new MessageBusError('OWNERSHIP_MISMATCH', `topic "${type}" has conflicting ownership`)
332
+ }
333
+ }
334
+ }
335
+
336
+ function mergeTopicManifest(registry: MessageBusRegistry, manifest: TopicManifest): void {
337
+ assertCompatibleTopicManifest(registry, manifest)
338
+ for (const [type, entry] of Object.entries(manifest)) registry.topics.set(type, entry)
339
+ }
340
+
341
+ function resolveEventSubject(
342
+ registry: MessageBusRegistry,
343
+ type: string,
344
+ ): Subject<MessageBusMessage<unknown, unknown>> {
345
+ let subject = registry.eventSubjects.get(type)
346
+ if (!subject) {
347
+ subject = new Subject<MessageBusMessage<unknown, unknown>>()
348
+ registry.eventSubjects.set(type, subject)
349
+ }
350
+ return subject
351
+ }
352
+
353
+ function settleReply(reply: PendingReply, outcome: ReplyOutcome): void {
354
+ if (reply.settled) return
355
+ reply.settled = true
356
+ if (reply.settlePromise) reply.settlePromise(outcome)
357
+ else reply.outcomeBeforeAwait = outcome
358
+ }
359
+
360
+ function createEventMessage(
361
+ appId: string,
362
+ moduleId: string,
363
+ type: string,
364
+ payload: unknown,
365
+ pendingReply: PendingReply,
366
+ ): MessageBusMessage<unknown, unknown> {
367
+ const message: MessageBusMessage<unknown, unknown> & {
368
+ [MESSAGE_BUS_PENDING_REPLY_KEY]?: PendingReply
369
+ } = {
370
+ type: type as TopicName,
371
+ payload,
372
+ meta: {appId, moduleId, timestamp: Date.now()},
373
+ reply: (value) => {
374
+ if (pendingReply.settled) {
375
+ console.warn(
376
+ `[sanity-sdk:message-bus] reply ignored for "${type}": no waiting caller or already replied`,
377
+ )
378
+ return
379
+ }
380
+ settleReply(pendingReply, {ok: true, value})
381
+ },
382
+ get signal() {
383
+ return pendingReply.responderSignal
384
+ },
385
+ }
386
+
387
+ message[MESSAGE_BUS_PENDING_REPLY_KEY] = pendingReply
388
+ return message
389
+ }
390
+
391
+ function createReplyPromise(
392
+ pendingReply: PendingReply,
393
+ options: MessageBusEmitOptions & {hadResponderAtEmission: boolean},
394
+ ): Promise<unknown> {
395
+ return new Promise<unknown>((resolve, reject) => {
396
+ const settlePromise = (outcome: ReplyOutcome) =>
397
+ outcome.ok ? resolve(outcome.value) : reject(outcome.error)
398
+
399
+ if (pendingReply.outcomeBeforeAwait) {
400
+ pendingReply.responderAbort.abort()
401
+ settlePromise(pendingReply.outcomeBeforeAwait)
402
+ return
403
+ }
404
+ if (!options.hadResponderAtEmission) {
405
+ reject(new MessageBusError('NO_RESPONDER'))
406
+ return
407
+ }
408
+
409
+ const timeoutMs = options.timeout === undefined ? DEFAULT_TIMEOUT_MS : options.timeout
410
+ let timer: ReturnType<typeof setTimeout> | undefined
411
+ const onAbort = () =>
412
+ settleReply(pendingReply, {ok: false, error: new MessageBusError('ABORTED')})
413
+
414
+ pendingReply.settlePromise = (outcome) => {
415
+ if (timer !== undefined) clearTimeout(timer)
416
+ options.signal?.removeEventListener('abort', onAbort)
417
+ pendingReply.responderAbort.abort()
418
+ settlePromise(outcome)
419
+ }
420
+
421
+ if (timeoutMs !== null) {
422
+ timer = setTimeout(
423
+ () => settleReply(pendingReply, {ok: false, error: new MessageBusError('TIMEOUT')}),
424
+ timeoutMs,
425
+ )
426
+ }
427
+ if (options.signal) {
428
+ if (options.signal.aborted) onAbort()
429
+ else options.signal.addEventListener('abort', onAbort, {once: true})
430
+ }
431
+ })
432
+ }
433
+
434
+ function createLazyReply<R>(awaitReply: () => Promise<R>): MessageBusEmitResult<R> {
435
+ return {
436
+ // oxlint-disable-next-line unicorn/no-thenable -- awaiting arms reply failure handling; fire-and-forget does not.
437
+ then: (onFulfilled, onRejected) => awaitReply().then(onFulfilled, onRejected),
438
+ catch: (onRejected) => awaitReply().then(undefined, onRejected),
439
+ finally: (onFinally) => awaitReply().finally(onFinally),
440
+ }
441
+ }
442
+
443
+ function emitEvent(
444
+ registry: MessageBusRegistry,
445
+ type: string,
446
+ payload: unknown,
447
+ options: MessageBusEmitOptions | undefined,
448
+ appId: string,
449
+ moduleId: string,
450
+ ): MessageBusEmitResult<unknown> {
451
+ const hadResponderAtEmission = (registry.responderCounts.get(type) ?? 0) > 0
452
+ const responderAbort = new AbortController()
453
+ const pendingReply: PendingReply = {
454
+ responderAbort,
455
+ responderSignal: scopeSignal(options?.signal, responderAbort.signal),
456
+ settled: false,
457
+ }
458
+
459
+ resolveEventSubject(registry, type).next(
460
+ createEventMessage(appId, moduleId, type, payload, pendingReply),
461
+ )
462
+
463
+ const awaitReply = () =>
464
+ (pendingReply.replyPromise ??= createReplyPromise(pendingReply, {
465
+ ...options,
466
+ hadResponderAtEmission,
467
+ }))
468
+ return createLazyReply(awaitReply)
469
+ }
470
+
471
+ const isStateTopic = (registry: MessageBusRegistry, type: string): boolean =>
472
+ registry.topics.get(type)?.kind === 'state'
473
+
474
+ const canRespond = (
475
+ registry: MessageBusRegistry,
476
+ ownership: {readonly type: 'same_app' | 'any_app'},
477
+ appId: string,
478
+ ): boolean => ownership.type === 'any_app' || appId === registry.appId
479
+
480
+ function emit(
481
+ registry: MessageBusRegistry,
482
+ type: string,
483
+ payload: unknown,
484
+ options: MessageBusEmitOptions | undefined,
485
+ appId: string,
486
+ moduleId: string,
487
+ connectionSignal: AbortSignal,
488
+ ): MessageBusEmitResult<unknown> {
489
+ if (isStateTopic(registry, type)) {
490
+ throw new MessageBusError(
491
+ 'OWNERSHIP_MISMATCH',
492
+ `Cannot emit state topic "${type}" from app "${appId}". The host writes state to each connection through its client handle. Read it with query() or subscribe().`,
493
+ )
494
+ }
495
+ return emitEvent(
496
+ registry,
497
+ type,
498
+ payload,
499
+ {
500
+ ...options,
501
+ signal: scopeSignal(options?.signal, registry.resetAbort.signal, connectionSignal),
502
+ },
503
+ appId,
504
+ moduleId,
505
+ )
506
+ }
507
+
508
+ function emitState(
509
+ registry: MessageBusRegistry,
510
+ connection: ConnectionRecord,
511
+ type: string,
512
+ value: unknown,
513
+ ): void {
514
+ const subject = resolveStateSubject(registry, connection, type)
515
+ if (!Object.is(subject.getValue(), value)) subject.next(value)
516
+ }
517
+
518
+ function query(
519
+ source: MessageBusStateSource<unknown>,
520
+ type: string,
521
+ options: MessageBusQueryOptions | undefined,
522
+ connectionSignal: AbortSignal,
523
+ resetSignal: AbortSignal,
524
+ ): Promise<unknown> {
525
+ const current = source.getCurrent()
526
+ if (current !== undefined) return Promise.resolve(current)
527
+
528
+ const signal = scopeSignal(options?.signal, resetSignal, connectionSignal)
529
+ if (signal.aborted) return Promise.reject(new MessageBusError('ABORTED'))
530
+ const timeoutMs = options?.timeout === undefined ? DEFAULT_TIMEOUT_MS : options.timeout
531
+ return new Promise((resolve, reject) => {
532
+ const timer =
533
+ timeoutMs === null
534
+ ? undefined
535
+ : setTimeout(
536
+ () => reject(new MessageBusError('TIMEOUT', `query("${type}") timed out`)),
537
+ timeoutMs,
538
+ )
539
+ const clear = () => {
540
+ if (timer !== undefined) clearTimeout(timer)
541
+ }
542
+ const onAbort = () => {
543
+ clear()
544
+ reject(new MessageBusError('ABORTED'))
545
+ }
546
+ signal?.addEventListener('abort', onAbort, {once: true})
547
+ void source.firstValue.then(
548
+ (value) => {
549
+ clear()
550
+ signal?.removeEventListener('abort', onAbort)
551
+ resolve(value)
552
+ },
553
+ (error) => {
554
+ clear()
555
+ signal?.removeEventListener('abort', onAbort)
556
+ reject(error)
557
+ },
558
+ )
559
+ })
560
+ }
561
+
562
+ function unsubscribeOnAbort(subscription: Subscription, signal: AbortSignal | undefined): void {
563
+ if (!signal) return
564
+ if (signal.aborted) {
565
+ subscription.unsubscribe()
566
+ return
567
+ }
568
+ signal.addEventListener('abort', () => subscription.unsubscribe(), {
569
+ once: true,
570
+ })
571
+ }
572
+
573
+ function scopeSignal(signal: AbortSignal | undefined, ...lifecycles: AbortSignal[]): AbortSignal {
574
+ return signal ? AbortSignal.any([signal, ...lifecycles]) : AbortSignal.any(lifecycles)
575
+ }
576
+
577
+ function invokeResponder(
578
+ handler: (message: MessageBusMessage<unknown, unknown>) => unknown,
579
+ message: MessageBusMessage<unknown, unknown> & {
580
+ [MESSAGE_BUS_PENDING_REPLY_KEY]?: PendingReply
581
+ },
582
+ ): void {
583
+ const pendingReply = message[MESSAGE_BUS_PENDING_REPLY_KEY]
584
+ const fail = (error: unknown) => {
585
+ if (pendingReply) {
586
+ settleReply(pendingReply, {
587
+ ok: false,
588
+ error: new MessageBusError('HANDLER_THREW', undefined, {
589
+ cause: error,
590
+ }),
591
+ })
592
+ }
593
+ }
594
+ try {
595
+ const result = handler(message)
596
+ if (result instanceof Promise) result.catch(fail)
597
+ } catch (error) {
598
+ fail(error)
599
+ }
600
+ }
601
+
602
+ function respond(
603
+ registry: MessageBusRegistry,
604
+ type: string,
605
+ handler: (message: MessageBusMessage<unknown, unknown>) => unknown,
606
+ options: MessageBusAbortOptions | undefined,
607
+ appId: string,
608
+ connectionSignal: AbortSignal,
609
+ ): void {
610
+ const topic = registry.topics.get(type)
611
+ if (topic?.kind === 'event' && !canRespond(registry, topic.ownership, appId)) {
612
+ throw new MessageBusError(
613
+ 'OWNERSHIP_MISMATCH',
614
+ `Cannot register a handler for event topic "${type}" from app "${appId}". Only the app that owns this topic can respond to it. Other apps can send it with emit().`,
615
+ )
616
+ }
617
+
618
+ registry.responderCounts.set(type, (registry.responderCounts.get(type) ?? 0) + 1)
619
+ const subscription = resolveEventSubject(registry, type).subscribe((message) =>
620
+ invokeResponder(handler, message),
621
+ )
622
+ subscription.add(() => {
623
+ const count = registry.responderCounts.get(type) ?? 0
624
+ if (count > 0) registry.responderCounts.set(type, count - 1)
625
+ })
626
+ unsubscribeOnAbort(
627
+ subscription,
628
+ scopeSignal(options?.signal, registry.resetAbort.signal, connectionSignal),
629
+ )
630
+ }
631
+
632
+ const bundledMigrations = () => new Map(Object.entries(topicMigrations))
633
+
634
+ /**
635
+ * Creates an isolated message bus without installing it globally and returns the host's
636
+ * connection to it.
637
+ * @internal
638
+ */
639
+ export function createIsolatedMessageBus(
640
+ appId: string,
641
+ config: {
642
+ migrations?: ReadonlyMap<string, readonly TopicMigration[]>
643
+ } = {},
644
+ ): MessageBusHost {
645
+ if (!appId) throwMissingAppId()
646
+
647
+ const registry: MessageBusRegistry = {
648
+ appId,
649
+ topics: new Map(),
650
+ connections: new Set(),
651
+ connected$: new Subject(),
652
+ eventSubjects: new Map(),
653
+ responderCounts: new Map(),
654
+ migrations: config.migrations ?? bundledMigrations(),
655
+ resetAbort: new AbortController(),
656
+ generation: 0,
657
+ }
658
+
659
+ mergeTopicManifest(registry, DASHBOARD_TOPIC_MANIFEST)
660
+
661
+ return createConnection(registry, {appId, migrations: config.migrations}) as MessageBusHost
662
+ }
663
+
664
+ type TopicVersionAdapter = {
665
+ toInstalled: (type: string, value: unknown) => unknown
666
+ toApplication: (type: string, value: unknown) => unknown
667
+ }
668
+
669
+ type TopicCompatibility = {
670
+ toInstalledEmission: TopicVersionAdapter['toInstalled']
671
+ toApplicationStateValue: TopicVersionAdapter['toApplication']
672
+ toApplicationEventPayload: TopicVersionAdapter['toApplication']
673
+ toInstalledEventReply: TopicVersionAdapter['toInstalled']
674
+ toApplicationEventReply: TopicVersionAdapter['toApplication']
675
+ }
676
+
677
+ type TopicMigrationTransform = Pick<TopicMigration, 'up' | 'down'>
678
+
679
+ const identityMigration: TopicMigrationTransform = {
680
+ up: (value) => value,
681
+ down: (value) => value,
682
+ }
683
+
684
+ function effectiveTopicVersions(
685
+ migrations: ReadonlyMap<string, readonly TopicMigration[]>,
686
+ ): Map<string, number> {
687
+ const versions = new Map<string, number>()
688
+ for (const [topic, steps] of migrations) {
689
+ let version = 1
690
+ for (const step of steps) if (step.to > version) version = step.to
691
+ versions.set(topic, version)
692
+ }
693
+ return versions
694
+ }
695
+
696
+ function migrationTransformAt(
697
+ steps: readonly TopicMigration[] | undefined,
698
+ version: number,
699
+ select: (step: TopicMigration) => TopicMigrationTransform | undefined,
700
+ ): TopicMigrationTransform {
701
+ const step = steps?.find((candidate) => candidate.from === version)
702
+ return (step ? select(step) : undefined) ?? identityMigration
703
+ }
704
+
705
+ function migrateVersionedValue(
706
+ steps: readonly TopicMigration[] | undefined,
707
+ value: unknown,
708
+ from: number,
709
+ to: number,
710
+ select: (step: TopicMigration) => TopicMigrationTransform | undefined,
711
+ ): unknown {
712
+ if (from === to) return value
713
+
714
+ let current = value
715
+ if (from < to) {
716
+ for (let version = from; version < to; version++) {
717
+ current = migrationTransformAt(steps, version, select).up(current)
718
+ }
719
+ } else {
720
+ for (let version = from; version > to; version--) {
721
+ current = migrationTransformAt(steps, version - 1, select).down(current)
722
+ }
723
+ }
724
+ return current
725
+ }
726
+
727
+ function createTopicCompatibility(
728
+ installedMigrations: ReadonlyMap<string, readonly TopicMigration[]>,
729
+ applicationMigrations: ReadonlyMap<string, readonly TopicMigration[]>,
730
+ ): TopicCompatibility {
731
+ const installedVersions = effectiveTopicVersions(installedMigrations)
732
+ const applicationVersions = effectiveTopicVersions(applicationMigrations)
733
+ const applicationVersion = (type: string) => applicationVersions.get(type) ?? 1
734
+ const installedVersion = (type: string) => installedVersions.get(type) ?? 1
735
+ const migrationChainFor = (type: string) =>
736
+ (applicationVersion(type) > installedVersion(type)
737
+ ? applicationMigrations
738
+ : installedMigrations
739
+ ).get(type)
740
+ const createVersionAdapter = (
741
+ select: (step: TopicMigration) => TopicMigrationTransform | undefined,
742
+ ): TopicVersionAdapter => ({
743
+ toInstalled: (type, value) =>
744
+ migrateVersionedValue(
745
+ migrationChainFor(type),
746
+ value,
747
+ applicationVersion(type),
748
+ installedVersion(type),
749
+ select,
750
+ ),
751
+ toApplication: (type, value) =>
752
+ migrateVersionedValue(
753
+ migrationChainFor(type),
754
+ value,
755
+ installedVersion(type),
756
+ applicationVersion(type),
757
+ select,
758
+ ),
759
+ })
760
+
761
+ const stateValueAndEventPayload = createVersionAdapter((step) => step)
762
+ const eventReply = createVersionAdapter((step) => step.reply)
763
+ return {
764
+ toInstalledEmission: stateValueAndEventPayload.toInstalled,
765
+ toApplicationStateValue: stateValueAndEventPayload.toApplication,
766
+ toApplicationEventPayload: stateValueAndEventPayload.toApplication,
767
+ toInstalledEventReply: eventReply.toInstalled,
768
+ toApplicationEventReply: eventReply.toApplication,
769
+ }
770
+ }
771
+
772
+ // Cache projections by input reference to keep state snapshots stable.
773
+ function projectCurrent(input: () => unknown, project: (value: unknown) => unknown): () => unknown {
774
+ let lastInput: unknown
775
+ let lastOutput: unknown
776
+ let cached = false
777
+ return () => {
778
+ const current = input()
779
+ if (current === undefined) return undefined
780
+ if (!cached || current !== lastInput) {
781
+ lastInput = current
782
+ lastOutput = project(current)
783
+ cached = true
784
+ }
785
+ return lastOutput
786
+ }
787
+ }
788
+
789
+ // The connection's view of one of its state boxes, in the connection's topic version.
790
+ function createStateSource(
791
+ subject: BehaviorSubject<unknown>,
792
+ project: (value: unknown) => unknown,
793
+ completeOn: Observable<unknown>,
794
+ ): MessageBusStateSource<unknown> {
795
+ const getCurrent = () => {
796
+ const current = subject.getValue()
797
+ return current === NO_VALUE ? undefined : current
798
+ }
799
+ return toStateSource(
800
+ subject.pipe(
801
+ filter((value) => value !== NO_VALUE),
802
+ map(project),
803
+ takeUntil(completeOn),
804
+ ),
805
+ projectCurrent(getCurrent, project),
806
+ )
807
+ }
808
+
809
+ function migrateEventMessage(
810
+ message: MessageBusMessage<unknown, unknown>,
811
+ payload: (value: unknown) => unknown,
812
+ reply: (value: unknown) => unknown,
813
+ ): MessageBusMessage<unknown, unknown> {
814
+ return {
815
+ type: message.type,
816
+ payload: payload(message.payload),
817
+ meta: message.meta,
818
+ reply: (value) => message.reply(reply(value)),
819
+ get signal() {
820
+ return message.signal
821
+ },
822
+ }
823
+ }
824
+
825
+ function migrateEventReply(
826
+ result: MessageBusEmitResult<unknown>,
827
+ project: (value: unknown) => unknown,
828
+ ): MessageBusEmitResult<unknown> {
829
+ let projected: Promise<unknown> | undefined
830
+ return createLazyReply(() => (projected ??= Promise.resolve(result).then(project)))
831
+ }
832
+
833
+ function createRejectedConnection(connectionError: () => unknown): MessageBusConnection {
834
+ const throwConnectionError = (): never => {
835
+ throw connectionError()
836
+ }
837
+ return {
838
+ emit: throwConnectionError,
839
+ // `query` is typed as a Promise, so it must reject rather than throw synchronously.
840
+ query: () => Promise.reject(connectionError()),
841
+ subscribe: throwConnectionError,
842
+ disconnect: () => {},
843
+ } as unknown as MessageBusConnection
844
+ }
845
+
846
+ /**
847
+ * Options for connecting an application to an isolated message bus.
848
+ * @internal
849
+ */
850
+ export interface ConnectApplicationToMessageBusOptions {
851
+ /** The application ID stamped on emitted messages. */
852
+ appId: string
853
+ /** The federation module id stamped on emitted messages. Defaults to `appId`. */
854
+ moduleId?: string
855
+ /** The topic migrations supported by the application. */
856
+ migrations?: ReadonlyMap<string, readonly TopicMigration[]>
857
+ }
858
+
859
+ /**
860
+ * Connects an application to an isolated message bus.
861
+ * @internal
862
+ */
863
+ export function connectApplicationToMessageBus(
864
+ installedMessageBus: MessageBus,
865
+ config: ConnectApplicationToMessageBusOptions,
866
+ ): MessageBusConnection {
867
+ if (!config.appId) throwMissingAppId()
868
+
869
+ const {appId} = config
870
+ const installedProtocol = (installedMessageBus as Partial<InternalMessageBus>)[
871
+ MESSAGE_BUS_PROTOCOL_KEY
872
+ ]
873
+ if (installedProtocol !== MESSAGE_BUS_PROTOCOL) {
874
+ console.error(
875
+ `[sanity-sdk:message-bus] protocol mismatch for "${appId}": installed ${String(installedProtocol)}, this copy speaks ${MESSAGE_BUS_PROTOCOL}`,
876
+ )
877
+ return createRejectedConnection(
878
+ () =>
879
+ new MessageBusError(
880
+ 'PROTOCOL_MISMATCH',
881
+ `installed message bus speaks protocol ${String(installedProtocol)}, this copy speaks ${MESSAGE_BUS_PROTOCOL}`,
882
+ ),
883
+ )
884
+ }
885
+
886
+ const registry = (installedMessageBus as Partial<InternalMessageBus>)[MESSAGE_BUS_REGISTRY_KEY]
887
+ if (!registry) {
888
+ console.error(`[sanity-sdk:message-bus] incompatible message bus for "${appId}"`)
889
+ return createRejectedConnection(
890
+ () =>
891
+ new MessageBusError(
892
+ 'PROTOCOL_MISMATCH',
893
+ 'installed message bus does not expose a compatible registry',
894
+ ),
895
+ )
896
+ }
897
+
898
+ try {
899
+ mergeTopicManifest(registry, DASHBOARD_TOPIC_MANIFEST)
900
+ } catch (error) {
901
+ console.error(`[sanity-sdk:message-bus] topic manifest conflict for "${appId}"`, {error})
902
+ return createRejectedConnection(() => error)
903
+ }
904
+
905
+ return createConnection(registry, config)
906
+ }
907
+
908
+ // Hands the host the same client object for a connection across `connections` subscriptions.
909
+ function createClient(
910
+ registry: MessageBusRegistry,
911
+ record: ConnectionRecord,
912
+ compatibility: TopicCompatibility,
913
+ ): MessageBusClient {
914
+ return {
915
+ appId: record.appId,
916
+ moduleId: record.moduleId,
917
+ closed: record.abort.signal,
918
+ emit: (type: string, value: unknown) => {
919
+ if (record.abort.signal.aborted) {
920
+ console.warn(
921
+ `[sanity-sdk:message-bus] "${type}" not written: connection "${record.moduleId}" has closed`,
922
+ )
923
+ return
924
+ }
925
+ emitState(registry, record, type, compatibility.toInstalledEmission(type, value))
926
+ },
927
+ } as MessageBusClient
928
+ }
929
+
930
+ // Open connections other than the host's own first, then each new one; one client object per
931
+ // connection so the host can keep them in a Set. Completes when the host connection disconnects.
932
+ function createConnectionsSource(
933
+ registry: MessageBusRegistry,
934
+ self: ConnectionRecord,
935
+ compatibility: TopicCompatibility,
936
+ completeOn: Observable<unknown>,
937
+ ): Observable<MessageBusClient> {
938
+ const clients = new WeakMap<ConnectionRecord, MessageBusClient>()
939
+ const clientFor = (record: ConnectionRecord) => {
940
+ let client = clients.get(record)
941
+ if (!client) {
942
+ client = createClient(registry, record, compatibility)
943
+ clients.set(record, client)
944
+ }
945
+ return client
946
+ }
947
+ return defer(() => concat(from([...registry.connections]), registry.connected$)).pipe(
948
+ filter((record) => record !== self),
949
+ map(clientFor),
950
+ takeUntil(completeOn),
951
+ )
952
+ }
953
+
954
+ function createConnection(
955
+ registry: MessageBusRegistry,
956
+ config: ConnectApplicationToMessageBusOptions,
957
+ ): MessageBusConnection {
958
+ const {appId} = config
959
+ const moduleId = config.moduleId ?? appId
960
+ // Each connection owns its lifetime; disconnect() aborts it without touching siblings.
961
+ const connectionAbort = new AbortController()
962
+ const connectionSignal = connectionAbort.signal
963
+ // ReplaySubject, not fromEvent: `abort` fires once, so a cold listener attached by a
964
+ // stream subscribed after disconnect() would never see it and never complete. Replaying
965
+ // the notification lets those late subscribers complete immediately.
966
+ const connectionAborted$ = new ReplaySubject<void>(1)
967
+ connectionSignal.addEventListener('abort', () => connectionAborted$.next(), {once: true})
968
+
969
+ const record: ConnectionRecord = {
970
+ appId,
971
+ moduleId,
972
+ stateSubjects: new Map(),
973
+ abort: connectionAbort,
974
+ }
975
+
976
+ const compatibility = createTopicCompatibility(
977
+ registry.migrations,
978
+ config.migrations ?? bundledMigrations(),
979
+ )
980
+ const isState = (type: string) => isStateTopic(registry, type)
981
+
982
+ // Reuse topic streams because React external-store snapshots require stable references.
983
+ const applicationStreams = new Map<string, MessageBusStateSource<unknown> | Observable<unknown>>()
984
+ let streamGeneration = registry.generation
985
+ const cachedStream = <T extends MessageBusStateSource<unknown> | Observable<unknown>>(
986
+ type: string,
987
+ create: () => T,
988
+ ): T => {
989
+ if (streamGeneration !== registry.generation) {
990
+ applicationStreams.clear()
991
+ streamGeneration = registry.generation
992
+ }
993
+ let stream = applicationStreams.get(type)
994
+ if (!stream) {
995
+ stream = create()
996
+ applicationStreams.set(type, stream)
997
+ }
998
+ return stream as T
999
+ }
1000
+ const stateSource = (type: string) =>
1001
+ cachedStream(type, () =>
1002
+ createStateSource(
1003
+ resolveStateSubject(registry, record, type),
1004
+ (value) => compatibility.toApplicationStateValue(type, value),
1005
+ connectionAborted$,
1006
+ ),
1007
+ )
1008
+ const eventStream = (type: string) =>
1009
+ cachedStream(type, () =>
1010
+ resolveEventSubject(registry, type).pipe(
1011
+ map((message) => compatibility.toApplicationEventPayload(type, message.payload)),
1012
+ takeUntil(connectionAborted$),
1013
+ ),
1014
+ )
1015
+
1016
+ // A disconnected connection must not reach siblings, so its operations fail before touching
1017
+ // the shared registry rather than only tearing down pending requests and subscriptions.
1018
+ const throwIfDisconnected = () => {
1019
+ if (connectionSignal.aborted) throw new MessageBusError('ABORTED')
1020
+ }
1021
+
1022
+ const connection = {
1023
+ emit: (type: string, payload: unknown, options?: MessageBusEmitOptions) => {
1024
+ throwIfDisconnected()
1025
+ return migrateEventReply(
1026
+ emit(
1027
+ registry,
1028
+ type,
1029
+ compatibility.toInstalledEmission(type, payload),
1030
+ options,
1031
+ appId,
1032
+ moduleId,
1033
+ connectionSignal,
1034
+ ),
1035
+ (value) => compatibility.toApplicationEventReply(type, value),
1036
+ )
1037
+ },
1038
+ query: (type: string, options?: MessageBusQueryOptions) => {
1039
+ if (connectionSignal.aborted) return Promise.reject(new MessageBusError('ABORTED'))
1040
+ return query(stateSource(type), type, options, connectionSignal, registry.resetAbort.signal)
1041
+ },
1042
+ subscribe: (
1043
+ type: string,
1044
+ handler?: (arg: never) => void,
1045
+ options?: MessageBusAbortOptions,
1046
+ ): MessageBusStateSource<unknown> | Observable<unknown> | undefined => {
1047
+ throwIfDisconnected()
1048
+ if (isState(type)) {
1049
+ const source = stateSource(type)
1050
+ if (!handler) return source
1051
+ unsubscribeOnAbort(
1052
+ source.subscribe(handler as (value: unknown) => void),
1053
+ scopeSignal(options?.signal, registry.resetAbort.signal, connectionSignal),
1054
+ )
1055
+ return undefined
1056
+ }
1057
+ if (!handler) return eventStream(type)
1058
+ respond(
1059
+ registry,
1060
+ type,
1061
+ (message) =>
1062
+ (handler as (message: MessageBusMessage<unknown, unknown>) => void)(
1063
+ migrateEventMessage(
1064
+ message,
1065
+ (value) => compatibility.toApplicationEventPayload(type, value),
1066
+ (value) => compatibility.toInstalledEventReply(type, value),
1067
+ ),
1068
+ ),
1069
+ options,
1070
+ appId,
1071
+ connectionSignal,
1072
+ )
1073
+ return undefined
1074
+ },
1075
+ disconnect: () => {
1076
+ if (connectionSignal.aborted) return
1077
+ connectionAbort.abort()
1078
+ for (const subject of record.stateSubjects.values()) subject.complete()
1079
+ record.stateSubjects.clear()
1080
+ registry.connections.delete(record)
1081
+ },
1082
+ }
1083
+
1084
+ if (appId === registry.appId) {
1085
+ Object.assign(connection, {
1086
+ connections: createConnectionsSource(registry, record, compatibility, connectionAborted$),
1087
+ })
1088
+ }
1089
+
1090
+ const instance = connection as unknown as InternalMessageBus
1091
+ instance[MESSAGE_BUS_REGISTRY_KEY] = registry
1092
+ instance[MESSAGE_BUS_PROTOCOL_KEY] = MESSAGE_BUS_PROTOCOL
1093
+
1094
+ // Announce last: a host subscriber may write to the new client synchronously.
1095
+ registry.connections.add(record)
1096
+ registry.connected$.next(record)
1097
+ return instance as unknown as MessageBusConnection
1098
+ }
1099
+
1100
+ function reset(registry: MessageBusRegistry): void {
1101
+ registry.resetAbort.abort()
1102
+ for (const connection of registry.connections) {
1103
+ for (const subject of connection.stateSubjects.values()) subject.complete()
1104
+ connection.stateSubjects.clear()
1105
+ }
1106
+ for (const subject of registry.eventSubjects.values()) subject.complete()
1107
+
1108
+ registry.topics.clear()
1109
+ registry.eventSubjects.clear()
1110
+ registry.responderCounts.clear()
1111
+ registry.resetAbort = new AbortController()
1112
+ registry.generation += 1
1113
+ mergeTopicManifest(registry, DASHBOARD_TOPIC_MANIFEST)
1114
+ }
1115
+
1116
+ declare const __SANITY_APP_ID__: string | undefined
1117
+
1118
+ function throwMissingAppId(): never {
1119
+ throw new MessageBusError(
1120
+ 'MISSING_APP_ID',
1121
+ 'Cannot initialize the message bus without an app ID. Build the application with the Sanity CLI or pass an app ID when connecting.',
1122
+ )
1123
+ }
1124
+
1125
+ const resolveAppId = (appId?: string): string | undefined =>
1126
+ appId ?? (typeof __SANITY_APP_ID__ === 'string' ? __SANITY_APP_ID__ : undefined)
1127
+
1128
+ function getInstalledMessageBus(): MessageBus | undefined {
1129
+ const bus = (globalThis as {[MESSAGE_BUS_KEY]?: unknown})[MESSAGE_BUS_KEY]
1130
+ return typeof bus === 'object' && bus !== null && MESSAGE_BUS_REGISTRY_KEY in bus
1131
+ ? (bus as unknown as MessageBus)
1132
+ : undefined
1133
+ }
1134
+
1135
+ /**
1136
+ * Returns whether a message bus registry is installed.
1137
+ * @internal
1138
+ */
1139
+ export function isMessageBusInstalled(): boolean {
1140
+ return getInstalledMessageBus() !== undefined
1141
+ }
1142
+
1143
+ /**
1144
+ * Options for connecting to an installed message bus.
1145
+ * @public
1146
+ */
1147
+ export interface ConnectMessageBusOptions {
1148
+ /** The application ID. Defaults to the ID embedded by the Sanity CLI. */
1149
+ appId?: string
1150
+ /** The federation module id stamped on emitted messages. Defaults to `appId`. */
1151
+ moduleId?: string
1152
+ }
1153
+
1154
+ /**
1155
+ * Connects to the installed message bus, or returns `undefined` when no compatible connection exists.
1156
+ * @public
1157
+ */
1158
+ export function connectMessageBus(
1159
+ options: ConnectMessageBusOptions = {},
1160
+ ): MessageBusConnection | undefined {
1161
+ const installedMessageBus = getInstalledMessageBus()
1162
+ if (!installedMessageBus) return undefined
1163
+
1164
+ const appId = resolveAppId(options.appId)
1165
+ if (!appId) {
1166
+ console.warn(
1167
+ '[sanity-sdk:message-bus] cannot connect without an app ID; build with the Sanity CLI or pass appId',
1168
+ )
1169
+ return undefined
1170
+ }
1171
+
1172
+ const connection = connectApplicationToMessageBus(installedMessageBus, {
1173
+ appId,
1174
+ moduleId: options.moduleId,
1175
+ })
1176
+ return MESSAGE_BUS_REGISTRY_KEY in connection ? connection : undefined
1177
+ }
1178
+
1179
+ /**
1180
+ * Resets the installed message bus for test isolation.
1181
+ * @internal
1182
+ */
1183
+ export function resetMessageBus(): void {
1184
+ const installedMessageBus = getInstalledMessageBus()
1185
+ if (!installedMessageBus) return
1186
+ reset((installedMessageBus as InternalMessageBus)[MESSAGE_BUS_REGISTRY_KEY])
1187
+ }
1188
+
1189
+ /**
1190
+ * Options for installing the shared message bus. The host is the whole app, so it carries
1191
+ * no `moduleId`.
1192
+ * @internal
1193
+ */
1194
+ export interface InstallMessageBusOptions {
1195
+ /** The application ID. Defaults to the ID embedded by the Sanity CLI. */
1196
+ appId?: string
1197
+ }
1198
+
1199
+ /**
1200
+ * Installs the shared message bus and returns the host's connection, or connects to its
1201
+ * existing installation. Only the host calls this; another app's ID is rejected because the
1202
+ * connection it would get has no `connections`.
1203
+ * @internal
1204
+ */
1205
+ export function installMessageBus(options: InstallMessageBusOptions = {}): MessageBusHost {
1206
+ const appId = resolveAppId(options.appId) ?? throwMissingAppId()
1207
+ const installedMessageBus = getInstalledMessageBus()
1208
+ if (installedMessageBus) {
1209
+ const installedAppId = (installedMessageBus as InternalMessageBus)[MESSAGE_BUS_REGISTRY_KEY]
1210
+ .appId
1211
+ if (installedAppId !== appId) {
1212
+ throw new MessageBusError(
1213
+ 'OWNERSHIP_MISMATCH',
1214
+ `Cannot install the message bus as "${appId}": it is already installed by "${installedAppId}". Applications connect with connectMessageBus().`,
1215
+ )
1216
+ }
1217
+ return connectApplicationToMessageBus(installedMessageBus, {appId}) as MessageBusHost
1218
+ }
1219
+
1220
+ const globals = globalThis as {[MESSAGE_BUS_KEY]?: MessageBus}
1221
+ if (globals[MESSAGE_BUS_KEY]) {
1222
+ // A foreign value already occupies the bus symbol (e.g. an older Workbench bus); overwriting it disconnects that bus.
1223
+ console.error(
1224
+ '[sanity-sdk:message-bus] overwriting an incompatible message bus already installed',
1225
+ )
1226
+ }
1227
+ const host = createIsolatedMessageBus(appId)
1228
+ globals[MESSAGE_BUS_KEY] = host
1229
+ return host
1230
+ }
1231
+
1232
+ /**
1233
+ * Declares state topics that are not in the bundled manifest, with the seed a new
1234
+ * connection's box starts from.
1235
+ * @internal
1236
+ */
1237
+ export function registerStateTopics(
1238
+ target: MessageBus,
1239
+ topics: Partial<{[K in StateTopic]: ValueOf<K> | undefined}>,
1240
+ ): void {
1241
+ const registry = (target as InternalMessageBus)[MESSAGE_BUS_REGISTRY_KEY]
1242
+ const manifest: TopicManifest = Object.fromEntries(
1243
+ Object.entries(topics).map(([name, seed]) => [name, {kind: 'state', seed}]),
1244
+ )
1245
+ mergeTopicManifest(registry, manifest)
1246
+ }