Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class BundleSmokeTest {
engine.evaluateModule("import \"tinyui-core\"; export const v = typeof globalThis.__tinyui.mount;").use {
assertEquals(JsValue.Str("function"), it.get("v"))
}
engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() }
engine.runBytecode(out.resolve("pages/counter.jsb").readBytes()).let { if (it is AutoCloseable) it.close() }
}
}

Expand Down
4 changes: 2 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/BuildManifest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ class BuildManifest(
) {
/** Every file of the package besides the manifest, as (path, key into [hashes]). */
val payload: List<Pair<String, String>>
get() = pages.map { file(it) + ".bin" to it } + (i18n?.files?.values?.map { it to it } ?: emptyList())
get() = pages.map { file(it) + ".jsb" to it } + (i18n?.files?.values?.map { it to it } ?: emptyList())

fun buildId(module: String): String = buildIds[module] ?: ""

/** Output path of [module] without extension (`pages/home`): append `.bin` or `.js.map`. */
/** Output path of [module] without extension (`pages/home`): append `.jsb` or `.js.map`. */
fun file(module: String): String = files[module] ?: module

companion object {
Expand Down
4 changes: 2 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/Bundle.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package app.tinyui
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

/** Where a package's bytes come from; [path] is relative to the package (`manifest.json`, `pages/home.bin`). Null when absent. */
/** Where a package's bytes come from; [path] is relative to the package (`manifest.json`, `pages/home.jsb`). Null when absent. */
fun interface BundleFiles {
suspend fun read(path: String): ByteArray?
}
Expand All @@ -24,7 +24,7 @@ class Bundle(val manifest: BuildManifest, private val files: BundleFiles) {

suspend fun page(name: String): LoadedPage {
require(name in manifest.pages) { "page $name is not in package ${manifest.name}: ${manifest.pages}" }
val bytecode = read(manifest.file(name) + ".bin")
val bytecode = read(manifest.file(name) + ".jsb")
val module = PageModule(name, bytecode, manifest.buildId(name), i18n())
return LoadedPage(module, sourceMaps(name), this)
}
Expand Down

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions compose/src/commonTest/kotlin/app/tinyui/BundleTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ class BundleTest {
private fun files(vararg extra: Pair<String, String>) = Files(
mapOf(
"manifest.json" to manifest,
"pages/home.bin" to "HOME",
"pages/cart.bin" to "CART",
"pages/home.jsb" to "HOME",
"pages/cart.jsb" to "CART",
*extra,
),
)
Expand All @@ -62,7 +62,7 @@ class BundleTest {
val bundle = Bundle.load(files)
bundle.page("shop/home")
bundle.page("shop/home")
assertEquals(listOf("manifest.json", "pages/home.bin", "pages/home.js.map", "pages/home.bin"), files.reads, "a missing map is asked for once")
assertEquals(listOf("manifest.json", "pages/home.jsb", "pages/home.js.map", "pages/home.jsb"), files.reads, "a missing map is asked for once")
}

@Test
Expand Down
4 changes: 2 additions & 2 deletions docs/adr-006-hot-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi
| `PageHost` 只收字节,库零 I/O,读资源在宿主 | `PageHost.kt`、TrendingAI `TinyUIHost.kt` | 热下发对库来说是"多一个字节来源 + 选哪个" |
| release 不带 map,靠 `buildId` 离线对映射 | build-chain.md §7 | 错误上报链路不变 |

产物尺寸:`core.bin` 20 KB、`native.bin` 3 KB、页面 1~4 KB;十几页的 App 整包不到 100 KB。运行时随宿主后(§2.11)包里只剩页面,运行时的 23 KB 随库进 App。
产物尺寸:`core.jsb` 20 KB、`native.jsb` 3 KB、页面 1~4 KB;十几页的 App 整包不到 100 KB。运行时随宿主后(§2.11)包里只剩页面,运行时的 23 KB 随库进 App。

## 2. 候选与取舍

Expand Down Expand Up @@ -202,7 +202,7 @@ ADR-005 把热下发划出当期,条件是"内核稳定后另立 ADR"。Trendi

| 项 | 结论 |
|---|---|
| 包 | App 由 N≥1 个包组成;一个包 = 一次 `tinyui build` 的产物(`pages/**/*.bin` + `manifest.json`;运行时随宿主,§2.11),整包原子生效,各包独立;不做单页下发、不做跨包共享模块、不做 zip、不做 diff |
| 包 | App 由 N≥1 个包组成;一个包 = 一次 `tinyui build` 的产物(`pages/**/*.jsb` + `manifest.json`;运行时随宿主,§2.11),整包原子生效,各包独立;不做单页下发、不做跨包共享模块、不做 zip、不做 diff |
| 包名与模块名 | 包名 `[a-z0-9-]+` 来自 `tinyui.config.json`;模块名 = 路由键 = `<pkg>/<相对路径>`,无 `pages/` 段 |
| 兼容键 | 宿主声明的 `hostVersion`,作服务端路径;`engine` 要求相等、`tinyui` 要求与宿主兼容(同 major 且宿主不低于包),只校验;单纯升 tinyui 不加 `hostVersion` |
| 运行时 | `tinyui-core` / `tinyui-native` 随库编成字节码进 App,包里不带;运行时公开面只增不删、不改语义(§2.11 铁律) |
Expand Down
10 changes: 5 additions & 5 deletions docs/build-chain.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

`tinyui build` 把每个页面的 TSX 编成一个 ESM 模块字节码;`tinyui-core` / `tinyui-native` 各编成一个运行时模块字节码,由库构建时编好、随库发布(ADR-006 §2.11)。每页一个 Runtime 按 ADR-002 K0 的顺序加载。

验收:sample(包名 `sample`)的 `src/pages/home.tsx` 经 CLI 产出 `pages/home.bin`,Android / iOS 上 App 启动后显示页面 `default` 导出的返回值,其中含 `import.meta.url` 的值 `tinyui:sample/home`——模块名、external、注册顺序三件事一次验完。CI 的 gradle 与 pnpm 两条 job 都经过这条链。
验收:sample(包名 `sample`)的 `src/pages/home.tsx` 经 CLI 产出 `pages/home.jsb`,Android / iOS 上 App 启动后显示页面 `default` 导出的返回值,其中含 `import.meta.url` 的值 `tinyui:sample/home`——模块名、external、注册顺序三件事一次验完。CI 的 gradle 与 pnpm 两条 job 都经过这条链。

## 2. 管线

```
src/pages/home.tsx ──esbuild──▶ dist/pages/home.js + .map ──qjsc-kmp -m -n <pkg>/home──▶ dist/pages/home.bin
tinyui-core 包入口 ──esbuild──▶ runtime/core.js ──qjsc-kmp -m -n tinyui-core──▶ runtime/core.bin (`pnpm runtime` 生成 compose 的 RuntimeBytecode.kt,随库发布)
src/pages/home.tsx ──esbuild──▶ dist/pages/home.js + .map ──qjsc-kmp -m -n <pkg>/home──▶ dist/pages/home.jsb
tinyui-core 包入口 ──esbuild──▶ runtime/core.js ──qjsc-kmp -m -n tinyui-core──▶ runtime/core.jsb (`pnpm runtime` 生成 compose 的 RuntimeBytecode.kt,随库发布)
tinyui-native 同上(对它而言 tinyui-core 是 external)
```

Expand All @@ -23,7 +23,7 @@ tinyui-native 同上(对它而言 tinyui-core 是 external)
| esbuild | TS 类型擦除;JSX → `h()`(`jsxFactory: "h"`,`inject` 一个内存模块自动引入 `h` / `Fragment`,页面不手写);业务内部相对 import 合并进页面模块;`tinyui-core` / `tinyui-native` 在插件 `onResolve` 里标 `external` + `sideEffects: false`,保持裸说明符且未用到时整条 import 被去掉;`format: "esm"`,`target: "esnext"`;输出 source map | 任何语法降级;对运行时模块的解析 |
| qjsc-kmp | 源码模块 → 字节码,`-n` 给定模块名,`--strip-source` 保留行号去源码 | 校验模块图(引擎加载时查表) |

**页面名即模块名即路由键**:`<pkg>/<src/pages 下的相对路径>`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages", "defaultLocale", "i18n": "i18n" }`,后两个见 §8,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,由库构建并随库发布,不进 `tinyui build` 的产物。产物目录 `pages/**/*.bin`、`manifest.json`(页面模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `tinyui` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.bin` / `.js.map`,见 [app-model.md](./app-model.md))。
**页面名即模块名即路由键**:`<pkg>/<src/pages 下的相对路径>`——包 `subscription` 的 `src/pages/home.tsx` → `subscription/home`,`src/pages/plans/detail.tsx` → `subscription/plans/detail`,无后缀。包名来自工程根的 `tinyui.config.json`(`{ "name", "publicKey", "pages": "src/pages", "defaultLocale", "i18n": "i18n" }`,后两个见 §8,`name` 匹配 `[a-z0-9-]+`,`publicKey` 见 updates.md §7),是命名空间,所以没有 `pages/` 段;一个 App 挂几个包(ADR-006 §2.10),路由键就靠这个前缀不撞。页面路径的每一段限定 `[A-Za-z0-9._-]+`(`tinyui build` 拒绝其余的,含中文文件名):它随产物路径进热下发的投递 URL,而 percent-encoding 要在 CLI、服务端、Kotlin 客户端三处各对称实现一次,任一处偏差就是客户端收不到更新且只能发 App 修(updates.md §6.1、§7 同一类风险)。页面内容与组件文案不受影响,只有文件名受限;日后若要放开,改 build 一处把模块名与输出路径解耦即可(`files` 本就是映射),协议与另外两端不动。运行时模块名固定 `tinyui-core`、`tinyui-native`,由库构建并随库发布,不进 `tinyui build` 的产物。产物目录 `pages/**/*.jsb`、`manifest.json`(页面模块清单、每个模块的产物路径 `files` 与 `buildIds`,包身份 `name` / `publicKey` 与热下发用的 `version` / `createdAt` / `engine` / `tinyui` / `hashes` 见 [updates.md](./updates.md) §1.1;Kotlin 侧路由表的来源,宿主用 `BuildManifest.file(name)` 定位 `.jsb` / `.js.map`,见 [app-model.md](./app-model.md))。

业务工程的 tsconfig 用 `jsx: "react-jsx"` + `jsxImportSource: "tinyui-core"` 做类型检查([jsx-transform.md](./jsx-transform.md) §4),而 esbuild 会读到这份 tsconfig 并按它产出 `import { jsx } from "tinyui-core/jsx-runtime"`,即使 `build()` 显式传了 `jsx: "transform"`;引擎里没有这个模块,加载报 `module 'tinyui-core/jsx-runtime' is not registered`(2026-09-16 CI 实测)。页面构建要传 `tsconfigRaw` 覆盖 tsconfig 的 jsx 三项。

Expand Down Expand Up @@ -106,7 +106,7 @@ rolldown 是真正的备选(Rollup 同形 API、oxc 转译)。CLI 只包一
| `packages/core` / `native` | 不动运行时 API,只保证入口能打成单模块,各导出 `VERSION` 供 sample 显示 |
| `compose/` | ADR-002 K0 最小版:注册 core → 注册 native → 运行页面模块 → 返回 namespace。命名待 M1 定,本期只保证序列在库里而不在 sample 里。commonTest 用 `JsBytecode.compile` 现场编字节码,不依赖宿主工具 |
| `sample/js` | 新 pnpm workspace 成员,包名 `sample`,`src/pages/home.tsx`。本期页面用纯 TS 只 import `VERSION`:`h` 的签名是 C 组的事,不在 core 放临时实现;JSX 变换的正确性由 CLI 单元测试覆盖 |
| `sample/shared` | Gradle:`pnpm run build` → `tinyui build` → `Sync` 只取 `.bin` 与清单 → `compose.resources.customDirectory`,`Res.readBytes("files/tinyui/…")` 读入后走 K0。AGP 9 的 KMP 库插件默认不处理 assets,Compose resources 在 Android 走 assets,必须 `androidResources { enable = true }`,否则 APK 里没有资源且构建不报错(2026-09-16 实测) |
| `sample/shared` | Gradle:`pnpm run build` → `tinyui build` → `Sync` 只取 `.jsb` 与清单 → `compose.resources.customDirectory`,`Res.readBytes("files/tinyui/…")` 读入后走 K0。AGP 9 的 KMP 库插件默认不处理 assets,Compose resources 在 Android 走 assets,必须 `androidResources { enable = true }`,否则 APK 里没有资源且构建不报错(2026-09-16 实测) |
| `settings.gradle.kts` | 有 `quickjs-kmp.dir` 时把 `qjsc-kmp` 路径传给 Exec 任务 |
| CI | gradle job 加 setup-node + pnpm,clone quickjs-kmp 的 catalog 版本 tag 编 `buildNativeHostJni`(Android host 测试要加载宿主 JNI 库,Maven 包里没有),经 `TINYUI_QUICKJS_HOST_JNI` 传入;`qjsc-kmp` 来自 npm 包(2026-09-23 起);ios.yml 同样 |

Expand Down
2 changes: 1 addition & 1 deletion docs/js-runtime.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ <h2 id="layers">0. 六层</h2>
<g>
<rect class="box" x="0" y="40" width="160" height="70" rx="4"/><text class="t b" x="80" y="68" text-anchor="middle">TSX 源码</text><text class="t s" x="80" y="88" text-anchor="middle">业务写的</text>
<rect class="box" x="200" y="40" width="160" height="70" rx="4"/><text class="t b" x="280" y="68" text-anchor="middle">h() + thunk()</text><text class="t s" x="280" y="88" text-anchor="middle">esbuild 产物</text>
<rect class="box" x="400" y="40" width="160" height="70" rx="4"/><text class="t b" x="480" y="68" text-anchor="middle">ESM 模块字节码</text><text class="t s" x="480" y="88" text-anchor="middle">pages/home.bin</text>
<rect class="box" x="400" y="40" width="160" height="70" rx="4"/><text class="t b" x="480" y="68" text-anchor="middle">ESM 模块字节码</text><text class="t s" x="480" y="88" text-anchor="middle">pages/home.jsb</text>
<rect class="box acc" x="600" y="40" width="160" height="70" rx="4"/><text class="t b" x="680" y="68" text-anchor="middle">运行时对象</text><text class="t s" x="680" y="88" text-anchor="middle">owner / effect / signal / id</text>
<rect class="box" x="800" y="40" width="160" height="70" rx="4"/><text class="t b" x="880" y="68" text-anchor="middle">patch 消息</text><text class="t s" x="880" y="88" text-anchor="middle">JSON 数组</text>
<rect class="box" x="1000" y="40" width="160" height="70" rx="4"/><text class="t b" x="1080" y="68" text-anchor="middle">Kotlin 节点表</text><text class="t s" x="1080" y="88" text-anchor="middle">ADR-003</text>
Expand Down
6 changes: 3 additions & 3 deletions docs/updates-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 +
|---|---|
| `tinyui.config.json` | 工程根,`name`(`[a-z0-9-]+`,必填)/ `publicKey`(必填)/ `pages`(缺省 `src/pages`);`build` 无配置文件即报错 |
| 模块名改 `<pkg>/<相对路径>` | esbuild 入口 → `-n <pkg>/home`,`import.meta.url = tinyui:<pkg>/home`;manifest 的 `pages` / `files` / `buildIds` / `hashes` 键随之改;sample 包名 `sample`,`sample/shared` 与 CLI 测试里的 `pages/…` 全部改 |
| `manifest.json` 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `protocol` / `hashes` | `name` / `publicKey` 抄配置;`engine` 从第一个 `.bin` 的文件头读(`QJKB` 起偏移 12 的 40 字节 hex);`protocol` 读 `tinyui-core/protocol` 子路径导出,core 包加这个入口 |
| `manifest.json` 加 `name` / `publicKey` / `version` / `createdAt` / `engine` / `protocol` / `hashes` | `name` / `publicKey` 抄配置;`engine` 从第一个 `.jsb` 的文件头读(`QJKB` 起偏移 12 的 40 字节 hex);`protocol` 读 `tinyui-core/protocol` 子路径导出,core 包加这个入口 |
| `tinyui keys generate` | Node `crypto.generateKeyPairSync("ec", { namedCurve: "P-256" })`;输出私钥 PKCS#8 PEM 与 X9.63 裸点 base64 公钥(updates.md §7) |
| `tinyui bundle --runtime-version --signing-key [--rollout] [--out]` | manifest 加 `runtimeVersion` 后写定为 `<pkg>/<rv>/<version>/manifest.json`,对该文件字节 `crypto.sign("sha256", …, { dsaEncoding: "der" })`,签名与 `version` / `rollout` 写 `<pkg>/<rv>/current.json`;`.bin` 复制进 `<version>/`;签名私钥的公钥必须等于 manifest 的 `publicKey`,否则拒绝 |
| `tinyui bundle --runtime-version --signing-key [--rollout] [--out]` | manifest 加 `runtimeVersion` 后写定为 `<pkg>/<rv>/<version>/manifest.json`,对该文件字节 `crypto.sign("sha256", …, { dsaEncoding: "der" })`,签名与 `version` / `rollout` 写 `<pkg>/<rv>/current.json`;`.jsb` 复制进 `<version>/`;签名私钥的公钥必须等于 manifest 的 `publicKey`,否则拒绝 |
| 测试 | 夹具目录 → 字段齐全、模块名带包名、`hashes` 对得上、`current.json` 的签名可用公钥对 `manifest.json` 字节验回、改 `current.json` 的 `rollout` 不影响验签、私钥与 `publicKey` 不配对被拒、缺配置文件被拒 |

验收:`sample/js` 跑 `tinyui build && tinyui bundle`,双端显示 `tinyui:sample/home`,产出目录用 `openssl dgst -sha256 -verify` 对 `<version>/manifest.json` 验 `current.json` 里的签名。
Expand Down Expand Up @@ -55,7 +55,7 @@ M1 之后 M2 → M3 与 M4 并行;M5.1、M5.2 与 tinyui 0.3.0(npm 三包 +
| 测试 | 假 `fetch` 按协议喂:指针解析失败、签名错(含公钥轮换的诊断信息)、`name` 不匹配、manifest `version` 与指针不符、engine 不匹配、旧于内置、rollout 不中、sha256 错、全通过各一条;两个包一坏一好互不影响;启动选择四种情况 + installed 文件被改一个字节即回内置并 `Failed(integrity)` + 清理被去掉的包与 rv 不匹配的 installed;Android host 与 iOS simulator 各跑一遍验签 |
| sample | 第二个包 `sample-extra`(一页即可)验多包;一个开关把 base URL 指向本机 `python3 -m http.server` 起的 `dist/ota/`,手动验"下次启动生效"与回退 |

验收:sample 在 Android 与 iOS 模拟器上从本机静态目录装上新包、重启生效;故意给一个包发坏包(改一个 `.bin` 字节、或页面顶层 `throw`)能回退并收到带 `pkg` 的 `RolledBack`,另一个包不受影响。
验收:sample 在 Android 与 iOS 模拟器上从本机静态目录装上新包、重启生效;故意给一个包发坏包(改一个 `.jsb` 字节、或页面顶层 `throw`)能回退并收到带 `pkg` 的 `RolledBack`,另一个包不受影响。

## M4 · `tinyui-updates-server` MVP

Expand Down
Loading
Loading