# Browser example

> A web page that talks to a voice with the browser SDK, and the small backend that creates its session. Every file, and how to run it.

Source: https://docs.voice.kheelona.com/examples/browser-basic/

A web page that talks to a Kheelona voice with the [browser SDK](https://docs.voice.kheelona.com/guides/browser-sdk.md), and the small backend it needs.
Copy it as a starting point. The [browser quickstart](https://docs.voice.kheelona.com/get-started/quickstart-browser.md) walks through it.

- **`public/main.js`** is a plain ES module with no build step. Start creates a session through your backend, then calls
  `KheelonaVoice.connect`. The listeners are attached **after** `await connect()`. Captions are set with `textContent`
  only. End calls `session.end()` and the text box calls `sendText`. If the browser refuses to play the voice, a
  **Tap to allow sound** button calls `session.resumeAudio()` directly in its click handler.
- **`public/controls.js`** wires the **Mute** and **Interrupt** buttons.
- **`public/errors.js`** holds the wording of the error line.
- **`server.ts`** is the whole backend, with only the Node standard library. It serves the page at `/`, the SDK's files at
  `/sdk/` and the route `POST /api/voice-session`, all from one origin, so there is no CORS. Your API key stays in this
  process.

You need Node 24 or newer, and the example folder and the SDK package: the examples you received from the Kheelona team, with the SDK package.

## Settings

| Variable | Default | Meaning |
|---|---|---|
| `KHEELONA_API_KEY` | none (exit 2 with a hint when missing) | Your API key. Server side only. |
| `KHEELONA_API_URL` | `https://api.voice.kheelona.com` | The API |
| `KHEELONA_VOICE_ID` | `lumi-dragon` | The voice |
| `HOST` / `PORT` | `127.0.0.1` / `5180` | Where the page and the backend listen |
| `ALLOWED_ORIGIN` | `http://localhost:5180` | The one page origin allowed to call the route (blank or `null` is refused at start) |
| `KHEELONA_REALTIME_ORIGINS` | `https://api.voice.kheelona.com wss://api.voice.kheelona.com` | The origins of the `connect` URLs, separated by spaces. The page's Content Security Policy allows connections to these and to nothing else. |

## Run

```sh
cd examples/browser-basic
npm install   # the SDK package file from the Kheelona team is already beside package.json
export KHEELONA_API_KEY=kv_live_…
npm start
```

Open `http://localhost:5180` and click **Start talking**. Open the page as `localhost`, which is what `ALLOWED_ORIGIN`
expects. If you serve it from another origin, set `ALLOWED_ORIGIN` to match, and add that origin in your console. See
[Security](https://docs.voice.kheelona.com/guides/security.md).

## Before you copy it

- **Add your own sign-in check** where `server.ts` says `Authenticate your user here`, and pass that user's opaque ID as
  `end_user_ref`.
- The route refuses a request whose `Origin` is missing or not `ALLOWED_ORIGIN` (403) and anything but JSON (415). It
  returns the session ID, the client secret and the connection addresses, and nothing else.
- Every response carries a strict Content Security Policy. See [Security](https://docs.voice.kheelona.com/guides/security.md#a-content-security-policy-for-your-page).
- The page shows the error code and, when it is retryable, "try again in N s". A retry creates a new session.

## The files

**browser-basic/server.ts**

```ts title="server.ts"
// The example's whole backend: it serves the page, the SDK files and the mint route from ONE origin (so no CORS), and
// it holds your API key. Run it with `npm start`, then open http://localhost:5180 (see Settings on the example's docs page).
import { readFile, realpath, stat } from 'node:fs/promises';
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { dirname, extname, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const HERE = dirname(fileURLToPath(import.meta.url));
const REQUEST_TIMEOUT_MS = 15_000;

/** The settings (see Settings on the example's docs page). */
export interface BrowserEnv {
  apiKey: string;
  apiUrl: string;
  voiceId: string;
  /** The one page origin that may call the mint route. */
  allowedOrigin: string;
  /** Origins the page may connect to (the `connect` URLs the API returns), space separated, for the CSP. */
  realtimeOrigins: string;
  host: string;
  port: number;
}

/** Where the files come from. Tests pass their own folders. */
export interface ServerOptions {
  publicDir?: string;
  sdkDir?: string;
}

/** Reads the settings; throws an Error with a one-line hint when something is missing or wrong (exit code 2). */
export function readEnv(env: Record<string, string | undefined>): BrowserEnv {
  const apiKey = env['KHEELONA_API_KEY'];
  if (!apiKey) throw new Error('KHEELONA_API_KEY is not set. Create an API key in the Kheelona console and export it.');
  const allowedOrigin = (env['ALLOWED_ORIGIN'] ?? 'http://localhost:5180').trim();
  if (allowedOrigin === '' || allowedOrigin.toLowerCase() === 'null') {
    throw new Error("ALLOWED_ORIGIN must be your page's origin, for example https://app.example.com");
  }
  const realtimeOrigins = (env['KHEELONA_REALTIME_ORIGINS'] ?? 'https://api.voice.kheelona.com wss://api.voice.kheelona.com').trim();
  // The value goes into a response header: plain origins only.
  if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s;,'"]+(?: [A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s;,'"]+)*$/.test(realtimeOrigins)) {
    throw new Error('KHEELONA_REALTIME_ORIGINS must be origins separated by spaces, for example https://host wss://host');
  }
  const port = Number(env['PORT'] ?? '5180');
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('PORT must be a port number.');
  return {
    apiKey,
    apiUrl: env['KHEELONA_API_URL'] || 'https://api.voice.kheelona.com',
    voiceId: env['KHEELONA_VOICE_ID'] || 'lumi-dragon',
    allowedOrigin,
    realtimeOrigins,
    host: env['HOST'] || '127.0.0.1',
    port,
  };
}

/** The strict policy the page runs under: this origin's files, and the Kheelona realtime origins to connect to. */
export function contentSecurityPolicy(realtimeOrigins: string): string {
  return [
    "default-src 'self'",
    "script-src 'self'",
    "style-src 'self'",
    `connect-src 'self' ${realtimeOrigins}`,
    "img-src 'self'",
    "media-src 'self'",
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join('; ');
}

const TYPES: Readonly<Record<string, string>> = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.map': 'application/json; charset=utf-8',
};

function send(res: ServerResponse, csp: string, status: number, type: string, body: string | Buffer, headers: Record<string, string> = {}): void {
  res.writeHead(status, {
    'Content-Type': type,
    'Content-Security-Policy': csp,
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'no-referrer',
    'Cache-Control': 'no-store',
    ...headers,
  });
  res.end(body);
}

const sendJson = (res: ServerResponse, csp: string, status: number, body: unknown, headers: Record<string, string> = {}): void => {
  send(res, csp, status, 'application/json', JSON.stringify(body), headers);
};

/**
 * Maps a request path to a file inside `root`, or `undefined`. The path is decoded first, then resolved, and refused
 * unless the result is still inside `root`: `..`, encoded dots and slashes, backslashes and NUL never get out.
 */
async function fileInside(root: string, requestPath: string): Promise<string | undefined> {
  let decoded: string;
  try {
    decoded = decodeURIComponent(requestPath);
  } catch {
    return undefined;
  }
  if (decoded.includes('\0') || decoded.includes('\\')) return undefined;
  const base = resolve(root);
  const file = resolve(base, `.${decoded}`);
  if (!file.startsWith(base + sep)) return undefined;
  if (!(extname(file) in TYPES)) return undefined;
  try {
    // A symlink must not lead out either: compare real paths. The root itself may be a link (pnpm links packages),
    // so it is resolved first and is the one explicitly trusted folder.
    const realFile = await realpath(file);
    if (!realFile.startsWith((await realpath(base)) + sep)) return undefined;
    return (await stat(realFile)).isFile() ? realFile : undefined;
  } catch {
    return undefined;
  }
}

async function serveStatic(req: IncomingMessage, res: ServerResponse, csp: string, dirs: Required<ServerOptions>): Promise<void> {
  const path = (req.url ?? '/').split('?')[0] ?? '/';
  const isSdk = path.startsWith('/sdk/');
  const file = await fileInside(isSdk ? dirs.sdkDir : dirs.publicDir, isSdk ? path.slice('/sdk'.length) : path === '/' ? '/index.html' : path);
  if (file === undefined) {
    sendJson(res, csp, 404, { error: { code: 'not_found' } });
    return;
  }
  send(res, csp, 200, TYPES[extname(file)] ?? 'application/octet-stream', req.method === 'HEAD' ? '' : await readFile(file));
}

/** The mint route: create the session with your API key, hand the page only what it needs. */
async function mintSession(env: BrowserEnv, req: IncomingMessage, res: ServerResponse, csp: string): Promise<void> {
  // Same-origin by design (no CORS headers). Only your own page may call this route: a request with no Origin, or
  // another one, never reaches Kheelona. JSON only, so a cross-site HTML form cannot spend your key.
  if (req.headers.origin !== env.allowedOrigin) {
    sendJson(res, csp, 403, { error: { code: 'origin_not_allowed' } });
    return;
  }
  if (!req.headers['content-type']?.toLowerCase().startsWith('application/json')) {
    sendJson(res, csp, 415, { error: { code: 'unsupported_media_type' } });
    return;
  }
  // Authenticate your user here: only signed-in users may start a session.
  // The request body is never read (this route takes no input), so its size and Content-Length don't matter.
  // One attempt only: to retry on 429/503, reuse one Idempotency-Key as examples/node-server does.
  const upstream = await fetch(`${env.apiUrl.replace(/\/+$/, '')}/v1/sessions`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${env.apiKey}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': globalThis.crypto.randomUUID(),
    },
    body: JSON.stringify({
      voice_id: env.voiceId,
      languages: ['en', 'hi'],
      end_user_ref: 'demo-user', // replace with the signed-in user's opaque ID
    }),
    signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
  });
  if (!upstream.ok) {
    const body = (await upstream.json().catch(() => ({}))) as { error?: { code?: unknown; request_id?: unknown } };
    const code = plain(body.error?.code) ?? 'unknown_error';
    const requestId = plain(body.error?.request_id);
    console.error(`error ${code} request_id ${requestId ?? '-'}`);
    const retryable = upstream.status === 429 || upstream.status === 503;
    const retryAfter = upstream.headers.get('retry-after');
    sendJson(res, csp, retryable ? upstream.status : 502, { error: { code, ...(requestId ? { requestId } : {}) } }, retryable && retryAfter && /^\d+$/.test(retryAfter) ? { 'Retry-After': retryAfter } : {});
    return;
  }
  const created = (await upstream.json()) as {
    session_id: string;
    client_secret: { value: string };
    connect: { webrtc_url: string; websocket_url: string };
  };
  // Return only what the page needs: never the API key, and nothing else from the answer.
  sendJson(res, csp, 201, {
    sessionId: created.session_id,
    clientSecret: created.client_secret.value,
    endpoints: { webrtcUrl: created.connect.webrtc_url, websocketUrl: created.connect.websocket_url },
  });
}

/** A short identifier made of plain characters, or undefined: nothing else from an answer is repeated. */
function plain(value: unknown): string | undefined {
  return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(value) ? value : undefined;
}

/** Creates the server (not listening yet): the page, the SDK files and the mint route on one origin. */
export function createServer(env: BrowserEnv, options: ServerOptions = {}): Server {
  const dirs: Required<ServerOptions> = {
    publicDir: options.publicDir ?? resolve(HERE, 'public'),
    sdkDir: options.sdkDir ?? resolve(HERE, 'node_modules/@kheelona/voice/dist'),
  };
  const csp = contentSecurityPolicy(env.realtimeOrigins);
  return createHttpServer((req, res) => {
    void (async () => {
      const path = (req.url ?? '/').split('?')[0];
      if (path === '/api/voice-session') {
        if (req.method !== 'POST') {
          sendJson(res, csp, 405, { error: { code: 'method_not_allowed' } }, { Allow: 'POST' });
          return;
        }
        await mintSession(env, req, res, csp);
      } else if (req.method === 'GET' || req.method === 'HEAD') {
        await serveStatic(req, res, csp, dirs);
      } else {
        sendJson(res, csp, 405, { error: { code: 'method_not_allowed' } }, { Allow: 'GET, HEAD' });
      }
    })().catch((error: unknown) => {
      // Only the kind of failure: an error message can echo a request.
      console.error(`error ${error instanceof Error ? error.name : 'unexpected'} request_id -`);
      if (!res.headersSent) sendJson(res, csp, 502, { error: { code: 'internal' } });
      else res.end();
    });
  });
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  let env: BrowserEnv;
  try {
    env = readEnv(process.env);
  } catch (error) {
    console.error((error as Error).message);
    process.exit(2);
  }
  createServer(env).listen(env.port, env.host, () => {
    console.log(`page on http://${env.host === '127.0.0.1' ? 'localhost' : env.host}:${String(env.port)} (page origin ${env.allowedOrigin})`);
  });
}
```

**browser-basic/public/index.html**

```html title="index.html"
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Kheelona Voice: browser example</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <main>
      <h1>Talk to a Kheelona voice</h1>
      <p class="status">State: <strong id="state">not connected</strong></p>
      <div class="row">
        <button id="start" type="button">Start talking</button>
        <button id="end" type="button" disabled>End</button>
        <button id="allow-sound" type="button" hidden>Tap to allow sound</button>
      </div>
      <div class="row">
        <button id="mute" type="button" aria-pressed="false" disabled>Mute</button>
        <button id="interrupt" type="button" disabled>Interrupt</button>
      </div>
      <p id="error" role="alert" hidden></p>
      <div id="captions" aria-live="polite"></div>
      <div class="row">
        <label for="say">Or type a message</label>
        <input id="say" type="text" maxlength="2000" autocomplete="off" disabled />
        <button id="send" type="button" disabled>Send</button>
      </div>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>
```

**browser-basic/public/main.js**

```js title="main.js"
/* global document, fetch */
// The page: mint a session on your server, connect with the SDK, show what is said.
// A plain ES module, no build step. The CSP allows only this origin: no inline scripts or styles.
import { KheelonaVoice } from '/sdk/index.js';
import { describeError, MintError } from '/errors.js';
import { wireControls } from '/controls.js';

const $ = (id) => document.getElementById(id);
let session = null;

async function connectToVoice() {
  // Your server creates the session with your API key. The page only ever gets a single-use client secret.
  const res = await fetch('/api/voice-session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: '{}',
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    const retryAfter = Number(res.headers.get('Retry-After'));
    throw new MintError(body.error?.code ?? 'mint_failed', res.status, retryAfter > 0 ? retryAfter * 1000 : undefined);
  }
  const { sessionId, clientSecret, endpoints } = await res.json();
  // Call this from a click: the browser starts the audio and the microphone only after a user gesture.
  // A failed connect needs a new session (a new secret), so every attempt mints again.
  return KheelonaVoice.connect({ sessionId, clientSecret, endpoints, workletBaseUrl: '/sdk/' });
}

function listen(voice) {
  // Attach after `await connect()`: nothing is missed, and `state` first gives the current state.
  voice.on('state', (state) => {
    $('state').textContent = state;
  });
  voice.on('transcript', ({ text }) => addCaption('You', text));
  voice.on('replySentence', ({ text }) => addCaption('Voice', text));
  voice.on('error', (e) => {
    if (e.code === 'audio_unavailable') {
      // The browser refused to play the voice: ask for a tap (see the "Tap to allow sound" button).
      $('allow-sound').hidden = false;
    } else {
      showError(e);
    }
  });
  voice.on('ended', () => finish());
}

// Captions are set with textContent only: what people and the voice say is never read as HTML.
function addCaption(who, text) {
  const line = document.createElement('p');
  line.className = who === 'You' ? 'you' : 'voice';
  line.textContent = `${who}: ${text}`;
  $('captions').append(line);
}

function showError(e) {
  // Show the code (and when to try again); never log error.cause, it can quote connection details.
  $('error').textContent = describeError(e);
  $('error').hidden = false;
}

function setRunning(running) {
  $('start').disabled = running;
  $('end').disabled = !running;
  $('say').disabled = !running;
  $('send').disabled = !running;
  $('mute').disabled = !running;
  $('interrupt').disabled = !running;
}

function finish() {
  session = null;
  setRunning(false);
  $('allow-sound').hidden = true;
  // The next session starts unmuted.
  $('mute').setAttribute('aria-pressed', 'false');
  $('mute').textContent = 'Mute';
  $('state').textContent = 'ended';
}

$('start').addEventListener('click', async () => {
  $('error').hidden = true;
  setRunning(true);
  $('state').textContent = 'connecting';
  try {
    session = await connectToVoice();
    listen(session);
  } catch (e) {
    showError(e);
    finish();
    $('state').textContent = 'not connected';
  }
});

wireControls(() => session, { muteButton: $('mute'), interruptButton: $('interrupt') });

$('end').addEventListener('click', async () => {
  if (session) await session.end();
});

function sendTyped() {
  const text = $('say').value.trim();
  if (!session || text === '') return;
  // The caption comes back as a `transcript` event, like a spoken turn's.
  session.sendText(text);
  $('say').value = '';
}
$('send').addEventListener('click', sendTyped);
$('say').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') sendTyped();
});

