> ## Documentation Index
> Fetch the complete documentation index at: https://iii.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Ch. 7: Bring in the browser

> Turn a browser tab into a iii worker that creates links, shows live clicks, and answers server-initiated prompts.

In this chapter the browser becomes a worker. It connects to the engine over WebSocket, calls
`link::create` directly (no REST API Gateway), binds to the live click feed from Chapter 5 to update
a counter, and registers a `user::confirm_destructive_op` function the server calls when a delete
needs a human's go-ahead.

## Add the workers

A browser worker connects through the `rbac-proxy` RBAC-gated port, separate from the trusted port
your local workers use. The `auth` worker holds the authentication logic that gates those
connections. Uncomment the Ch. 7 block in `worker-compose.yaml`:

```yaml worker-compose.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
auth:
  worker: path://./auth
  env_file: ["./.env"]
```

The `env_file` gives the worker `LINKLY_BROWSER_TOKEN` from `.env`; the scaffold ships `dev-token`
for local work. `auth::browser` admits a browser only when the token it receives matches, so change
this value for anything beyond your machine.

> [!NOTE]
>
> If you're continuing from the previous chapter you might still be in the `channel-client` folder.
> Make sure to `cd ..`!

## Put a proxy in front for browsers

The engine's port at `49134` is the **trusted** listener; local workers (link worker, analytics
worker) connect there. The browser must not. The `rbac-proxy` worker opens a separate public port
and reverse-proxies to the engine, authenticating every connection and gating every call it makes.
Uncomment the Ch. 7 `rbac-proxy` container:

```yaml worker-compose.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
rbac-proxy:
  worker: package://rbac-proxy
  version: "1.0.6"
  config_name: rbac-proxy
  config_override:
    host: 127.0.0.1
    port: 3110
    engine_url: ws://127.0.0.1:49134
    rbac:
      auth_function_id: auth::browser
      expose_functions:
        - match("link::create")
        - match("link::request_delete")
        - match("link::click_summary")
```

Restart Compose so it starts the proxy:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger compose::restart
```

`expose_functions` is an allowlist of which functions a browser session can call. The browser hears
about clicks through the `click-streamer::click` trigger type, not a function call, and reads the
starting total with `link::click_summary`, so that query is on the list too. `auth_function_id`
names a function `rbac-proxy` invokes on every connection to admit or reject it; you write that
next.

## Gate connections with an auth function

The `auth` worker owns connection gating, so the `link` worker stays focused on links.
`auth::browser` runs once per browser connection: it receives the request's `headers`,
`query_params`, and `ip_address`, and returns the session's permissions (allow/deny additions,
arbitrary context). Throw to reject. Create `auth/src/index.ts`:

```typescript auth/src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
import { registerWorker } from "iii-sdk";
import { Logger } from "@iii-dev/helpers/observability";

const worker = registerWorker(process.env.III_URL ?? "ws://localhost:49134", {
  workerName: "auth",
});
const logger = new Logger();

worker.registerFunction(
  "auth::browser",
  async (input: {
    headers: Record<string, string>;
    query_params: Record<string, string[]>;
    ip_address: string;
  }) => {
    // Fail closed: no LINKLY_BROWSER_TOKEN set means no browser is admitted.
    const expected = process.env.LINKLY_BROWSER_TOKEN;
    const token = input.query_params.token?.[0];
    if (!expected || token !== expected) {
      throw new Error("unauthorized");
    }
    // Each tab sends a unique session id and registers in its own
    // `browser-<session>` namespace, so the functions two tabs register never
    // collide. Granting only that namespace key keeps a tab out of `default`.
    const session = input.query_params.session?.[0];
    if (!session) {
      throw new Error("missing session");
    }
    return {
      allow_trigger_type_registration: false,
      // The tab may listen to the live click feed and nothing else.
      allowed_trigger_types: ["click-streamer::click"],
      allow_function_registration: true,
      namespaces: {
        [`browser-${session}`]: ["ui::on_click", "user::confirm_destructive_op"],
      },
      context: { source: "browser" },
    };
  },
);

