宿主适配

引擎不依赖环境,
运行环境由宿主提供

packages/runtime 是宿主无关的内核;浏览器能力放在 @tiny/host-browser,内置组件放在 @tiny/host-web-components。createTinyRuntime 只需要两条 Transport、manifest 和初始页面;createStandaloneDualThreadHost 负责创建 iframe、转移端口、等待就绪并完成握手。canvas、资源 URL、渲染后端和挂载点都通过参数注入。

2 宿主包1 service iframe每页 render iframe4 注入能力
内核不依赖宿主canvas 注入资源解析在线调试器

宿主分层

宿主分成几层

把宿主当成一个包,很难看清边界。它其实由几层可替换的装配组成。

层入口环境假设职责
内核编排createTinyRuntime(options)不假设有 DOM两个 BridgeConnection、HostApiRegistry、诊断仓库和启动顺序
线程启动bootServiceThread / bootRenderThreadservice 需要 JS 全局;render 需要 glass-easel 后端安装注册表/API、定义组件、响应消息;canvas、资源和 builtinComponents 通过参数注入
浏览器适配@tiny/host-browserwindow、iframe、MessageChannel、documentiframe、端口、DOM canvas、资源 URL、DevtoolRuntimeClient
内置组件@tiny/host-web-componentsDOM 和 CSStiny-* 宿主定义、共享 behaviors、基础 CSS,通过 builtinComponents 注入 render
应用宿主tests/host · apps/devtool静态资源服务器 / rspack选择应用、加载 manifest、提供界面或自动化钩子、调用 bootstrap
应用外壳(host.ts / devtool React)
              |
              v
@tiny/host-browser                浏览器环境适配
  createStandaloneDualThreadHost
  canvas-bridge / runtime-port
  DevtoolRuntimeClient

@tiny/host-web-components         内置组件实现
  webBuiltinComponents(定义 + 基础 CSS)
              |
              v  createTinyRuntime
createTinyRuntime                 内核编排
              |
              v  boot*Thread(transport, manifest, canvas, ...)
service iframe / render iframe

原生宿主替换的是浏览器适配层,不是 runtime 语义。
standalone 只是其中一种入口

runtime 单测不使用 iframe:它通过 createInMemoryTransportPair() 启动两个线程,再用 createTinyRuntime 组装宿主。 可替换性不是口号,现有测试一直在这么做。

内核约束

内核不引用浏览器 API

这是宿主适配最重要的约束:内核只定义语义,环境能力全部由宿主注入。

检查脚本

pnpm check:runtime-purity

脚本会从 packages/runtime/src/index.ts 遍历相对 import,去掉注释后搜索宿主全局:

window · document · navigator
localStorage · sessionStorage
HTMLCanvasElement · HTMLImageElement · Image
requestAnimationFrame · cancelAnimationFrame
MessageChannel · postMessage · indexedDB
XMLHttpRequest · WebSocket · fetch · performance

同时禁止依赖图里出现名字带 browser 的模块,或导入 @tiny/host-*。一旦违规,验收直接失败。

内核

packages/runtime

导出 types、diagnostics、glass-easel-adapter、service、render 和 host。canvas、resolveAssetUrl、backend、mountComponent、builtinComponents 都是可选参数。

宿主

@tiny/host-browser

负责 iframe、MessageChannel、DOM canvas、资源 URL 规则和 DevtoolRuntimeClient;tests/host 和 devtool 都从这里导入。

组件

@tiny/host-web-components

负责 tiny-* 宿主定义、behaviors 和基础 CSS;tests/host 与 devtool 会在 render 入口注入 webBuiltinComponents。

结果

可移植性

原生宿主只要实现 Transport、渲染后端、canvas 适配器、资源解析和宿主 API,不需要假装自己是浏览器。

启动流程

先交端口,再握手,最后启动页面

