The WebSocket tunnel, and who is looking at what

The tunnel is how the server reaches a browser without being asked: a reloaded block, a refreshed stylesheet, "this record just changed", "someone else is on this page". Everything below is server-initiated; the POST path is covered in Events and the POST.

Who gets a tunnel

BSPK_WS_CONFIG decides that per page and writes the result as attributes on <body> (data-ws, data-ws-indicator), which is where bspk.js reads it.

Two settings, two scopes, deliberately:

  • BSPK_WEB_DOMAIN.useWebSocket — does this site need real time at all? Per domain, not per page: a socket opened and closed on every navigation would be pointless.
  • BSPK_WEB_DOMAIN_MENU.showWebSocketState — does this page show the link's state? A display choice, so page by page. No tunnel, no indicator.

A few consequences that are easy to get wrong:

  • The editor always has its tunnel, whatever the domain says: it is the dev panel's own tool — CSS reload, undo/redo state, and "someone else is editing this page".
  • But it does not inherit record presence. The tunnel is a means (domain or edit right); record presence is a site feature (domain and page, no exception).
  • The tunnel is not reserved to logged-in users. A server-pushed render makes as much sense for an anonymous visitor; the old restriction was a client-side accident (bspk.js only connected when the registerClient cookie existed).

BSPK_WS_DOMAIN_OFF is the greying-out condition of the two real-time settings on /bweb/domain-menu. It is a method rather than an inline formula because 4D has no lazy evaluation: Entity#Null | Not(Bool(Entity.useWebSocket)) evaluates the second term anyway. The guard has to be a real instruction.

The service protocol

WSClientHandler.onMessage accepts four shapes:

MessageMeaningAnswer
{vt_Action: "hello", vt_ClientId, ...}The tab introduces itself (formerly init, still accepted)setClientWsName
{vt_Action: "ping"}Application heartbeat{vt_Action: "pong"}
{vt_Action: "who"}"Who else is looking at what I am looking at?"a presence message
{vt_Url: ...}An HTTP request simulated over the socketthe rendered response

That last one is worth knowing: a WS message carrying vt_Url goes straight into BSPK_WEB_ON_CONNECTION. The socket leads to the same router as HTTP requests.

The connection number identifies nothing

$ws.id changes at every reconnection. Anything addressed by that number goes silent the moment the link comes back — the pipe is there, but nobody knows how to address it, and nothing reports the failure.

So the tab names itself, once, and the registry translates that stable identity to the connection of the moment (BSPKWebSocketServer.sendTo). The identity lives in sessionStorage (bspkWsClientId): two tabs on the same site are two clients with two sockets. In strict private browsing the access can throw, and it falls back to an in-memory id lost on reload — the earlier behaviour, not a regression.

sendTo still accepts a raw connection number for existing callers, and "broadcast" for everyone.

The heartbeat, and why there is one

The browser's WebSocket API does not expose protocol ping frames. A cleanly cut link raises close; a frozen link (proxy, tunnel, sleep) raises nothing at all — the tab sits at readyState OPEN on a dead pipe. Only an application round trip reveals it.

ConstantValueWhy
PING_EVERY25 sunder the usual 60 s proxy inactivity timeout
PONG_TIMEOUT10 s
MAX_MISSED_PONG2two unanswered beats and the client closes the socket itself — closing is what triggers reconnection
BACKOFF1, 2, 4, 8, 15 scapped at 15 and not 30: a 4D restart takes about 30 s, and a 30 s step could add another 30 s of dead link after the server is back (about 50 s measured before this)

The delay carries jitter (0.7 + random * 0.6): without it, every tab reopened after a 4D restart knocks at the same millisecond.

hello is sent on the open event, never on a timer — a setTimeout(1000) sometimes fired on a socket that was not open yet.

The registry

Storage.vo_SharedStorage.vc_RegisterClient, one entry per live connection.

An entry must stay flat. No nested collection: OB Copy(entry; ck shared) on an object containing a collection already constitutes its shared group, and assigning it afterwards into vo_SharedStorage raises "already belongs to another shared group". That is why scopePage, scopeRecord and scopeTables are strings, not collections. The whole registry is rewritten from an unshared copy (_writeRegistry) rather than modified in place — the same idiom as WebFormController.redo.

Only onTerminate removes an entry. onError deliberately does nothing: an error does not mean the connection is closed (4D calls onTerminate for that), and removing the entry there would make a live client unaddressable. Without that discipline the registry becomes an arrivals log that never forgets anyone.

An entry holds three distinct notions:

FieldReads as
scopePagepage:<key> — where I am
scopeRecordrec:<table>:<pk> — which record I have open
scopeTablesthe tables displayed by this page's listboxes, read from the DOM (data-listboxtable) — "my screen could go stale if T changes"

Scopes are computed at render (BSPK_PRESENCE_SCOPE) and handed back verbatim by the browser: the client only ever sees an address, and two addresses can designate the same record. Guard every conversion with #Null first — String(Null) is the text "null", and a client without a scope would land in a scope literally named null (see The 4D language).

canEdit and wantsPresence are likewise computed by the served page and sent by the client: the WebSocket process has neither the page nor the session at hand.

Listboxes arrive after the socket. Measured: at hello time none is in the DOM yet, so the tab announced itself with no table and never received anything. The client now watches the DOM and re-announces when the signature of displayed tables changes — which also covers lazy-loaded listboxes, blocks replaced by a reload, and tables added by the dev panel.

What the server pushes

BSPKWebSocketServer offers broadCast (the whole serialised WebFormController to every connection), sendTo (one identity) and sendToScope (every connection of a scope, except one identity — typically the author of the change).

GG_DATACLASS is what fires the data messages, from the ORDA events: rowChanged / rowDropped (a listbox row), recordChanged (the record behind a scope), structureChanged. See Writing 4D functions.

refreshCss reloads output.css into the Storage and tells every client. It carries a 500 ms debounce and a vb_disableRefreshCss flag for bulk imports — without them a mass operation floods every open tab.

Client side, the router in bspk.js intercepts pong, recordChanged, rowChanged / rowDropped, structureChanged and presence by string match before parsing, then falls through to the generic action dispatcher. A pong never reaches the dispatcher: it concerns the tunnel, not the page.

Pitfalls

Errors in the WS process are invisible. It is not a web request: no page, no session, and a failure leaves no toast. _traceError writes the last one to Storage.vo_SharedStorage.vt_WsLastError — read it there, it is often the only trace.

A 4D restart loses every tab's socket. Reconnection is automatic (see the backoff above), but anything the server was holding for that connection number is gone. This is why the stable identity exists.

broadCast serialises the entire WebFormController to every client, including tabs on other pages. Prefer sendToScope.

See also