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
47 changes: 46 additions & 1 deletion apps/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,30 @@ Provide a `Slots` object in `redirects.json` to define routing rules. The table
| `appendPath` | boolean | `true` | Whether to append the remaining path when using `prefix` or `proxy` mode. Not applicable to `exact`. |
| `status` | number | `302` | HTTP status code from 200 through 599 for non-proxy responses. Do not set for `proxy`. |
| `priority` | number | by order | Determines rule precedence for the same path. Smaller numbers are matched first. |
| `proxyPolicy` | object | legacy compatibility | Explicit request, response, redirect, cache, and resource-limit policy. Only valid for `proxy`. |

- Keys must start with `/` and can use colon parameters such as `:id` or the `*` wildcard. Captures can be referenced in the target with `$1`, `:id`, and so on.
- When multiple path patterns match, literal segments take precedence over colon parameters, parameters take precedence over `*`, and deeper patterns win when shared segments have equal specificity.
- The `proxy` type forwards the request to the destination and returns the upstream response. Other types respond with a `Location` redirect.
- To configure multiple rules for the same path, provide an array. Array order controls the default priority, or you can specify `priority` explicitly.

New proxy rules created by WebUI use an explicit `isolated` policy. Existing rules without
`proxyPolicy` keep the previous forwarding behavior for compatibility until you assign a profile:

| Profile | Use case | Default credential handling | Default cache |
|---------|----------|-----------------------------|---------------|
| `isolated` | External or untrusted targets | Strips cookies, authorization, Origin, Referer, and client IP metadata | `bypass` |
| `asset` | Public static assets | Strips cookies, authorization, and source metadata | Public cache headers |
| `trusted-api` | APIs you operate | Credentials remain stripped unless explicitly allowed | `bypass` |

All explicit profiles preserve upstream CSP and frame protections, check every followed redirect
against the initial or configured allowed origins, and process each `Set-Cookie` header
independently. `cache.mode: "public"` emits portable HTTP cache headers; provider-specific cache
APIs are not used.
Public caching cannot be combined with forwarded request cookies or authorization. Configuration
validation rejects that combination, and the Runtime still forces `private, no-store` if an
unvalidated policy reaches the proxy.

Add the schema reference below to unlock autocomplete and validation in supporting editors. The schema lives on `main`, so it still applies if the JSON sits in a data branch:

```jsonc
Expand Down Expand Up @@ -179,18 +197,45 @@ Add the schema reference below to unlock autocomplete and validation in supporti
"type": "proxy",
"target": "https://api.example.com",
"appendPath": true,
"proxyPolicy": {
"profile": "trusted-api",
"request": {
"methods": ["GET", "POST"],
"cookies": {
"mode": "allowlist",
"names": ["session"]
}
},
"response": {
"cookies": {
"mode": "allowlist",
"names": ["session"],
"domain": "remove",
"path": "proxy-base"
}
},
"cache": {
"mode": "bypass"
}
},
"priority": 10
},
{
"type": "proxy",
"target": "https://backup-api.example.com",
"appendPath": true,
"proxyPolicy": {
"profile": "isolated"
},
"priority": 20
}
],
"/media/*": {
"type": "proxy",
"target": "https://cdn.example.com/$1"
"target": "https://cdn.example.com/$1",
"proxyPolicy": {
"profile": "asset"
}
},
"/admin": {
"type": "prefix",
Expand Down
41 changes: 40 additions & 1 deletion apps/runtime/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,24 @@ pnpm runtime:build:nf
| `appendPath` | boolean | `true` | `prefix` 或 `proxy` 模式下是否拼接剩余路径,`exact` 不适用。 |
| `status` | number | `302` | 非 `proxy` 响应使用 200 到 599 的状态码,`proxy` 不要设置。 |
| `priority` | number | 按顺序 | 同一路径存在多条规则时用于排序,数字越小越先匹配。 |
| `proxyPolicy` | object | 旧版兼容行为 | 显式控制请求、响应、跳转、缓存和资源限制,仅适用于 `proxy`。 |

- 键名需要以 `/` 开头,可以使用冒号参数,例如 `:id`,也可以使用 `*` 通配符。匹配结果可以在目标地址中用 `$1`、`:id` 等占位符引用。
- 多个路径模式同时匹配时,字面量片段优先于冒号参数,参数优先于 `*`;共享片段特异性相同时,层级更深的模式优先。
- `proxy` 类型会把请求转发到目标地址并返回上游响应,其他类型返回 `Location` 重定向。
- 如果需要为同一路径配置多条规则,可以把值写成数组。数组顺序决定默认优先级,也可以通过 `priority` 显式指定。

WebUI 新建的代理规则会使用显式 `isolated` 策略。没有 `proxyPolicy` 的现有规则会继续沿用旧版转发行为,直到你为其选择预设:

| 预设 | 适用场景 | 默认凭据处理 | 默认缓存 |
|------|----------|--------------|----------|
| `isolated` | 外部或不受信任的目标 | 移除 Cookie、Authorization、Origin、Referer 和客户端 IP 元数据 | `bypass` |
| `asset` | 公开静态资源 | 移除 Cookie、Authorization 和来源元数据 | 公开缓存头 |
| `trusted-api` | 自己运营的 API | 默认仍移除凭据,必须显式放行 | `bypass` |

所有显式预设都会保留上游 CSP 与防嵌入响应头;每次跟随跳转时都会检查初始来源或配置的允许来源;每个 `Set-Cookie` 也会单独处理。`cache.mode: "public"` 只输出跨平台的 HTTP 缓存头,不调用平台专属缓存 API。
公共缓存不能与放行的请求 Cookie 或 Authorization 同时使用。配置校验会拒绝这种组合;即使未经校验的策略进入代理,Runtime 也会强制改为 `private, no-store`。

在文件顶部添加下面的 schema 引用,可以在支持的编辑器里获得自动补全和校验。Schema 放在 `main` 分支,即使 `redirects.json` 在 `data` 分支也能生效:

```jsonc
Expand Down Expand Up @@ -179,18 +191,45 @@ pnpm runtime:build:nf
"type": "proxy",
"target": "https://api.example.com",
"appendPath": true,
"proxyPolicy": {
"profile": "trusted-api",
"request": {
"methods": ["GET", "POST"],
"cookies": {
"mode": "allowlist",
"names": ["session"]
}
},
"response": {
"cookies": {
"mode": "allowlist",
"names": ["session"],
"domain": "remove",
"path": "proxy-base"
}
},
"cache": {
"mode": "bypass"
}
},
"priority": 10
},
{
"type": "proxy",
"target": "https://backup-api.example.com",
"appendPath": true,
"proxyPolicy": {
"profile": "isolated"
},
"priority": 20
}
],
"/media/*": {
"type": "proxy",
"target": "https://cdn.example.com/$1"
"target": "https://cdn.example.com/$1",
"proxyPolicy": {
"profile": "asset"
}
},
"/admin": {
"type": "prefix",
Expand Down
11 changes: 6 additions & 5 deletions apps/runtime/src/lib/handlers/analytics/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
normalizeAnalyticsHostname
} from "./attribution";
import type { VerifiedAttributionToken } from "./attribution";
import { DEFAULT_STATUS } from "../core/constants";
import type {
AnalyticsBotCategory,
AnalyticsBotConfidence,
Expand Down Expand Up @@ -149,7 +150,7 @@ export async function createMatchedAnalyticsEvent(
eventKind: "link",
analyticsId,
routePath: input.routePath,
linkType: input.rule.type === "proxy" ? "proxy" : "redirect",
linkType: input.rule.action.type,
matchKind: input.matchKind,
matchOutcome: "matched",
...resolveReferrerField(input.request),
Expand Down Expand Up @@ -288,10 +289,10 @@ async function resolveAnalyticsId(path: string, rule: NormalizedRule): Promise<s
const legacyIdentity = JSON.stringify([
"legacy-v1",
path,
rule.type,
rule.target,
rule.appendPath,
rule.status
rule.sourceType,
rule.action.target,
rule.action.appendPath,
rule.action.type === "redirect" ? rule.action.status : DEFAULT_STATUS
]);
return `legacy_${await createSha256(legacyIdentity)}`;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/runtime/src/lib/handlers/analytics/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ async function finalizeMatchedAnalyticsInternal(input: MatchedAnalyticsInput): P
let finalResponse = input.response;
const attributionKey = input.analytics.settings.attributionKey;
const sourceHostname = input.analytics.settings.sourceHostname;
if (input.rule.type !== "proxy" && attributionKey && sourceHostname) {
if (input.rule.action.type !== "proxy" && attributionKey && sourceHostname) {
try {
finalResponse = await attachUpstreamAttribution(
finalResponse,
Expand Down
30 changes: 27 additions & 3 deletions apps/runtime/src/lib/handlers/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
} from "@i0c/analytics-domain/classification";
import type {
DataConfig,
ProxyPolicy,
RedirectsConfig as SharedRedirectsConfig,
SlotBranch as SharedSlotBranch
} from "@i0c/config";
Expand All @@ -33,6 +34,7 @@ import type {
import type { RuntimePluginInstallations } from "@i0c/runtime-host/installations";

export type RouteType = "prefix" | "exact" | "proxy";
export type RouteMatchType = "exact" | "prefix" | "pattern";
export type AnalyticsProvider = AnalyticsProviderType;
export type AnalyticsRequestClass = "human" | "link_preview" | "crawler" | "monitor" | "asset" | "unknown";
export type AnalyticsEventKind = "link" | "runtime";
Expand Down Expand Up @@ -71,15 +73,37 @@ export interface RouteConfig {
appendPath?: boolean;
status?: number | string;
priority?: number | string;
proxyPolicy?: ProxyPolicy;
}

export interface NormalizedRule {
analyticsId?: string;
type: RouteType;
export interface NormalizedRouteMatch {
type: RouteMatchType;
}

export interface NormalizedRedirectAction {
type: "redirect";
target: string;
appendPath: boolean;
status: number;
}

export interface NormalizedProxyAction {
type: "proxy";
target: string;
appendPath: boolean;
policy?: ProxyPolicy;
}

export type NormalizedRouteAction =
| NormalizedProxyAction
| NormalizedRedirectAction;

export interface NormalizedRule {
analyticsId?: string;
match: NormalizedRouteMatch;
action: NormalizedRouteAction;
priority: number;
sourceType: RouteType;
}

export interface CompiledEntry {
Expand Down
2 changes: 1 addition & 1 deletion apps/runtime/src/lib/handlers/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function inferEffectivePath(

let bestBase: string | null = null;
for (const entry of compiledList) {
if (entry.rule.type !== "proxy") continue;
if (entry.rule.action.type !== "proxy") continue;
const base = entry.base;
if (base === "/") continue;
if (refPath === base || refPath.startsWith(`${base}/`)) {
Expand Down
6 changes: 3 additions & 3 deletions apps/runtime/src/lib/handlers/routing/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export async function dispatchRouteRequest({
for (let index = 0; index < compiledList.length; index += 1) {
const item = compiledList[index];
const { rule, base } = item;
if (!rule.target) {
if (!rule.action.target) {
continue;
}

Expand All @@ -171,7 +171,7 @@ export async function dispatchRouteRequest({
}
const { targetUrl, matchKind } = resolved;

if (isStaticAssetPath && rule.type === "proxy") {
if (isStaticAssetPath && rule.action.type === "proxy") {
const collected = collectProxyRaceCandidates(
compiledList,
index,
Expand Down Expand Up @@ -211,7 +211,7 @@ export async function dispatchRouteRequest({
const requestClone = request.clone() as Request;
const response = await respondUsingRule(requestClone, rule, targetUrl, runtime, base);

const failureReason = rule.type === "proxy"
const failureReason = rule.action.type === "proxy"
? classifyProxyFailure(response)
: null;
if (failureReason) {
Expand Down
Loading