浏览器宿主的启动顺序里有一个真实竞态:端口转移早于连接安装会丢消息。就绪消息就是为解决它加的。

  1. 创建 MessageChannel

    单 render 模式创建两对端口;每页 render 模式先创建 service 端口,渲染端口等到具体页面创建时再建。

    createHostMessageChannelPair()
  2. 创建 iframe

    service iframe 被隐藏(0×0、absolute、opacity 0);render iframe 填满容器。每页 render 模式下,每新建一个页面就创建一个 render iframe。

    createIframe('tiny-runtime-service-frame', serviceUrl)
  3. 等待线程就绪

    宿主监听 window message,只接受类型为 tiny-runtime-port-ready、channel 匹配,并且来源是目标 iframe 的消息,最多等 15 秒。

    waitForThreadReady(iframe, 'service')
  4. 转移端口

    宿主把 port2 作为 transferable 发给对应 iframe,线程再用 createMessagePortTransport 包装成 Transport。

    postMessage({ type: 'tiny-runtime-port' }, '*', [port])
  5. 创建运行时并握手

    createTinyRuntime 用两条 hostTransport 建立连接,然后并行等待 service 和 render 的握手。

    Promise.all([service.handshake(), render.handshake()])
  6. 启动首屏

    service 返回 pageId、initialData、componentSchemas 和 pageComponents;render 注册样式和 schema、挂载页面、等组件树稳定后返回组件;宿主再把组件并入 page.ready。

    await runtime.host.bootstrap()
竞态

为什么线程要先装好连接

如果线程在安装 connection 之前就发 ready,宿主会立刻转移端口;端口消息到达时还没有监听器,第一帧就丢了。

因此 render 入口会先导入 templates、拉取 styles,再调用 listenForRuntimePort;devtool 的 service 入口也会先取 manifest。

超时

15 秒固定超时

iframe 超过 15 秒没有发 ready 会直接 reject,不会重试。产物很大或网络很慢时,要调整宿主实现,而不是改 runtime。

// 宿主侧(tests/host/src/host.ts)
const runtime = await createStandaloneDualThreadHost({
  manifest,
  serviceUrl: '/service.html',
  renderUrl: '/render.html',
  initialPath: 'pages/todos/todos',
  container: document.getElementById('app'),
  systemInfo: {
    pixelRatio: devicePixelRatio,
    windowWidth: innerWidth,
    windowHeight: innerHeight,
  },
})
window.__tinyRuntime = runtime
await runtime.host.bootstrap()

// render 线程
bootRenderThread({
  transport, manifest,
  artifacts: { templates },
  backend, initialPath,
  loadStyle,
  resolveAssetUrl,
  canvas: { measure, paint },
  mountComponent, unmountComponent,
  builtinComponents: webBuiltinComponents,
})

线程参数

线程只接收宿主显式传入的参数

service 和 render 不读取 URL,也不读取全局 manifest,所有输入都由宿主传入。

ServiceThreadOptions用途是否必需
transport宿主连接是
manifest页面路径、组件、样式、运行期兼容信息和诊断是
initialPath首屏页面路径是
loadArtifacts在 bootstrap 时导入 service.js,保证 App/Page 已注册实际必需
apiExtensions注入设备信息和额外的同步 API否
canvascreateLegacyCanvasContext / createCanvasNode使用 canvas API 时必需
capabilities握手元数据,目前没有用于协商否
RenderThreadOptions用途是否必需
transport宿主连接是
manifest页面、样式、组件产物和运行期兼容信息是
artifacts.templates从 render.js 导入的 groupList/content是
backendglass-easel GeneralBackendContext是
initialPath / initialData首屏页面和数据;bootstrap 消息里的值优先initialPath 必需
loadStyle按 outputPath 返回 CSS 文本实际必需
resolveAssetUrl把 app-root 资源路径映射成可加载 URL(模板 img / CSS url)使用本地资源时必需
canvasmeasure / paint 适配器使用 canvas 时必需
queryDOM 查询 + IntersectionObserver 适配器使用 selector query 或组件级 IntersectionObserver 时必需
builtinComponents内置组件定义和基础样式使用内置组件时必需
mountComponent / unmountComponent把根组件挂到宿主节点否;不传时使用 pretendAttached
onDiagnostic本地诊断回调;跨线程诊断仍走 Bridge否

