diff --git a/README.md b/README.md index 7b16cb8..1e16b22 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Two kinds of `GET`, relative to `https:////`: | Path | Content | Cache | |---|---|---| | `//current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` | -| `///…` | immutable content: `manifest.json`, `runtime/*.bin`, `pages/**/*.bin` | `immutable` | +| `///…` | immutable content: `manifest.json`, `pages/**/*.bin` (the runtime ships inside the App, not in packages) | `immutable` | -`` is the positive integer a host App declares for what it provides to pages: it goes up when host components or capabilities change or the embedded TinyUI is upgraded. It is not the version of the TinyUI runtime or the JS engine (the manifest's `engine` / `protocol` cover those); it plays the role of Expo's `runtimeVersion` but is always a positive integer that counts host changes, never an App version such as `1.2.0`; see the TinyUI repo's `docs/updates.md`. +`` is the positive integer a host App declares for what it promises to pages: it goes up when host components or capabilities change, when pages may use a newer TinyUI runtime, or when the JS engine changes. It is not the TinyUI version: upgrading TinyUI alone leaves it as is, and the manifest's `engine` / `tinyui` are checked on their own; it plays the role of Expo's `runtimeVersion` but is always a positive integer that counts host changes, never an App version such as `1.2.0`; see the TinyUI repo's `docs/updates.md`. Content is stored once per `(app, pkg, hostVersion, version)`; a channel is only a pointer, so promoting a verified version from `staging` to `production` moves the pointer and transfers nothing. diff --git a/README.zh-CN.md b/README.zh-CN.md index af645d6..b5b76e7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,9 +13,9 @@ | 路径 | 内容 | 缓存 | |---|---|---| | `//current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` | -| `///…` | 不可变内容:`manifest.json`、`runtime/*.bin`、`pages/**/*.bin` | `immutable` | +| `///…` | 不可变内容:`manifest.json`、`pages/**/*.bin`(运行时随 App,不在包里) | `immutable` | -`` 是宿主 App 为"它给页面提供了什么"声明的正整数:宿主组件、宿主能力增删或改动,或者升级了内置的 TinyUI,都加 1。它不是 TinyUI 运行时或 JS 引擎的版本(那两者由 manifest 的 `engine` / `protocol` 校验),作用同 Expo 的 `runtimeVersion`,但取值只能是正整数、数的是宿主的变化,不是 `1.2.0` 这类 App 版本号,见 TinyUI 仓的 `docs/updates.md`。 +`` 是宿主 App 为"它对页面承诺了什么"声明的正整数:宿主组件、宿主能力增删或改动,页面要用更新的 TinyUI 运行时,或者换了 JS 引擎,都加 1。它不是 TinyUI 版本:单纯升级 TinyUI 不动它,manifest 的 `engine` / `tinyui` 另行校验,作用同 Expo 的 `runtimeVersion`,但取值只能是正整数、数的是宿主的变化,不是 `1.2.0` 这类 App 版本号,见 TinyUI 仓的 `docs/updates.md`。 内容按 `(app, pkg, hostVersion, version)` 存一份;channel 只是指针,staging 验过的版本晋级到 production 只动指针,不重传。 diff --git a/src/app.ts b/src/app.ts index e146cd8..b8144c2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -208,7 +208,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono { if (manifest.hostVersion !== hostVersion) return fail(c, 400, `manifest hostVersion ${manifest.hostVersion} is not the path host version ${hostVersion}`); if (manifest.version !== version) return fail(c, 400, `manifest version ${manifest.version} is not the pointer version ${version}`); if (manifest.publicKey !== registered.publicKey) return fail(c, 400, "manifest publicKey is not the registered one"); - for (const module of [...manifest.runtime, ...manifest.pages]) { + for (const module of manifest.pages) { const path = manifest.files[module] + ".bin"; const info = await storage.headObject(objectKey(appId, pkg, hostVersion, version, path)); if (!info) return fail(c, 409, `${version}/${path} is not uploaded yet`); diff --git a/src/ids.ts b/src/ids.ts index 886f39b..5c53be1 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -13,7 +13,7 @@ export function isSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/.test(value) && value !== "." && value !== ".."; } -/** A file path inside a version directory: `runtime/core.bin`, `pages/home.bin`, `manifest.json`. */ +/** A file path inside a version directory: `pages/home.bin`, `manifest.json`. */ export function isObjectPath(value: string): boolean { const segments = value.split("/"); return segments.length > 0 && segments.every(isSegment); diff --git a/src/manifest.ts b/src/manifest.ts index bd8fd43..1d0cfb0 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -3,7 +3,6 @@ import { looksLikePublicKey } from "./signature.ts"; /** The signed `/manifest.json` `tinyui bundle` wrote (tinyui docs/updates.md §1.1), the fields this server checks. */ export interface Manifest { - runtime: string[]; pages: string[]; files: Record; hashes: Record; @@ -24,7 +23,7 @@ export function parseManifest(text: string): Manifest { } if (typeof raw !== "object" || raw === null) throw new Error("manifest.json is not an object"); const m = raw as Record; - const names = (key: "runtime" | "pages"): string[] => { + const names = (key: "pages"): string[] => { const list = m[key]; if (!Array.isArray(list) || !list.every((x) => typeof x === "string")) throw new Error(`manifest.json ${key} must list module names`); return list as string[]; @@ -40,7 +39,6 @@ export function parseManifest(text: string): Manifest { return value; }; const manifest: Manifest = { - runtime: names("runtime"), pages: names("pages"), files: table("files"), hashes: table("hashes"), @@ -55,11 +53,11 @@ export function parseManifest(text: string): Manifest { if (!isHostVersion(manifest.hostVersion)) throw new Error("manifest.json hostVersion is not a positive integer"); if (instant(manifest.createdAt) === null) throw new Error("manifest.json createdAt is not a real YYYY-MM-DDTHH:MM:SSZ instant"); if (!looksLikePublicKey(manifest.publicKey)) throw new Error("manifest.json publicKey is not a P-256 point"); - const modules = [...manifest.runtime, ...manifest.pages]; + const modules = manifest.pages; if (new Set(modules).size !== modules.length) throw new Error("manifest.json lists a module twice"); for (const key of ["files", "hashes"] as const) { const keys = Object.keys(manifest[key]).sort(); - if (keys.join("\n") !== [...modules].sort().join("\n")) throw new Error(`manifest.json ${key} does not cover exactly the modules in runtime and pages`); + if (keys.join("\n") !== [...modules].sort().join("\n")) throw new Error(`manifest.json ${key} does not cover exactly the modules in pages`); } for (const path of Object.values(manifest.files)) { if (!isObjectPath(path + ".bin")) throw new Error(`manifest.json files entry ${path} is not a file path`); diff --git a/test/helpers.ts b/test/helpers.ts index 7a4d4d5..1bbdb83 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -31,13 +31,12 @@ export async function signedPackage(options: { const name = options.name ?? "shop"; const version = options.version ?? "20260922T100000Z-abcdef1"; const key = options.key ?? (await newKeyPair()); - const contents = options.files ?? { "runtime/core.bin": `CORE-${version}`, "runtime/native.bin": `NATIVE-${version}`, "pages/home.bin": `HOME-${version}` }; + const contents = options.files ?? { "pages/home.bin": `HOME-${version}` }; const files = Object.fromEntries(Object.entries(contents).map(([p, text]) => [p, new TextEncoder().encode(text)])); - const modules: Record = { "tinyui-core": "runtime/core", "tinyui-native": "runtime/native", [`${name}/home`]: "pages/home" }; + const modules: Record = { [`${name}/home`]: "pages/home" }; const hashes: Record = {}; for (const [module, path] of Object.entries(modules)) hashes[module] = await sha256Hex(files[`${path}.bin`]!); const manifest: Record = { - runtime: ["tinyui-core", "tinyui-native"], pages: [`${name}/home`], files: modules, buildIds: Object.fromEntries(Object.keys(modules).map((m) => [m, "00000000"])), @@ -46,7 +45,7 @@ export async function signedPackage(options: { version, createdAt: options.createdAt ?? "2026-09-22T10:00:00Z", engine: "e".repeat(40), - protocol: 1, + tinyui: "0.7.0", hashes, hostVersion: options.hostVersion ?? "1", }; diff --git a/test/publish.test.ts b/test/publish.test.ts index afa0cfe..26cc9bb 100644 --- a/test/publish.test.ts +++ b/test/publish.test.ts @@ -22,10 +22,10 @@ describe("publishing and delivery", () => { expect(manifest.status).toBe(200); expect(manifest.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); expect(new Uint8Array(await manifest.arrayBuffer())).toEqual(p.manifest); - const core = await request(`/${app}/production/${pkg}/1/${p.version}/runtime/core.bin`); - expect(new TextDecoder().decode(await core.arrayBuffer())).toBe(`CORE-${p.version}`); + const home = await request(`/${app}/production/${pkg}/1/${p.version}/pages/home.bin`); + expect(new TextDecoder().decode(await home.arrayBuffer())).toBe(`HOME-${p.version}`); // the channel is a pointer, not a place: another channel reads the same bytes even before it points there - expect((await request(`/${app}/staging/${pkg}/1/${p.version}/runtime/core.bin`)).status).toBe(200); + expect((await request(`/${app}/staging/${pkg}/1/${p.version}/pages/home.bin`)).status).toBe(200); expect((await request(`/${app}/staging/${pkg}/1/current.json`)).status).toBe(404); }); @@ -39,9 +39,9 @@ describe("publishing and delivery", () => { await request(`/${app}/${pkg}/1/${p.version}/manifest.json`, { method: "PUT", token, body: p.manifest as BodyInit }); const missing = await publishPointer(app, "production", pkg, "1", p, token); expect(missing.status).toBe(409); - expect(await missing.text()).toContain("runtime/core.bin is not uploaded yet"); + expect(await missing.text()).toContain("pages/home.bin is not uploaded yet"); - const tampered = await signedPackage({ files: { "runtime/core.bin": "CORE-x", "runtime/native.bin": "NATIVE-x", "pages/home.bin": "HOME-x" }, key: { publicKey: p.publicKey, privateKey: p.privateKey } }); + const tampered = await signedPackage({ files: { "pages/home.bin": "HOME-x" }, key: { publicKey: p.publicKey, privateKey: p.privateKey } }); for (const [path, bytes] of Object.entries(tampered.files)) { await request(`/${app}/${pkg}/1/${p.version}/${path}`, { method: "PUT", token, body: bytes as BodyInit }); } @@ -184,26 +184,18 @@ describe("publishing and delivery", () => { it("accepts a package tinyui bundle signed with Node's crypto", async () => { // the same fixture the Kotlin client verifies (tinyui updates/src/commonTest Fixture.kt) - const publicKey = "BCsQWJxiruNm+rZEwQBeGs/1nTV3QGB2TObvtD61sZlSbBGee/gnfxIWuOMCRUcrCRWGONKylaGqkcRiEq4Qy3g="; - const signature = "MEUCIHX9BKQtT7O1VUQCLj7czqmjDjWpSXQa8vcf1dZXBMC6AiEAqtfPgEn72H8PnwD9w3+wKFLe8wd3FnnyckEdtsico5A="; + const publicKey = "BEbccwd4v7RnidrsHeZ6qqJF+yNy4DTEe/JHkHL470V74M9sYGxpekukLlpXv2qrru7ptyfKS1c7eC26sn7wKdI="; + const signature = "MEUCIQCFnOw6pTRT9OYOsp/tdMzCD9DB8SnFmjop2KssJl0QEwIgXr0EWLdYlxagsjCVFwz0GTP5UZBlI0U1pGcQMWzOjuw="; const version = "20260922T100000Z-abcdef1"; const manifest = [ "{", - ' "runtime": [', - ' "tinyui-core",', - ' "tinyui-native"', - " ],", ' "pages": [', ' "shop/home"', " ],", ' "files": {', - ' "tinyui-core": "runtime/core",', - ' "tinyui-native": "runtime/native",', ' "shop/home": "pages/home"', " },", ' "buildIds": {', - ' "tinyui-core": "aaaaaaaa",', - ' "tinyui-native": "bbbbbbbb",', ' "shop/home": "cccccccc"', " },", ' "name": "shop",', @@ -211,10 +203,8 @@ describe("publishing and delivery", () => { ` "version": "${version}",`, ' "createdAt": "2026-09-22T10:00:00Z",', ' "engine": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",', - ' "protocol": 1,', + ' "tinyui": "0.5.0",', ' "hashes": {', - ' "tinyui-core": "d63180d8a590da5a74a6a62a700b4f1c1ccf8eb1286e0509e6b30fc72d92697b",', - ' "tinyui-native": "8a65da82b504e482deb6e49382cd8b2abca80b6c6ad67ff82aa642f76c8fcc4d",', ' "shop/home": "6ca9202ab8e55afbd5f0e68113ef733655c190b1531cc7e1fc17ea3bb8d32230"', " },", ' "hostVersion": "1"', @@ -222,7 +212,7 @@ describe("publishing and delivery", () => { "", ].join("\n"); const { app, pkg, token } = await setup({ publicKey }); - for (const [path, text] of Object.entries({ "runtime/core.bin": "CORE2", "runtime/native.bin": "NATIVE2", "pages/home.bin": "HOME2" })) { + for (const [path, text] of Object.entries({ "pages/home.bin": "HOME2" })) { expect((await request(`/${app}/${pkg}/1/${version}/${path}`, { method: "PUT", token, body: text })).status).toBe(201); } expect((await request(`/${app}/${pkg}/1/${version}/manifest.json`, { method: "PUT", token, body: manifest })).status).toBe(201);