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";
| State | Direct mode | Hub mode |
|---|
disconnected | Initial / after disconnect | WS closed, not reconnecting |
connecting | During poll subscription setup | WS opening or authenticating |
connected | Polling active | WS open and authenticated |
reconnecting | N/A | WS dropped, attempting reconnect |
hubOffline | N/A | Hub 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:
| Feature | Implementation |
|---|
| Connection | HTTP polling via subscribe() at configurable interval (default 2s) |
| Leases | Local tab-level locks via orderLocks.ts (45s TTL, same-tab only) |
| Table locks | Disabled — acquireTableLock() always returns { granted: true } |
| Order sync | HTTP outbox: queueOutbound() + flushOutbound() via POST to Odoo |
| Table sync | No-op (table updates flow through order sync events) |
| Conflict detection | Not supported (no versioning in direct mode) |
| Reconnect | Polling is fire-and-forget; connected state set on subscribe |
| Reconnection events | No-op — onReconnecting() and onReconnected() return empty unsubscribe |
Hub transport
nu_pos_react/src/realtime/hubTransport.ts
| Feature | Implementation |
|---|
| Connection | WebSocket via hubConnection.ts, auto-reconnect with exponential backoff |
| Auth | Sends AUTH { token, deviceId } on connect via dynamic getToken(), waits for AUTH_OK. On AUTH_FAIL, attempts token refresh via onTokenExpired before giving up |
| Leases | Sends LEASE_ACQUIRE/RELEASE/HEARTBEAT messages through WS |
| Order sync | Sends ORDER_SNAPSHOT through WS; falls back to outbox when disconnected |
| Table sync | Sends TABLE_UPDATE through WS; listens for TABLE_UPDATED |
| Table locks | Sends TABLE_LOCK_ACQUIRE/RELEASE/HEARTBEAT/FORCE_ACQUIRE through WS |
| Conflict detection | Listens for CONFLICT messages, notifies via onConflict() |
| Reconnect | Exponential 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
| HubConnectionState | TransportState |
|---|
disconnected | disconnected |
connecting | connecting |
authenticating | connecting |
connected | connected |
reconnecting | reconnecting |
Hub discovery
nu_pos_react/src/realtime/hub/hubDiscovery.ts
Resolution priority:
nu_hub_url from bootstrap — used as-is if starts with ws:// or wss://, otherwise prefixed
nu_hub_fallback_ip from bootstrap — prefixed with ws:// and :8766
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