Skip to content

The shared socket ​

Every GomaPulse widget that reads live data needs a WAMP connection. They do not open one each. The first widget to ask opens a connection for its (socketUrl, socketRealm) pair, every later widget joins the same one, and it closes a few seconds after the last widget lets go. A page with a sports feed, a betslip, a game-details panel and three promo cards holds one socket, not six.

Your page can join it too.

Why join rather than open your own ​

Sportsbook providers cap concurrent connections per player. Every socket your host page opens for its own data comes out of the same budget the widgets are sharing, and it is the budget, not the bandwidth, that runs out first. A host that opens its own connection for one RPC can be the reason a widget's connection is refused.

acquireSharedConnection() makes your page an ordinary holder of the connection the widgets are already using. Your calls ride the socket that is open anyway.

Getting a handle ​

js
import { acquireSharedConnection } from '@gomagaming/network'

const socket = await acquireSharedConnection({
  socketUrl: 'wss://example.provider.com/ws',
  socketRealm: 'realm1',
})

Use the same socketUrl and socketRealm your widgets are configured with. That pair is the key the connection is pooled under. A different pair opens a different socket, which is exactly what you are trying to avoid.

OptionRequiredDefaultWhat it is
socketUrlyesnoneThe WAMP endpoint. Must match your widgets'.
socketRealmyesnoneThe WAMP realm. Must match your widgets'.
providerno'everymatrix'Which transport to use. A transport you registered yourself resolves here too.
networknobuilt-in defaultsThe same config.network policy object widgets take. The handle uses two parts of it: connectTimeoutMs limits every connect, and retry (maxAttempts, baseMs, capMs) sets how the handle reconnects on its own after a drop. rpcTimeoutMs is not used, see below.

socketUrl and socketRealm are checked as soon as you call acquireSharedConnection(), before anything asynchronous happens: leave one out and the call throws right there, naming the missing key, instead of rejecting a tick later somewhere harder to trace.

The handle ​

MemberShapeNotes
call(procedure, args, kwargs, options)Promise<result>The raw session call, unchanged.
wcall(url, params, options)Promise<result>Convenience: params ride as kwargs, positional args are always [].
subscribe(topic, handler, options)Promise<{ unsubscribe() }>Your handler receives (args, kwargs, details) for each event, and nothing more. Resolves to an object with only unsubscribe(). Neither one is the raw WAMP subscription object, so you cannot reach the socket's own close() through them. unsubscribe() is safe to call after a drop or a second time; it always resolves.
register(options)Promise<() => void>Convenience: initial dump plus live updates. Needs onInitialDump or onUpdate (or both), each a function, see below. Resolves to the unregister function.
onStateChange(cb)() => voidReturns an unsubscribe function. Fires 'connected' / 'dropped'.
state'connected' | 'dropped' | 'released'Getter.
release()voidIdempotent. Ends your hold.
close()booleanIdempotent. true only if the socket actually closed.

call() and wcall() have no timeout of their own. A socket can report that it is open while it is in fact dead, and a call on it can then stay pending forever. Wrap them in your own timeout, for example with Promise.race() against a timer that you clear when the call settles.

You get a handle, not the connection object itself. The connection carries a close() that would let your page kill a socket widgets are using, and its session is replaced on every reconnect, so a reference you held would be a dead one after the first drop. The handle has neither problem.

The handle protects you from mistakes. It is not a security boundary. The connection pool is stored on the page under a global key, Symbol.for('goma.network.connectionRegistry.v1'), so that every copy of the package on the page can share it. Any script on the page can read that key and reach the pooled connections directly, including their close() and the session of the signed-in player. Treat every script you load on the page as able to do anything a widget can do.

register() needs a listener ​

Call register() with at least one of onInitialDump or onUpdate, given as a function. Passing only afterInitialDump is not enough, and any of these options that you do supply must actually be a function, or the call is refused.

Here is why: if you register with no onInitialDump or onUpdate listener, there is nothing for your later unregister() call to remove. Instead of quietly doing nothing, it falls back to tearing down the whole server-side registration for that topic, the one every widget subscribed to it is also using. Requiring a real listener up front keeps unregister() safe to call: it only ever removes your own listeners, never the shared registration.

onUnregister is optional. It runs when your unregister() removes the last listener on that URL, just before the registration is removed on the server. If a widget still listens on the same URL, it does not run. It does not count as the listener that register() requires.

A one-shot call ​

js
const socket = await acquireSharedConnection({ socketUrl, socketRealm })

try {
  const response = await socket.wcall('/sports#initialDump', { topic })
  render(response.kwargs.records)
} finally {
  socket.release()
}

A live subscription ​

js
const socket = await acquireSharedConnection({ socketUrl, socketRealm })

