All files / tao-socket-io/src index.js

100% Statements 52/52
100% Branches 38/38
100% Functions 16/16
100% Lines 52/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364                                                                                                                                                                                                                                                                                    14x 14x 14x 14x 14x   14x                                           3x   2x   3x 3x   3x                     8x                         9x 7x                                   1x 1x 1x                                       10x         10x 10x 1x         9x             10x       4x           10x 2x                                   14x 10x 10x   10x 10x 10x   3x 3x   10x 2x 2x 2x     10x 5x                                                                             15x 15x 5x 3x 3x 3x 3x     10x 10x 1x     1x   9x        
/**
 * @tao.js/socket.io — wire a TAO signal network to socket.io on either side
 * of the connection.
 *
 * One factory, {@link wireTaoJsToSocketIO}, adapts to its environment
 * (detected by `typeof window` at module load):
 *
 * - in the **browser** it connects a socket.io client and bridges it to the
 *   kernel as a duplex transport,
 * - on the **server** it attaches per-connection middleware that scopes each
 *   client to its own `Channel` with a per-client reply path.
 *
 * socket.io itself is **not** a dependency: the `io` and socket parameters
 * are typed structurally ({@link SocketIoClientFactory},
 * {@link SocketIoServerLike}, {@link SocketLike}) with exactly the members
 * this package uses, so any conformant implementation works.
 *
 * Both directions speak the 0.20 wire contract (ENVELOPE-SPEC.md §9): a
 * signal crosses the socket as `{ tao, data, envelope: { v, chain } }` —
 * only the envelope's `chain` scope is portable — and the receiving side
 * re-enters it stamping its own hop-scope `source` marker for echo
 * suppression, continuing the received chain through its local reducers.
 *
 * @module @tao.js/socket.io
 */
 
/**
 * @typedef {import('@tao.js/core').Trigram} Trigram
 * @typedef {import('@tao.js/utils').NetworkSurface} NetworkSurface
 * @typedef {import('@tao.js/utils').WireEnvelope} WireEnvelope
 */
 
/**
 * The payload framing a TAO signal on the socket (ENVELOPE-SPEC.md §9),
 * emitted as the `'fromClient'` / `'fromServer'` events.
 *
 * @typedef {Object} SocketPayload
 * @property {Trigram} tao - the signal's trigram (short or long keys;
 *           long-form keys win on receipt)
 * @property {*} data - the signal's datagram(s)
 * @property {WireEnvelope} [envelope] - the portable wire envelope
 *           `{ v, chain }`; absent from pre-0.20 senders and then treated as
 *           a fresh (`null`) chain — one-sided backward compatibility
 */
 
/**
 * Structural shape of the socket.io Socket this package actually uses —
 * client and server sockets both conform.
 *
 * @typedef {Object} SocketLike
 * @property {string} [id] - socket identifier (server side): names the
 *           per-client Channel and the `socket:<id>` origin marker
 * @property {{ auth: * }} [handshake] - connection handshake (server side);
 *           `handshake.auth` is handed to {@link AuthTransform}
 * @property {function(string, SocketPayload): *} emit - emit a TAO event
 *           (`'fromServer'` from the server, `'fromClient'` from the client)
 * @property {function(string, Function): *} on - subscribe to socket events
 *           (`'fromServer'` / `'fromClient'` / `'disconnect'`)
 */
 
/**
 * A socket.io client factory (the `io` export of `socket.io-client`): called
 * with `` `${host}/${namespace}` `` and the `opts.io` connection options.
 *
 * @callback SocketIoClientFactory
 * @param {string} url - `` `${host}/${namespace}` ``
 * @param {Object} [opts] - connection options, passed through verbatim from
 *        `opts.io`
 * @returns {SocketLike} the connected namespace socket
 */
 
/**
 * Structural shape of a socket.io Server: only `of(nsp)` is used, to obtain
 * the namespace the connection middleware is attached to.
 *
 * @typedef {Object} SocketIoServerLike
 * @property {function(string): { use: function(SocketIoMiddleware): * }} of -
 *           resolve the `/{namespace}` namespace
 */
 