logger.info("auth worker ready");
```

The client sends the same token as `VITE_LINKLY_TOKEN`, which you set below. A real deployment would
look the token up in a session store; the shape stays the same. The token travels in a query
parameter because browsers cannot send custom WebSocket headers.

Each tab also sends a unique `session` id. The engine allows one function per id in a namespace, so
two tabs both registering `ui::on_click` in `default` would collide. `namespaces` gives each session
its own `browser-<session>` namespace and permits registration only there: the tab's functions live
in isolation, and it still calls `link::*` in `default` through the `expose_functions` allowlist.
`allowed_trigger_types` does the same for bindings: the tab may bind the live click feed and no other
trigger type.

## Add a server-initiated delete

First give the `link` worker a `link::delete` that removes a link from both the database and the
`state` cache. Add it to `link/src/index.ts`:

```typescript link/src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction("link::delete", async (payload: { code: string }) => {
  await worker.trigger({
    function_id: "database::execute",
    payload: { db: DB, sql: "DELETE FROM links WHERE code = ?", params: [payload.code] },
  });
  await worker.trigger({
    function_id: "state::delete",
    payload: { scope: "links", key: payload.code },
  });
  logger.info("link deleted", { code: payload.code });
  return { deleted: true };
});
```

Now add a wrapper that asks the connected browser first, then deletes only if the browser confirms.
The server-side `worker.trigger` of a browser-registered function is the same primitive you've used
between server workers, in reverse. This lets us reverse the typical pattern: instead of the browser
asking the server for confirmation, the server asks the browser. Other implementations would achieve
this with SSE but here it works like normal synchronous code.

```typescript link/src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction(
  "link::request_delete",
  async (payload: { code: string; session: string }) => {
    const { confirmed } = await worker.trigger<
      { code: string; action: string },
      { confirmed: boolean }
    >({
      function_id: "user::confirm_destructive_op",
      // Ask the one tab that owns this session, in its private namespace.
      namespace: `browser-${payload.session}`,
      payload: { code: payload.code, action: `delete link "${payload.code}"` },
    });
    if (!confirmed) {
      return { deleted: false };
    }
    await worker.trigger({ function_id: "link::delete", payload: { code: payload.code } });
    return { deleted: true };
  },
);
```

## Scaffold the frontend

### Initialize a Vite project

From the project root (the folder with `worker-compose.yaml`), create a Vite + React + TypeScript
app. `create-vite` creates the `frontend/` folder for you:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
npm create vite@latest frontend -- --template react-ts --no-interactive
```

> [!NOTE]
>
> `--no-interactive` makes `create-vite` skip its questions: it scaffolds `frontend/` with its
> default linter and neither installs nor starts anything, since `iii-browser-sdk` has to be
> installed first. npm may first ask "Ok to proceed?" to download `create-vite`; answer yes.

Now install the dependencies:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
cd frontend
npm install
npm install iii-browser-sdk
```

`iii-browser-sdk` installs at 0.24.x while the backend workers pin `iii-sdk` 0.23.0. The two work
together: each one connects to the engine, never to the other.

Then tell the app where the proxy is and which token to send. Create `frontend/.env.local`; Vite
reads it at startup. `VITE_LINKLY_TOKEN` must equal `LINKLY_BROWSER_TOKEN` in the project's `.env`,
and `VITE_LINKLY_PROXY_URL` uses the rbac-proxy `port`:

```bash frontend/.env.local theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
VITE_LINKLY_TOKEN=dev-token
VITE_LINKLY_PROXY_URL=ws://localhost:3110
```

### Setup a client-side worker

Connect the SDK to our iii instance in `frontend/src/iii.ts`. Note how we're using a different URL
format and port, this goes back to the setup we did with `rbac-proxy` and RBAC.

```typescript src/iii.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
import { registerWorker } from "iii-browser-sdk";

// Both come from frontend/.env.local.
const TOKEN = import.meta.env.VITE_LINKLY_TOKEN;
const PROXY_URL = import.meta.env.VITE_LINKLY_PROXY_URL;
if (!TOKEN || !PROXY_URL) {
  throw new Error("Set VITE_LINKLY_TOKEN and VITE_LINKLY_PROXY_URL in frontend/.env.local");
}

// A per-tab id. The tab registers its functions in `browser-<SESSION>`, so two
// open tabs never collide on `ui::on_click` or `user::confirm_destructive_op`.
export const SESSION = crypto.randomUUID();

export const worker = registerWorker(
  `${PROXY_URL}?token=${encodeURIComponent(TOKEN)}&session=${SESSION}`,
  { namespace: `browser-${SESSION}` },
);
```

### Create the application

We'll build `src/App.tsx` in pieces. You can replace the template's `src/App.tsx` with the code
samples below.

#### Add imports

First the imports and types: `Click` is the notification `click-streamer::click` delivers, and
`Summary` is what `link::click_summary` returns.

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
import { useEffect, useState } from "react";
import { worker, SESSION } from "./iii.js";

type Click = { id: number; code: string; clicked_at: string };
type Summary = { total: number; last_id: number };
```

#### Add client-side state

