Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt

Use this file to discover all available pages before exploring further.

The transport layer is a unified interface (SyncTransport) that abstracts the communication mechanism between the POS frontend and the sync backend. Two implementations exist:
  • directTransport — HTTP polling + localStorage outbox (existing behavior, no hub needed)
  • hubTransport — WebSocket connection to the hub server
The active transport is chosen at runtime by a factory function based on bootstrap config.

SyncTransport interface

Defined in nu_pos_react/src/realtime/transport.ts:
interface SyncTransport {
  readonly mode: 'direct' | 'hub';

  connect(): Promise<void>;
  disconnect(): void;

  // Leases
  acquireLease(posReference: string, version: number): Promise<LeaseResult>;
  releaseLease(posReference: string, finalSnapshot?: OrderSnapshotData): void;
  isLeaseHeld(posReference: string): boolean;
  onLeaseRevoked(handler: (posRef: string, reason: string) => void): () => void;

  // Orders
  sendOrderSnapshot(posReference: string, version: number, snapshot: OrderSnapshotData): void;
  onOrderUpdated(handler: (update: OrderUpdate) => void): () => void;
  onConflict(handler: (conflict: ConflictInfo) => void): () => void;
  onSyncInit(handler: (orders: SyncInitOrder[]) => void): () => void;

  // Tables
  sendTableUpdate(tableId: number, status: string, posReference?: string): void;
  onTableUpdated(handler: (update: TableUpdate) => void): () => void;

  // Floor Plan
  sendFloorPlanUpdate(floorId: number, tables: FloorPlanTableInfo[], layoutMode?: string): void;
  onFloorPlanChanged(handler: (change: FloorPlanChange) => void): () => void;

  // Table Locks
  acquireTableLock(tableId: number): Promise<TableLockResult>;
  releaseTableLock(tableId: number): void;
  forceAcquireTableLock(tableId: number): Promise<TableLockResult>;
  onTableLockRevoked(handler: (tableId: number, reason: string) => void): () => void;
  onTableLockReleased(handler: (tableId: number) => void): () => void;
  onTableLocksState(handler: (locks: TableLockInfo[]) => void): () => void;

  // Order Finalization
  finalizeOrder(posReference: string, snapshot: OrderSnapshotData, configId: number): Promise<FinalizeOrderResult>;

  // Ticket Numbers
  requestTicketNumber(orderType: "takeout" | "delivery"): Promise<string>;

  // Outbox
  queueForRetry(action: string, payload: Record<string, unknown>): void;
  flushOutbox(): Promise<{ sent: number; failed: number }>;
  getOutboxDepth(): number;

  // State
  onStateChange(handler: (state: TransportState) => void): () => void;
  getState(): TransportState;

  // Reconnection events
  onReconnecting(handler: () => void): () => void;
  onReconnected(handler: () => void): () => void;
}
All on* methods return an unsubscribe function.

Transport states

type TransportState = "disconnected" | "connecting" | "connected" | "reconnecting" | "hubOffline";
StateDirect modeHub mode
disconnectedInitial / after disconnectWS closed, not reconnecting
connectingDuring poll subscription setupWS opening or authenticating
connectedPolling activeWS open and authenticated
reconnectingN/AWS dropped, attempting reconnect
hubOfflineN/AHub unreachable after max retries

Factory: createTransport()