service 线程还要做什么

  • 先安装 App/Page/Component/Behavior/getApp 和命名后的 API 全局
  • 再导入 service.js,让注册调用按 manifest 顺序发生
  • 在 bootstrap 内 await loadArtifacts
  • canvas API 由注入的适配器提供,内核不碰 document

render 线程还要做什么

  • 准备一个 glass-easel 后端实例
  • 从 render.js 取出模板 group
  • 按 outputPath 预加载样式文本
  • 把资源路径映射到真实 URL,并能测量和绘制 canvas
  • 提供确定的挂载容器
bootstrap 消息优先于线程参数

render 的 bootstrap 处理器会优先使用消息里的 manifest、initialPath、initialData、componentSchemas 和 pageComponents;线程参数只是默认值,以及样式、模板和资源的加载来源。

注入能力

宿主可以注入五类能力

跨线程 API、同步宿主信息、canvas 实现、DOM 查询、资源 URL 各有自己的入口,放错层就会出现“内核偷偷依赖浏览器”的问题。

宿主

apiHandlers:跨线程 API

注册在宿主的 HostApiRegistry 里,通过 api.* 调用由 service 使用。

  • navigate.to / navigate.back / navigate.switchTab:宿主协调 service 和 render
  • storage.get/set:宿主持久化
  • toast.show:宿主 UI(目前只返回 errMsg)
  • system.info:系统信息快照
  • 文件、网络、设备等自定义 handler
service

apiExtensions:同步宿主信息

直接合并进 service 的 API host,在 service 线程执行。

  • getMenuButtonBoundingClientRect
  • getPerformance(now/mark/measure/observer)
  • 其他依赖设备或宿主壳的同步 API

测试宿主和 devtool 各有一份实现;核心 runtime 不写死这些值。

query

RenderQueryAdapter

createSelectorQuery / createIntersectionObserver 由宿主在 render 入口注入:浏览器实现是 createBrowserRenderQuery()(真实 DOM 查询 + 原生 IntersectionObserver)。不注入时普通查询返回空、观察器返回 unsupported。

canvas

ServiceCanvasAdapter / RenderCanvasAdapter

service 需要 createLegacyCanvasContext 和 createCanvasNode;render 需要 measure 和 paint。浏览器实现位于 hosts/browser/src/canvas-bridge.ts。

资源

resolveAssetUrl + backend + mount

resolveAssetUrl(assetPath, { sourcePath }) 会把 app-root 资源映射成 URL;adapter 用它重写模板 img 和 CSS url()。backend 和挂载钩子决定节点最终落在哪里。

问题应该放在哪里原因
菜单按钮位置随设备变化apiExtensionsservice 需要同步调用,属于宿主信息
页面跳转要同时改两个线程apiHandlers只有宿主同时持有两侧连接
storage 需要持久化apiHandlers真实存储由宿主管理
canvas 绘制和测量canvas 适配器内核不能依赖 document / Image
本地图片和 CSS 背景图resolveAssetUrl只有宿主知道静态资源的 URL 规则
selector query / 相交观测RenderThreadOptions.query内核不能依赖 Element / IntersectionObserver
节点挂到哪里backend + mountComponentDOM 或原生树归宿主所有
仓库里已经定下的规则

宿主信息通过 ServiceThreadOptions.apiExtensions 注入,canvas 通过 ServiceThreadOptions.canvas / RenderThreadOptions.canvas 注入,DOM 查询与相交观测通过 RenderThreadOptions.query 注入。不要再把这些能力放回 packages/runtime。

浏览器宿主

@tiny/host-browser 负责什么

它把 iframe 生命周期、端口转移、DOM canvas、资源 URL 和调试器客户端收在一个包里。

它负责

  • 创建 MessageChannel 和 iframe
  • service iframe 隐藏,render iframe 可见
  • 等待 ready 消息,最多 15 秒
  • 转移 port2,创建 createTinyRuntime
  • 完成两条连接的握手
  • 提供 canvas、资源和 devtool client 适配器
  • 返回 host 和 iframe 句柄,并提供清理方法

