iii 实战:把浏览器变成 Worker——基于 iii-browser-sdk 构建 Linkly 前端并接入 RBAC 门控监听
【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii
本篇是 Linkly 教程的收尾章节(第 7 章),讲解如何让一个浏览器标签页成为 iii 的"一等公民"Worker:它通过 WebSocket 直连引擎,直接调用link::create(中间没有任何 REST API 网关),订阅实时点击流更新计数器,并注册一个user::confirm_destructive_op函数供服务端在删除链接前向用户请求人工确认。读完本文,你将掌握iii-worker-manager的双监听器部署、基于 auth 函数的连接准入(RBAC 门控)、iii-browser-sdk的客户端 Worker 接入,以及"服务端回调浏览器"这一反向调用模式的完整实现。
背景:浏览器成为总线上的普通 Worker
前六章中,Linkly 已经是一个由多个 Worker 组成的系统:linkWorker 提供link::create/link::resolve,databaseWorker 负责 SQLite 持久化,click-streamerWorker 用iii-stream把每次点击实时推送给订阅者(详见 overview)。
本章的核心观点是:浏览器客户端与其他任何 Worker 没有任何功能差别。它连接到引擎后,可以:
- 直接调用服务端函数(
link::create),不需要fetch或 REST API 网关; - 订阅实时流(
clicks)更新 UI 计数器; - 注册自己的函数(
user::confirm_destructive_op),让服务端反过来调用它。
唯一的区别在于信任边界:浏览器是不可信的连接方,因此它必须通过iii-worker-manager的 RBAC 门控监听器接入,而不是直接连到本地 Worker 使用的受信任端口。
第一步:添加iii-worker-manager与authWorker
浏览器 Worker 通过iii-worker-manager的 RBAC 门控监听器连接,与本地 Worker 使用的受信任端口相互独立。我们把连接准入逻辑封装在一个独立的authWorker 中,使linkWorker 保持职责单一(只处理链接业务)。用与第 1 章搭建link相同的方式初始化:
iii worker add iii-worker-manager iii worker init auth --language typescript说明:文档中标注
iii worker add iii-worker-manager正在加入 registry,命令端到端可用后需重新验证;如果当前版本命令尚不支持,可对照仓库中iii-worker-manager的配置格式手动写入配置。
第二步:运行双监听器(受信任端口 + 浏览器门控端口)
引擎内置的49134端口是受信任监听器,本地 Worker(link、analytics 等)连接到这里,浏览器绝不能使用它。因此需要在config.yaml中添加两条iii-worker-manager配置:一条受信任的(本地 Worker 继续使用),一条面向浏览器的 RBAC 门控监听器,运行在3110端口:
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:允许浏览器会话调用的函数白名单。白名单之外的函数即使被调用也会被拒绝。auth_function_id:指定一个函数名,iii-worker-manager会在每个连接建立时调用它以决定准入或拒绝。这个函数就是你接下来要实现的auth::browser。
源码级的 RBAC 语义
从源码看,iii-worker-manager的 RBAC 决策远不止"白名单"一层。在 engine/src/workers/worker/rbac_config.rs 中,RbacConfig定义了auth_function_id与expose_functions字段;而 engine/src/workers/worker/rbac_session.rs 中Session结构体的字段揭示了完整的权限模型:
namespaces:按命名空间作用域的授权(如{ "orders": ["svc::*"] }),空 map 表示连接保持命名空间前的行为;allowed_functions:不限定命名空间的授权,仅在default命名空间生效;forbidden_functions:全局拒绝。源码注释明确指出"deny-wins"(拒绝优先)原则——一个在某命名空间成立、在另一命名空间不成立的拒绝会让人无法推理,因此拒绝必须全局生效、以拒绝方为兜底;allow_trigger_type_registration/allow_function_registration:是否允许该会话注册触发器类型与函数;context:随会话携带的任意上下文(如{ source: "browser" });function_registration_prefix:函数注册前缀约束。
权限裁决的优先级(见 rbac_config.rs 中的注释)为:先查forbidden_functions(命中即拒绝),再查allowed_functions(命中即允许),最后检查expose_functions过滤器是否匹配;命名空间发现类函数(如engine::functions::list)始终放行,不受expose_functions限制。
Session::authenticate的输入结构(rbac_session.rs)与 auth 函数的参数完全对应:它把 WebSocket 握手时的uri解析为query_params(query_to_multi_map),把headers转为 map,连同ip_address一起构造成{ headers, query_params, ip_address }JSON 传给auth_function_id指定的函数;函数抛错或返回无法解析的结果时,连接以AUTH_ERROR被拒绝。这解释了为什么auth::browser的入参恰好是headers、query_params、ip_address三个字段。
第三步:用 auth 函数门控连接
authWorker 负责连接准入,因此linkWorker 可以专注于链接业务。auth::browser对每次浏览器连接运行一次:它接收请求的headers、query_params和ip_address,返回会话的权限(允许/拒绝的函数、任意上下文);抛出异常即拒绝连接。替换生成的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");几点值得注意:
- Token 走 query 参数:浏览器无法发送自定义 WebSocket 头,所以 token 只能放在 URL query 里。生产部署时应在会话存储(session store)中查询 token,但返回值结构保持不变。
- 返回值字段与源码一致:
allowed_functions、forbidden_functions、allow_trigger_type_registration、allow_function_registration、context正是 rbac_session.rs 中AuthResult反序列化的字段。其中allow_function_registration默认值为true(default_true),context默认{},allow_trigger_type_registration默认false——所以本例里显式开启函数注册、关闭触发器类型注册。 - 这里
allowed_functions留空,实际准入完全交给expose_functions白名单兜底,符合"最小权限"思路。
把它注册到你的项目:
iii worker add ./auth第四步:服务端主动发起删除(服务端→浏览器回调)
4.1 先给linkWorker 添加link::delete
link::delete同时从数据库和iii-state缓存中删除一条链接。在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 }; });4.2 添加link::request_delete:先问浏览器,确认才删
link::request_delete是一个包装函数:先向已连接的浏览器触发user::confirm_destructive_op,只有浏览器确认后才真正执行link::delete。服务端对浏览器注册函数的worker.trigger,与你一直在服务端 Worker 之间使用的原语完全相同,只是方向反过来:
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 }; });注意worker.trigger的两个泛型参数:第一个是发送给目标函数的 payload 类型({ code, action }),第二个是期望的返回类型({ confirmed: boolean })。这与你调用link::create时的类型约束方向一致——被调函数可以不在当前进程中,类型契约由你显式声明。
第五步:搭建前端工程
5.1 初始化 Vite 项目
在linkly/frontend/下创建 Vite + React + TypeScript 应用:
npm create vite@latest frontend -- --template react-tsVite 可能会询问 "Install with npm and start now",此处选择 no——我们还需要先安装
iii-browser-sdk。
安装依赖:
cd frontend npm install npm install iii-browser-sdk5.2 配置客户端 Worker
在src/iii.ts中接入 SDK。核心是registerWorker:它创建并连接引擎,返回 SDK 实例(该初始化 API 在 sdk/packages/node/iii/README.md 中有对应文档)。token 通过 URL query 传入,正好被auth::browser从query_params.token读到:
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)}`);VITE_LINKLY_TOKEN是 Vite 的环境变量(import.meta.env),未设置时回退到与 auth 函数默认值一致的dev-token。注意连接地址是3110(RBAC 门控监听器),而不是本地 Worker 的 49134。
5.3 编写应用:src/App.tsx
我们将分片段构建src/App.tsx,可以直接用下面的代码替换模板生成的src/App.tsx。
导入与类型:Click是clicks表的一行,StreamEvent是iii-stream投递给订阅者的包装结构:
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 }; };客户端状态:表单字段、刚创建的链接、实时点击计数:
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)订阅clicks流:订阅第 5 章搭建的clicks流。useEffect注册一个浏览器暴露的函数(ui::on_click)和一个stream触发器,把每条新记录路由给它;清理函数在卸载时同时反注册两者:
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(); }; }, []);registerTrigger返回一个可unregister()的触发器句柄,registerFunction返回可unregister()的函数句柄(这两个 API 在 sdk/packages/node/iii/src/types.ts 中有类型定义)。这里group_id: "all"表示所有订阅者都收到每条点击事件。
注册被回调函数:注册服务端需要人工确认时回调的函数。它弹出原生确认框并返回用户决定:
该函数与前面章节注册的其他函数完全一致地注册与运行。除了鉴权与权限管理之外,客户端与服务端函数在功能上没有差别。
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(); }, []);直接创建链接,无网关:提交表单时直接调用link::create。
这里没有任何
fetch或 REST API 挡在中间,浏览器中的客户端 Worker 与其他所有 Worker 的工作方式完全相同。
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(""); }UI:短链表单、最近创建的链接、实时流式点击计数器:
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> ) }第六步:运行并验证
启动 UI:
npm run dev打开浏览器访问 Vite 默认地址 http://localhost:5173。
场景一:缩短链接,实时看到访问流
用表单缩短一个链接,然后多次访问http://localhost:3111/s/<code>。你会看到 "Live clicks" 计数器实时增长——每一次点击都经过iii-stream从click-streamer推送到浏览器中的ui::on_click函数。
场景二:从后端直接请求用户确认
接着通过 iii CLI 在浏览器中运行一个函数:
iii trigger link::request_delete code=<code>浏览器会弹出确认提示框,只有你点击 OK 后服务端才执行删除——link::request_delete→user::confirm_destructive_op(浏览器)→link::delete的完整回调链路就此打通。
结论
回顾这一章的核心收获:
- 客户端就是一个与其他 Worker 完全相同的 Worker。它通过
iii-worker-manager的 RBAC 门控监听器连接(经由 auth 函数准入),因为浏览器不像其他本地 Worker 那样受信任——但这种门控方式同样适用于任何其他 Worker,并不局限于浏览器场景。 - 一旦配置完成,客户端就能直接调用服务端函数(
link::create)、订阅流获取实时更新(clicks→ui::on_click),并注册函数供服务端回调(user::confirm_destructive_op),全部运行在同一条 iii 总线上,与 Linkly 的其他部分无异。 - 权限模型由三层构成:
forbidden_functions全局拒绝优先、allowed_functions/命名空间作用域授权、expose_functions白名单兜底,源码实现见 engine/src/workers/worker/rbac_session.rs 与 engine/src/workers/worker/rbac_config.rs。
至此,Linkly 教程的完整形态达成:一个从单函数短链服务起步、演进为包含持久化、可观测、实时流、批量导入与浏览器 Worker 的多 Worker 系统——每一次能力扩展都是新增一个 Worker,而无需重写任何既有代码。
【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考