npm package: @fatagnus/dink-web
Description: Browser SDK for Dink edge mesh platform via NATS WebSocket
Author: Ozan Turksever
Latest version: 2.32.0

# @fatagnus/dink-web

Browser SDK for connecting to [dinkd](https://github.com/ozanturksever/dink) via NATS WebSocket.

## Install

```bash
npm install @fatagnus/dink-web
```

## Quick Start

```typescript
import { DinkWebClient } from '@fatagnus/dink-web';

const client = new DinkWebClient({
  url: 'wss://dinkd.example.com:9222',
  token: 'dk_dev_wb_...',
  appId: 'my-app',
});

await client.connect();

// Call an edge service
const result = await client.call('edge-1', 'SensorService', 'ReadTemperature', {
  sensorId: 'temp-1',
});

// Call a center (cloud) service
const user = await client.callCenter('UserService', 'GetProfile', { userId: '123' });

// Subscribe to streaming updates
const sub = await client.subscribe(
  'edge-1', 'SensorService', 'WatchTemperature',
  { sensorId: 'temp-1' },
  (update) => console.log('Temperature:', update),
);

// Later
sub.unsubscribe();
await client.close();
```

## Token Refresh

Web tokens expire (default 1 hour, max 24 hours). The SDK can auto-refresh them:

```typescript
const client = new DinkWebClient({
  url: 'wss://dinkd.example.com:9222',
  token: initialToken,
  expiresAt: initialExpiresAt,
  appId: 'my-app',

  // Called 1 minute before expiry (configurable via refreshBeforeExpiryMs)
  tokenRefresher: async () => {
    const resp = await fetch('/api/keys/web-token', {
      method: 'POST',
      headers: { 'X-API-Key': backendApiKey },
      body: JSON.stringify({ app_id: 'my-app' }),
    });
    return resp.json(); // { token, ws_url, expires_at }
  },
});
```

The client will drain the old connection and reconnect with the new token seamlessly.

## Connection Quality

```typescript
// Check stats at any time
const { status, reconnectCount, rttMs, tokenExpiresAt } = client.stats;
console.log(`RTT: ${rttMs}ms, reconnects: ${reconnectCount}`);

// React to status changes
const client = new DinkWebClient({
  // ...
  onStatusChange: (status) => {
    if (status === 'error') showOfflineBanner();
    if (status === 'connected') hideOfflineBanner();
  },
});
```

RTT is measured every 30 seconds via NATS flush.

## Generated Clients

Use `dink codegen --output-mode web-client` to generate typed clients:

```typescript
import { DinkWebClient } from '@fatagnus/dink-web';
import { SensorServiceClient } from './generated/sensorservice.client';

const client = new DinkWebClient({ url, token, appId });
await client.connect();

const sensor = new SensorServiceClient('edge-1', client);
const reading = await sensor.ReadTemperature({ sensorId: 'temp-1' });
```

## API

### `DinkWebClient`

| Method | Description |
|--------|-------------|
| `connect()` | Establish WebSocket connection |
| `close()` | Drain connection and close |
| `call(edgeId, service, method, req)` | Request/reply to edge service |
| `callCenter(service, method, req)` | Request/reply to center service |
| `subscribe(edgeId, service, method, req, handler)` | Stream from edge service |
| `exposeService(handler)` | Serve RPC methods from this browser client |
| `service(edgeId, ClientClass)` | Create typed service client |
| `status` | Current connection status |
| `stats` | Connection statistics (RTT, reconnects, token expiry) |

### Config

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `url` | `string` | required | WebSocket URL |
| `token` | `string` | required | Web token |
| `appId` | `string` | required | App ID |
| `edgeId` | `string` | `web-{random}` | Edge ID for this browser client (required for `exposeService`) |
| `expiresAt` | `string \| Date` | - | Token expiry (enables auto-refresh) |
| `timeout` | `number` | 30000 | Request timeout (ms) |
| `onStatusChange` | `function` | - | Status change callback |
| `tokenRefresher` | `function` | - | Returns fresh token |
| `refreshBeforeExpiryMs` | `number` | 60000 | Refresh lead time (ms) |

## Exposing Services

Serve RPC methods from the browser so remote edges and agents can call into it.

```typescript
import { DinkWebClient } from '@fatagnus/dink-web';

const client = new DinkWebClient({
  url: 'wss://dinkd.example.com:9222',
  token: 'dk_dev_wb_...',
  appId: 'my-app',
  edgeId: 'ui-workspace-1', // stable ID for reconnects
});

await client.connect();

const handler = {
  definition() {
    return { name: 'GreetingService', version: '1.0', methods: ['SayHello'] };
  },
  async handleRequest(method, data) {
    const { name } = JSON.parse(new TextDecoder().decode(data));
    return new TextEncoder().encode(JSON.stringify({ message: `Hello, ${name}!` }));
  },
};

const { unexpose } = await client.exposeService(handler);

// Later: stop serving
unexpose();
```

### ServiceHandler Interface

```typescript
interface ServiceHandler {
  definition(): ServiceDefinition;
  handleRequest(method: string, data: Uint8Array): Promise<Uint8Array>;
  handleStream?(method: string, data: Uint8Array, emit: (data: Uint8Array) => Promise<void>, signal?: AbortSignal): Promise<void>;
  handleChannel?(method: string, channel: EdgeChannel, request: unknown): Promise<void>;
}
```

| Pattern | Method | Description |
|---------|--------|-------------|
| Request/reply | `handleRequest` | Single request → single response |
| Server stream | `handleStream` | Single request → multiple emitted responses |
| Bidirectional | `handleChannel` | Full-duplex data channel |

The service registers with dinkd for edge discovery. Other edges find it via `discoverEdges()`.

### Streaming Example

```typescript
const handler = {
  definition() {
    return { name: 'EventService', version: '1.0', methods: ['Watch'] };
  },
  async handleRequest(method, data) { /* ... */ },
  async handleStream(method, data, emit, signal) {
    const unsub = eventSource.subscribe((event) => {
      if (signal?.aborted) return;
      void emit(encode(event));
    });
    // Wait until cancelled
    await new Promise((resolve) => signal?.addEventListener('abort', resolve, { once: true }));
    unsub();
  },
};
```

## React Hooks

### `useExposeService(handler)`

Manages the expose/unexpose lifecycle in a React component.

```tsx
import { useExposeService } from '@fatagnus/dink-web/react';

function MyServiceProvider() {
  const handler = useMemo(() => createMyHandler(), []);
  useExposeService(handler); // exposes on connect, unexposes on unmount
  return null;
}
```

Pass `null` to skip exposing (e.g., when data isn't ready yet).

## License

Apache-2.0