nu_pos_react/src/realtime/createTransport.ts:
function createTransport(options: CreateTransportOptions): SyncTransport {
  const hubConfig = getHubConfig(options.bootstrapConfig);
  const hubUrl = resolveHubUrl(hubConfig);

  if (hubConfig.enabled && hubUrl) {
    return createHubTransport({
      hubUrl,
      getToken: () => getStandaloneAccessToken() ?? options.token,
      deviceId: options.deviceId,
      onTokenExpired: () => refreshAccessToken(),
    });
  }

  return createDirectTransport({ sinceId, pollIntervalMs, ... });
}
The factory reads three fields from the bootstrap config (set in Odoo):
  • nu_hub_enabled (boolean) — Master switch
  • nu_hub_url (string) — WebSocket URL (e.g., ws://10.0.0.5:8766)
  • nu_hub_fallback_ip (string) — IP-only fallback, auto-prefixed with ws:// and port 8766

Direct transport

nu_pos_react/src/realtime/directTransport.ts Wraps the existing sync modules with no behavior change:
FeatureImplementation
ConnectionHTTP polling via subscribe() at configurable interval (default 2s)
LeasesLocal tab-level locks via orderLocks.ts (45s TTL, same-tab only)
Table locksDisabled — acquireTableLock() always returns { granted: true }
Order syncHTTP outbox: queueOutbound() + flushOutbound() via POST to Odoo
Table syncNo-op (table updates flow through order sync events)
Conflict detectionNot supported (no versioning in direct mode)
ReconnectPolling is fire-and-forget; connected state set on subscribe
Reconnection eventsNo-op — onReconnecting() and onReconnected() return empty unsubscribe

Hub transport

nu_pos_react/src/realtime/hubTransport.ts
FeatureImplementation
ConnectionWebSocket via hubConnection.ts, auto-reconnect with exponential backoff
AuthSends AUTH { token, deviceId } on connect via dynamic getToken(), waits for AUTH_OK. On AUTH_FAIL, attempts token refresh via onTokenExpired before giving up
LeasesSends LEASE_ACQUIRE/RELEASE/HEARTBEAT messages through WS
Order syncSends ORDER_SNAPSHOT through WS; falls back to outbox when disconnected
Table syncSends TABLE_UPDATE through WS; listens for TABLE_UPDATED
Table locksSends TABLE_LOCK_ACQUIRE/RELEASE/HEARTBEAT/FORCE_ACQUIRE through WS
Conflict detectionListens for CONFLICT messages, notifies via onConflict()
ReconnectExponential backoff (1s to 30s) with 0-2s random jitter. Visibility change detection force-closes stale sockets on tab focus (sleep/wake recovery)

Lease tracking

  • heldLeases: Set<string> — Order references currently leased by this terminal
  • pendingLeases: Map<string, { resolve, timer }> — In-flight acquire requests
  • Heartbeat timer: sends LEASE_HEARTBEAT for all held leases every 20s

Table lock tracking

  • heldTableLocks: Set<number> — Table IDs currently locked by this terminal
  • pendingTableLocks: Map<number, { resolve, timer }> — In-flight lock requests
  • Separate heartbeat timer: sends TABLE_LOCK_HEARTBEAT for all held locks every 15s

Offline fallback

When connection.send() returns false (WS not open), sendOrderSnapshot() queues the snapshot to the localStorage outbox:
sendOrderSnapshot(posRef, version, snapshot) {
  const sent = connection.send({ type: "ORDER_SNAPSHOT", ... });
  if (!sent) {
    const action = snapshot.odooId ? "orders/update" : "orders/create";
    queueOutbound(action, { pos_reference: posRef, _hubSnapshot: snapshot });
  }
}

Reconnection events

The hub transport fires onReconnecting and onReconnected callbacks after the initial connection has succeeded at least once (hasConnectedBefore flag). These are wired through to the SyncUIProvider to show a full-screen reconnection overlay. The direct transport provides no-op stubs since it has no WebSocket lifecycle.

Token refresh on AUTH_FAIL

HubTransportConfig uses a dynamic token getter instead of a static token:
interface HubTransportConfig {
  hubUrl: string;
  getToken: () => string;       // Called on each connect attempt
  deviceId: string;
  onTokenExpired?: () => Promise<string | null>;  // Refresh before giving up
}
When the hub responds with AUTH_FAIL, the connection layer calls onTokenExpired() to attempt a JWT refresh. If the refresh succeeds, it reconnects with the new token (via getToken()). If the refresh token is also expired, it fires onAuthFailed for a permanent logout redirect. A tokenRefreshAttempted flag prevents infinite refresh loops within a single connection cycle.

Visibility change detection

hubConnection.ts listens to document.visibilitychange events. When a tab regains focus after laptop sleep, the browser often leaves the WebSocket in a stale state (TCP connection dead but readyState not updated). The visibility handler force-closes dead sockets to trigger an immediate reconnect instead of waiting 30-90s for the TCP timeout.

Connection state mapping

HubConnectionStateTransportState
disconnecteddisconnected
connectingconnecting
authenticatingconnecting
connectedconnected
reconnectingreconnecting

Hub discovery

nu_pos_react/src/realtime/hub/hubDiscovery.ts Resolution priority:
  1. nu_hub_url from bootstrap — used as-is if starts with ws:// or wss://, otherwise prefixed
  2. nu_hub_fallback_ip from bootstrap — prefixed with ws:// and :8766
  3. null — hub mode disabled, factory returns direct transport

File map

nu_pos_react/src/realtime/
├── transport.ts                    # SyncTransport interface + shared types (OrderSnapshotData is the single source of truth)
├── createTransport.ts              # Factory: direct vs hub
├── directTransport.ts              # Direct-to-Odoo implementation
├── hubTransport.ts                 # Hub WebSocket implementation
├── TableLockGuard.tsx              # Renderless component for lock lifecycle
├── HubOrderBridge.tsx              # Renderless bridge: usePOS ↔ hub transport (outbound snapshots, inbound updates)
├── FloorPlanSyncBridge.tsx         # Renderless bridge: floor plan usePOS ↔ hub (order occupation sync)
├── FloorPlanStructureSyncBridge.tsx # Renderless bridge: floor plan structure changes ↔ hub
├── hub/
│   ├── hubConnection.ts            # WS lifecycle, reconnect, heartbeat
│   └── hubDiscovery.ts             # Resolve hub URL from config
├── sync/
│   ├── outbox.ts                   # localStorage outbox queue
│   ├── reconcile.ts                # Conflict reconciliation
│   ├── applyRemote.ts              # Remote event dispatcher
│   └── clock.ts                    # Logical clock (Date.now)
├── locks/
│   └── orderLocks.ts               # Local tab-level locks
└── bus/
    ├── subscribe.ts                # HTTP polling subscriber
    ├── messageTypes.ts             # Sync event types
    └── channels.ts