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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Two kinds of `GET`, relative to `https://<host>/<app>/<channel>`:
| Path | Content | Cache |
|---|---|---|
| `<pkg>/<hostVersion>/current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` |
| `<pkg>/<hostVersion>/<version>/…` | immutable content: `manifest.json`, `pages/**/*.bin` (the runtime ships inside the App, not in packages) | `immutable` |
| `<pkg>/<hostVersion>/<version>/…` | immutable content: `manifest.json`, `pages/**/*.jsb` (the runtime ships inside the App, not in packages) | `immutable` |

`<hostVersion>` 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`.

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
| 路径 | 内容 | 缓存 |
|---|---|---|
| `<pkg>/<hostVersion>/current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` |
| `<pkg>/<hostVersion>/<version>/…` | 不可变内容:`manifest.json`、`pages/**/*.bin`(运行时随 App,不在包里) | `immutable` |
| `<pkg>/<hostVersion>/<version>/…` | 不可变内容:`manifest.json`、`pages/**/*.jsb`(运行时随 App,不在包里) | `immutable` |

`<hostVersion>` 是宿主 App 为"它对页面承诺了什么"声明的正整数:宿主组件、宿主能力增删或改动,页面要用更新的 TinyUI 运行时,或者换了 JS 引擎,都加 1。它不是 TinyUI 版本:单纯升级 TinyUI 不动它,manifest 的 `engine` / `tinyui` 另行校验,作用同 Expo 的 `runtimeVersion`,但取值只能是正整数、数的是宿主的变化,不是 `1.2.0` 这类 App 版本号,见 TinyUI 仓的 `docs/updates.md`。

Expand Down
2 changes: 1 addition & 1 deletion src/ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `pages/home.bin`, `manifest.json`. */
/** A file path inside a version directory: `pages/home.jsb`, `manifest.json`. */
export function isObjectPath(value: string): boolean {
const segments = value.split("/");
return segments.length > 0 && segments.every(isSegment);
Expand Down
4 changes: 2 additions & 2 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface Manifest {
/** Every file of the version besides `manifest.json`, with its key into `hashes`. */
export function payload(manifest: Manifest): { path: string; hashKey: string }[] {
return [
...manifest.pages.map((module) => ({ path: `${manifest.files[module]}.bin`, hashKey: module })),
...manifest.pages.map((module) => ({ path: `${manifest.files[module]}.jsb`, hashKey: module })),
...Object.values(manifest.i18n?.files ?? {}).map((path) => ({ path, hashKey: path })),
];
}
Expand Down Expand Up @@ -82,7 +82,7 @@ export function parseManifest(text: string): Manifest {
if (keys.join("\n") !== [...expected[key]].sort().join("\n")) throw new Error(`manifest.json ${key} does not cover exactly the modules in pages${key === "hashes" ? " and the i18n files" : ""}`);
}
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`);
if (!isObjectPath(path + ".jsb")) throw new Error(`manifest.json files entry ${path} is not a file path`);
}
for (const path of strings) {
if (!isObjectPath(path)) throw new Error(`manifest.json i18n file ${path} is not a file path`);
Expand Down
4 changes: 2 additions & 2 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ export async function signedPackage(options: {
const version = options.version ?? "20260922T100000Z-abcdef1";
const key = options.key ?? (await newKeyPair());
const strings = Object.fromEntries(Object.entries(options.i18n ?? {}).map(([language, text]) => [`i18n/${language}.json`, text]));
const contents = { ...(options.files ?? { "pages/home.bin": `HOME-${version}` }), ...strings };
const contents = { ...(options.files ?? { "pages/home.jsb": `HOME-${version}` }), ...strings };
const files = Object.fromEntries(Object.entries(contents).map(([p, text]) => [p, new TextEncoder().encode(text)]));
const modules: Record<string, string> = { [`${name}/home`]: "pages/home" };
const hashes: Record<string, string> = {};
for (const [module, path] of Object.entries(modules)) hashes[module] = await sha256Hex(files[`${path}.bin`]!);
for (const [module, path] of Object.entries(modules)) hashes[module] = await sha256Hex(files[`${path}.jsb`]!);
for (const path of Object.keys(strings)) hashes[path] = await sha256Hex(files[path]!);
const manifest: Record<string, unknown> = {
pages: [`${name}/home`],
Expand Down
24 changes: 12 additions & 12 deletions test/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 home = await request(`/${app}/production/${pkg}/1/${p.version}/pages/home.bin`);
const home = await request(`/${app}/production/${pkg}/1/${p.version}/pages/home.jsb`);
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}/pages/home.bin`)).status).toBe(200);
expect((await request(`/${app}/staging/${pkg}/1/${p.version}/pages/home.jsb`)).status).toBe(200);
expect((await request(`/${app}/staging/${pkg}/1/current.json`)).status).toBe(404);
});

Expand All @@ -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("pages/home.bin is not uploaded yet");
expect(await missing.text()).toContain("pages/home.jsb is not uploaded yet");

const tampered = await signedPackage({ files: { "pages/home.bin": "HOME-x" }, key: { publicKey: p.publicKey, privateKey: p.privateKey } });
const tampered = await signedPackage({ files: { "pages/home.jsb": "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 });
}
Expand All @@ -54,7 +54,7 @@ describe("publishing and delivery", () => {
it("publishes a package with strings only once they are uploaded and match, and serves them", async () => {
const p = await signedPackage({ i18n: { en: '{"hi":"Hi"}', zh: '{"hi":"你好"}' } });
const { app, pkg, token } = await setup({ publicKey: p.publicKey });
await request(`/${app}/${pkg}/1/${p.version}/pages/home.bin`, { method: "PUT", token, body: p.files["pages/home.bin"] as BodyInit });
await request(`/${app}/${pkg}/1/${p.version}/pages/home.jsb`, { method: "PUT", token, body: p.files["pages/home.jsb"] as BodyInit });
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);
Expand Down Expand Up @@ -88,14 +88,14 @@ describe("publishing and delivery", () => {
it("keeps a version immutable: the same bytes again are fine, different ones are refused", async () => {
const p = await signedPackage();
const { app, pkg, token } = await setup({ publicKey: p.publicKey });
const first = await request(`/${app}/${pkg}/1/${p.version}/pages/home.bin`, { method: "PUT", token, body: p.files["pages/home.bin"] as BodyInit });
const first = await request(`/${app}/${pkg}/1/${p.version}/pages/home.jsb`, { method: "PUT", token, body: p.files["pages/home.jsb"] as BodyInit });
expect(first.status).toBe(201);
const again = await request(`/${app}/${pkg}/1/${p.version}/pages/home.bin`, { method: "PUT", token, body: p.files["pages/home.bin"] as BodyInit });
const again = await request(`/${app}/${pkg}/1/${p.version}/pages/home.jsb`, { method: "PUT", token, body: p.files["pages/home.jsb"] as BodyInit });
expect(again.status).toBe(200);
expect(await again.json()).toMatchObject({ existing: true });
const other = await request(`/${app}/${pkg}/1/${p.version}/pages/home.bin`, { method: "PUT", token, body: "something else" });
const other = await request(`/${app}/${pkg}/1/${p.version}/pages/home.jsb`, { method: "PUT", token, body: "something else" });
expect(other.status).toBe(409);
const tooBig = await request(`/${app}/${pkg}/1/${p.version}/pages/big.bin`, { method: "PUT", token, body: new Uint8Array(1048577) as BodyInit });
const tooBig = await request(`/${app}/${pkg}/1/${p.version}/pages/big.jsb`, { method: "PUT", token, body: new Uint8Array(1048577) as BodyInit });
expect(tooBig.status).toBe(413);
});

Expand Down Expand Up @@ -246,7 +246,7 @@ describe("publishing and delivery", () => {
"",
].join("\n");
const { app, pkg, token } = await setup({ publicKey });
for (const [path, text] of Object.entries({ "pages/home.bin": "HOME2" })) {
for (const [path, text] of Object.entries({ "pages/home.jsb": "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);
Expand All @@ -263,7 +263,7 @@ describe("publishing and delivery", () => {
const { app, pkg, token } = await setup({ publicKey: v1.publicKey });

// two different bodies race for one path: exactly one wins, the other is told so
const race = await Promise.all(["one", "two"].map((body) => request(`/${app}/${pkg}/1/v1/pages/race.bin`, { method: "PUT", token, body })));
const race = await Promise.all(["one", "two"].map((body) => request(`/${app}/${pkg}/1/v1/pages/race.jsb`, { method: "PUT", token, body })));
expect(race.map((r) => r.status).sort()).toEqual([201, 409]);

await uploadContent(app, pkg, "1", v1, token);
Expand All @@ -280,7 +280,7 @@ describe("publishing and delivery", () => {
const p = await signedPackage();
const { app, pkg, token } = await setup({ publicKey: p.publicKey });
expect((await request(`/${app}/${pkg}/1/../manifest.json`, { method: "PUT", token, body: "x" })).status).not.toBe(201);
expect((await request(`/${app}/${pkg}/1/v1/..%2Fescape.bin`, { method: "PUT", token, body: "x" })).status).toBe(404);
expect((await request(`/${app}/${pkg}/1/v1/..%2Fescape.jsb`, { method: "PUT", token, body: "x" })).status).toBe(404);
expect((await request(`/${app}/Prod/${pkg}/1/current.json`)).status).toBe(404);
// `apps` is reserved: never served as an app
expect((await request(`/apps/production/${pkg}/1/current.json`)).status).toBe(404);
Expand Down
Loading