news 2026/9/14 13:39:48

iii 实战:把浏览器变成 Worker——基于 iii-browser-sdk 构建 Linkly 前端并接入 RBAC 门控监听

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
iii 实战:把浏览器变成 Worker——基于 iii-browser-sdk 构建 Linkly 前端并接入 RBAC 门控监听

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::resolvedatabaseWorker 负责 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-managerauthWorker

浏览器 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_idexpose_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_paramsquery_to_multi_map),把headers转为 map,连同ip_address一起构造成{ headers, query_params, ip_address }JSON 传给auth_function_id指定的函数;函数抛错或返回无法解析的结果时,连接以AUTH_ERROR被拒绝。这解释了为什么auth::browser的入参恰好是headersquery_paramsip_address三个字段。

第三步:用 auth 函数门控连接

authWorker 负责连接准入,因此linkWorker 可以专注于链接业务。auth::browser对每次浏览器连接运行一次:它接收请求的headersquery_paramsip_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_functionsforbidden_functionsallow_trigger_type_registrationallow_function_registrationcontext正是 rbac_session.rs 中AuthResult反序列化的字段。其中allow_function_registration默认值为truedefault_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-ts

Vite 可能会询问 "Install with npm and start now",此处选择 no——我们还需要先安装iii-browser-sdk

安装依赖:

cd frontend npm install npm install iii-browser-sdk

5.2 配置客户端 Worker

src/iii.ts中接入 SDK。核心是registerWorker:它创建并连接引擎,返回 SDK 实例(该初始化 API 在 sdk/packages/node/iii/README.md 中有对应文档)。token 通过 URL query 传入,正好被auth::browserquery_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

导入与类型Clickclicks表的一行,StreamEventiii-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-streamclick-streamer推送到浏览器中的ui::on_click函数。

场景二:从后端直接请求用户确认

接着通过 iii CLI 在浏览器中运行一个函数:

iii trigger link::request_delete code=<code>

浏览器会弹出确认提示框,只有你点击 OK 后服务端才执行删除——link::request_deleteuser::confirm_destructive_op(浏览器)→link::delete的完整回调链路就此打通。

结论

回顾这一章的核心收获:

  • 客户端就是一个与其他 Worker 完全相同的 Worker。它通过iii-worker-manager的 RBAC 门控监听器连接(经由 auth 函数准入),因为浏览器不像其他本地 Worker 那样受信任——但这种门控方式同样适用于任何其他 Worker,并不局限于浏览器场景。
  • 一旦配置完成,客户端就能直接调用服务端函数link::create)、订阅流获取实时更新clicksui::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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 13:38:44

DeepSeek Harness配置实战:通用设置与Agent预设拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/14 13:38:03

OpenClaw 跑 baidu-search Skill:模型 Key 走 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华