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

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

[TinyUI](https://github.com/tiny-ui/tinyui) 的构建工具:`tinyui build` 把 `tinyui.config.json` 描述的包(`src/pages/**/*.tsx`)编成每页一个 ESM 模块字节码(含运行时模块与 `manifest.json`),`tinyui bundle` 把一次 build 签名成可上传 / 可静态托管的热下发目录,`tinyui keys generate` 生成签名密钥对,`tinyui schema` 从组件 schema 生成 TS 类型与 Kotlin 侧代码。

`tinyui publish` 先对照目标宿主版本的快照核对包用到的宿主能力与组件,再把 `bundle` 的产物传到热下发服务并切换 channel 指针;`tinyui apps` / `packages` / `tokens` / `releases` / `hosts` 是这套服务的管理面(建 app 与签发 app token、登记包的验签公钥、签发与吊销发布 token、列 release 与回滚 / 晋级 / 改灰度、宿主 CI 上传快照)。实例地址取 `--url`、`$TINYUI_UPDATES_URL`、`https://updates.tinyui.app` 三者之一;凭据只从环境变量读,不接受命令行参数:`$TINYUI_TOKEN` 是发布 token,`$TINYUI_ADMIN_TOKEN` 放实例的管理 token 或某个 app 的 app token。`--app` 可用 `$TINYUI_APP` 代替,`--pkg` 缺省取当前目录 `tinyui.config.json` 的 `name`,`--channel` 必须每次显式写。联网命令遵循 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`(大小写均可),与 curl、npm 一致;没设代理变量时不加载代理实现。
`tinyui publish` 先对照目标宿主版本的快照核对包用到的宿主能力与组件,再把 `bundle` 的产物传到热下发服务并切换 channel 指针;`tinyui apps` / `packages` / `tokens` / `releases` / `hosts` 是这套服务的管理面(建 app 与签发 app token、登记包的验签公钥、签发与吊销发布 token、列 release 与回滚 / 晋级 / 改灰度、宿主 CI 上传快照)。实例地址取 `--url`、`$TINYUI_UPDATES_URL`、`https://updates.tinyui.app` 三者之一;凭据只从环境变量读,不接受命令行参数:`$TINYUI_TOKEN` 是发布 token,`$TINYUI_ADMIN_TOKEN` 放实例的管理 token 或某个 app 的 app token。`--app` 可用 `$TINYUI_APP` 代替,`--pkg` 缺省取当前目录 `tinyui.config.json` 的 `name`,`--channel` 必须每次显式写。联网命令遵循 `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`(大小写均可),与 curl、npm 一致;本机回环地址(`localhost`、`127.0.0.1`、`::1`)始终直连,代理变量无法解析时提示后直连、不影响离线命令;没设代理变量时不加载代理实现。

字节码编译用 quickjs-kmp 的宿主工具 `qjsc-kmp`:依赖里的 npm 包 `qjsc-kmp` 带 macOS 与 Linux 的预编译版,装完即用;其他平台用 `--qjsc` 或 `TINYUI_QJSC` 指向自己编的那份。`qjsc-kmp` 的版本钉在与 tinyui 所用 quickjs-kmp 相同的版本上,编出的字节码才认 App 里的引擎。用法见仓库 `docs/build-chain.md` 与 `docs/updates.md`;与 `tinyui-core` 同版本号。
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tinyui-cli",
"version": "0.9.1",
"version": "0.9.2",
"description": "TinyUI build tool: TSX -> h() (ES2025) -> ESM module bytecode.",
"license": "MIT",
"type": "module",
Expand Down
38 changes: 26 additions & 12 deletions packages/cli/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,35 @@
const PROXY_ENV = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] as const;
/** The loopback hosts `requireSecureUrl` accepts over plain http, in the form undici's NO_PROXY matching expects. */
const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];

/**
* Node's built-in fetch ignores the proxy environment variables that curl, npm and pnpm honour (unless the process was
* started with NODE_USE_ENV_PROXY), so behind a proxy every command that talks to the updates server fails with
* "fetch failed". When one is set, route the global fetch through undici's EnvHttpProxyAgent, which also reads NO_PROXY.
* undici is imported only then: environments without a proxy, such as reproducible builds, never load it.
*
* Loopback always goes direct: a local instance would be unreachable through the proxy, and over plain http the proxy
* would see the bearer token. A proxy setting the agent rejects is reported and skipped rather than failing the
* command, so offline commands (build, bundle, schema) keep working.
*/
export async function useEnvProxy(env: NodeJS.ProcessEnv = process.env): Promise<boolean> {
if (!PROXY_ENV.some((name) => env[name])) return false;
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
const httpProxy = env.HTTP_PROXY ?? env.http_proxy;
const httpsProxy = env.HTTPS_PROXY ?? env.https_proxy;
const noProxy = env.NO_PROXY ?? env.no_proxy;
setGlobalDispatcher(new EnvHttpProxyAgent({
...(httpProxy !== undefined && { httpProxy }),
...(httpsProxy !== undefined && { httpsProxy }),
...(noProxy !== undefined && { noProxy }),
}));
return true;
// `||`, not `??`: an empty uppercase variable must not hide a configured lowercase one
const httpProxy = env.HTTP_PROXY || env.http_proxy;
const httpsProxy = env.HTTPS_PROXY || env.https_proxy;
if (!httpProxy && !httpsProxy) return false;
const noProxy = [env.NO_PROXY || env.no_proxy, ...LOOPBACK].filter(Boolean).join(",");
let undici: typeof import("undici") | undefined;
try {
undici = await import("undici");
undici.setGlobalDispatcher(new undici.EnvHttpProxyAgent({
...(httpProxy ? { httpProxy } : {}),
...(httpsProxy ? { httpsProxy } : {}),
noProxy,
}));
return true;
} catch (e) {
// make "directly" true even if an earlier call had installed a proxy
undici?.setGlobalDispatcher(new undici.Agent());
process.stderr.write(`tinyui: ignoring the proxy settings (${e instanceof Error ? e.message : String(e)}); connecting directly\n`);
Comment thread
codeseerbot[bot] marked this conversation as resolved.
return false;
}
}
54 changes: 47 additions & 7 deletions packages/cli/test/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import assert from "node:assert/strict";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { after, before, describe, it } from "node:test";
import { after, before, beforeEach, describe, it } from "node:test";
import { Agent, setGlobalDispatcher } from "undici";
import { useEnvProxy } from "../src/proxy.ts";