它不负责

  • 加载 manifest 或编译产物
  • 调用 bootstrap
  • 提供业务渲染后端或挂载点
  • 注入 systemInfo、storage 或 apiExtensions
  • 管理多应用、诊断展示或界面
tests/host

无界面单应用宿主

Vite 提供三个入口:index.html 宿主、service.html 和 render.html。宿主把页面挂到 #app,并暴露 window.__tinyRuntime 给 Playwright。

service 入口注入菜单按钮、performance 和 canvas;render 入口使用 CurrentWindowBackendContext,提供资源解析、canvas 测量/绘制和挂载钩子。

静态资源

产物怎么到线程里

manifest 由打包器静态导入;render.js 动态导入;styles 通过 fetch('/generated/...') 取文本后交给 loadStyle;资源通过 /generated/<path> 提供。

宿主需要能按 manifest 里的相对路径提供静态文件。

render.html
  |-- import render.js
  |-- fetch manifest + styles
  |-- new CurrentWindowBackendContext
  |-- listenForRuntimePort('render')
  +-- bootRenderThread({
        transport, manifest, artifacts, backend,
        initialPath, loadStyle, resolveAssetUrl,
        canvas: { measure: measureBrowserCanvas,
                  paint: paintBrowserCanvasFrame },
        mountComponent, unmountComponent,
        builtinComponents: webBuiltinComponents,
      })

service.html
  |-- installBrowserCanvasCompat()
  |-- listenForRuntimePort('service')
  +-- bootServiceThread({
        transport, manifest, initialPath,
        apiExtensions,
        canvas: { createLegacyCanvasContext, createCanvasNode },
        loadArtifacts: () => import(service.js),
      })

调试器宿主

devtool 在宿主之上加了控制界面

React/Redux 不直接发送协议消息,只调用 DevtoolRuntimeClient,并订阅它投影出来的状态。

DevtoolRuntimeClient 方法底层动作状态变化
launch()创建容器(如果没提供)→ 启动宿主 → bootstrap → inspectTreeconnecting → running / error
terminate()关闭连接、移除 iframe 和自有容器terminated
reload()terminate → idle → launchrunning
navigate(url)调用宿主的 navigate.to handler,再 inspectTreepageStack 由 RUNTIME_STATE 更新
switchTab(index)调用宿主的 navigate.switchTab handlertabBar 和当前页面更新
back(delta)调用宿主的 navigate.back handler,再 inspectTreepageStack 收缩
foreground()直接调用 service.control page.show页面生命周期变化
background()直接调用 service.control page.hide页面生命周期变化
inspectTree()render.control runtime.inspectTree元素树更新

面板和宿主壳

  • RuntimePanel:读取 ./runtime/apps.json,管理应用列表和最多 3 个应用会话
  • PreviewStage:iPhone X 375×812 设备框、自动缩放、宿主导航栏和 tabBar
  • DevtoolToolbar:当前应用、页面栈和上限、导航、Reload/Close app
  • DebugPanel:Console 诊断和 Elements 元素树

多应用和生命周期

