import { KIND_TEXT as LEGACY_KIND_TEXT, KIND_COMPONENT as LEGACY_KIND_COMPONENT, KIND_FRAGMENT as LEGACY_KIND_FRAGMENT, ACTION_MOUNT as LEGACY_ACTION_MOUNT, ACTION_INSERT_CHILD as LEGACY_ACTION_INSERT_CHILD, ACTION_REMOVE_CHILD as LEGACY_ACTION_REMOVE_CHILD, ACTION_UPDATE_PROPS as LEGACY_ACTION_UPDATE_PROPS, ACTION_UPDATE_TEXT as LEGACY_ACTION_UPDATE_TEXT, type RemoteChannel as LegacyRemoteChannel, type ActionArgumentMap as LegacyActionArgumentMap, type RemoteComponentSerialization as LegacyRemoteComponentSerialization, type RemoteTextSerialization as LegacyRemoteTextSerialization, type RemoteFragmentSerialization as LegacyRemoteFragmentSerialization, } from '@remote-ui/core'; import { ROOT_ID, NODE_TYPE_TEXT, NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, type RemoteMutationRecord, type RemoteTextSerialization, type RemoteElementSerialization, type RemoteConnection, type RemoteNodeSerialization, type RemoteCommentSerialization, } from '@remote-dom/core'; export interface LegacyRemoteChannelElementMap { [key: string]: string; } export interface LegacyRemoteChannelOptions { /** * A map of element types to their corresponding remote-ui element types. */ elements?: LegacyRemoteChannelElementMap; slotProps?: { /** * The element type to use for slots. */ wrapper?: string; }; } /** * Adapts a Remote DOM `RemoteConnection` object into a `remote-ui` `RemoteChannel`. * This allows you to use a Remote DOM receiver class on the host, even if the remote * environment is using `remote-ui`. * * @example * ```tsx * import {DOMRemoteReceiver} from '@remote-dom/core/receivers'; * import {adaptToLegacyRemoteChannel} from '@remote-dom/compat'; * * const receiver = new DOMRemoteReceiver(); * const channel = adaptToLegacyRemoteChannel(receiver.connection); * * // Do something with the channel * sendChannelToRemoteEnvironment(channel); * ``` */ export function adaptToLegacyRemoteChannel( connection: RemoteConnection, options?: LegacyRemoteChannelOptions, ): LegacyRemoteChannel { const tree = new Map(); function mutate(records: RemoteMutationRecord[]) { for (const record of records) { const [mutationType, parentId] = record; switch (mutationType) { case MUTATION_TYPE_INSERT_CHILD: { const node = record[2]; const index = record[3]; persistNode(parentId, node, index); break; } case MUTATION_TYPE_REMOVE_CHILD: { const index = record[2]; removeNode(parentId, index); break; } } } connection.mutate(records); } function persistNode( parentId: string, node: RemoteNodeSerialization, index: number, ) { if (!tree.has(parentId)) { tree.set(parentId, []); } const parentNode = tree.get(parentId)!; parentNode.splice(index, 0, { id: node.id, slot: 'attributes' in node ? node.attributes?.slot : undefined, }); if ('children' in node && node.children) { for (const [childIndex, child] of node.children.entries()) { persistNode(node.id, child, childIndex); } } } function removeNode(parentId: string, index: number) { const parentNode = tree.get(parentId); if (!parentNode?.[index]) return; const id = parentNode[index].id; parentNode.splice(index, 1); cleanupNode(id); } function cleanupNode(id: string) { const nodeChildren = tree.get(id); if (nodeChildren) { for (const child of nodeChildren) { cleanupNode(child.id); } tree.delete(id); } } return function remoteChannel( type: T, ...payload: LegacyActionArgumentMap[T] ) { switch (type) { case LEGACY_ACTION_MOUNT: { const [nodes] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_MOUNT]; const records = nodes.map( (node, index) => [ MUTATION_TYPE_INSERT_CHILD, ROOT_ID, adaptLegacyNodeSerialization(node, options), index, ] satisfies RemoteMutationRecord, ); mutate(records); break; } case LEGACY_ACTION_INSERT_CHILD: { const [parentId = ROOT_ID, index, child] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_INSERT_CHILD]; const records = []; const parentNode = tree.get(parentId); if (parentNode) { const existingChildIndex = parentNode.findIndex( ({id}) => id === child.id, ); if (existingChildIndex >= 0) { records.push([ MUTATION_TYPE_REMOVE_CHILD, parentId, existingChildIndex, ] satisfies RemoteMutationRecord); } } records.push([ MUTATION_TYPE_INSERT_CHILD, parentId, adaptLegacyNodeSerialization(child, options), index, ] satisfies RemoteMutationRecord); mutate(records); break; } case LEGACY_ACTION_REMOVE_CHILD: { const [parentID, removeIndex] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_REMOVE_CHILD]; mutate([ [MUTATION_TYPE_REMOVE_CHILD, parentID ?? ROOT_ID, removeIndex], ]); break; } case LEGACY_ACTION_UPDATE_TEXT: { const [textId, text] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_UPDATE_TEXT]; mutate([[MUTATION_TYPE_UPDATE_TEXT, textId, text]]); break; } case LEGACY_ACTION_UPDATE_PROPS: { const [id, props] = payload as LegacyActionArgumentMap[typeof LEGACY_ACTION_UPDATE_PROPS]; const parentNode = tree.get(id); const records = []; for (const [key, value] of Object.entries(props)) { const index = parentNode?.findIndex(({slot}) => slot === key) ?? -1; if (isFragment(value)) { if (index >= 0) { records.push([ MUTATION_TYPE_REMOVE_CHILD, id, index, ] satisfies RemoteMutationRecord); } records.push([ MUTATION_TYPE_INSERT_CHILD, id, adaptLegacyPropFragmentSerialization(key, value, options), tree.get(id)?.length ?? 0, ] satisfies RemoteMutationRecord); } else { if (index >= 0) { records.push([ MUTATION_TYPE_REMOVE_CHILD, id, index, ] satisfies RemoteMutationRecord); } else { records.push([ MUTATION_TYPE_UPDATE_PROPERTY, id, key, value, ] satisfies RemoteMutationRecord); } } } mutate(records); break; } default: throw new Error(`Unsupported action type: ${type}`); } }; } function adaptLegacyNodeSerialization( child: | LegacyRemoteComponentSerialization | LegacyRemoteTextSerialization | LegacyRemoteFragmentSerialization, options?: LegacyRemoteChannelOptions, ): | RemoteElementSerialization | RemoteTextSerialization | RemoteCommentSerialization { switch (child.kind) { case LEGACY_KIND_TEXT: return adaptLegacyTextSerialization(child); case LEGACY_KIND_COMPONENT: return adaptLegacyComponentSerialization(child, options); default: return { id: child.id, type: NODE_TYPE_COMMENT, data: 'added by remote-ui legacy adaptor to replace a fragment rendered as a child', }; } } function adaptLegacyTextSerialization({ id, text, }: LegacyRemoteTextSerialization): RemoteTextSerialization { return { id, type: NODE_TYPE_TEXT, data: text, }; } function adaptLegacyComponentSerialization( {id, type, props, children}: LegacyRemoteComponentSerialization, options?: LegacyRemoteChannelOptions, ): RemoteElementSerialization { const element = options?.elements?.[type] ?? type; const [fragments, properties] = adaptFragmentsInProps(props); return { id, type: NODE_TYPE_ELEMENT, element, properties, children: [ ...children.map((child) => { return adaptLegacyNodeSerialization(child, options); }), ...adaptLegacyFragmentsSerialization(fragments, options), ], }; } function adaptFragmentsInProps( props: Record, ): [ fragments: Record, properties: Record, ] { const fragments: Record = {}; const properties: Record = {}; for (const [key, value] of Object.entries(props)) { if (isFragment(value)) { fragments[key] = value; } else { properties[key] = value; } } return [fragments, properties]; } function isFragment(prop: unknown): prop is LegacyRemoteFragmentSerialization { return ( prop != null && typeof prop === 'object' && 'kind' in prop && prop.kind === LEGACY_KIND_FRAGMENT ); } function adaptLegacyFragmentsSerialization( fragments: Record, options?: LegacyRemoteChannelOptions, ): RemoteElementSerialization[] { return Object.entries(fragments).map(([slot, fragment]) => { return adaptLegacyPropFragmentSerialization(slot, fragment, options); }); } function adaptLegacyPropFragmentSerialization( slot: string, fragment: LegacyRemoteFragmentSerialization, options?: LegacyRemoteChannelOptions, ): RemoteElementSerialization { return { id: fragment.id, element: options?.slotProps?.wrapper ?? 'remote-fragment', attributes: { slot, }, type: NODE_TYPE_ELEMENT, children: fragment.children.map((child) => { return adaptLegacyNodeSerialization(child, options); }), }; }