describe("useEnvProxy", () => {
let proxy: Server;
let origin: Server;
let proxyUrl: string;
let originPort: number;
const seen: string[] = [];

before(async () => {
// undici tunnels through the proxy with CONNECT even for http targets; answer it, then serve the tunnelled
// request from an in-process "origin" so no real network is touched
// "origin" answers both requests tunnelled through the proxy and direct loopback requests; `seen` tells them apart
origin = createServer((req, res) => {
seen.push(`GET ${req.headers.host}${req.url}`);
res.end("via proxy");
res.end("ok");
});
await new Promise<void>((resolve) => origin.listen(0, "::", resolve));
originPort = (origin.address() as AddressInfo).port;
// undici tunnels through the proxy with CONNECT even for http targets; answer it, then hand the socket to
// origin so no real network is touched
proxy = createServer();
proxy.on("connect", (req, socket) => {
seen.push(`CONNECT ${req.url}`);
Expand All @@ -28,22 +32,58 @@ describe("useEnvProxy", () => {
proxyUrl = `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`;
});

beforeEach(() => {
seen.length = 0;
setGlobalDispatcher(new Agent());
});

after(async () => {
setGlobalDispatcher(new Agent());
// the proxy agent keeps its connection alive; close() alone would wait for it forever
// the agents keep their connections alive; close() alone would wait for them forever
proxy.closeAllConnections();
origin.closeAllConnections();
await new Promise((resolve) => proxy.close(resolve));
await new Promise((resolve) => origin.close(resolve));
});

it("leaves fetch alone without proxy variables", async () => {
assert.equal(await useEnvProxy({}), false);
assert.equal(await useEnvProxy({ HTTP_PROXY: "", https_proxy: "" }), false);
});

it("routes the global fetch through HTTP_PROXY", async () => {
assert.equal(await useEnvProxy({ HTTP_PROXY: proxyUrl }), true);
const response = await fetch("http://updates.example.invalid/demo/ping");
assert.equal(await response.text(), "via proxy");
await (await fetch("http://updates.example.invalid/demo/ping")).text();
assert.deepEqual(seen, ["CONNECT updates.example.invalid:80", "GET updates.example.invalid/demo/ping"]);
});

it("falls back to the lowercase variable when the uppercase one is empty", async () => {
assert.equal(await useEnvProxy({ HTTP_PROXY: "", http_proxy: proxyUrl }), true);
await (await fetch("http://updates.example.invalid/demo/ping")).text();
assert.equal(seen[0], "CONNECT updates.example.invalid:80");
});

it("keeps loopback direct even when NO_PROXY does not list it", async () => {
assert.equal(await useEnvProxy({ HTTP_PROXY: proxyUrl, NO_PROXY: "corp.example" }), true);
for (const host of ["localhost", "127.0.0.1", "[::1]"]) {
await (await fetch(`http://${host}:${originPort}/local`)).text();
}
assert.deepEqual(seen, [
`GET localhost:${originPort}/local`,
`GET 127.0.0.1:${originPort}/local`,
`GET [::1]:${originPort}/local`,
]);
});

it("skips a proxy setting it cannot use instead of failing the command", async () => {
assert.equal(await useEnvProxy({ HTTPS_PROXY: "not a url" }), false);
});

it("goes direct after an unusable setting even if a proxy was installed before", async () => {
assert.equal(await useEnvProxy({ HTTP_PROXY: proxyUrl }), true);
assert.equal(await useEnvProxy({ HTTP_PROXY: "not a url" }), false);
// direct, a name that does not resolve fails; through the stale proxy it would have succeeded
await assert.rejects(fetch("http://updates.example.invalid/after"));
assert.deepEqual(seen, []);
});
});
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tinyui-core",
"version": "0.9.1",
"version": "0.9.2",
"description": "TinyUI runtime: signals, ownership, h/For/Show, patch emission.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/** The tinyui-core version; a package records it as the runtime it needs (docs/updates.md §1.1). */
export const VERSION = "0.9.1";
export const VERSION = "0.9.2";
2 changes: 1 addition & 1 deletion packages/native/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tinyui-native",
"version": "0.9.1",
"version": "0.9.2",
"description": "TinyUI host capability API for business code: http, storage, toast, navigation, i18n.",
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/native/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const VERSION = "0.9.1";
export const VERSION = "0.9.2";
export * as navigation from "./navigation.ts";
export * as store from "./store.ts";
export * as events from "./events.ts";
Expand Down
Loading