// A tap is the gesture the browser needs: call resumeAudio() directly in the handler, with no await before it.
$('allow-sound').addEventListener('click', () => {
  session?.resumeAudio();
  $('allow-sound').hidden = true;
});
```

**browser-basic/public/controls.js**

```js title="controls.js"
// Mute and interrupt buttons for a live session. Kept apart from main.js so it can be tested without a browser.

/** Wires a mute toggle and an interrupt button. `getSession` returns the live session, or null when there is none. */
export function wireControls(getSession, { muteButton, interruptButton }) {
  muteButton.addEventListener('click', () => {
    const session = getSession();
    if (!session) return;
    // The button holds the state: aria-pressed is "true" while the microphone is muted.
    const muted = muteButton.getAttribute('aria-pressed') !== 'true';
    session.mute(muted);
    muteButton.setAttribute('aria-pressed', String(muted));
    muteButton.textContent = muted ? 'Unmute' : 'Mute';
  });
  interruptButton.addEventListener('click', () => {
    // Stops the voice now, as if the person had started speaking.
    getSession()?.interrupt();
  });
}
```

**browser-basic/public/errors.js**

```js title="errors.js"
// What the page shows for an error. Kept apart from main.js (which imports the SDK) so it can be tested alone.

/** The mint route refused or failed: carries what the page needs to explain it. */
export class MintError extends Error {
  constructor(code, status, retryAfterMs) {
    super(code);
    this.name = 'MintError';
    this.code = code;
    this.retryable = status === 429 || status === 503;
    this.retryAfterMs = retryAfterMs;
  }
}

/** One line for the person: the code, a friendlier hint for a refused microphone, and "try again in N s" if it helps. */
export function describeError(e) {
  const code = typeof e?.code === 'string' ? e.code : 'error';
  const wait = e?.retryable && e?.retryAfterMs ? `, try again in ${String(Math.ceil(e.retryAfterMs / 1000))} s` : '';
  if (code === 'microphone_unavailable') {
    return `The microphone is blocked or missing. Allow it in the browser, then try again (${code})`;
  }
  return `Something went wrong (${code})${wait}`;
}
```

**browser-basic/public/style.css**

```css title="style.css"
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; }
main { max-width: 40rem; margin: 0 auto; padding: 1rem 16px 3rem; }
.row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 1rem 0; }
button { font: inherit; padding: 0.5rem 1rem; }
input { font: inherit; flex: 1 1 12rem; padding: 0.5rem; }
#error { padding: 0.5rem 0.75rem; border: 2px solid currentColor; border-radius: 0.25rem; }
#captions p { margin: 0.4rem 0; }
#captions .you { opacity: 0.75; }
```

The examples are Kheelona's own and are given to partners privately. Contact the Kheelona team for licensing.