/**
 * Per-connection middleware: attached via `namespace.use(...)` when a server
 * is given, or returned from {@link wireTaoJsToSocketIO} for manual
 * attachment.
 *
 * @callback SocketIoMiddleware
 * @param {SocketLike} socket - the connecting client socket
 * @param {function(): *} [next] - socket.io's middleware continuation;
 *        called (and its value returned) when provided
 * @returns {*}
 */
 
/**
 * Server-side connection hook: receives the per-client Channel and the raw
 * socket. An optional returned function is invoked with the disconnect
 * reason when the client disconnects.
 *
 * @callback OnConnect
 * @param {Channel} clientTAO - the Channel scoping this client's signals
 * @param {SocketLike} socket - the connected socket
 * @returns {(function(string=): void|*)} optional disconnect cleanup
 */
 
/**
 * Server-side hook transforming inbound data before it enters the network —
 * e.g. stamping the socket's handshake auth onto the datagram.
 *
 * @callback AuthTransform
 * @param {Trigram} tao - the inbound signal's trigram
 * @param {*} data - the inbound datagram(s)
 * @param {*} auth - `socket.handshake.auth`
 * @returns {(Promise<*>|*)} the data to enter (awaited)
 */
 
/**
 * Options for {@link wireTaoJsToSocketIO}.
 *
 * @typedef {Object} WireOptions
 * @property {string} [namespace='tao'] - socket.io namespace to connect
 *           to / attach on
 * @property {string} [ns] - alias for `namespace` (`namespace` wins)
 * @property {string} [host=''] - client only: host prefix for the connection
 *           url `` `${host}/${namespace}` ``
 * @property {Object} [io] - client only: connection options passed verbatim
 *           to the client factory
 * @property {OnConnect} [onConnect] - server only: per-connection hook
 * @property {AuthTransform} [authTransform] - server only: inbound data
 *           transform
 */
 
import { AppCtx } from '@tao.js/core';
import {
  Channel,
  createTransport,
  chainFromWire,
  wireEnvelope,
} from '@tao.js/utils';
 
const DEFAULT_NAMESPACE = 'tao';
const IS_SERVER = typeof window === 'undefined';
const EMIT_EVENT = IS_SERVER ? 0 : 1;
const ON_EVENT = IS_SERVER ? 1 : 0;
const EVENTS = ['fromServer', 'fromClient'];
 
const NOOP = () => {};
 
/**
 * Client side: bridge the kernel and the socket as a duplex transport
 * (`createTransport` from `@tao.js/utils`).
 *
 * Outbound, every hop on the network is emitted — phase-blind — as
 * `'fromClient'` with its wire envelope (the chain crosses the boundary —
 * ENVELOPE-SPEC.md §9), except hops that arrived from this socket (echo
 * suppression with the bidirectional reflex). Inbound `'fromServer'`
 * payloads go through `transport.receive`, entering the network with the
 * transport's own hop-scope `source` marker and the received chain.
 *
 * @param {NetworkSurface} TAO - the kernel (or bare Network) to bridge
 * @param {SocketLike} socket - the connected client socket
 * @returns {{ name: string, receive: Function, dispose: Function }} the
 *          transport handle
 */
function decorateNetwork(TAO, socket) {
  // duplex transport: every hop is emitted with its wire envelope (chain
  // crosses the boundary — ENVELOPE-SPEC.md §9); arriving signals enter
  // with the transport's hop marker + continued chain
  const transport = createTransport(TAO, {
    send: (tao, data, envelope) =>
      socket.emit(EVENTS[EMIT_EVENT], { tao, data, envelope }),
  });
  socket.on(EVENTS[ON_EVENT], ({ tao, data, envelope }) =>
    transport.receive(tao, data, envelope),
  );
  return transport;
}
 
