用 iii-browser-sdk 把浏览器变成一等 Worker: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 章的完整实战:把浏览器标签页变成一个与本地 worker 完全同级的 iii worker。你将掌握三件事——通过iii-worker-manager的 RBAC 网关(auth函数 +expose_functions白名单)安全接入不受信任的浏览器连接;用iii-browser-sdk直接调用link::create而无需任何 REST API 网关;以及订阅clicks实时流刷新计数器,并注册一个由服务器反向调用的user::confirm_destructive_op函数实现“删除前人工确认”。读完本章,你会看到客户端与服务器端在 iii 总线上再无功能差别,只有鉴权与权限管理的差异。
本章目标:让浏览器成为第 N 个 worker
在 Linkly 前几章中,link、analytics、click-streamer等 worker 都通过引擎内置端口连接。本章要把浏览器也拉上同一条总线:
- 浏览器经 WebSocket 直连引擎,直接调用
link::create(中间没有 REST API 网关); - 订阅
clicks实时流,更新页面上的实时点击计数器; - 注册一个
user::confirm_destructive_op函数,当服务器要删除链接时反向询问浏览器里的真人。
关键差异在于信任模型:浏览器不是你亲手运行的进程,不能像本地 worker 一样默认信任。因此浏览器连接必须走iii-worker-manager的RBAC 网关监听器,与本地 worker 使用的可信端口分开。
第一步:创建 auth worker
认证逻辑单独封装进一个authworker,让linkworker 继续只关注链接本身。先在项目目录下脚手架一个新的 TypeScript worker(若你刚从第 6 章test-channels目录过来,先cd ..):
iii worker init auth --language typescript第二步:同时跑两个监听器
引擎内置端口49134是可信监听器,本地 worker(link、analytics 等)都从这里接入;浏览器绝不能走这里。在config.yaml中声明两个iii-worker-manager:一个保留可信的49134,一个在3110上提供 RBAC 网关给浏览器:
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是允许列表:一个浏览器会话能调用哪些函数 ID,每条规则是函数 ID 上的通配符match("...");auth_function_id指定iii-worker-manager在每次连接时调用的鉴权函数,用它决定准入或拒绝,就是你下一步要写的那个函数。
关于可信监听器的替换规则
有一个必须牢记的细节(同样写在 worker-manager 访问控制文档 中):只要在config.yaml里声明了任意一个iii-worker-manager,默认的49134可信监听器就会被替换掉。所以你必须把可信的49134监听器和 RBAC 监听器一起声明,否则你自己的 worker 会失去它们原本的接入端口。
第三步:用 auth 函数把关每个连接
auth::browser在每个浏览器连接建立时运行一次:它收到请求的headers、query_params和ip_address,返回该会话的权限(额外的允许/拒绝项、任意上下文);抛异常即拒绝连接。替换生成的auth/src/index.ts:
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; }) => { 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 查询参数携带。生产环境应当从会话存储(session store)查 token,但函数签名与返回结构保持不变。
- auth worker 自身走可信端口:它通过
III_URL(默认ws://localhost:49134)连接可信监听器——它是你自己的 worker;只有不受信任的浏览器客户端从3110进来。这个连接拓扑在 worker-manager 文档 中有明确图示说明。 - 返回对象就是
AuthResult,完整字段语义如下:
| 字段 | 含义 |
|---|---|
allowed_functions | 在expose_functions之外额外允许的函数 ID |
forbidden_functions | 即使匹配也拒绝的函数 ID,优先级最高 |
allowed_trigger_types | 会话可以绑定的触发器类型,省略则允许全部 |
allow_trigger_type_registration | 会话是否可以注册新的触发器类型 |
allow_function_registration | 会话是否可以注册函数(默认true) |
context | 任意对象,转发给每次调用的中间件 |
function_registration_prefix | 应用于会话注册的每个函数的前缀 |
示例返回中allowed_functions与forbidden_functions都为空、allow_function_registration为true,意味着会话可注册自己的回调(前端需要注册ui::on_click与user::confirm_destructive_op),但不能注册新的触发器类型。
注册到项目:
iii worker add ./auth源码层面的 RBAC 决策流
从引擎侧源码 rbac_config.rs 可以看到,一次调用的放行判定顺序(该文件注释中的决策流)为:
function_id在forbidden_functions中 →拒绝(且全局生效,跨命名空间);- 命中会话按命名空间授予的 grants(
session_namespaces)→ 放行; - 无命名空间的
allowed_functions仅在default命名空间生效 → 放行; INFRASTRUCTURE_FUNCTIONS(如engine::log::*、engine::workers::register、engine::channels::create)始终放行,保证连接建立、日志、上下文传播可用;- 任一
expose_functions过滤规则匹配 → 放行; - 否则拒绝(默认拒绝,fail-closed)。
expose_functions的match("...")通配符语法也由该文件实现:支持前缀engine::*、后缀*::public、中间api::*::read等任意*通配,另有基于函数metadata的过滤规则(见 worker-manager 文档 的expose_functions小节)。引擎的端到端测试 rbac_infrastructure_e2e.rs 验证了“受限expose_functions下基础设施函数仍可用”与“forbidden_functions优先于基础设施放行”这两条关键性质。
第四步:服务器发起的删除(反向调用)
先给linkworker 增加一个真正的link::delete,同时清理数据库与state缓存:
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 }; });再包一层“先问浏览器、确认后才删”的封装。服务器端worker.trigger一个浏览器注册的函数,用的正是你在服务器 worker 之间用过无数次的原语,只是方向反转:不是浏览器问服务器要确认,而是服务器问浏览器。别的架构要用 SSE 才能做到的事,这里就是普通的同步代码:
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 }; });第五步:脚手架前端
初始化 Vite 项目
在linkly/frontend/下创建 Vite + React + TypeScript 应用:
npm create vite@latest frontend -- --template react-ts注意:Vite 会询问 “Install with npm and start now”,此处回答 no,因为我们还要先装iii-browser-sdk。随后安装依赖:
cd frontend npm install npm install iii-browser-sdk建立客户端 worker
在frontend/src/iii.ts中把 SDK 连到 iii 实例。注意这里使用了与服务器 worker 不同的 URL 格式和端口——这正是前面iii-worker-manager+ RBAC 配置的落地:
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与服务器端LINKLY_BROWSER_TOKEN(或默认dev-token)必须一致,因为auth::browser校验的就是这个 token。
浏览器 SDK 的连接行为
从浏览器 SDK 源码 sdk/packages/node/iii-browser/src/iii.ts 可以看到registerWorker的几个值得注意的默认行为:
- 浏览器没有 pid 可区分身份,
workerName默认为browser:<随机后缀>,避免两个标签页共享固定名字时互相驱逐; - 浏览器没有
process.env,命名空间必须通过options.namespace显式传入,否则走引擎的default; - 内置断线重连(指数退避 + 抖动)、调用超时默认 30000ms、连接状态监听等能力与 Node SDK 一致;
- 浏览器 WebSocket 无法携带自定义头,SDK 明确忽略
headers选项,鉴权凭证只能走 query 参数或 cookie——这与前面 token 走 query 的设计相互印证。
第六步:编写应用
下面按片段组装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 章(streaming.mdx)搭好的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(); }; }, []);这段代码与第 5 章中click-streamer的stream::set(写clicks/all组)正好闭合:stream::set既存储条目,又把它推送给订阅了该流与组的所有 WebSocket。SDK 侧StreamTriggerConfig支持stream_name、group_id、item_id与condition_function_id(见 sdk/packages/node/iii-browser/src/stream.ts),这里按流 + 组订阅即可收到全部新点击。
注册服务器反向调用的函数
注册服务器需要人工确认时回调的函数:弹原生confirm并返回用户决定。注意——这个函数的注册与运行方式和前面章节的服务器函数完全一样;除了鉴权与权限管理,客户端与服务器端没有任何功能差别:
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 devVite 默认把本地站点托管在 http://localhost:5173。
缩短一个链接,实时看到访问流入
在表单里缩短一个链接,然后多次访问http://localhost:3111/s/<code>——页面的 “Live clicks” 计数会实时上涨。数据链路:link::record_click发布link.clicked(pubsub)→click-streamer的subscribe触发器触发click-streamer::broadcast→stream::set写入clicks/all→ 浏览器ui::on_click收到StreamEvent更新计数。
从后端直接请求用户确认
在终端里通过 iii 触发浏览器中的函数:
iii trigger link::request_delete code=<code>浏览器会弹出 confirm 提示框,只有你点击 OK 后服务器才真正执行删除(此时link::request_delete继续调用link::delete清理数据库与state缓存);点击取消则返回{ deleted: false },链接保留。
小结
客户端就是一个与所有其他 worker 完全相同的 worker。它通过iii-worker-manager提供的 RBAC 网关监听器接入,由一个auth函数负责准入——因为浏览器不像我们的其他 worker 那样受信任。这套机制并不局限于浏览器:任何其他 worker 都可以用同样的方式被网关保护。
一切就绪后,客户端直接调用服务器函数、订阅实时流获取更新、注册函数供服务器回调——全部发生在与 Linkly 其他部分相同的 iii 总线上。想要更系统地理解 RBAC 网关(含中间件拦截、注册钩子与多语言示例),可继续阅读 worker-manager 访问控制文档;若要完整重跑整个 Linkly 项目,可回到 教程总览 从第 1 章开始。
【免费下载链接】iiiEffortlessly compose, extend, and observe every service in real-time for the first time ever.项目地址: https://gitcode.com/GitHub_Trending/mo/iii
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考