Skip to main content
In this chapter the browser becomes a worker. It connects to the engine over WebSocket, calls link::create directly (no REST API Gateway), subscribes to the live click stream 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 iii-worker-manager’s RBAC-gated listener, separate from the trusted port your local workers use. We’ll encapsulate the authentication logic in an auth worker to gate those connections, so scaffold it the same way you scaffolded link in Chapter 1:
iii worker add iii-worker-manager
iii worker init auth --language typescript

Run two listeners

The engine’s built-in port at 49134 is the trusted listener; local workers (link worker, analytics worker) connect there. The browser must not. Add two iii-worker-manager entries: the trusted one (local workers keep using it) and an RBAC-gated one on 3110 for browsers:
config.yaml
workers:
  # ...
  # Trusted listener for local workers. Replaces the engine's built-in 49134.
  - name: iii-worker-manager
    config:
      port: 49134

  # Browser-facing listener. The auth function gates every connection; only the
  # functions in `expose_functions` are reachable from sessions it admits.
  - name: iii-worker-manager
    config:
      host: 127.0.0.1
      port: 3110
      rbac:
        auth_function_id: auth::browser
        expose_functions:
          - match("link::create")
          - match("link::request_delete")
          - match("stream::*")
expose_functions is an allowlist of which functions a browser session can call. auth_function_id names a function iii-worker-manager 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. Replace the generated auth/src/index.ts:
auth/src/index.ts
import { registerWorker, Logger } from "iii-sdk";

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;
  }) => {
    const token = input.query_params.token?.[0];
    if (!token || token !== (process.env.LINKLY_BROWSER_TOKEN ?? "dev-token")) {
      throw new Error("unauthorized");
    }
    return {
      allowed_functions: [],
      forbidden_functions: [],
      allow_trigger_type_registration: false,
      allow_function_registration: true,
      context: { source: "browser" },
    };
  },
);

logger.info("auth worker ready");
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. Register it with your project:
iii worker add ./auth

Add a server-initiated delete

First give the link worker a link::delete that removes a link from both the database and the iii-state cache. Add it to link/src/index.ts:
link/src/index.ts
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:
link/src/index.ts
worker.registerFunction("link::request_delete", async (payload: { code: string }) => {
  const { confirmed } = await worker.trigger<
    { code: string; action: string },
    { confirmed: boolean }
  >({
    function_id: "user::confirm_destructive_op",
    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

Create a Vite + React + TypeScript app under linkly/frontend/:
npm create vite@latest frontend -- --template react-ts
Vite may ask you to “Install with npm and start now”, answer no here as we first need to install iii-browser-sdk
Now install the dependencies:
cd frontend
npm install
npm install iii-browser-sdk

Setup a client-side worker

Wire the SDK in src/iii.ts:
src/iii.ts
import { registerWorker } from "iii-browser-sdk";

const TOKEN = import.meta.env.VITE_LINKLY_TOKEN ?? "dev-token";

export const worker = registerWorker(`ws://localhost:3110?token=${encodeURIComponent(TOKEN)}`);

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 one row from the clicks table, and StreamEvent is the wrapper iii-stream delivers to subscribers.
src/App.tsx
import { useEffect, useState } from "react";
import { worker } from "./iii.js";

type Click = { code: string; clicked_at: string };
type StreamEvent = {
  event: { type: "create" | "update" | "delete"; data: Click };
};

Add client-side state

Open the component and declare its state: the form fields, the newly created link, and the live click counter.
src/App.tsx
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)

Subscribe to clicks

Subscribe to the clicks stream we setup in Chapter 5. The useEffect registers a function the browser exposes (ui::on_click) and a stream trigger that routes every new row to it; the cleanup unregisters both on unmount:
src/App.tsx
useEffect(() => {
  const fn = worker.registerFunction("ui::on_click", async (event: StreamEvent) => {
    setClicks((n) => n + 1);
    setLatest(event.event.data);
    return null;
  });
  const trig = worker.registerTrigger({
    type: "stream",
    function_id: "ui::on_click",
    config: { stream_name: "clicks", group_id: "all" },
  });
  return () => {
    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:
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.
src/App.tsx
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();
}, []);
Submit the form by calling link::create directly.
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.
src/App.tsx
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",
    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 streaming click counter.
src/App.tsx
  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>
    </main>
  )
}

See it work

Start the UI:
npm run dev
Open your browser, Vite typically hosts local websites at http://localhost:5173. Shorten a link from the form, then visit http://localhost:3111/s/<code> a few times. You’ll see the “Live clicks” counter goes up in real time.

Request user confirmation directly from the backend

Then run a function in the browser via iii by runnning:
iii trigger link::request_delete code=<code>
The browser will show a confirm prompt, and the server deletes only after you click OK.

Conclusion

The client is a worker that is exactly the same as every other worker. We connected it through an RBAC-gated listener (via iii-worker-manager) 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, subscribes to streams for live updates, and registers functions the server calls back, all on the same iii bus as the rest of Linkly.