/**
 * Build an AppCtx from a wire trigram + datagram(s); long-form keys win.
 *
 * @param {Trigram} trigram - short or long keys
 * @param {*} data - datagram(s)
 * @returns {AppCtx}
 */
function makeAppCtx({ t, term, a, action, o, orient }, data) {
  return new AppCtx(term || t, action || a, orient || o, data);
}
 
/**
 * Server side: inbound `'fromClient'` handler without an auth transform —
 * enters the payload on the per-client Channel with the socket's origin
 * marker and the continued wire chain (§9).
 *
 * @param {Channel} TAO - the per-client Channel
 * @param {string} sourceName - this socket's origin marker (`socket:<id>`)
 * @returns {function(SocketPayload): void}
 */
function onEventPlain(TAO, sourceName) {
  return ({ tao, data, envelope }) =>
    TAO.enter(makeAppCtx(tao, data), {
      hop: { source: sourceName },
      chain: chainFromWire(envelope),
    });
}
 
/**
 * Server side: inbound `'fromClient'` handler that awaits
 * `authTransform(tao, data, handshake.auth)` and enters the transformed
 * data — same origin marker + chain continuation as {@link onEventPlain}.
 *
 * @param {Channel} TAO - the per-client Channel
 * @param {*} auth - `socket.handshake.auth`
 * @param {AuthTransform} authTransform
 * @param {string} sourceName - this socket's origin marker (`socket:<id>`)
 * @returns {function(SocketPayload): Promise<void>}
 */
function onEventAuth(TAO, auth, authTransform, sourceName) {
  return async ({ tao, data, envelope }) => {
    const useData = await authTransform(tao, data, auth);
    TAO.enter(makeAppCtx(tao, useData), {
      hop: { source: sourceName },
      chain: chainFromWire(envelope),
    });
  };
}
 
/**
 * Server side: wire one client socket to its Channel — the inbound entry
 * path (`'fromClient'` → `Channel.enter` with `hop.source` = `socket:<id>`
 * per ENVELOPE-SPEC.md §9) and the per-client reply path (an `onProceed`
 * decoration on the Channel's private network emitting `'fromServer'`).
 * The decoration is disposed on disconnect.
 *
 * @param {Channel} TAO - the per-client Channel
 * @param {SocketLike} socket - the connected socket
 * @param {AuthTransform} [authTransform] - optional inbound data transform
 * @returns {void}
 */
function decorateSocket(TAO, socket, authTransform) {
  const { auth } = socket.handshake;
  // §9: the receiving side stamps its own hop-scope origin marker so any
  // phase-blind transport decoration on the same kernel suppresses the
  // arriving hop (the channel-scoped reply path below is structurally
  // immune either way — entries are never mirrored)
  const sourceName = `socket:${socket.id}`;
  if (typeof authTransform === 'function') {
    socket.on(
      EVENTS[ON_EVENT],
      onEventAuth(TAO, auth, authTransform, sourceName),
    );
  } else {
    socket.on(EVENTS[ON_EVENT], onEventPlain(TAO, sourceName));
  }
 
  // per-client reply path: a veto-respecting emitter on the client Channel's
  // private network — intercept-halted/-diverted signals stay suppressed
  // (ENVELOPE-SPEC.md §10 invariant 5) and the emitted signal carries its
  // chain across the boundary
  const undecorate = TAO.decorate({
    // Stryker disable next-line StringLiteral: decoration name is a diagnostic label with no observable behavior
    name: `socket:${socket.id}`,
    onProceed: (ac, envelope) =>
      socket.emit(EVENTS[EMIT_EVENT], {
        tao: ac.unwrapCtx(),
        data: ac.data,
        envelope: wireEnvelope(envelope),
      }),
  });
  socket.on('disconnect', () => {
    undecorate();
  });
}
 
