Skip to main content

packages/client/src/connect.ts

import { getRootProxy } from "./proxy"
import {
  disconnectConnectionState,
  mountConnectionRoot,
  openConnectionState,
  unmountConnectionRoot
} from "./runtime"
import type { ConnectionState, SocketLike } from "./runtime"
import type { ExternalUploader, StoreModule, StoreProxy } from "./types"

export interface ConnectOptions {
  topic?: string
  uploaders?: Record<string, ExternalUploader>
}

export interface MountStoreOptions<
  M extends StoreModule<R>,
  R
> {
  module: M
  id: string
  params?: Record<string, unknown>
}

export interface MountedStore<M extends StoreModule<R>, R> {
  readonly store: StoreProxy<M, R>
  readonly unmount: () => Promise<void>
}

export interface MusubiConnection<R> {
  mountStore<M extends StoreModule<R>>(
    options: MountStoreOptions<M, R>
  ): Promise<MountedStore<M, R>>
  disconnect(): Promise<void>
}

/**
 * Opens one Musubi connection over `socket`.
 *
 * Usage:
 *
 *     const connection = await connect<Musubi.Stores>(socket)
 *
 *     const { store, unmount } = await connection.mountStore({
 *       module: "MyApp.Stores.DashboardStore",
 *       id: "dashboard"
 *     })
 *
 * The `R` generic is bound once on `connect`; the `module` literal then
 * drives type inference for every later `mountStore` call. React consumers
 * usually go through `createMusubi<R>()` in `@musubi/react` instead, which
 * binds `R` once for the connection and all hooks.
 */
export async function connect<R>(
  socket: SocketLike,
  options: ConnectOptions = {}
): Promise<MusubiConnection<R>> {
  const openOptions: { topic?: string; uploaders?: Record<string, ExternalUploader> } = {}
  if (options.topic !== undefined) openOptions.topic = options.topic
  if (options.uploaders !== undefined) openOptions.uploaders = options.uploaders

  const { connection, ready } = openConnectionState(socket, openOptions)
  await ready

  return buildConnectionApi<R>(connection)
}

function buildConnectionApi<R>(connectionState: ConnectionState): MusubiConnection<R> {
  return {
    async mountStore<M extends StoreModule<R>>(
      options: MountStoreOptions<M, R>
    ): Promise<MountedStore<M, R>> {
      const { connection, ready } = mountConnectionRoot(connectionState, {
        module: options.module,
        id: options.id,
        ...(options.params !== undefined ? { params: options.params } : {})
      })

      await ready

      const store = getRootProxy<M, R>(connection)
      let unmounted = false
      const unmount = (): Promise<void> => {
        if (unmounted) {
          return Promise.resolve()
        }

        unmounted = true
        return unmountConnectionRoot(connectionState, options.id)
      }

      return { store, unmount }
    },

    async disconnect(): Promise<void> {
      await disconnectConnectionState(connectionState)
    }
  }
}