> ## 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.

# Transport layer

> Frontend abstraction, direct vs hub transport, factory pattern, and offline fallback.

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`:

```typescript theme={null}
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

```typescript theme={null}
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`:

```typescript theme={null}
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:

```typescript theme={null}
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](/sync/frontend-integration#reconnectionoverlay).

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:

```typescript theme={null}
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:

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
```
