跳到正文

Runtime Protocol(运行时协议)

本页译自英文。 阅读英文页面

状态:草案,由 zeyn::server 和 assets/zeyn/*.js(以 /_zeyn/zeyn.js 提供)实现。

本文是 Zeyn 服务端与浏览器运行时之间的约定。界面(Screen)在服务端渲染。浏览器运行时是一个很小的脚本,它把用户操作发送给服务端,并应用服务端返回的结果。应用代码从不编写 JavaScript。

1. Page load(页面加载)

GET {route}?params={json} 返回完整的 HTML 文档。params 是 Screen 参数经 URL 编码的 JSON;Screen 没有参数时省略。

带路径段的路由,例如 /orders/{id},从路径(/orders/42)读取对应的 params 字段,其余字段从 params 读取。无法解析的路径段值返回 404。值经过百分号编码,包括点号(%2E),因此 Screen 的 URL 在运行时看来永远不像文件。返回 Screen::moved_to 的 Screen 会得到 301 Moved Permanently,新 URL 位于 Location 中。

失败的页面以对应状态码(404、403、500 等)返回,内容是应用的错误 Screen(App::not_found、App::server_error)或内置的简短提示,并置于外壳(shell)之内。带有 x-zeyn-partial: 1 的请求只会得到该 Screen,响应带有 x-zeyn-partial: 1 和 x-zeyn-title,运行时据此无需整页加载即可换入。错误详情绝不会到达浏览器。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="zeyn-csrf" content="TOKEN">
  <title>Products · Inventory</title>
  <link rel="stylesheet" href="/_zeyn/zeyn.css?v=BUILD">
  <script type="module" src="/_zeyn/zeyn.js?v=BUILD"></script>
</head>
<body>
  <a class="z-skip" href="#z-main">Skip to content</a>
  <div class="z-app">
    <nav class="z-nav" aria-label="Main">...</nav>
    <main class="z-main" id="z-main">SCREEN</main>
  </div>
  <div class="z-toasts" role="status" aria-live="polite"></div>
</body>
</html>

菜单只列出当前用户可以打开的 Screen。当前菜单项带有 aria-current="page"。

head 中每个框架脚本和样式表都带有 ?v= 和构建 id(meta[name=zeyn-build],来自 ZEYN_BUILD_ID 或可执行文件的哈希);theme.css 带有主题文本的哈希。带当前值的请求会得到 Cache-Control: public, max-age=31536000, immutable,因此热页面加载不会再请求它们;其他任何请求(部署后的旧标签页、没有 v)得到 no-cache 和 ETag。运行时稍后导入的模块,会从导入它们的脚本的 URL 中沿用同一个 v。

每个页面都会加载 zeyn.js(请求、效果、窗口、表单、菜单、快捷键和事件委托)、nav.js、components.js,以及在应用外壳中加载 pro.js 和 enterprise.js。其余的只在用到它们的页面上加载,nav.js 换入某个 Screen 时也是如此:内容第一次包含 [data-zeyn-grid] 时,zeyn.js 会导入 grid 运行时 grid.js(带有 grid 的完整页面在 <link rel="modulepreload"> 中指明它),而 components.js 会导入 catalog.js(卡片 grid、搜索框)、media.js(播放器、嵌入内容)、files.js(上传)、charts.js 和 assistant.js(聊天面板,或第一次点击启动器时)。它们的样式表仍留在 head 中,所以脚本到达时外观不会变化。在展示库仪表板上,这意味着 5 个脚本、258 KB(传输 74 KB),而不是 9 个脚本、403 KB(116 KB)。

2. Screen markup(Screen 标记)

每个 Screen 根元素都带有其名称和参数:

<section class="z-screen" data-zeyn-screen="product_list" data-zeyn-params="null" aria-labelledby="product_list-title">

Window 使用 class="z-window" role="dialog" aria-modal="true" 和 --z-width 样式。

每个主体项都包裹在一个节点元素中。其路径是下面各端点所使用的地址。容器(Row、Stack、Panel、Card、Tabs、Split)以同样方式包裹每个子项,子项的路径追加其索引:主体项 1 的第一个子项的第三个子项为 "1.0.2"。

<div class="z-node" data-zeyn-node="1">
  <div class="z-node" data-zeyn-node="1.0">...</div>
</div>

端点的 node 参数可以是点分隔的字符串,对于主体项也可以是纯数字(0)。路径最多 16 层数字;其他情况一律以 422 拒绝。

3. Attributes the runtime reads(运行时读取的属性)

AttributeOnMeaning
data-zeyn-effectbutton, tr点击时执行的 JSON effect(行:双击或按 Enter 时)
aria-keyshortcutsbutton键盘快捷键,例如 Control+N
data-zeyn-exportbutton同一 Screen 上 grid 的导出格式(csv、xlsx)
data-zeyn-gridgrid root标记一个 grid;同时带有 data-zeyn-mode、data-zeyn-paging、data-zeyn-page-size、data-zeyn-total、data-zeyn-next、data-zeyn-offset
data-field, aria-sort, aria-colindexth列的字段名、当前排序、位置
data-filter, data-optionsth筛选输入类型(text、number、money、date、datetime、time、choice、bool)以及 choice 选项 [{value,label}]
data-pinth固定列的 start 或 end(选择列和行操作列同样适用)
data-edit, data-optionalth可编辑列的输入类型,面向有保存权限的用户
data-zeyn-texts, data-zeyn-columns, data-zeyn-viewgrid root本地化的控件文本、全部列 [{field,label,hidden,pin,width}]、已保存的视图
data-zeyn-viewsgrid root视图保存在服务端时为 server
data-zeyn-version-field, data-zeyn-exportablegrid root行内编辑所用的行版本字段名;grid 可导出时为 csv
data-zeyn-key, data-zeyn-version, data-zeyn-menutr行键、行版本、上下文菜单项
data-vtd可编辑单元格的输入值
data-zeyn-detail, aria-expandedtr该行的详情 Screen,形式为 {screen, params};是否已展开
data-zeyn-expandbutton in a row打开或关闭该行的详情、分组或子项
data-zeyn-level, aria-level, data-zeyn-pathtr in a treegrid分组 grid 或树形 grid 中的深度(顶层为 0);该行展开时要发送的分组值或祖先键的路径
z-grouptr分组标题行;z-branch-more 是分支的“加载更多”行
data-zeyn-runbutton in a row要执行的行项目(a0 是第一个行操作)
data-aggtfoot td该列显示的合计(sum avg min max count)
data-zeyn-bulkdiv before the grid带有 data-zeyn-bulk-run 按钮的选择栏
data-zeyn-menuscreen rootScreen 的上下文菜单
data-zeyn-native-menu="off"html除文本输入框外,关闭浏览器自带的上下文菜单
data-zeyn-rowstbody行的放置位置
data-zeyn-pagepager buttonprev 或 next
data-zeyn-page-infospan形如 1-50 of 230 的文本,由 grid 根元素上的本地化模板生成
data-zeyn-text-range, data-zeyn-text-range-total, data-zeyn-text-loadedgrid root本地化模板:{from}-{to}、{from}-{to} of {total}、{count} rows loaded
data-zeyn-morediv无限滚动哨兵,包含“加载更多”按钮
data-zeyn-submitform提交 action 的名称
data-zeyn-editorinput自定义字段类型的客户端编辑器

Effects:

EffectJSONRuntime does
Open window{"effect":"open_window","screen":"product_form","params":{...}}将该 Screen(第 4.1 节)加载到模态 <dialog> 中
Navigate{"effect":"navigate","screen":"...","params":{...}}加载 /_zeyn/route/{screen}?params=,它会重定向到该页面;其路由带路径段时使用路径形式
Toast{"effect":"toast","message":"Saved"}显示 4 秒的 toast
Close and refresh{"effect":"close_and_refresh"}关闭最上层的 dialog,并重新加载其下方 Screen 上的每个 grid

4. Endpoints(端点)

所有 POST 请求都发送 JSON,必须携带请求头 x-zeyn-csrf,其值取自 zeyn-csrf meta 标签,否则以 403 拒绝。

未登录状态下打开的公开 Screen,其 zeyn-csrf meta 标签为空,也不带 Set-Cookie,以便可以被缓存。在第一次 POST 之前,运行时会调用 GET /_zeyn/csrf,它返回 { "token": "..." } 并带有 Cache-Control: no-store,在浏览器还没有 cookie 时设置 zeyn_csrf cookie,并拒绝 Sec-Fetch-Site 不是 same-origin 的请求。令牌被放入 meta 标签;调用失败时,POST 不带令牌发出,并像其他请求一样被拒绝。不会有任何静默重试。

应用内导航(nav.js)以 x-zeyn-partial: 1 请求 Screen,只得到其 HTML,页面标题位于 x-zeyn-title。当 Screen 设置了 head 标签(description、canonical URL、预览图、robots、JSON-LD)时,正文以 <template data-zeyn-head> 开头,其中包含这些标签;nav.js 会移除文档 head 中所有 [data-zeyn-head] 元素,把该 template 的标签移到那里,然后丢弃该 template。没有 head 标签的 Screen 不发送 template,因此旧标签只会被移除。

4.1 Screen fragment(Screen 片段)

GET /_zeyn/screen/{screen}?params={json} 只返回 Screen 的 HTML,不含页面外壳。用于 Window 和行详情:打开带有 data-zeyn-detail 的行时,会把它的 Screen 加载到紧随其后的新 tr.z-detail 中(一个横跨整行的单元格,内含 role="region"),关闭时该行被移除。该 Screen 的权限与任何 Screen 一样被检查;详情中的 grid 使用详情 Screen 自己的名称和参数与服务端通信。

4.2 Grid rows(Grid 行)

POST /_zeyn/grid

{
  "screen": "product_list",
  "params": null,
  "node": 0,
  "query": {
    "page": { "type": "offset", "offset": 0, "limit": 50 },
    "sort": [{ "field": "code", "desc": false }],
    "filters": [{ "field": "name", "contains": "tea" }, { "field": "price", "from": "10", "to": "99.90" }],
    "with_total": true,
    "aggregates": [{ "field": "price", "op": "sum" }]
  },
  "view": { "columns": [{ "field": "code", "width": 140, "pin": "start" }, { "field": "name" }] },
  "head": false
}

筛选条件可以设置 contains、starts_with、equals、from、to、any_of 和 is(参见 docs/grid.md)。view.columns 是渲染行时使用的列布局;head: true 时,响应还会包含 head,即表头行。

无限滚动时,page 为 { "type": "after", "cursor": "...", "limit": 100 },第一批的 cursor 为 null。

响应 200:

{ "rows": "<tr>...</tr>", "foot": "<tr>...</tr>", "head": null, "count": 50, "total": 230, "next": "50" }

请求了合计时,foot 是合计行,否则为 null。

total 和 next 可以为 null。运行时对分页 grid 替换行,对无限滚动则追加行。

分组 grid 和树形 grid(<table role="treegrid">)通过 POST /_zeyn/grid 发送 path 来加载一个分支:即正在展开的那一行的分组值(或祖先键),取自它的 data-zeyn-path。响应结构相同;rows 包含分组行或更深一层的行,total 统计它们的数量。运行时把它们放在被展开的行之后,并在该行关闭时移除其后所有更深层的行。路径长度超过 grid 的分组层数(树形则超过 32)时返回 404。

4.2.1 More grid endpoints(更多 grid 端点)

EndpointBodyDoes
POST /_zeyn/grid/view{screen, params, node, view}保存用户的视图(null 表示删除)。204;没有视图存储时为 404
POST /_zeyn/grid/run{screen, params, node, key, item}执行行项目(a0 为行操作,m2.1 为菜单路径)。服务端按键加载该行并构造输入。{effects}
POST /_zeyn/grid/bulk{screen, params, node, action, keys, all, query}对选中的键执行批量操作;all 时对匹配 query.filters 的每一行执行。{effects, count}
POST /_zeyn/grid/edit{screen, params, node, key, values, view}保存行内编辑;values 包含被编辑的列和行版本,格式与表单发送的一致。{row};不可编辑的列为 403,另有 409、422
GET /_zeyn/grid/export?screen&params&node&q={sort,filters,columns}以 CSV 流式输出所有匹配的行
POST /_zeyn/menu{screen, params, item}执行 Screen 上下文菜单中的某个 action 项

4.2.2 Virtual scrolling(虚拟滚动)

带有 data-zeyn-paging="virtual" 的 grid 在所有行之上显示一个可滚动的列表,无论共有多少行。服务端渲染第一个块(data-zeyn-page-size 行,偏移 0),并带有 data-zeyn-total:前 64 行在表格中,其余的在它之后的 <template data-zeyn-rows-later> 中;如果 grid 显示 40 列或更多列,则所有行都在 template 中。grid 根元素还带有 data-zeyn-row-height(像素)和 CSS 变量 --z-row-height。

运行时的行为:

  • 把表格主体放进一个滚动容器,其内部占位元素高度为 total * rowHeight 像素,上限为 8,000,000 px。超过上限时,滚动位置按比例映射到行索引(row = scrollTop / (spacerHeight - viewport) * (total - visibleRows)),因此 1,000,000 行仍然可以滚到末尾。
  • DOM 中只保留可见行以及上下各 20 行,并用 transform: translateY(...) 定位。行高固定;过长的文本以省略号截断。
  • 以 POST /_zeyn/grid 加载每块 data-zeyn-page-size 行的数据块,使用 page: { "type": "offset", "offset": blockStart, "limit": blockSize },首次调用之后 with_total: false。缓存最多保留 20 个块且最多 100,000 个单元格(最近最少使用者先淘汰;63 列的 grid 为 7 个块,但不少于 3 个),为 reload 保留的块计入同一上限并最先被丢弃,沿滚动方向预取下一个块,并取消已离开视野的块的请求(AbortController)。
  • 块加载期间从不清空可见行。缺失的行显示带有 aria-busy="true" 的占位行,直到其块到达,因此界面不会闪烁。
  • 排序和筛选变化会丢弃缓存并滚动到顶部。
  • 键盘:方向键、Page Up、Page Down、Home 和 End 移动活动行并将其滚动到可见位置。表格带有 aria-rowcount = total,每一行带有 aria-rowindex。
  • 位置文本(data-zeyn-page-info)使用 data-zeyn-text-range-total。

4.3 Form submit(表单提交)

POST /_zeyn/form

{ "screen": "product_form", "params": { "id": 7 }, "node": 0, "values": { "code": "P-001", "price": "1.234,56", "active": true } }

值按用户输入的原样发送:输入框和下拉框为字符串,复选框为布尔值。服务端按字段类型解析它们。

服务端以 Screen 加载的 model 为起点,只覆盖位于表单上且非只读的字段。客户端发来的其他值一律忽略。键绝不来自客户端。

响应:

  • 200 { "effects": [ ... ] }:已保存;按顺序执行这些 effects。
  • 422 { "errors": [ { "field": "code", "rule": { "rule": "required" }, "message": "Code is required." } ] }:在各字段下方显示对应消息(#{scope}-{field}-error),在输入框上设置 aria-invalid="true",并聚焦第一个无效的输入框。field 为空的错误显示在表单顶部。

4.4 Errors(错误)

任何端点都可能返回:

StatusBodyMeaning
403{ "error": "forbidden", "message": "..." }缺少权限、guard 拒绝或 CSRF 令牌无效
404{ "error": "not_found", "message": "..." }未知的 Screen 或节点
409{ "error": "conflict", "message": "..." }他人修改了该记录
422见 4.3校验
504{ "error": "timeout", "message": "..." }调用耗时超过服务端限制
500{ "error": "internal", "message": "An unexpected error occurred." }详情只在服务端日志中

运行时将 message 作为错误 toast 显示。

4.5 Language(语言)

POST /_zeyn/locale,带 { "locale": "tr" },把选择存入 zeyn_locale cookie 并返回 204。运行时随后重新加载页面。没有 cookie 时,服务端先根据 Accept-Language 选择,再使用应用默认值。<html lang> 始终与所选 locale 一致。

应用有多个 locale 时,外壳会渲染一个语言选择器(select[data-zeyn-locale])。

4.6 Theme(主题)

除非应用隐藏它或只提供单一主题,外壳会渲染一个主题选择器(select[data-zeyn-theme-picker])。每个选项带有 data-scheme(light、dark,System 为空),应用自定义主题带有 data-custom。变更时,运行时立即在 <html> 上设置 data-theme 和 data-zeyn-theme,并设置 color-scheme meta,然后发送 POST /_zeyn/theme,带 { "theme": "dark" }。服务端返回带 zeyn_theme cookie 的 204,对应用未提供的主题则返回 404。页面使用该 cookie 中的主题渲染,因此没有重新加载,也没有闪烁。参见 docs/theming.md。

4.7 Enterprise endpoints(企业端点)

enterprise.js 和 enterprise.css 与运行时一同加载。详情见 docs/enterprise.md。

EndpointPurpose
POST /_zeyn/reveal {screen, params, node, field, key?}被掩码字段的原始值,{value};需要取消掩码权限,始终记入审计
POST /_zeyn/reason {screen, reason}标记为 reason 的 Screen 所需的理由;返回带 HttpOnly cookie 的 204
POST /_zeyn/import {screen, params, node, csv, commit}CSV 预览(commit: false)或导入
POST /_zeyn/keepalive空闲警告之后的“保持登录”;204
GET /_zeyn/health, GET /_zeyn/ready存活与就绪探测;health 不受限流,readiness 仅限 probe_ips
GET /_zeyn/jobs对拥有 jobs 权限的用户返回任务状态

每个响应都带有 x-zeyn-build。运行时会处理的拒绝响应:428 reason_required(询问理由并重复请求)和 401 reauth_required(要求用户重新登录并重复请求)。

5. Security(安全)

  • 每个响应都设置 Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'、X-Content-Type-Options: nosniff 和 Referrer-Policy: same-origin。App::csp 会把指定名称的 https 主机加入 img-src、media-src(以及 blob:)、frame-src、connect-src 和 font-src;其他指令绝不改变。该响应头文本在启动时一次性构建。
  • 没有内联脚本,也没有内联事件处理器。内联 style 属性只用于 CSS 自定义属性(--z-width、--z-flex)和固定的列宽,因此策略中加入了 style-src-attr 'unsafe-inline'。脚本仍保持严格限制。
  • CSRF 令牌存放在 HttpOnly、SameSite=Strict 的 cookie 中,并以常量时间与 x-zeyn-csrf 请求头比较。
  • 页面、totp 表单和 GET /_zeyn/csrf 从不原样携带 cookie 中的令牌:每个响应都带有一个新的随机填充(pad),后面跟着令牌与该填充的异或结果(128 个十六进制数字),因此 gzip 无法泄露它(BREACH)。服务器在比较前先还原,并仍接受原来的 64 位明文令牌。
  • 1 KB 或更大的动态响应(页面、局部 Screen、grid 块、导出)在浏览器接受时以级别 2 进行 gzip 压缩,并带有 Vary: Accept-Encoding。从不压缩的有:已经带有 Content-Encoding 的响应(框架资源,压缩一次后缓存)、范围请求(Content-Range 以及任何带 Range 的请求)、视频、音频、SVG 以外的图片、application/octet-stream、压缩包和 PDF、text/event-stream、application/x-ndjson(助手的流)以及 WebSocket 升级。
  • 每次调用依次检查:身份认证、Screen 的访问权限、Feature 的 guard、节点 action 的访问权限,然后运行拦截器(interceptor)和 action。

6. Runtime behavior(运行时行为)

  • 没有 JavaScript 时,页面仍会渲染每个 grid 的第一页。表单和分页需要运行时。
  • 键盘:方向键在 grid 行之间移动,Enter 打开一行,Escape 关闭最上层的 dialog,来自 aria-keyshortcuts 的快捷键在除文本输入框之外的任何位置都有效。在 grid 中:Space 选择该行,F2 编辑,Ctrl+Z 撤销上一次编辑,Shift+F10 或上下文菜单键打开该行或表头的菜单,Shift+点击按多列排序,Right 和 Left(在从右到左的页面中镜像)打开和关闭行的详情、分组或子项,在子行上按 Left 回到其父行。在某一行上按 Right 进入其单元格:方向键、Home、End、Ctrl+Home、Ctrl+End、Tab 和 Shift+Tab 移动单元格光标,Shift 扩展范围(单元格上的 aria-selected),Ctrl+C 把该范围复制为以制表符分隔的文本,Ctrl+V 粘贴到可编辑单元格,每个变更的行对应一次 POST /_zeyn/grid/edit。有 40 列或更多列的 grid 只在页面中保留视野内的列(带固定宽度的 colgroup,带 colspan 的 td.z-col-gap 占位单元格,单元格上的 aria-colindex)。
  • 在定义了 Zeyn 菜单的地方,右键会打开该菜单(表头、带 data-zeyn-menu 的行、带 data-zeyn-menu 的 Screen);其他任何地方保留浏览器自带的菜单。
  • dialog 打开时焦点移入其中,关闭时焦点回到打开它的元素。
  • 客户端检查(required、minlength、maxlength、min、max、type="email")在提交前通过浏览器的约束验证 API 运行。服务端始终会再次校验。

7. Component library(组件库)

zeyn::ui 组件由另外两个资源提供,它们在页面 head 中链接在运行时之后:/_zeyn/components.js 和 /_zeyn/components.css。与运行时一样,它们无需登录。

7.1 Endpoints(端点)

全部为带 JSON 和 CSRF 请求头的 POST,并且会先检查 Screen 的访问权限和 Feature 的 guard。

EndpointBodyAnswer
/_zeyn/node{ screen, params, node }一个节点的 HTML,包含其 grid 的第一页。用于懒加载标签页。
/_zeyn/data{ screen, params, node, key, input }来自该节点数据源 key 的 JSON:查找(lookup)选项(key 为字段名)、树的子节点(key 为空)、步骤检查(key 为 _step)。会检查数据源 action 的权限并运行拦截器,与 grid 相同。不符合 action 的输入以 404 拒绝。
/_zeyn/parse{ kind, text, label }服务端如何按用户的语言、时区和时钟解读键入的日期或时间:{ ok, text, iso, utc, zone } 或 { ok: false, message }。kind 为 date、time、datetime、local 或 presets(今天和快捷范围)。

7.2 Values the forms submit(表单提交的值)

  • Lookup 以文本提交 id:"7",多个时为 "3,7,9"。
  • Lines 提交 "{field}": "rows",每个单元格一个值,键为 "{field}.{line}.{column}",另有 "{field}.{line}._i",即该行来源的已加载 line 的索引(新增 line 为空)。服务端根据已加载的 line 重建每一行,因此只有显示出来且可编辑的列会改变。
  • line 的校验错误使用 { "field": "lines", "rule": { "rule": "line", "value": { "row": 0, "field": "quantity" } } }。

7.3 Events(事件)

EventOnDetail
zeyn:contentdocument带有新内容的元素(导航、懒加载面板)。两个脚本都会初始化其中的内容。
zeyn:errorsthe form422 错误,在运行时显示之后触发。line 编辑器和分步表单使用它。
zeyn:effectdocument运行时不认识的 effect:{ effect, source, run }。run(next) 执行另一个 effect。confirm 使用它。

Effect {"effect":"confirm","message":"...","title":"...","ok":"...","cancel":"...","then":{...}} 会在模态警告对话框中询问,只有在用户确认后才执行 then。

7.4 Media(媒体)

Video 和 Audio 由 /_zeyn/media.js 和 /_zeyn/media.css 提供,链接在组件库之后。参见 docs/media.md。

EndpointDoes
GET /_zeyn/media/{source}/{id}已注册媒体源的文件,支持 range 请求:200、206、304、416。
GET /_zeyn/media/position?source=&id={ at, kept },即已登录用户上次停止的位置;媒体源不保存位置时 kept 为 false(此时播放器改用 local storage)。
POST /_zeyn/media/position带 CSRF 请求头的 { source, id, at } 用于保存位置:204,位置无效时为 422。

7.5 Islands(岛)

启用 islands feature 后,包含 div[data-zeyn-island] 的 Screen 会附带 <meta name="zeyn-islands">(岛名称到 { js, wasm } URL 的映射)、/_zeyn/islands.js 以及带有 'wasm-unsafe-eval' 的策略;其他页面不变。包含岛的局部响应带有 x-zeyn-islands: 1,当文档缺少岛策略时,nav.js 会整页加载它。

Attribute or endpointMeaning
data-zeyn-island, data-zeyn-load, data-zeyn-props岛名称、visible、interaction 或 idle、props JSON
data-zeyn-island-fallback回退内容,岛挂载后隐藏
data-zeyn-island-state由加载器设置:loading、ready、failed
GET /_zeyn/islands/{file}manifest 中列出的构建产物,缓存一年
POST /_zeyn/data with key = remote name来自岛的类型化调用,包含 7.1 的所有检查

参见 docs/islands.md。

7.6 Realtime(实时)

启用 realtime feature 后,div[data-zeyn-live="topic"] 标记在服务端通知该 topic 时刷新的节点。包含此类节点的页面会链接 /_zeyn/live.js;包含此类节点的局部响应带有 x-zeyn-live: 1,nav.js 会导入该脚本。元素上的 zeyn:reload 会就地重新加载其内部的 grid。

EndpointDoes
GET /_zeyn/live/topics?t=a,b&csrf=WebSocket;文本帧 { "topic": "a" }
GET /_zeyn/live/doc/{kind}/{doc}?csrf=WebSocket;双向的二进制帧 0x01 + Y update (v1)

握手在升级之前会检查 origin、与 cookie 比对的 CSRF 令牌、已登录用户以及权限。参见 docs/realtime.md。

播放器在回退到渐进式文件之前,会在其根元素上派发可取消的 zeyn:media-adaptive 事件({ media, kind, url, hls, dash });流媒体库调用 preventDefault() 即可接管。

8. Desktop class shell(桌面级外壳)

/_zeyn/pro.js 和 /_zeyn/pro.css 在组件库之后加载。与运行时一样,它们无需登录。

8.1 Commands and the palette(命令与命令面板)

命令在 Rust 中声明(Command::open、Command::navigate、Command::effect、Command::action),并通过 Feature::command 注册。外壳为用户可以运行的每个命令(Feature 的 guard 通过且命令的访问权限允许)在 div[data-zeyn-commands] 内渲染一个隐藏按钮:

AttributeMeaning
data-zeyn-command命令 id
data-zeyn-effecteffect 命令的 effect,点击时由运行时执行
data-zeyn-command-runaction 命令的 id,通过 POST /_zeyn/command 执行
aria-keyshortcuts规范化后的快捷键(Control+Alt+N);运行时的快捷键处理会点击该按钮
data-hint, data-icon向用户显示的快捷键、图标名称
data-text-* on the container本地化的命令面板文本

App::check 会拒绝:无法解析的快捷键、单字符快捷键(WCAG 2.1.4)、命令面板自身的快捷键、被两个命令使用的快捷键或 id、浏览器从不传给页面的快捷键(BROWSER_RESERVED:Ctrl+N、Ctrl+T、Ctrl+W、Ctrl+Tab、Alt+F4 及其 macOS 形式),并且除非命令调用 .override_browser(),还会拒绝浏览器自己使用的快捷键(BROWSER_DEFAULTS:打印、保存、查找、地址栏、重新加载、缩放、开发者工具、复制和粘贴,F1 到 F12)。工具栏按钮在运行时遵循相同规则:此类快捷键不会被绑定,并记录原因。可自由使用的组合是 Alt+Shift+<letter> 和 Ctrl+Alt+<letter>。

Alt+Shift+P(macOS 上为 Option+Shift+P)默认打开命令面板;App::palette_shortcut("Ctrl+Shift+P") 可以更改它,也可以有意改成浏览器使用的按键。快捷键位于 data-palette-keys 中,字母按物理按键匹配,因此 Option 和 AltGr 不会破坏它。命令面板也可以通过菜单中的 button[data-zeyn-palette-open] 打开。它是一个带有 ARIA combobox 和 listbox 的模态 <dialog>。它列出应用的命令、当前 Screen 的工具栏按钮、菜单栏命令和上下文菜单项(有 Window 打开时为最上层的 Window),以及菜单中的 Screen。匹配是模糊的,忽略大小写和重音符号;最近运行的八个命令排在最前(localStorage 键 zeyn.palette.recent)。Escape 将其关闭,焦点回到原来的位置。

EndpointBodyAnswer
POST /_zeyn/command{ command }以服务端固定的输入运行一个 action 命令:CSRF、Feature 的 guard、action 的权限和拦截器。{ effects };未知 id 和 effect 命令为 404
POST /_zeyn/search{ query }运行用户可以运行的每个 Feature::search(CSRF、Feature 的 guard、action 的权限、拦截器),查询截断为 100 个字符。{ hits: [{ label, group, url }] },最多 20 条,且只含应用内路径

容器带有 data-search 时,命令面板在最后一次按键后 150 ms 发送查询(两个字符或更多),并把命中项列在命令下方。只绘制最新一次查询的响应。选择某个命中项会发送 zeyn:navigate,并在 url 前加上基础路径;其中的 #anchor 会在 Screen 换入之后滚动到该元素。

运行时在 document 上监听 zeyn:run,以便其他脚本通过它执行工作:{ effects, source } 执行 effects,{ screen, item } 执行 Screen 的上下文菜单项,{ error } 显示错误 toast。

8.2 Menu bar, toolbox, property inspector, status bar(菜单栏、工具箱、属性检查器、状态栏)

MarkupBehavior
ul[role=menubar][data-zeyn-menubar]W3C APG 菜单栏。顶层项是带有 roving tab stop 的 button[role=menuitem][aria-haspopup=menu];每个控制一个关闭时为 hidden 的 ul[role=menu]。各项带有 8.1 的命令属性。Left 和 Right 在菜单之间移动,Down、Enter 和 Space 打开,Up 在最后一项处打开,Home 和 End 跳转,字母键移到下一个以它开头的项,Escape 关闭并回到菜单栏,Tab 和点击外部会关闭。用户不能运行的菜单和菜单项不会被渲染。
div[role=toolbar][aria-orientation=vertical][data-zeyn-toolbox]button.z-tool[aria-pressed] 的切换组。方向键、Home 和 End 移动 roving tab stop;点击、Enter 或 Space 开启一个工具,发送 zeyn:tool(detail.tool),并把 data-text-selected 写入状态栏。
section.z-inspector一个带有 Form::section 分组(details.z-form-section,其主体为 role=group)的表单,通过检查器节点路径上的 POST /_zeyn/form 保存。包含失败字段的分组会在浏览器检查之前以及 422 之后展开。
div[data-zeyn-statusbar]位于起始端和末尾端的项,以及 [data-zeyn-status-live](role=status、aria-live=polite)。Screen 中任意元素发出的 zeyn:status(detail.text)会替换实时文本。

8.3 Docking panels(停靠面板)

div.z-dock[data-zeyn-dock] 是由四个 section.z-dock-area[data-area](left、center、bottom、right)和三个 div.z-dock-bar[role=separator][data-edge] 分隔条组成的网格。尺寸为自定义属性 --z-dock-left、--z-dock-right(宽度的百分比)和 --z-dock-bottom(高度的百分比),取值 10 到 60。每个区域有一个 role=tablist,包含可拖动的 button.z-dock-tab[data-panel],一个选项按钮,以及容纳子节点的 role=tabpanel 面板(路径与任何容器相同;面板移动时路径不变)。没有打开面板的侧边区域会被隐藏。

键盘:Left 和 Right(在从右到左的页面中镜像)、Home 和 End 在区域内已打开的标签页之间移动;Delete 关闭获得焦点的面板;Shift+F10 或上下文菜单键打开该区域的选项(移动到另一个区域、关闭、显示已关闭的面板);分隔条上的方向键以 5% 调整大小,Home 和 End 跳到极限值。用 MenuList::panels 创建的菜单栏条目把每个面板列为 menuitemcheckbox。变化会在 [data-zeyn-dock-live] 中播报。

EndpointBodyAnswer
POST /_zeyn/dock{ screen, params, node, save: false }{ layout }:已登录用户保存的布局,或 null
POST /_zeyn/dock{ screen, params, node, save: true, layout }204;layout: null 表示删除

layout 为 { sizes: { left, right, bottom }, panels: [{ id, area, open, active }] },按标签页顺序排列。服务端检查 CSRF、Screen 的访问权限和 Feature 的 guard,只保留该 dock 自己的面板(每个一次)并限制尺寸范围,然后将其存入应用的视图存储(App::datagrid_views),键为租户、Screen 和节点。没有存储时端点返回 404,没有用户时返回 403;此时浏览器把布局保存在 localStorage 中。

9. AI agents: MCP and WebMCP(AI 代理:MCP 与 WebMCP)

外部 AI 代理通过两个入口访问应用,两者默认关闭,用 App::mcp 开启:

SettingEndpoint MCPWebMCP toolsWrites
Mcp::off() (default)404404no
Mcp::read_only()ononno
Mcp::on()onon表单保存和执行写入的 API action

.server(false) 和 .webmcp(false) 关闭其中一个入口。由 zeyn new 创建的新项目以 Mcp::read_only() 起步。

为什么默认关闭:该端点允许任何持有有效 Authorization 请求头的人通过模型驱动应用,而模型可能被它所读取的数据所操纵。框架让每个应用都为代理做好准备(工具由代码生成,无需编写任何内容),但应用是否打开这个入口,以及代理是否可以写入,应由运行它的人决定。只读模式只需一行即可启用。

9.1 Tools(工具)

工具是为已登录用户根据应用构建的:

  • 每个 grid 一个搜索工具({screen}_{node}_search,输入:一个 DataQuery),启用写入时,每个表单一个保存工具({screen}_{node}_save,输入:按键入原样的表单值),范围是每个无需 params 即可打开的路由 Screen;
  • 每个已发布的 API action 一个工具(api_{feature}_{action})。输入为 DataQuery 的 action 属于读取;其他所有 API action 都算作写入。

描述由 Screen 标题、Screen 和 action 的 /// 文档注释的第一段,以及它们的 ai = "..." 指引构建。用户无权使用的 grid 和表单,以及标记了 ai = false 的 Screen 和 action,绝不会被列出,被调用时也会被拒绝。

#[action(confirm)] 标记一个只有在用户于应用内确认之后,模型才可以运行的 action。/_zeyn/mcp 既不列出也不运行此类工具(该通道上没有人能够确认)。WebMCP 工具会在模态对话框中询问(zeyn:confirm),然后随表单发送 confirmed: true;内置的助手则在其聊天中询问(参见 docs/assistant.md)。

9.2 Endpoints(端点)

EndpointDoes
GET /_zeyn/tools?screen&params当前所见 Screen 的 { tools: [{ name, description, inputSchema, kind, screen, node, writes, confirm }], texts }。zeyn.js 用 document.modelContext 注册它们
POST /_zeyn/mcpJSON-RPC:initialize、tools/list、tools/call。需要 Authorization 请求头和已登录用户
POST /_zeyn/form with via_agent: trueWebMCP 表单保存;需要启用 WebMCP 的 Mcp::on()。只有在用户确认之后才可使用 confirmed: true

每次代理调用都以该用户的身份运行:Screen 的访问权限、guard、action 的权限、拦截器、限流和时间限制。代理调用始终会被审计(via_agent: true),被掩码的字段对它们始终保持遮蔽,即使用户本人可以取消掩码。OpenAPI 用 x-zeyn-ai 标记每个操作:ai = false 时为 false,否则为 { hint, confirm, writes }。