npm package: @mapnests/gateway-web-sdk
Description: Session token management SDK with automatic refresh for React/Next.js applications
Author: Md. Yeasir Arafat
Latest version: 1.0.9

# Gateway Session SDK

A lightweight, production-ready session token management SDK for React and Next.js applications. Handles automatic token refresh with configurable intervals to prevent race conditions and token expiration issues.

## Features

- Automatic token refresh with configurable background intervals
- HttpOnly cookie support for secure token storage
- React hook (`useSession`) for seamless integration
- Singleton pattern ensuring a single session instance across your app
- Zero runtime dependencies (`react` and `react-dom` as peer dependencies)
- Next.js compatible (SSR-safe)
- Built-in Fetch and Axios interceptors with automatic session readiness gating and 401 handling
- Resilient to browser close/reopen — stale cookies are cleared and tokens refresh before any API call
- Full TypeScript definitions included

## Installation

```bash
npm install @mapnests/gateway-web-sdk
```

---

## Implementation Guide

Choose one of the three approaches below based on your preferred HTTP client. All three approaches share the same **Step 1** (environment setup) and **Step 2** (session initialization).

> **Next.js users:** Skip the Common Setup below and go directly to the [Next.js Integration](#nextjs-integration) section, which provides its own Steps 1–2. Then return here for Approach A, B, or C.

---

### Common Setup (Vite / CRA / React Apps)

#### Step 1 — Environment Variables

Create a `.env` file in your project root with the following variables:

```env
VITE_API_BASE_URL=https://your-gateway.example.com
VITE_BOOTSTRAP_PATH=/api/session/bootstrap
VITE_TOKEN_COOKIE_NAME=token
```

| Variable | Description |
|----------|-------------|
| `VITE_API_BASE_URL` | Base URL of your API gateway |
| `VITE_BOOTSTRAP_PATH` | Path to the session bootstrap endpoint |
| `VITE_TOKEN_COOKIE_NAME` | Name of the token cookie set by your server |

Then create a config helper to read these values:

```js
// src/config.js
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const BOOTSTRAP_PATH = import.meta.env.VITE_BOOTSTRAP_PATH;
const TOKEN_COOKIE_NAME = import.meta.env.VITE_TOKEN_COOKIE_NAME;

if (!API_BASE_URL) throw new Error('VITE_API_BASE_URL is not defined');
if (!BOOTSTRAP_PATH) throw new Error('VITE_BOOTSTRAP_PATH is not defined');
if (!TOKEN_COOKIE_NAME) throw new Error('VITE_TOKEN_COOKIE_NAME is not defined');

export { API_BASE_URL, BOOTSTRAP_PATH, TOKEN_COOKIE_NAME };
```

#### Step 2 — Initialize the Session Manager

Configure and initialize the SDK once at your app's entry point:

```jsx
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { SessionManager } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL, BOOTSTRAP_PATH, TOKEN_COOKIE_NAME } from './config.js';
import App from './App';

const sessionManager = SessionManager.getInstance();

sessionManager.configure({
  bootstrapUrl: `${API_BASE_URL}${BOOTSTRAP_PATH}`,
  tokenCookieName: TOKEN_COOKIE_NAME,
});

sessionManager.initialize().catch(err =>
  console.error('Failed to initialize session:', err)
);

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
```

Now proceed with one of the three approaches below.

---

### Approach A — Fetch Interceptor

The simplest option. Drop-in replacement for `fetch` that automatically handles session headers and 401 retry.

#### Step 3 — Create the API layer

```js
// src/api.js
import { fetchInterceptor } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL } from './config.js';

export const getUser = () =>
  fetchInterceptor(`${API_BASE_URL}/api/user`);
```

#### Step 4 — Use in a component

```jsx
// src/Dashboard.jsx
import { useEffect, useState } from 'react';
import { useSession } from '@mapnests/gateway-web-sdk';
import { getUser } from './api.js';

export default function Dashboard() {
  const { isInitialized, isLoading, error } = useSession();
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!isInitialized) return;

    getUser()
      .then(res => res.json())
      .then(setUser)
      .catch(err => console.error('Failed to fetch user:', err));
  }, [isInitialized]);

  if (isLoading) return <p>Loading session...</p>;
  if (error) return <p>Session error: {error}</p>;
  if (!user) return <p>Loading data...</p>;

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
```

---

### Approach B — Axios Interceptor

Best if you already use Axios. Wraps an Axios instance with automatic session headers and 401 retry.

> Requires `axios` as a dependency: `npm install axios`

#### Step 3 — Create the Axios instance and API layer

```js
// src/api.js
import axios from 'axios';
import { setupAxiosInterceptor } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL } from './config.js';

const api = setupAxiosInterceptor(
  axios.create({
    baseURL: API_BASE_URL,
    withCredentials: true,
  })
);

export const getUser = () => api.get('/api/user');
```

#### Step 4 — Use in a component

```jsx
// src/Dashboard.jsx
import { useEffect, useState } from 'react';
import { useSession } from '@mapnests/gateway-web-sdk';
import { getUser } from './api.js';

export default function Dashboard() {
  const { isInitialized, isLoading, error } = useSession();
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!isInitialized) return;

    getUser()
      .then(res => setUser(res.data))
      .catch(err => console.error('Failed to fetch user:', err));
  }, [isInitialized]);

  if (isLoading) return <p>Loading session...</p>;
  if (error) return <p>Session error: {error}</p>;
  if (!user) return <p>Loading data...</p>;

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
```

---

### Approach C — Manual Implementation

Full control over request construction and 401 handling. Use this when you need custom logic or don't want to use the built-in interceptors.

#### Step 3 — Create a manual fetch wrapper

```js
// src/api.js
import { SessionManager } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL } from './config.js';

const sm = SessionManager.getInstance();

async function request(url, init = {}) {
  // Ensure session is bootstrapped before sending (handles browser close/reopen, tab wake, etc.)
  await sm.ensureReady();

  const opts = {
    ...init,
    credentials: 'include',
    headers: { ...(init.headers || {}) },
  };

  // Attach session headers
  opts.headers['cf-session-id'] = sm.getSessionId();
  opts.headers['x-client-platform'] = 'web';

  if (sm.shouldUseTokenHeader()) {
    const token = sm.getToken(sm.config.tokenCookieName);
    if (token) opts.headers[sm.config.tokenCookieName] = token;
  }

  let res = await fetch(url, opts);

  // Handle 401 with the configured invalidSessionError
  if (res.status === 401) {
    const cloned = res.clone();
    try {
      const body = await cloned.json();
      if (body.error_msg === sm.config.invalidSessionError) {
        // Wait for any in-progress refresh, or trigger a new one
        if (sm.isRefreshing()) {
          await sm.waitForRefresh();
        } else {
          await sm.refreshToken();
        }

        // Update headers with refreshed session
        opts.headers['cf-session-id'] = sm.getSessionId();
        if (sm.shouldUseTokenHeader()) {
          const newToken = sm.getToken(sm.config.tokenCookieName);
          if (newToken) opts.headers[sm.config.tokenCookieName] = newToken;
        }

        // Retry the request
        res = await fetch(url, opts);
      }
    } catch {
      // Response was not JSON — return the original response
    }
  }

  return res;
}

export const getUser = () => request(`${API_BASE_URL}/api/user`);
```

#### Step 4 — Use in a component

```jsx
// src/Dashboard.jsx
import { useEffect, useState } from 'react';
import { useSession } from '@mapnests/gateway-web-sdk';
import { getUser } from './api.js';

export default function Dashboard() {
  const { isInitialized, isLoading, error } = useSession();
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!isInitialized) return;

    getUser()
      .then(res => res.json())
      .then(setUser)
      .catch(err => console.error('Failed to fetch user:', err));
  }, [isInitialized]);

  if (isLoading) return <p>Loading session...</p>;
  if (error) return <p>Session error: {error}</p>;
  if (!user) return <p>Loading data...</p>;

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
```

---

## Next.js Integration

> **Note:** For Next.js, use `NEXT_PUBLIC_` prefixed environment variables instead of `VITE_`, and replace the common **Step 1** config helper and **Step 2** initialization with the Next.js-specific setup below.

### Step 1 — Environment Variables

```env
NEXT_PUBLIC_API_BASE_URL=https://your-gateway.example.com
NEXT_PUBLIC_BOOTSTRAP_PATH=/api/session/bootstrap
NEXT_PUBLIC_TOKEN_COOKIE_NAME=token
```

### Step 2 — Config Helper

```js
// src/config.js
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;
const BOOTSTRAP_PATH = process.env.NEXT_PUBLIC_BOOTSTRAP_PATH;
const TOKEN_COOKIE_NAME = process.env.NEXT_PUBLIC_TOKEN_COOKIE_NAME;

if (!API_BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is not defined');
if (!BOOTSTRAP_PATH) throw new Error('NEXT_PUBLIC_BOOTSTRAP_PATH is not defined');
if (!TOKEN_COOKIE_NAME) throw new Error('NEXT_PUBLIC_TOKEN_COOKIE_NAME is not defined');

export { API_BASE_URL, BOOTSTRAP_PATH, TOKEN_COOKIE_NAME };
```

### App Router

#### Step 3 — Create a Session Provider

Create a client component that initializes the session. This keeps the root layout as a Server Component, preserving the benefits of React Server Components.

```jsx
// app/providers/SessionProvider.jsx
'use client';

import { useEffect } from 'react';
import { SessionManager } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL, BOOTSTRAP_PATH, TOKEN_COOKIE_NAME } from '@/src/config';

// Configure at module level so the SDK is ready before any child component mounts.
// configure() only sets config values — no browser APIs needed, safe during SSR.
const sessionManager = SessionManager.getInstance();
sessionManager.configure({
  bootstrapUrl: `${API_BASE_URL}${BOOTSTRAP_PATH}`,
  tokenCookieName: TOKEN_COOKIE_NAME,
});

export default function SessionProvider({ children }) {
  // initialize() calls the bootstrap API — must run in useEffect (client-only)
  useEffect(() => {
    sessionManager.initialize().catch(err =>
      console.error('Failed to initialize session:', err)
    );
  }, []);

  return children;
}
```

#### Step 4 — Add the Provider to the Root Layout

The root layout stays as a Server Component — do **not** add `'use client'` here.

```jsx
// app/layout.js
import SessionProvider from './providers/SessionProvider';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  );
}
```

#### Step 5 — Create an API Layer and Use in a Page

After your router setup is complete, create an API layer using any of **Approach A / B / C** from the [Implementation Guide](#implementation-guide) above. The only change needed is to replace `import.meta.env.VITE_*` references with imports from your `src/config.js`.

For example, using Approach A (Fetch Interceptor):

```js
// src/api.js
import { fetchInterceptor } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL } from './config';

export const getUser = () =>
  fetchInterceptor(`${API_BASE_URL}/api/user`);
```

Then use it in a page component. Page components that use hooks must be client components.

```jsx
// app/dashboard/page.jsx
'use client';

import { useEffect, useState } from 'react';
import { useSession } from '@mapnests/gateway-web-sdk';
import { getUser } from '@/src/api';

export default function DashboardPage() {
  const { isInitialized, isLoading, error } = useSession();
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!isInitialized) return;

    getUser()
      .then(res => res.json())
      .then(setUser)
      .catch(err => console.error('Failed to fetch user:', err));
  }, [isInitialized]);

  if (isLoading) return <p>Loading session...</p>;
  if (error) return <p>Session error: {error}</p>;
  if (!user) return <p>Loading data...</p>;

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
```

### Pages Router

#### Step 3 — Initialize in `_app.js`

```jsx
// pages/_app.js
import { useEffect } from 'react';
import { SessionManager } from '@mapnests/gateway-web-sdk';
import { API_BASE_URL, BOOTSTRAP_PATH, TOKEN_COOKIE_NAME } from '../src/config';

// Configure at module level — runs once when the module is first imported.
const sessionManager = SessionManager.getInstance();
sessionManager.configure({
  bootstrapUrl: `${API_BASE_URL}${BOOTSTRAP_PATH}`,
  tokenCookieName: TOKEN_COOKIE_NAME,
});

function MyApp({ Component, pageProps }) {
  // initialize() needs the browser — must run in useEffect
  useEffect(() => {
    sessionManager.initialize().catch(err =>
      console.error('Failed to initialize session:', err)
    );
  }, []);

  return <Component {...pageProps} />;
}

export default MyApp;
```

#### Step 4 — Create an API Layer and Use in a Page

Same as the App Router — create an `src/api.js` using any of **Approach A / B / C**, then use it in your page:

```jsx
// pages/dashboard.jsx
import { useEffect, useState } from 'react';
import { useSession } from '@mapnests/gateway-web-sdk';
import { getUser } from '../src/api';

export default function Dashboard() {
  const { isInitialized, isLoading, error } = useSession();
  const [user, setUser] = useState(null);

  useEffect(() => {
    if (!isInitialized) return;

    getUser()
      .then(res => res.json())
      .then(setUser)
      .catch(err => console.error('Failed to fetch user:', err));
  }, [isInitialized]);

  if (isLoading) return <p>Loading session...</p>;
  if (error) return <p>Session error: {error}</p>;
  if (!user) return <p>Loading data...</p>;

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}
```

---

## API Reference

### `useSession(options?)`

React hook for session management.

**Parameters:**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `options.autoInitialize` | boolean | `true` | Automatically initialize session on mount |

**Returns:**

```typescript
{
  isInitialized: boolean;
  isLoading: boolean;
  error: string | null;
  lastRefreshTime: number | null;
  nextRefreshTime: number | null;
  timeUntilRefresh: number | null;
  initializationFailed: boolean;
  refresh: () => Promise<void>;
  initialize: () => Promise<void>;
}
```

### `SessionManager`

Core session management class (Singleton).

#### `SessionManager.getInstance()`

Returns the singleton instance.

#### `configure(config)`

```javascript
sessionManager.configure({
  bootstrapUrl: '/session/bootstrap',    // Required: Bootstrap API endpoint
  tokenCookieName: 'token',             // Optional: Token cookie name (default: 'token')
  maxRetries: 3,                         // Optional: Max retry attempts (default: 3)
  headers: {},                           // Optional: Additional headers for bootstrap calls
  credentials: true,                     // Optional: Include credentials (default: true)
  logLevel: 'WARN',                      // Optional: 'NONE' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'
});
```

> `refreshInterval` and `tokenExpiry` are automatically set by the server's bootstrap response (`refresh_time` and `expire_time` fields). You don't need to configure them manually.

#### `initialize()`

Initialize session by calling the bootstrap endpoint.

```javascript
await sessionManager.initialize();
```

#### `refreshToken()`

Manually trigger a session token refresh.

```javascript
await sessionManager.refreshToken();
```

#### `getSessionId()`

Returns the current `cf-session-id`.

#### `getToken(name?)`

Returns the token value from the named cookie, or `null`.

#### `shouldUseTokenHeader()`

Returns `true` if the token should be sent as a request header (i.e. when the app is served over HTTP, not HTTPS).

#### `ensureReady()`

Ensures the session is ready before making API calls. This is the recommended way to gate requests on session readiness.

- If initialized and token is fresh, resolves immediately (zero overhead).
- If initialization is in progress, waits for it to complete.
- If not initialized but configured, triggers `initialize()` and waits.
- If not configured or permanently failed, resolves and lets the request proceed (the 401 handler will deal with it).

```javascript
await sessionManager.ensureReady();
// Session is now ready — safe to make API calls
```

> **Note:** `fetchInterceptor` and `setupAxiosInterceptor` call `ensureReady()` automatically before every request. You only need to call it explicitly when building a manual fetch wrapper (Approach C).

#### `needsRefresh()`

Returns `true` if the session is initialized but the token has likely expired. Useful for proactive checks.

```javascript
if (sessionManager.needsRefresh()) {
  await sessionManager.refreshToken();
}
```

#### `isRefreshing()`

Returns `true` if a refresh/initialize is currently in progress.

#### `waitForRefresh()`

Returns a promise that resolves when the in-progress refresh completes.

#### `getSessionStatus()`

Returns the current session state object.

```typescript
{
  isInitialized: boolean;
  isLoading: boolean;
  lastRefreshTime: number | null;
  nextRefreshTime: number | null;
  tokenExpiry: number | null;
  error: string | null;
  errorCode: string | null;
  initializationFailed: boolean;
  timeUntilRefresh: number | null;
}
```

> **Note:** `getSessionStatus()` returns `tokenExpiry` and `errorCode` which are not exposed by the `useSession` hook. Use this method directly if you need those fields.

#### `subscribe(listener)`

Subscribe to session state changes. Returns an unsubscribe function.

```javascript
const unsubscribe = sessionManager.subscribe((state) => {
  console.log('Session state:', state);
});
```

#### `destroy()`

Clean up timers, listeners, and cookies. Resets the session manager.

### `fetchInterceptor(url, options?)`

Drop-in `fetch` wrapper. Automatically gates on session readiness via `ensureReady()`, attaches session headers, and retries on 401 `INVALID_SESSION`. Safe to call even before the session is initialized — the request will wait for bootstrap to complete.

```javascript
import { fetchInterceptor } from '@mapnests/gateway-web-sdk';
const response = await fetchInterceptor('/api/data');
```

### `setupAxiosInterceptor(axiosInstance)`

Attaches request/response interceptors to an Axios instance. Returns the same instance. Every request automatically gates on session readiness via `ensureReady()`, attaches session headers, and retries on 401 `INVALID_SESSION`.

```javascript
import axios from 'axios';
import { setupAxiosInterceptor } from '@mapnests/gateway-web-sdk';
const api = setupAxiosInterceptor(axios.create({ baseURL: '/api' }));
```

### Error Classes

The SDK exports custom error classes for typed error handling:

```javascript
import {
  SessionError,       // Base error class (code, details)
  ConfigurationError, // Invalid configuration (code: 'CONFIGURATION_ERROR')
  BootstrapError,     // Bootstrap API failure (code: 'BOOTSTRAP_ERROR')
  NetworkError,       // Network-level failure (code: 'NETWORK_ERROR')
  SSRError,           // Called in non-browser environment (code: 'SSR_ERROR')
} from '@mapnests/gateway-web-sdk';
```

All errors extend `SessionError`, which provides `code` (string) and `details` (object) properties.

### `logger` and `LOG_LEVELS`

The SDK's internal logger is exported for advanced use (e.g. setting log level independently of `configure()`):

```javascript
import { logger, LOG_LEVELS } from '@mapnests/gateway-web-sdk';

logger.setLevel('DEBUG');     // or logger.setLevel(LOG_LEVELS.DEBUG)
logger.info('Custom log');    // [SessionManager] Custom log
```

Available levels: `NONE` (0), `ERROR` (1), `WARN` (2, default), `INFO` (3), `DEBUG` (4).

---

## Security Notice

This SDK prioritizes **server-set HttpOnly cookies** for maximum security. The SDK includes a fallback to set cookies client-side, but these cannot be HttpOnly and are accessible to JavaScript.

**Recommended:** Always set cookies from your server using the `Set-Cookie` header with `HttpOnly` flag. The SDK will detect server cookies and skip client-side cookie setting automatically.

---

## Best Practices

1. **Single Instance** — Call `configure()` once at app startup and reuse the singleton.
2. **Token Timing** — The server controls refresh and expiry timing via the bootstrap response.
3. **Error Handling** — Handle errors gracefully and provide user feedback for limited mode.
4. **HTTPS** — Always use HTTPS in production environments.
5. **CORS/Credentials** — If cross-origin, ensure your server CORS allows credentials.
6. **Initialization Order** — Always call `configure()` then `initialize()` before making API calls.

---

## Troubleshooting

### Cookies not being set
- Verify your API returns `Set-Cookie` header with `HttpOnly` flag
- Check CORS configuration allows credentials
- Ensure `credentials: true` in configuration

### Automatic refresh not working
- Check browser console for errors
- Verify server is sending `refresh_time` in bootstrap response
- Ensure timer isn't being cleared prematurely

### Multiple initializations
- The SDK uses singleton pattern, but ensure you're not calling `initialize()` multiple times
- Use `autoInitialize: false` in `useSession()` if you want manual control

### 401 errors after browser close and reopen
- The SDK automatically clears stale cookies and re-bootstraps on fresh page loads
- Both `fetchInterceptor` and `setupAxiosInterceptor` call `ensureReady()` before every request, which waits for bootstrap to complete
- If using a manual fetch wrapper (Approach C), ensure you call `await sm.ensureReady()` before each request
- Verify your 401 handler compares against `sm.config.invalidSessionError` (default: `'INVALID-GW-SESSION'`), not a hardcoded string

### Next.js SSR errors
- The SDK detects SSR environments and prevents initialization
- Always wrap initialization in `useEffect` or client components (`'use client'`)
- Do not call `initialize()` during server-side rendering

---

## License

MIT