@sanity/comlink
Version:
A library for one-to-many cross-origin communication between Window contexts, built on the postMessage API.
1 lines • 87 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/common.ts","../src/constants.ts","../src/request.ts","../src/connection.ts","../src/controller.ts","../src/util.ts","../src/node.ts"],"sourcesContent":["import {bufferCount, concatMap, defer, filter, fromEvent, map, pipe, take} from 'rxjs'\nimport {fromEventObservable} from 'xstate'\nimport type {ListenInput, ProtocolMessage} from './types'\n\nexport const listenInputFromContext =\n (\n config: (\n | {\n include: string | string[]\n exclude?: string | string[]\n }\n | {\n include?: string | string[]\n exclude: string | string[]\n }\n ) & {\n matches?: boolean\n count?: number\n responseType?: string\n },\n ) =>\n <\n TContext extends {\n domain: string\n connectTo: string\n name: string\n target: MessageEventSource | undefined\n },\n >({\n context,\n }: {\n context: TContext\n }): ListenInput => {\n const {count, include, exclude, responseType = 'message.received'} = config\n return {\n count,\n domain: context.domain,\n from: context.connectTo,\n include: include ? (Array.isArray(include) ? include : [include]) : [],\n exclude: exclude ? (Array.isArray(exclude) ? exclude : [exclude]) : [],\n responseType,\n target: context.target,\n to: context.name,\n }\n }\n\nexport const listenFilter =\n (input: ListenInput) =>\n (event: MessageEvent<ProtocolMessage>): boolean => {\n const {data} = event\n return (\n (input.include.length ? input.include.includes(data.type) : true) &&\n (input.exclude.length ? !input.exclude.includes(data.type) : true) &&\n data.domain === input.domain &&\n data.from === input.from &&\n data.to === input.to &&\n (!input.target || event.source === input.target)\n )\n }\n\nexport const eventToMessage =\n <TType>(type: TType) =>\n (\n event: MessageEvent<ProtocolMessage>,\n ): {type: TType; message: MessageEvent<ProtocolMessage>} => ({\n type,\n message: event,\n })\n\nexport const messageEvents$ = defer(() =>\n fromEvent<MessageEvent<ProtocolMessage>>(window, 'message'),\n)\n\n/**\n * @public\n */\nexport const createListenLogic = (\n compatMap?: (event: MessageEvent<ProtocolMessage>) => MessageEvent<ProtocolMessage>,\n // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\n) =>\n fromEventObservable(({input}: {input: ListenInput}) => {\n return messageEvents$.pipe(\n compatMap ? map(compatMap) : pipe(),\n filter(listenFilter(input)),\n map(eventToMessage(input.responseType)),\n input.count\n ? pipe(\n bufferCount(input.count),\n concatMap((arr) => arr),\n take(input.count),\n )\n : pipe(),\n )\n })\n","import type {MessageType} from './types'\n\n/** @internal */\nexport const DOMAIN = 'sanity/comlink'\n\n/** @internal */\nexport const RESPONSE_TIMEOUT_DEFAULT = 3_000\n\n/** @internal */\nexport const FETCH_TIMEOUT_DEFAULT = 10_000\n\n/** @internal */\nexport const HEARTBEAT_INTERVAL = 1000\n\n/** @internal */\nexport const HANDSHAKE_INTERVAL = 500\n\n/**\n * @public\n */\nexport const MSG_RESPONSE = 'comlink/response'\n\n/**\n * @public\n */\nexport const MSG_HEARTBEAT = 'comlink/heartbeat'\n\n/** @internal */\nexport const MSG_DISCONNECT = 'comlink/disconnect'\n\n/** @internal */\nexport const MSG_HANDSHAKE_SYN = 'comlink/handshake/syn'\n\n/** @internal */\nexport const MSG_HANDSHAKE_SYN_ACK = 'comlink/handshake/syn-ack'\n\n/** @internal */\nexport const MSG_HANDSHAKE_ACK = 'comlink/handshake/ack'\n\n/** @internal */\nexport const HANDSHAKE_MSG_TYPES = [\n MSG_HANDSHAKE_SYN,\n MSG_HANDSHAKE_SYN_ACK,\n MSG_HANDSHAKE_ACK,\n] satisfies MessageType[]\n\n/** @internal */\nexport const INTERNAL_MSG_TYPES = [\n MSG_RESPONSE,\n MSG_DISCONNECT,\n MSG_HEARTBEAT,\n ...HANDSHAKE_MSG_TYPES,\n] satisfies MessageType[]\n","import {EMPTY, filter, fromEvent, map, take, takeUntil, type Observable} from 'rxjs'\nimport {v4 as uuid} from 'uuid'\nimport {\n assign,\n fromEventObservable,\n sendTo,\n setup,\n type ActorRefFrom,\n type AnyActorRef,\n} from 'xstate'\nimport {MSG_RESPONSE, RESPONSE_TIMEOUT_DEFAULT} from './constants'\nimport type {Message, MessageData, MessageType, ProtocolMessage, ResponseMessage} from './types'\n\nconst throwOnEvent =\n <T>(message?: string) =>\n (source: Observable<T>) =>\n source.pipe(\n take(1),\n map(() => {\n throw new Error(message)\n }),\n )\n\n/**\n * @public\n */\nexport interface RequestMachineContext<TSends extends Message> {\n channelId: string\n data: MessageData | undefined\n domain: string\n expectResponse: boolean\n from: string\n id: string\n parentRef: AnyActorRef\n resolvable: PromiseWithResolvers<TSends['response']> | undefined\n response: TSends['response'] | null\n responseTimeout: number | undefined\n responseTo: string | undefined\n signal: AbortSignal | undefined\n suppressWarnings: boolean | undefined\n sources: Set<MessageEventSource>\n targetOrigin: string\n to: string\n type: MessageType\n}\n\n/**\n * @public\n */\nexport type RequestActorRef<TSends extends Message> = ActorRefFrom<\n ReturnType<typeof createRequestMachine<TSends>>\n>\n\n/**\n * @public\n */\nexport const createRequestMachine = <\n TSends extends Message,\n // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\n>() => {\n return setup({\n types: {} as {\n children: {\n 'listen for response': 'listen'\n }\n context: RequestMachineContext<TSends>\n // @todo Should response types be specified?\n events: {type: 'message'; data: ProtocolMessage<ResponseMessage>} | {type: 'abort'}\n emitted:\n | {type: 'request.failed'; requestId: string}\n | {type: 'request.aborted'; requestId: string}\n | {\n type: 'request.success'\n requestId: string\n response: MessageData | null\n responseTo: string | undefined\n }\n input: {\n channelId: string\n data?: TSends['data']\n domain: string\n expectResponse?: boolean\n from: string\n parentRef: AnyActorRef\n resolvable?: PromiseWithResolvers<TSends['response']>\n responseTimeout?: number\n responseTo?: string\n signal?: AbortSignal\n sources: Set<MessageEventSource> | MessageEventSource\n suppressWarnings?: boolean\n targetOrigin: string\n to: string\n type: TSends['type']\n }\n output: {\n requestId: string\n response: TSends['response'] | null\n responseTo: string | undefined\n }\n },\n actors: {\n listen: fromEventObservable(\n ({\n input,\n }: {\n input: {\n requestId: string\n sources: Set<MessageEventSource>\n signal?: AbortSignal\n }\n }) => {\n const abortSignal$ = input.signal\n ? fromEvent(input.signal, 'abort').pipe(\n throwOnEvent(`Request ${input.requestId} aborted`),\n )\n : EMPTY\n\n const messageFilter = (event: MessageEvent<ProtocolMessage<ResponseMessage>>) =>\n event.data?.type === MSG_RESPONSE &&\n event.data?.responseTo === input.requestId &&\n !!event.source &&\n input.sources.has(event.source)\n\n return fromEvent<MessageEvent<ProtocolMessage<ResponseMessage>>>(window, 'message').pipe(\n filter(messageFilter),\n take(input.sources.size),\n takeUntil(abortSignal$),\n )\n },\n ),\n },\n actions: {\n 'send message': ({context}, params: {message: ProtocolMessage}) => {\n const {sources, targetOrigin} = context\n const {message} = params\n\n sources.forEach((source) => {\n source.postMessage(message, {targetOrigin})\n })\n },\n 'on success': sendTo(\n ({context}) => context.parentRef,\n ({context, self}) => {\n if (context.response) {\n context.resolvable?.resolve(context.response)\n }\n return {\n type: 'request.success',\n requestId: self.id,\n response: context.response,\n responseTo: context.responseTo,\n }\n },\n ),\n 'on fail': sendTo(\n ({context}) => context.parentRef,\n ({context, self}) => {\n if (!context.suppressWarnings) {\n // eslint-disable-next-line no-console\n console.warn(\n `[@sanity/comlink] Received no response to message '${context.type}' on client '${context.from}' (ID: '${context.id}').`,\n )\n }\n context.resolvable?.reject(new Error('No response received'))\n return {type: 'request.failed', requestId: self.id}\n },\n ),\n 'on abort': sendTo(\n ({context}) => context.parentRef,\n ({context, self}) => {\n context.resolvable?.reject(new Error('Request aborted'))\n return {type: 'request.aborted', requestId: self.id}\n },\n ),\n },\n guards: {\n expectsResponse: ({context}) => context.expectResponse,\n },\n delays: {\n initialTimeout: 0,\n responseTimeout: ({context}) => context.responseTimeout ?? RESPONSE_TIMEOUT_DEFAULT,\n },\n }).createMachine({\n /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOlwgBswBiAD1gBd0GwT0AzFgJ2QNwdzoKAFVyowAewCuDItTRY8hUuSoBtAAwBdRKAAOE2P1wT8ukLUQBGAEwBWEgBYAnK+eOAzB7sB2DzY8rABoQAE9rDQc3V0cNTw8fAA4NHwBfVJCFHAJiElgwfAgCKGpNHSQQAyMBU3NLBDsrDxI7DTaAjQA2OOcNDxDwhHsNJx9Ou0TOq2cJxP9HdMyMbOU8gqL8ErUrcv1DY1qK+sbm1vaPLp6+gcRnGydo9wDGycWQLKVc9AB3dGNN6jiWCwdAwMrmKoHMxHRCJRKOEiJHwuZKBZwXKzBMKIGyYkhtAkXOweTqOHw2RJvD45Ug-P4CAH0JgsNicMA8LhwAz4fKicTSWTyZafWm-f5QcEVSE1aGgepwhFIlF9aYYrGDC4+JzEppjGzOUkeGbpDIgfASCBwczU5QQ-YyuqIAC0nRuCBd+IJXu9KSpwppZEoYDt1RMsosiEcNjdVjiJEeGisiSTHkcVgWpptuXyhWKIahjqGzi1BqRJINnVcdkcbuTLS9VYC8ISfsUAbp4vzDphCHJIyjBvJNlxNmRNexQ3sJGH43GPj8jWJrZWuXYfyoEC7YcLsbrgRsjkcvkmdgNbopVhIPhVfnsh8ClMz-tWsCkmEwcHgUvt257u8v+6Hse4xnhOdZnImVidPqCRNB4JqpEAA */\n context: ({input}) => {\n return {\n channelId: input.channelId,\n data: input.data,\n domain: input.domain,\n expectResponse: input.expectResponse ?? false,\n from: input.from,\n id: `msg-${uuid()}`,\n parentRef: input.parentRef,\n resolvable: input.resolvable,\n response: null,\n responseTimeout: input.responseTimeout,\n responseTo: input.responseTo,\n signal: input.signal,\n sources: input.sources instanceof Set ? input.sources : new Set([input.sources]),\n suppressWarnings: input.suppressWarnings,\n targetOrigin: input.targetOrigin,\n to: input.to,\n type: input.type,\n }\n },\n initial: 'idle',\n on: {\n abort: '.aborted',\n },\n states: {\n idle: {\n after: {\n initialTimeout: [\n {\n target: 'sending',\n },\n ],\n },\n },\n sending: {\n entry: {\n type: 'send message',\n params: ({context}) => {\n const {channelId, data, domain, from, id, responseTo, to, type} = context\n const message = {\n channelId,\n data,\n domain,\n from,\n id,\n to,\n type,\n responseTo,\n }\n return {message}\n },\n },\n always: [\n {\n guard: 'expectsResponse',\n target: 'awaiting',\n },\n 'success',\n ],\n },\n awaiting: {\n invoke: {\n id: 'listen for response',\n src: 'listen',\n input: ({context}) => ({\n requestId: context.id,\n sources: context.sources,\n signal: context.signal,\n }),\n onError: 'aborted',\n },\n after: {\n responseTimeout: 'failed',\n },\n on: {\n message: {\n actions: assign({\n response: ({event}) => event.data.data,\n responseTo: ({event}) => event.data.responseTo,\n }),\n target: 'success',\n },\n },\n },\n failed: {\n type: 'final',\n entry: 'on fail',\n },\n success: {\n type: 'final',\n entry: 'on success',\n },\n aborted: {\n type: 'final',\n entry: 'on abort',\n },\n },\n output: ({context, self}) => {\n const output = {\n requestId: self.id,\n response: context.response,\n responseTo: context.responseTo,\n }\n return output\n },\n })\n}\n\n// export const delayedRequestMachine = requestMachine.provide({\n// delays: {\n// initialTimeout: 500,\n// },\n// })\n","import {v4 as uuid} from 'uuid'\nimport {\n assertEvent,\n assign,\n createActor,\n emit,\n enqueueActions,\n fromCallback,\n raise,\n setup,\n stopChild,\n type ActorRefFrom,\n type EventObject,\n} from 'xstate'\nimport {createListenLogic, listenInputFromContext} from './common'\nimport {\n DOMAIN,\n HANDSHAKE_INTERVAL,\n MSG_DISCONNECT,\n MSG_HANDSHAKE_ACK,\n MSG_HANDSHAKE_SYN,\n MSG_HANDSHAKE_SYN_ACK,\n MSG_HEARTBEAT,\n MSG_RESPONSE,\n} from './constants'\nimport {createRequestMachine, type RequestActorRef} from './request'\nimport type {\n BufferAddedEmitEvent,\n BufferFlushedEmitEvent,\n Message,\n MessageEmitEvent,\n ProtocolMessage,\n RequestData,\n Status,\n StatusEmitEvent,\n WithoutResponse,\n} from './types'\n\n/**\n * @public\n */\nexport type ConnectionActorLogic<TSends extends Message, TReceives extends Message> = ReturnType<\n typeof createConnectionMachine<TSends, TReceives>\n>\n/**\n * @public\n */\nexport type ConnectionActor<TSends extends Message, TReceives extends Message> = ActorRefFrom<\n ReturnType<typeof createConnectionMachine<TSends, TReceives>>\n>\n\n/**\n * @public\n */\nexport type Connection<TSends extends Message = Message, TReceives extends Message = Message> = {\n actor: ConnectionActor<TSends, TReceives>\n connect: () => void\n disconnect: () => void\n id: string\n name: string\n machine: ReturnType<typeof createConnectionMachine<TSends, TReceives>>\n on: <TType extends TReceives['type'], TMessage extends Extract<TReceives, {type: TType}>>(\n type: TType,\n handler: (data: TMessage['data']) => Promise<TMessage['response']> | TMessage['response'],\n ) => () => void\n onStatus: (handler: (status: Status) => void, filter?: Status) => () => void\n post: <TType extends TSends['type'], TMessage extends Extract<TSends, {type: TType}>>(\n ...params: (TMessage['data'] extends undefined ? [TType] : never) | [TType, TMessage['data']]\n ) => void\n setTarget: (target: MessageEventSource) => void\n start: () => () => void\n stop: () => void\n target: MessageEventSource | undefined\n}\n\n/**\n * @public\n */\nexport interface ConnectionInput {\n connectTo: string\n domain?: string\n heartbeat?: boolean\n name: string\n id?: string\n target?: MessageEventSource\n targetOrigin: string\n}\n\nconst sendBackAtInterval = fromCallback<\n EventObject,\n {event: EventObject; immediate?: boolean; interval: number}\n>(({sendBack, input}) => {\n const send = () => {\n sendBack(input.event)\n }\n\n if (input.immediate) {\n send()\n }\n\n const interval = setInterval(send, input.interval)\n\n return () => {\n clearInterval(interval)\n }\n})\n\n/**\n * @public\n */\nexport const createConnectionMachine = <\n TSends extends Message, // Sends\n TReceives extends Message, // Receives\n TSendsWithoutResponse extends WithoutResponse<TSends> = WithoutResponse<TSends>,\n // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\n>() => {\n const connectionMachine = setup({\n types: {} as {\n children: {\n 'listen for handshake': 'listen'\n 'listen for messages': 'listen'\n 'send heartbeat': 'sendBackAtInterval'\n 'send syn': 'sendBackAtInterval'\n }\n context: {\n buffer: Array<TSendsWithoutResponse>\n channelId: string\n connectTo: string\n domain: string\n heartbeat: boolean\n id: string\n name: string\n requests: Array<RequestActorRef<TSends>>\n target: MessageEventSource | undefined\n targetOrigin: string\n }\n emitted:\n | BufferAddedEmitEvent<TSendsWithoutResponse>\n | BufferFlushedEmitEvent<TSendsWithoutResponse>\n | MessageEmitEvent<TReceives>\n | StatusEmitEvent\n events:\n | {type: 'connect'}\n | {type: 'disconnect'}\n | {type: 'message.received'; message: MessageEvent<ProtocolMessage<TReceives>>}\n | {type: 'post'; data: TSendsWithoutResponse}\n | {type: 'response'; respondTo: string; data: Pick<TSends, 'response'>}\n | {type: 'request.aborted'; requestId: string}\n | {type: 'request.failed'; requestId: string}\n | {\n type: 'request.success'\n requestId: string\n response: TSends['response'] | null\n responseTo: string | undefined\n }\n | {type: 'request'; data: RequestData<TSends> | RequestData<TSends>[]}\n | {type: 'syn'}\n | {type: 'target.set'; target: MessageEventSource}\n input: ConnectionInput\n },\n actors: {\n requestMachine: createRequestMachine<TSends>(),\n listen: createListenLogic(),\n sendBackAtInterval,\n },\n actions: {\n 'buffer message': enqueueActions(({enqueue}) => {\n enqueue.assign({\n buffer: ({event, context}) => {\n assertEvent(event, 'post')\n return [...context.buffer, event.data]\n },\n })\n enqueue.emit(({event}) => {\n assertEvent(event, 'post')\n return {\n type: 'buffer.added',\n message: event.data,\n } satisfies BufferAddedEmitEvent<TSendsWithoutResponse>\n })\n }),\n 'create request': assign({\n requests: ({context, event, self, spawn}) => {\n assertEvent(event, 'request')\n const arr = Array.isArray(event.data) ? event.data : [event.data]\n const requests = arr.map((request) => {\n const id = `req-${uuid()}`\n return spawn('requestMachine', {\n id,\n input: {\n channelId: context.channelId,\n data: request.data,\n domain: context.domain,\n expectResponse: request.expectResponse,\n from: context.name,\n parentRef: self,\n responseTo: request.responseTo,\n sources: context.target!,\n targetOrigin: context.targetOrigin,\n to: context.connectTo,\n type: request.type,\n },\n })\n })\n return [...context.requests, ...requests]\n },\n }),\n 'emit received message': enqueueActions(({enqueue}) => {\n enqueue.emit(({event}) => {\n assertEvent(event, 'message.received')\n return {\n type: 'message',\n message: event.message.data,\n }\n })\n }),\n 'emit status': emit((_, params: {status: Status}) => {\n return {\n type: 'status',\n status: params.status,\n } satisfies StatusEmitEvent\n }),\n 'post message': raise(({event}) => {\n assertEvent(event, 'post')\n return {\n type: 'request' as const,\n data: {\n data: event.data.data,\n expectResponse: true,\n type: event.data.type,\n },\n }\n }),\n 'remove request': enqueueActions(({context, enqueue, event}) => {\n assertEvent(event, ['request.success', 'request.failed', 'request.aborted'])\n stopChild(event.requestId)\n enqueue.assign({requests: context.requests.filter(({id}) => id !== event.requestId)})\n }),\n 'respond': raise(({event}) => {\n assertEvent(event, 'response')\n return {\n type: 'request' as const,\n data: {\n data: event.data,\n type: MSG_RESPONSE,\n responseTo: event.respondTo,\n },\n }\n }),\n\n 'send handshake ack': raise({\n type: 'request',\n data: {type: MSG_HANDSHAKE_ACK},\n }),\n 'send disconnect': raise(() => {\n return {\n type: 'request' as const,\n data: {type: MSG_DISCONNECT},\n }\n }),\n 'send handshake syn': raise({\n type: 'request',\n data: {type: MSG_HANDSHAKE_SYN},\n }),\n 'send pending messages': enqueueActions(({enqueue}) => {\n enqueue.raise(({context}) => ({\n type: 'request',\n data: context.buffer.map(({data, type}) => ({data, type})),\n }))\n enqueue.emit(({context}) => {\n return {\n type: 'buffer.flushed',\n messages: context.buffer,\n } satisfies BufferFlushedEmitEvent<TSendsWithoutResponse>\n })\n enqueue.assign({\n buffer: [],\n })\n }),\n 'set target': assign({\n target: ({event}) => {\n assertEvent(event, 'target.set')\n return event.target\n },\n }),\n },\n guards: {\n 'has target': ({context}) => !!context.target,\n 'should send heartbeats': ({context}) => context.heartbeat,\n },\n }).createMachine({\n /** @xstate-layout N4IgpgJg5mDOIC5QGMAWBDAdpsAbAxAC7oBOMhAdLGIQNoAMAuoqAA4D2sAloV+5ixAAPRAHZRAJgoAWABz0ArHICMy2QGZZCgJwAaEAE9EE+tIrb6ANgkLl46fTuj1AXxf60WHARJgAjgCucJSwAcjIcLAMzEggHNy8-IIiCKLS2hQS6qb2yurisrL6RgjK9LIyCuqq0g7WstZuHhjYePi+gcEUAGboXLiQ0YLxPHwCsSmiCgoykpayDtqS6trqxYjKEk0gnq24FFwQA-jI-DjIdEzDnKNJExuOZpZ12eq29OrSCuupypYUojUaTKCnm5Wk2123gORzA+HilxibBuiXGoBSGnUAIU4gU9FWamUtR+lmUM1EllBEkslMUEnpkJa0JaEFgGAA1lxMFB8LADJghrERqjkhtshk3mTtNo5OpqpYfqCKhTptoqpY1WUtu4dky8BQWWz0Jzue1-EFYIjrgkxqLSupqRRPpoPqJtLI0hIioZENJJE7NnJ8ZYHVk1YyvPrDRyuTyEYLkTa7uixVlMh81KGFhS1j6EPkZlpVjTphr8mkI3sDVhWTHTQBbSLoGAUXwRLgAN0GVyFKNt91KimUFEKXvKC2s9R+6X+jipnzJeSqEJ1UKjNaNJp5EC4sFOrQuCbifeTwg2cgoym0RPxDtqkj0eaB9Ao8zSolMEivZVcq71+33c5CEgeFOCtXskzRM8EDxKRpmkSw3QJbQsmpH5tHmV8JHSbJpDsakV2aSMALOMALhAjoLXAxNbiglI-SxWw1Vw0QNDw0Qfg9KQ7EJSxHHxApK2hQCyOAiAzVgDhMGoI9hX7FMEHSF8cWkelpHURCbBsb481xAEgT9BQJCmWQsiE-URPI8TG1gWBmzAVsyLATtuyRY9ILtWoKmlL82Kqd0tAVJ91LMHFZDKIkVlkNVZHMkiDzE-Adz3UjDx7GiRQHCKnheD53k+HSSkDDIwpBVTqQwuKKEssSDTAUhCAAI3qyg0DIrd8Fkk86MQUMnVM+RynoegTDJH48hGp0vR-FDRqqKqasgOqGua9AQjATAd1NSiul6fpXOtWi7Wy19cslD4vnG7IX3oVjVDUVYEJQqrksW8SdstLqPKy0wKgG1RhtMWogqKhoMjkWp6XxUyFBe3c3tAz70vco6fq+V8PTkGUFzdQqNnELEM2yClrwwzQ4ZShKQJqr7UYU98AS0W9pT4z5pHG0yXwMkNNTyGk3B1TB2AgOBBDXXBDsyhSFG9EovQqN5i1JeRcKqw4Bkl+ToMx8x0j+EaqQ9XMSkBURMgMkEwQWKro2NWNNdPFJAzN0lJGM4slDxhBEJfXyplBd03wW1KxIdnrBxBh4JAyW75C8rJpmDqmIGWkgmpasPjqUcaHooMLHA0uU1UkJOgKW1B6rT1bWor5At0zgcTAkK7hrz1irB0D8cW0UvRPLyv07WqgNq2qAG+l9SnXUz0UOXD5xuMs3Y4+DVJBX7UiKrV6Q8gcfoJO54rFefLLqfJYX1WKYNLxL4NO1NwgA */\n id: 'connection',\n context: ({input}) => ({\n id: input.id || `${input.name}-${uuid()}`,\n buffer: [],\n channelId: `chn-${uuid()}`,\n connectTo: input.connectTo,\n domain: input.domain ?? DOMAIN,\n heartbeat: input.heartbeat ?? false,\n name: input.name,\n requests: [],\n target: input.target,\n targetOrigin: input.targetOrigin,\n }),\n on: {\n 'target.set': {\n actions: 'set target',\n },\n 'request.success': {\n actions: 'remove request',\n },\n 'request.failed': {\n actions: 'remove request',\n },\n },\n initial: 'idle',\n states: {\n idle: {\n entry: [{type: 'emit status', params: {status: 'idle'}}],\n on: {\n connect: {\n target: 'handshaking',\n guard: 'has target',\n },\n post: {\n actions: 'buffer message',\n },\n },\n },\n handshaking: {\n id: 'handshaking',\n entry: [{type: 'emit status', params: {status: 'handshaking'}}],\n invoke: [\n {\n id: 'send syn',\n src: 'sendBackAtInterval',\n input: () => ({\n event: {type: 'syn'},\n interval: HANDSHAKE_INTERVAL,\n immediate: true,\n }),\n },\n {\n id: 'listen for handshake',\n src: 'listen',\n input: (input) =>\n listenInputFromContext({\n include: MSG_HANDSHAKE_SYN_ACK,\n count: 1,\n })(input),\n /* Below would maybe be more readable than transitioning to\n 'connected' on 'message', and 'ack' on exit but having onDone when\n using passing invocations currently breaks XState Editor */\n // onDone: {\n // target: 'connected',\n // actions: 'ack',\n // },\n },\n ],\n on: {\n 'syn': {\n actions: 'send handshake syn',\n },\n 'request': {\n actions: 'create request',\n },\n 'post': {\n actions: 'buffer message',\n },\n 'message.received': {\n target: 'connected',\n },\n 'disconnect': {\n target: 'disconnected',\n },\n },\n exit: 'send handshake ack',\n },\n connected: {\n entry: ['send pending messages', {type: 'emit status', params: {status: 'connected'}}],\n invoke: {\n id: 'listen for messages',\n src: 'listen',\n input: listenInputFromContext({\n exclude: [MSG_RESPONSE, MSG_HEARTBEAT],\n }),\n },\n on: {\n 'post': {\n actions: 'post message',\n },\n 'request': {\n actions: 'create request',\n },\n 'response': {\n actions: 'respond',\n },\n 'message.received': {\n actions: 'emit received message',\n },\n 'disconnect': {\n target: 'disconnected',\n },\n },\n initial: 'heartbeat',\n states: {\n heartbeat: {\n initial: 'checking',\n states: {\n checking: {\n always: {\n guard: 'should send heartbeats',\n target: 'sending',\n },\n },\n sending: {\n on: {\n 'request.failed': {\n target: '#handshaking',\n },\n },\n invoke: {\n id: 'send heartbeat',\n src: 'sendBackAtInterval',\n input: () => ({\n event: {type: 'post', data: {type: MSG_HEARTBEAT, data: undefined}},\n interval: 2000,\n immediate: false,\n }),\n },\n },\n },\n },\n },\n },\n disconnected: {\n id: 'disconnected',\n entry: ['send disconnect', {type: 'emit status', params: {status: 'disconnected'}}],\n on: {\n request: {\n actions: 'create request',\n },\n post: {\n actions: 'buffer message',\n },\n connect: {\n target: 'handshaking',\n guard: 'has target',\n },\n },\n },\n },\n })\n\n return connectionMachine\n}\n\n/**\n * @public\n */\nexport const createConnection = <TSends extends Message, TReceives extends Message>(\n input: ConnectionInput,\n machine: ConnectionActorLogic<TSends, TReceives> = createConnectionMachine<TSends, TReceives>(),\n): Connection<TSends, TReceives> => {\n const id = input.id || `${input.name}-${uuid()}`\n const actor = createActor(machine, {\n input: {...input, id},\n })\n\n const eventHandlers: Map<\n string,\n Set<(event: TReceives['data']) => Promise<TReceives['response']> | TReceives['response']>\n > = new Map()\n\n const unhandledMessages: Map<string, Set<ProtocolMessage<Message>>> = new Map()\n\n const on = <TType extends TReceives['type'], TMessage extends Extract<TReceives, {type: TType}>>(\n type: TType,\n handler: (data: TMessage['data']) => Promise<TMessage['response']> | TMessage['response'],\n options?: {replay?: number},\n ) => {\n const handlers = eventHandlers.get(type) || new Set()\n\n if (!eventHandlers.has(type)) {\n eventHandlers.set(type, handlers)\n }\n\n // Register the new handler\n handlers.add(handler)\n\n // Process any unhandled messages for this type\n const unhandledMessagesForType = unhandledMessages.get(type)\n if (unhandledMessagesForType) {\n const replayCount = options?.replay ?? 1\n const messagesToReplay = Array.from(unhandledMessagesForType).slice(-replayCount)\n\n // Replay messages to the new handler\n messagesToReplay.forEach(async ({data, id}) => {\n const response = await handler(data)\n if (response) {\n actor.send({\n type: 'response',\n respondTo: id,\n data: response,\n })\n }\n })\n\n // Clear the unhandled messages for this type\n unhandledMessages.delete(type)\n }\n\n return () => {\n handlers.delete(handler)\n }\n }\n\n const connect = () => {\n actor.send({type: 'connect'})\n }\n\n const disconnect = () => {\n actor.send({type: 'disconnect'})\n }\n\n const onStatus = (handler: (status: Status) => void, filter?: Status) => {\n const {unsubscribe} = actor.on('status', (event: StatusEmitEvent & {status: Status}) => {\n if (filter && event.status !== filter) {\n return\n }\n\n handler(event.status)\n })\n\n return unsubscribe\n }\n\n const setTarget = (target: MessageEventSource) => {\n actor.send({type: 'target.set', target})\n }\n\n const post = <TType extends TSends['type'], TMessage extends Extract<TSends, {type: TType}>>(\n type: TType,\n data?: TMessage['data'],\n ) => {\n const _data = {type, data} as WithoutResponse<TMessage>\n actor.send({type: 'post', data: _data})\n }\n\n actor.on('message', async ({message}) => {\n const handlers = eventHandlers.get(message.type)\n\n if (handlers) {\n // Execute all registered handlers for this message type\n handlers.forEach(async (handler) => {\n const response = await handler(message.data)\n if (response) {\n actor.send({type: 'response', respondTo: message.id, data: response})\n }\n })\n return\n }\n\n // Store unhandled messages for potential replay\n const unhandledMessagesForType = unhandledMessages.get(message.type)\n if (unhandledMessagesForType) {\n unhandledMessagesForType.add(message)\n } else {\n unhandledMessages.set(message.type, new Set([message]))\n }\n })\n\n const stop = () => {\n actor.stop()\n }\n\n const start = () => {\n actor.start()\n return stop\n }\n\n return {\n actor,\n connect,\n disconnect,\n id,\n name: input.name,\n machine,\n on,\n onStatus,\n post,\n setTarget,\n start,\n stop,\n get target() {\n return actor.getSnapshot().context.target\n },\n }\n}\n\n// Helper function to cleanup a connection\nexport const cleanupConnection: (connection: Connection<Message, Message>) => void = (\n connection,\n) => {\n connection.disconnect()\n // Necessary to allow disconnect messages to be sent before the connection\n // actor is stopped\n setTimeout(() => {\n connection.stop()\n }, 0)\n}\n","import {\n cleanupConnection,\n createConnection,\n createConnectionMachine,\n type Connection,\n type ConnectionActorLogic,\n type ConnectionInput,\n} from './connection'\nimport {type InternalEmitEvent, type Message, type StatusEvent} from './types'\n\n/**\n * @public\n */\nexport type ChannelInput = Omit<ConnectionInput, 'target' | 'targetOrigin'>\n\n/**\n * @public\n */\nexport interface ChannelInstance<TSends extends Message, TReceives extends Message> {\n on: <TType extends TReceives['type'], TMessage extends Extract<TReceives, {type: TType}>>(\n type: TType,\n handler: (data: TMessage['data']) => Promise<TMessage['response']> | TMessage['response'],\n ) => () => void\n onInternalEvent: <\n TType extends InternalEmitEvent<TSends, TReceives>['type'],\n TEvent extends Extract<InternalEmitEvent<TSends, TReceives>, {type: TType}>,\n >(\n type: TType,\n handler: (event: TEvent) => void,\n ) => () => void\n onStatus: (handler: (event: StatusEvent) => void) => void\n post: <TType extends TSends['type'], TMessage extends Extract<TSends, {type: TType}>>(\n ...params: (TMessage['data'] extends undefined ? [TType] : never) | [TType, TMessage['data']]\n ) => void\n start: () => () => void\n stop: () => void\n}\n\n/**\n * @public\n */\nexport interface Controller {\n addTarget: (target: MessageEventSource) => () => void\n createChannel: <TSends extends Message, TReceives extends Message>(\n input: ChannelInput,\n machine?: ConnectionActorLogic<TSends, TReceives>,\n ) => ChannelInstance<TSends, TReceives>\n destroy: () => void\n}\n\ninterface Channel<\n TSends extends Message = Message,\n TReceives extends Message = Message,\n TType extends InternalEmitEvent<TSends, TReceives>['type'] = InternalEmitEvent<\n TSends,\n TReceives\n >['type'],\n> {\n input: ChannelInput\n connections: Set<Connection<TSends, TReceives>>\n internalEventSubscribers: Set<{\n type: TType\n handler: (event: Extract<InternalEmitEvent<TSends, TReceives>, {type: TType}>) => void\n unsubscribers: Array<() => void>\n }>\n machine: ConnectionActorLogic<TSends, TReceives>\n statusSubscribers: Set<{\n handler: (event: StatusEvent) => void\n unsubscribers: Array<() => void>\n }>\n subscribers: Set<{\n type: TReceives['type']\n handler: (event: TReceives['data']) => Promise<TReceives['response']> | TReceives['response']\n unsubscribers: Array<() => void>\n }>\n}\n\nconst noop = () => {}\n\n/**\n * @public\n */\nexport const createController = (input: {targetOrigin: string}): Controller => {\n const {targetOrigin} = input\n const targets = new Set<MessageEventSource>()\n const channels = new Set<Channel>()\n\n const addTarget = (target: MessageEventSource) => {\n // If the target has already been added, return just a noop cleanup\n if (targets.has(target)) {\n return noop\n }\n\n if (!targets.size || !channels.size) {\n targets.add(target)\n\n // If there are existing channels, set the target on all existing\n // connections, and trigger a connection event\n channels.forEach((channel) => {\n channel.connections.forEach((connection) => {\n connection.setTarget(target)\n connection.connect()\n })\n })\n // We perform a 'soft' cleanup here: disconnect only as we want to\n // maintain at least one live connection per channel\n return () => {\n targets.delete(target)\n channels.forEach((channel) => {\n channel.connections.forEach((connection) => {\n if (connection.target === target) {\n connection.disconnect()\n }\n })\n })\n }\n }\n\n targets.add(target)\n\n // Maintain a list of connections to cleanup\n const targetConnections = new Set<Connection<Message, Message>>()\n\n // If we already have targets and channels, we need to create new\n // connections for each source with all the associated subscribers.\n channels.forEach((channel) => {\n const connection = createConnection(\n {\n ...channel.input,\n target,\n targetOrigin,\n },\n channel.machine,\n )\n\n targetConnections.add(connection)\n channel.connections.add(connection)\n\n channel.subscribers.forEach(({type, handler, unsubscribers}) => {\n unsubscribers.push(connection.on(type, handler))\n })\n channel.internalEventSubscribers.forEach(({type, handler, unsubscribers}) => {\n unsubscribers.push(connection.actor.on(type, handler).unsubscribe)\n })\n channel.statusSubscribers.forEach(({handler, unsubscribers}) => {\n unsubscribers.push(\n connection.onStatus((status) => handler({connection: connection.id, status})),\n )\n })\n\n connection.start()\n connection.connect()\n })\n\n // We perform a more 'aggressive' cleanup here as we do not need to maintain\n // these 'duplicate' connections: disconnect, stop, and remove the connections from\n // all channels\n return () => {\n targets.delete(target)\n targetConnections.forEach((connection) => {\n cleanupConnection(connection)\n channels.forEach((channel) => {\n channel.connections.delete(connection)\n })\n })\n }\n }\n\n const createChannel = <TSends extends Message, TReceives extends Message>(\n input: ChannelInput,\n machine: ConnectionActorLogic<TSends, TReceives> = createConnectionMachine<TSends, TReceives>(),\n ): ChannelInstance<TSends, TReceives> => {\n const channel: Channel<TSends, TReceives> = {\n connections: new Set(),\n input,\n internalEventSubscribers: new Set(),\n machine,\n statusSubscribers: new Set(),\n subscribers: new Set(),\n }\n\n channels.add(channel as unknown as Channel)\n\n const {connections, internalEventSubscribers, statusSubscribers, subscribers} = channel\n\n if (targets.size) {\n // If targets have already been added, create a connection for each target\n targets.forEach((target) => {\n const connection = createConnection<TSends, TReceives>(\n {\n ...input,\n target,\n targetOrigin,\n },\n machine,\n )\n connections.add(connection)\n })\n } else {\n // If targets have not been added yet, create a connection without a target\n const connection = createConnection<TSends, TReceives>({...input, targetOrigin}, machine)\n connections.add(connection)\n }\n\n const post: ChannelInstance<TSends, TReceives>['post'] = (...params) => {\n const [type, data] = params\n connections.forEach((connection) => {\n connection.post(type, data)\n })\n }\n\n const on: ChannelInstance<TSends, TReceives>['on'] = (type, handler) => {\n const unsubscribers: Array<() => void> = []\n connections.forEach((connection) => {\n unsubscribers.push(connection.on(type, handler))\n })\n const subscriber = {type, handler, unsubscribers}\n subscribers.add(subscriber)\n return () => {\n unsubscribers.forEach((unsub) => unsub())\n subscribers.delete(subscriber)\n }\n }\n\n const onInternalEvent = <\n TType extends InternalEmitEvent<TSends, TReceives>['type'],\n TEvent extends Extract<InternalEmitEvent<TSends, TReceives>, {type: TType}>,\n >(\n type: TType,\n handler: (event: TEvent) => void,\n ) => {\n const unsubscribers: Array<() => void> = []\n connections.forEach((connection) => {\n // @ts-expect-error @todo @help\n unsubscribers.push(connection.actor.on(type, handler).unsubscribe)\n })\n const subscriber = {type, handler, unsubscribers}\n // @ts-expect-error @todo @help\n internalEventSubscribers.add(subscriber)\n return () => {\n unsubscribers.forEach((unsub) => unsub())\n // @ts-expect-error @todo @help\n internalEventSubscribers.delete(subscriber)\n }\n }\n\n const onStatus = (handler: (event: StatusEvent) => void) => {\n const unsubscribers: Array<() => void> = []\n connections.forEach((connection) => {\n unsubscribers.push(\n connection.onStatus((status) => handler({connection: connection.id, status})),\n )\n })\n const subscriber = {handler, unsubscribers}\n statusSubscribers.add(subscriber)\n return () => {\n unsubscribers.forEach((unsub) => unsub())\n statusSubscribers.delete(subscriber)\n }\n }\n\n // Stop a connection, cleanup all connections and remove the connection itself\n // from the controller\n // @todo Remove casting\n const stop = () => {\n const connections = channel.connections as unknown as Set<Connection>\n connections.forEach(cleanupConnection)\n connections.clear()\n channels.delete(channel as unknown as Channel)\n }\n\n const start = () => {\n connections.forEach((connection) => {\n connection.start()\n connection.connect()\n })\n\n return stop\n }\n\n return {\n on,\n onInternalEvent,\n onStatus,\n post,\n start,\n stop,\n }\n }\n\n // Destroy the controller, cleanup all connections in all channels\n const destroy = () => {\n channels.forEach(({connections}) => {\n connections.forEach(cleanupConnection)\n connections.clear()\n })\n channels.clear()\n targets.clear()\n }\n\n return {\n addTarget,\n createChannel,\n destroy,\n }\n}\n","// Returns Promise.withResolvers or polyfill if unavailable\nexport function createPromiseWithResolvers<T = unknown>(): {\n promise: Promise<T>\n resolve: (value: T | PromiseLike<T>) => void\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n reject: (reason?: any) => void\n} {\n if (typeof Promise.withResolvers === 'function') {\n return Promise.withResolvers<T>()\n }\n\n let resolve!: (value: T | PromiseLike<T>) => void\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let reject!: (reason?: any) => void\n\n const promise = new Promise<T>((res, rej) => {\n resolve = res\n reject = rej\n })\n\n return {promise, resolve, reject}\n}\n","import {v4 as uuid} from 'uuid'\nimport {\n assertEvent,\n assign,\n createActor,\n emit,\n enqueueActions,\n raise,\n setup,\n stopChild,\n type ActorRefFrom,\n} from 'xstate'\nimport {createListenLogic, listenInputFromContext} from './common'\nimport {\n DOMAIN,\n FETCH_TIMEOUT_DEFAULT,\n MSG_DISCONNECT,\n MSG_HANDSHAKE_ACK,\n MSG_HANDSHAKE_SYN,\n MSG_HANDSHAKE_SYN_ACK,\n MSG_HEARTBEAT,\n MSG_RESPONSE,\n} from './constants'\nimport {createRequestMachine, type RequestActorRef} from './request'\nimport type {\n BufferAddedEmitEvent,\n BufferFlushedEmitEvent,\n HeartbeatEmitEvent,\n HeartbeatMessage,\n Message,\n MessageEmitEvent,\n ProtocolMessage,\n RequestData,\n Status,\n StatusEmitEvent,\n WithoutResponse,\n} from './types'\nimport {createPromiseWithResolvers} from './util'\n\n/**\n * @public\n */\nexport interface NodeInput {\n name: string\n connectTo: string\n domain?: string\n}\n\n/**\n * @public\n */\nexport type NodeActorLogic<TSends extends Message, TReceives extends Message> = ReturnType<\n typeof createNodeMachine<TSends, TReceives>\n>\n\n/**\n * @public\n */\nexport type NodeActor<TSends extends Message, TReceives extends Message> = ActorRefFrom<\n NodeActorLogic<TSends, TReceives>\n>\n\n/**\n * @public\n */\nexport type Node<TSends extends Message, TReceives extends Message> = {\n actor: NodeActor<TSends, TReceives>\n fetch: <TType extends TSends['type'], TMessage extends Extract<TSends, {type: TType}>>(\n ...params:\n | (TMessage['data'] extends undefined ? [TType] : never)\n | [TType, TMessage['data']]\n | [TType, TMessage['data'], {signal?: AbortSignal; suppressWarnings?: boolean}]\n ) => TSends extends TMessage\n ? TSends['type'] extends TType\n ? Promise<TSends['response']>\n : never\n : never\n machine: NodeActorLogic<TSends, TReceives>\n on: <TType extends TReceives['type'], TMessage extends Extract<TReceives, {type: TType}>>(\n type: TType,\n handler: (event: TMessage['data']) => TMessage['response'],\n ) => () => void\n onStatus: (\n handler: (status: Exclude<Status, 'disconnected'>) => void,\n filter?: Exclude<Status, 'disconnected'>,\n ) => () => void\n post: <TType extends TSends['type'], TMessage extends Extract<TSends, {type: TType}>>(\n ...params: (TMessage['data'] extends undefined ? [TType] : never) | [TType, TMessage['data']]\n ) => void\n start: () => () => void\n stop: () => void\n}\n\n/**\n * @public\n */\nexport const createNodeMachine = <\n TSends extends Message, // Sends\n TReceives extends Message, // Receives\n TSendsWithoutResponse extends WithoutResponse<TSends> = WithoutResponse<TSends>,\n // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types\n>() => {\n const nodeMachine = setup({\n types: {} as {\n children: {\n 'listen for disconnect': 'listen'\n 'listen for handshake ack': 'listen'\n 'listen for handshake syn': 'listen'\n 'listen for heartbeat': 'listen'\n 'listen for messages': 'listen'\n }\n context: {\n buffer: Array<{\n data: TSendsWithoutResponse\n resolvable?: PromiseWithResolvers<TSends['response']>\n options?: {\n signal?: AbortSignal\n suppressWarnings?: boolean\n }\n }>\n channelId: string | null\n connectTo: string\n domain: string\n // The handshake buffer is a workaround to maintain backwards\n // compatibility with the Sanity channels package, which may incorrectly\n // send buffered messages _before_ it completes the handshake (i.e.\n // sends an ack message). It should be removed in the next major.\n handshakeBuffer: Array<{\n type: 'message.received'\n message: MessageEvent<ProtocolMessage<TReceives>>\n }>\n name: string\n requests: Array<RequestActorRef<TSends>>\n target: MessageEventSource | undefined\n targetOrigin: string | null\n }\n emitted:\n | BufferAddedEmitEvent<TSendsWithoutResponse>\n | BufferFlushedEmitEvent<TSendsWithoutResponse>\n | HeartbeatEmitEvent\n | MessageEmitEvent<TReceives>\n | (StatusEmitEvent & {status: Exclude<Status, 'disconnected'>})\n events:\n | {type: 'heartbeat.received'; message: MessageEvent<ProtocolMessage<HeartbeatMessage>>}\n | {type: 'message.received'; message: MessageEvent<ProtocolMessage<TReceives>>}\n | {type: 'handshake.syn'; message: MessageEvent<ProtocolMessage<TReceives>>}\n | {\n type: 'post'\n data: TSendsWithoutResponse\n resolvable?: PromiseWithResolvers<TSends['response']>\n options?: {\n responseTimeout?: number\n signal?: AbortSignal\n suppressWarnings?: boolean\n }\n }\n | {type: 'request.aborted'; requestId: string}\n | {type: 'request.failed'; requestId: string}\n | {\n type: 'request.success'\n requestId: string\n response: TSends['response'] | null\n responseTo: string | undefined\n }\n | {type: 'request'; data: RequestData<TSends> | RequestData<TSends>[]} // @todo align with 'post' type\n input: NodeInput\n },\n actors: {\n requestMachine: createRequestMachine<TSends>(),\n listen: createListenLogic(),\n },\n actions: {\n 'buffer handshake': assign({\n handshakeBuffer: ({event, context}) => {\n assertEvent(event, 'message.received')\n return [...context.handshakeBuffer, event]\n },\n }),\n 'buffer message': enqueueActions(({enqueue}) => {\n enqueue.assign({\n buffer: ({event, context}) => {\n assertEvent(event, 'post')\n return [\n ...context.buffer,\n {\n data: event.data,\n resolvable: event.resolvable,\n options: event.options,\n },\n ]\n },\n })\n enqueue.emit(({event}) => {\n assertEvent(event, 'post')\n return {\n type: 'buffer.added',\n message: event.data,\n } satisfies BufferAddedEmitEvent<TSendsWithoutResponse>\n })\n }),\n 'create request': assign({\n requests: ({context, event, self, spawn}) => {\n assertEvent(event, 'request')\n const arr = Array.isArray(event.data) ? event.data : [event.data]\n const requests = arr.map((request) => {\n const id = `req-${uuid()}`\n return spawn('requestMachine', {\n id,\n input: {\n channelId: context.channelId!,\n data: request.data,\n domain: context.domain!,\n expectResponse: request.expectResponse,\n from: context.name,\n parentRef: self,\n resolvable: request.resolvable,\n responseTimeout: request.options?.responseTimeout,\n responseTo: request.responseTo,\n signal: request.options?.signal,\n sources: context.target!,\n suppressWarnings: req