link::create directly (no REST API Gateway), observes 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 therbac-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:
worker-compose.yaml
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.
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 at49134 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:
worker-compose.yaml
expose_functions is an allowlist of the functions a browser session can call and observe. The tab
creates links with link::create, asks for deletes with link::request_delete, reads the starting
click total with link::click_summary, and observes the live click feed,
click-streamer::on-click. The feed is private (Chapter 5): the engine accepts calls of it only
from click-streamer, so the tab can observe it and cannot call it. 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
Theauth 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:
auth/src/index.ts
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.
Observers follow the session’s grants: a session may observe a function its grants cover, with a
target its grants cover, and a private function such as the click feed is observable this way. The
tab observes click-streamer::on-click, which expose_functions lists, with ui::on_click, a
function in its own namespace, as the target.
Add a server-initiated delete
First give thelink worker a link::delete that removes a link from both the database and the
state cache. Add it to link/src/index.ts:
link/src/index.ts
worker.call 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.
link/src/index.ts
Scaffold the frontend
Initialize a Vite project
From the project root (the folder withworker-compose.yaml), create a Vite + React + TypeScript
app. create-vite creates the frontend/ folder for you:
--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.iii-browser-sdk is pinned to 0.26.0, the same version as the iii-sdk the backend workers pin:
the tab uses the 0.26 observe and call API. 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:
frontend/.env.local
Setup a client-side worker
Connect the SDK to our iii instance infrontend/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.
src/iii.ts
Create the application
We’ll buildsrc/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 what click-streamer::on-click returns for each click, and
Summary is what link::click_summary returns.
src/App.tsx
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
Count clicks live
Observe theclick-streamer::on-click feed 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 observes the
feed with it, then reads the starting total with link::click_summary. The feed is registered in
default and the tab in its own namespace, so the observer names the feed’s namespace.
Two details keep the counter correct:
- Observe first, then read. A click that lands between the two is still delivered. Clicks that
arrive before the read returns wait in
early. The read’slast_idtells the tab which clicks the total already includes, and the tab remembers each later click id it counts. The queue delivers several clicks at once, so they can arrive out of order, and nothing is counted twice or skipped. - Read again after a reconnect. Clicks sent while the tab was disconnected are not replayed, but the stored total is always current.
src/App.tsx
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
Create links directly, no gateways
Submit the form by callinglink::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
Create the UI
Finally, the UI: a link shortener form, the last-created link, and the live click counter.src/App.tsx
See it work
Start the UI:Shorten a link, then watch the clicks arrive in real time
Shorten a link from the form, then visithttp://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 call the delete for one of your links, naming that session so the server calls back the right tab: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 (viarbac-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, observes a server function for
live updates, and registers functions the server calls back, all on the same iii bus as the rest of
Linkly.