/**
 * Server side: build the per-connection middleware. Each connecting socket
 * gets its own `Channel` on the kernel (id = `socket.id`), wired by
 * {@link decorateSocket}; `onConnect` runs with the Channel + socket, and
 * its returned cleanup (when a function) is invoked with the disconnect
 * reason.
 *
 * @param {NetworkSurface} TAO - the kernel the per-client Channels wrap
 * @param {{ onConnect?: OnConnect, authTransform?: AuthTransform }} [opts]
 * @returns {SocketIoMiddleware}
 */
// change, options object now instead of just onConnect
// (both internal call sites always pass the options object)
const ioMiddleware =
  (TAO, { onConnect, authTransform }) =>
  (socket, next) => {
    let clientTAO = new Channel(TAO, socket.id);
    /** @type {function(string=): void} */
    let onDisconnect = NOOP;
    decorateSocket(clientTAO, socket, authTransform);
    if (onConnect && typeof onConnect === 'function') {
      // change: pass the whole socket to onConnect
      onDisconnect = onConnect(clientTAO, socket);
      onDisconnect = typeof onDisconnect === 'function' ? onDisconnect : NOOP;
    }
    socket.on('disconnect', (reason) => {
      onDisconnect(reason);
      clientTAO = null;
      onDisconnect = null;
    });
 
    if (next && typeof next === 'function') {
      return next();
    }
  };
 
/**
 * Wire a TAO signal network to socket.io — the 0.20 wire contract
 * (ENVELOPE-SPEC.md §9) on both sides of the connection. Environment is
 * detected by `typeof window` at module load:
 *
 * **Client** (`window` defined) — `io` must be a socket.io client factory;
 * it is called with `` `${host}/${namespace}` `` and `opts.io`, and the
 * resulting socket is bridged to `TAO` as a duplex transport
 * (`createTransport`): every hop is emitted as `'fromClient'` with
 * `{ tao, data, envelope: { v, chain } }`, and every inbound `'fromServer'`
 * payload re-enters through `transport.receive` — stamping the transport's
 * own hop-scope `source` marker (echo suppression) and continuing the
 * received chain. Returns the connected socket; returns `undefined` when
 * `io` is not a function.
 *
 * **Server** (no `window`) — each connecting socket gets its own `Channel`
 * on `TAO` (id = `socket.id`). Inbound `'fromClient'` payloads enter the
 * Channel with `hop: { source: 'socket:<id>' }` and the wire envelope's
 * continued chain (absent/unknown-version envelopes enter with a fresh
 * chain); replies are emitted per client as `'fromServer'` by an
 * `onProceed` decoration on the Channel — veto-respecting, so
 * intercept-halted/-diverted signals are never emitted — carrying the
 * dispatch envelope's chain as `{ v, chain }`. When `io` exposes `of()`,
 * the middleware is attached to `io.of('/{namespace}')` and `undefined` is
 * returned; otherwise the middleware function is returned for manual
 * attachment.
 *
 * @param {NetworkSurface} TAO - the Kernel (or bare Network) to bridge
 * @param {(SocketIoClientFactory|SocketIoServerLike|null)} [io] - client
 *        factory (browser), server (attach middleware), or `null`/non-server
 *        to get the middleware back (server)
 * @param {WireOptions} [opts]
 * @returns {(SocketLike|SocketIoMiddleware|undefined)}
 */
export default function wireTaoJsToSocketIO(TAO, io, opts = {}) {
  const ns = opts.namespace || opts.ns || DEFAULT_NAMESPACE;
  if (!IS_SERVER) {
    if (io && typeof io === 'function') {
      const host = opts.host || '';
      const socket = io(`${host}/${ns}`, opts.io);
      decorateNetwork(TAO, socket);
      return socket;
    }
  } else {
    const { onConnect, authTransform } = opts;
    if (io && typeof /** @type {SocketIoServerLike} */ (io).of === 'function') {
      const namespacedEngine = /** @type {SocketIoServerLike} */ (io).of(
        `/${ns}`,
      );
      namespacedEngine.use(ioMiddleware(TAO, { onConnect, authTransform }));
    } else {
      return ioMiddleware(TAO, { onConnect, authTransform });
    }
  }
}