脚本会扫描 tests/apps/*,把产物放到 ./runtime/apps/<app>/generated,并生成 apps.json。应用列表页按需启动应用,最多保活最近 3 个;每个应用有自己的 client、页面栈和 render 帧。

在线版本是同一份构建:#/debugger 通过同源 iframe 嵌入。

为什么用 new Function import

devtool 的 service/render 入口用 new Function('url', 'return import(url)') 加载生成产物, 避免 rspack 把动态 import 改写成自己的模块系统。这只是打包器适配,不是协议要求。

宿主对比

几种宿主形态

区别不在 runtime,而在谁提供环境、谁负责控制界面。

宿主线程容器渲染后端canvas / 资源控制方式
tests/host1 个 service + 1 个 render iframe(Vite)CurrentWindowBackendContexthost-browser canvas + /generated 资源URL ?path + window.__tinyRuntime
apps/devtool1 个 service + 每页一个 render iframe(rspack)CurrentWindowBackendContexthost-browser canvas + 相对 URLDevtoolRuntimeClient + React/Redux
runtime 单测同进程EmptyBackendContext无 canvas,manifest 内联直接持有 BridgeConnection
原生宿主(未实现)JSCore / 原生容器 + 渲染层需要实现 GeneralBackendContext需要实现 canvas 适配器和资源解析自定义控制界面

共享的部分

  • manifest 和 naming 约定
  • BridgeConnection 和消息列表
  • service/render 参数
  • bootstrap 顺序和 page.ready
  • HostApiRegistry 的默认语义

各自实现的部分

  • Transport 和线程容器
  • 渲染后端和挂载方式
  • canvas 实现
  • 样式、模板和资源加载策略
  • 控制界面和 UI

接入步骤

接入一个新宿主需要做什么

按顺序完成这些步骤,才能算真正跑通 Tiny。

  1. 加载并校验 manifest

    读取 schemaVersion=2、runtimeCompatibility 和产物路径。不要自己复制一份产物类型。

  2. 创建两条 Transport

    service 和 render 各一条;帧边界、非空 messages 和 JSON 安全由 Bridge 兜底,但传输必须保序。

  3. 实现环境适配器

    glass-easel 后端、mount/unmount、样式和模板加载、resolveAssetUrl、canvas measure/paint,以及内置组件。

  4. 启动线程并等待连接装好

    service 安装命名后的全局对象并 loadArtifacts;等 transport 接收端就绪后再握手,用 ready 或同步点避免丢帧。

  5. 提供宿主信息

    systemInfo、storage 初始快照;设备相关的同步 API 走 apiExtensions,网络和文件等跨线程 API 走 apiHandlers。

  6. 实现导航和 page.ready

    navigate.to/back/switchTab 必须由宿主先调用 service、再调用 render;navigateTo 和 switchTab 要把 render 返回的组件并入 page.ready。

  7. 接入诊断和控制

    收集 diagnostic.*;按需暴露 inspectTree、page.show/hide 和关闭方法。

  8. 定义关闭语义

    关闭两条连接、释放线程、卸载页面。当前 host.close 只关连接,不自动移除 iframe;standalone 的 dispose 会负责清理。

  9. 跑真实测试小程序

    至少覆盖 demo、mini、Taro 3 中的一条;否则只能证明“能启动”,不能证明“能跑应用”。

  10. 跑内核约束检查

    确认新宿主没有把浏览器或平台依赖反向塞回 packages/runtime。

    pnpm check:runtime-purity
原生宿主还没有实现

目前没有 native transport、native 渲染后端,也没有非浏览器的 service 运行时。好消息是内核已经移除了 document/Image/iframe, 原生宿主只需要提供 canvas 适配器、渲染后端、资源解析和控制界面,不必模拟浏览器。

风险

当前宿主实现的已知问题

这些是生产化之前需要知道的事实,不是设计目标。

origin

postMessage 使用 '*'

宿主转移端口、线程发送 ready 时都使用 '*' 作为 targetOrigin;线程侧也没有校验 origin。当前默认是同源静态部署。

sandbox

iframe 没有 sandbox

service iframe 只是视觉上隐藏,没有 sandbox 属性,仍然可以访问同源 DOM。隔离依赖同源部署和约定,不是浏览器安全边界。

close

关闭连接不等于卸载页面

host.close() 只关闭连接并 reject 等待中的调用。页面 onUnload、iframe 移除和容器清理由调用方负责;standalone 宿主的 dispose() 会统一清理。

back

单 render 模式返回不补 page.ready

每页 render 模式会保留页面 DOM;单 render 模式返回时需要重新挂载,而且不会把 render 返回的组件快照重新发给 service。

capability

没有能力协商

握手会携带 capabilities,但宿主没有注入,也没有降级逻辑。不同宿主的能力差异只能靠代码分支和诊断暴露。

reload

没有热更新

产物变化后必须重新 reload 或 launch,没有 HMR,也没有协议级的 artifact reload。devtool 的 Reload 会重建整个 iframe 运行时。

subpackage

没有子包按需加载

service bundle 包含所有页面脚本,子包只是 manifest 里的路径;宿主不能按子包延迟下载或执行。

继续阅读

看测试怎么覆盖这些宿主:测试与验收; 看问题对应的计划:现状。