@sanity/sdk 3.1.0 → 3.3.0-rc.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 +52 -21
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +10 -9
- 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 +3 -2
- package/dist/_exports/comlink.js.map +1 -1
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C-OEkGaM.d.ts → authStore-BGoEGxOq.d.ts} +531 -63
- package/dist/authStore-BGoEGxOq.d.ts.map +1 -0
- package/dist/{clientStore-gUALQ0qW.js → clientStore-QDrJNTM4.js} +128 -7
- package/dist/clientStore-QDrJNTM4.js.map +1 -0
- package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
- package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +371 -54
- package/dist/index.js.map +1 -1
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-DXvuH9h4.d.ts +435 -0
- package/dist/topicStore-DXvuH9h4.d.ts.map +1 -0
- package/dist/{version-C90rNWaX.js → version-CIhX5ppJ.js} +6 -6
- package/dist/{version-C90rNWaX.js.map → version-CIhX5ppJ.js.map} +1 -1
- package/package.json +20 -19
- package/src/_exports/_internal.ts +12 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.ts +39 -14
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.test.ts +18 -0
- package/src/auth/authStore.ts +15 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/handleAuthCallback.test.ts +33 -0
- package/src/auth/handleAuthCallback.ts +11 -1
- package/src/auth/logout.test.ts +53 -0
- package/src/auth/logout.ts +19 -1
- package/src/auth/oauth/oauthActions.test.ts +593 -0
- package/src/auth/oauth/oauthActions.ts +438 -0
- package/src/auth/oauth/oauthAuth.test.ts +229 -0
- package/src/auth/oauth/oauthAuth.ts +178 -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/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 +3 -3
- package/src/document/documentStore.ts +12 -12
- package/src/document/processMutations.ts +58 -22
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.ts +2 -19
- package/src/projection/getProjectionState.ts +2 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/query/queryStore.ts +3 -3
- 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/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
- package/dist/clientStore-gUALQ0qW.js.map +0 -1
- package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
- package/dist/object-BwynGPRZ.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
|
+
}
|