Open the component and declare its state: the form fields, the newly created link, and the live
click counter.

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
export default function App() {
  const [url, setUrl] = useState('')
  const [code, setCode] = useState('')
  const [created, setCreated] = useState<{ code: string; url: string } | null>(null)
  const [clicks, setClicks] = useState(0)
  const [latest, setLatest] = useState<Click | null>(null)
```

#### Count clicks live

Bind to the `click-streamer::click` trigger type from Chapter 5. The tab is a worker like any other,
so it listens the same way `watch.ts` did: it registers a function (`ui::on_click`) and a trigger
that routes every click to it, then reads the starting total with `link::click_summary`.

Two details keep the counter correct:

* **Bind first, then read.** A click that lands between the two is still delivered. Clicks that
  arrive before the read returns wait in `early`, and the click id tells the tab whether the total
  already includes them, so nothing is counted twice.
* **Read again after a reconnect.** Clicks sent while the tab was disconnected are not replayed, but
  the stored total is always current.

The cleanup unregisters everything on unmount:

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
useEffect(() => {
  let lastId = 0; // highest click id already counted
  let ready = false; // false until the read lands
  const early: Click[] = [];

  function apply(click: Click) {
    if (click.id <= lastId) return; // a duplicate, or already in the total
    lastId = click.id;
    setClicks((n) => n + 1);
    setLatest(click);
  }

  async function sync() {
    ready = false;
    const summary = await worker.trigger<Record<string, never>, Summary>({
      function_id: "link::click_summary",
      // This tab is in its own namespace; link::click_summary lives in `default`.
      namespace: "default",
      payload: {},
    });
    lastId = summary.last_id;
    setClicks(summary.total);
    ready = true;
    early.splice(0).forEach(apply);
  }

  const fn = worker.registerFunction("ui::on_click", async (click: Click) => {
    if (ready) apply(click);
    else early.push(click);
    return null;
  });
  const trig = worker.registerTrigger({
    type: "click-streamer::click",
    function_id: "ui::on_click",
    config: {},
    // ui::on_click lives in this tab's namespace, so the trigger resolves it
    // there. registerTrigger fills that in from the worker's namespace.
  });
  sync();
  const stopListening = worker.addConnectionStateListener((state) => {
    if (state === "connected") sync();
  });
  return () => {
    stopListening();
    trig.unregister();
    fn.unregister();
  };
}, []);
```

#### Create a function

Register the function the server calls back when it needs human confirmation. It shows a native
prompt and returns the user's decision:

> [!NOTE]
>
> This function registers and runs the exact same as other functions did in previous chapters.
> Except for managing auth and permissions there is no functional difference between client side and
> server side.

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
useEffect(() => {
  const fn = worker.registerFunction(
    "user::confirm_destructive_op",
    async (data: { action: string; code: string }) => {
      const confirmed = window.confirm(`Confirm: ${data.action}?`);
      return { confirmed };
    },
  );
  return () => fn.unregister();
}, []);
```

#### Create links directly, no gateways

Submit the form by calling `link::create` directly.

> [!NOTE]
>
> There is no `fetch` or REST API in the way here, the client worker in the browser works the exact
> same as every other worker.

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
async function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  const link = await worker.trigger<{ url: string; code?: string }, { code: string; url: string }>({
    function_id: "link::create",
    // This tab is in its own namespace; link::create lives in `default`.
    namespace: "default",
    payload: { url, code: code || undefined },
  });
  setCreated(link);
  setUrl("");
  setCode("");
}
```

#### Create the UI

Finally, the UI: a link shortener form, the last-created link, and the live click counter.

```tsx src/App.tsx theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  return (
    <main>
      <h1>Linkly</h1>

      <form onSubmit={onSubmit}>
        <label>URL <input value={url} onChange={(e) => setUrl(e.target.value)} required /></label>
        <label>Code (optional) <input value={code} onChange={(e) => setCode(e.target.value)} /></label>
        <button type="submit">Shorten</button>
      </form>

      {created && (
        <p>
          Created <code>{created.code}</code> → <code>{created.url}</code>.
        </p>
      )}

      <section>
        <h2>Live clicks: {clicks}</h2>
        {latest && (
          <p>Last: <code>{latest.code}</code> at <code>{latest.clicked_at}</code></p>
        )}
      </section>

      <footer>
        <small>This tab is session <code>{SESSION}</code>.</small>
      </footer>
    </main>
  )
}
```

## See it work

Start the UI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
npm run dev
```

Open your browser, Vite typically hosts local websites at
[http://localhost:5173](http://localhost:5173).

### Shorten a link, then watch the clicks arrive in real time

Shorten a link from the form, then visit `http://localhost:3111/s/<code>` a few times. You'll see
the "Live clicks" counter go up in real time. Reload the tab and the counter starts from the stored
total, not from zero.

### Request user confirmation directly from the backend

Copy the session id the tab prints, then trigger the delete for one of your links, naming that
session so the server calls back the right tab:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger link::request_delete code=<code> session=<session from the tab>
```

That tab shows a confirm prompt, and the server deletes only after you click OK. The `session` names
the tab's namespace, so the server asks exactly the browser that owns it, never another tab.

## Conclusion

The client is a worker that is exactly the same as every other worker. We connected it through an
RBAC-gated port (via `rbac-proxy`) that uses an auth function to admit it because the browser isn't
trusted like our other workers. However any other worker can be gated this same way.

Once everything is set up our client calls server functions directly, binds a trigger type for live
updates, and registers functions the server calls back, all on the same iii bus as the rest of
Linkly.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.