const unregister = await socket.register({
  url: topic,
  initialDumpUrl: '/sports#initialDump',
  onInitialDump: (dump) => seed(dump),
  onUpdate: (change) => apply(change),
})

// Later, when the page is done with it:
unregister()
socket.release()

register() deduplicates per connection by url. If you register a topic a widget already holds, the two share one server-side registration and each gets its own callbacks. Your unregister() removes only yours. This is the same sharing widgets already get between themselves.

Who keeps the socket alive ​

Your handle is one holder among however many widgets are mounted. The socket closes once every holder has let go, and not before.

SituationWhat happens
You acquire, no widget mountedThe socket opens and stays open.
The last widget unmounts, you still holdThe socket stays open.
You release, widgets still holdThe socket stays open.
You call close(), widgets still holdThe socket stays open; close() returns false.
You call close(), nobody else holdsThe socket closes at once; close() returns true.
You call release(), nobody else holdsThe socket closes a few seconds later.
You re-acquire inside that windowThe same socket is reused.

The asymmetry between release() and close() is worth knowing. release() returns nothing because it cannot yet know the answer: its close, if any, happens after a short grace window, and another holder may arrive inside it. close() answers immediately because it either closed the socket or found someone else holding it.

Neither one fires onStateChange. Those callbacks report the socket; ending your own hold is not news about the socket. After either call, state reads 'released', the handle is spent. Calls to call(), wcall(), subscribe() and register() reject; release() and close() are safe to call again (they do nothing); onStateChange() returns an unsubscribe function. Acquire a new handle with acquireSharedConnection() again to get one that works.

release() and close() only end your hold on the socket. They do not unsubscribe or unregister anything you subscribed or registered through this handle. Call unsubscribe(), or the unregister function register() gave you, yourself first. Skip that and your callbacks keep firing for as long as any widget on the page keeps the socket open.

Reconnects: you must re-subscribe ​

When the socket drops, every subscription and registration on it, yours and the widgets', is gone. They were server-side registrations on a connection that no longer exists.

The handle recovers the connection for you:

  1. state becomes 'dropped' and your onStateChange callback gets 'dropped'.
  2. The handle then reconnects by itself. It uses the retry part of the network option, the same setting widgets take in config.network: it waits, tries, and waits longer before each new try, up to retry.maxAttempts tries. You do not have to call anything for this to happen.
  3. When a try succeeds, state becomes 'connected' and your callback gets 'connected', once.
  4. If you call call / wcall / subscribe / register while the handle is still dropped, that call reconnects at once and does not wait for the next try. You never touch a dead session, and you still get only one 'connected'.

If every try fails, the handle stops trying: state stays 'dropped' and no 'connected' comes. This limit is on purpose, because providers cap connections per player. Your next call on the handle tries again.

What the handle does not do is re-establish your subscriptions. That is yours to do, in your 'connected' callback. A page that skips it gets a live socket and silently no data.

js
const socket = await acquireSharedConnection({ socketUrl, socketRealm })

let unregister = await subscribeToOurTopic()

// The handle reconnects by itself after a drop. 'connected' fires when the
// socket is back, even if this page makes no call in the meantime.
socket.onStateChange(async (state) => {
  if (state === 'connected') {
    // A fresh socket carries none of our old registrations.
    unregister = await subscribeToOurTopic()
  }
})

async function subscribeToOurTopic() {
  return socket.register({
    url: topic,
    initialDumpUrl: '/sports#initialDump',
    onInitialDump: (dump) => seed(dump),
    onUpdate: (change) => apply(change),
  })
}

Widgets use the same split. Their connection ladder reconnects on the same network.retry curve, and each widget sets up its own subscriptions again once the socket is back. So the page has one reconnect model, not two: the connection comes back by itself, and each owner restores its own subscriptions.

Both delivery lanes ​

LaneHow to reach it
npm / bundlerpnpm add @gomagaming/network, then import { acquireSharedConnection } from '@gomagaming/network'. Your bundler dedupes it against the widgets' copy.
CDN loaderconst { acquireSharedConnection } = await import('@gomagaming/network'). The loader's import map already resolves the bare specifier.
MixedWorks only when both copies come from a release that includes acquireSharedConnection(). Those releases keep the connection pool under a page-level key, so a copy you bundle and a copy served from the CDN share one socket per (socketUrl, socketRealm). An older CDN copy keeps its own pool and opens a second socket.

Always use acquireSharedConnection(). The page-level key that holds the pool is internal: it gives you the raw connections, with none of the protection the handle adds. @gomagaming/sports-domain does not re-export this function. The entry point lives with the transport layer.

What this is not ​

This hands you the connection, not the sports API. Topics, market shapes, the normalized store model and the betting calls stay inside the widgets. If you are calling a provider RPC from your page, you are writing the topic string and reading the raw records yourself. That is the point: it is for data GomaPulse does not surface.