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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes are documented here. This project follows [Semantic Versioni

## [Unreleased]

## [2.4.2] - 2026-07-18

### Changed

- Expanded the production-governance guides in English and Chinese with plain-language explanations, operating examples, and integration steps for remote policy/circuit breaking, observability, and route-contract CI.
- Added English comments at URLRouter integration points in the demo so the scene host, policy lifecycle, observability, and circuit-breaker behavior are easy to follow.

## [2.4.1] - 2026-07-18

### Added
Expand Down
68 changes: 52 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,20 @@ The registry rejects duplicate module IDs, a route returned by the wrong module,

### Remote policy and emergency circuit breaker

`ModuleRouteRemotePolicy` is a Codable restriction document that the App Shell can fetch from any approved remote-config service. The library never fetches configuration itself: the host must authenticate, validate, cache, and roll back the document. A remote policy can only restrict a local policy; it cannot grant authorization.
In plain terms, a remote policy is a route control panel that the backend can change without waiting for an App Store release. If the article module has an incident, the backend can disable `content`; the next article link is safely rejected. If routing itself is unsafe, setting `isCircuitBreakerOpen` to `true` pulls the main switch: all module routes stop immediately while the rest of the app continues to work.

The backend sends ordinary JSON such as:

```json
{
"isCircuitBreakerOpen": false,
"disabledModuleIDs": ["content"],
"allowedPresentationStyles": ["push", "tab"],
"acceptedContractVersions": ["1"]
}
```

`ModuleRouteRemotePolicy` represents this JSON in Swift. URLRouter never fetches configuration itself: the host app fetches it from a company service, Firebase, or another configuration service and owns authentication, signature verification, caching, and rollback. A remote policy can only **tighten** local rules; it cannot bypass local authorization or re-enable a locally rejected contract version.

For the recommended cache-first app lifecycle, add the optional product from this same package:

Expand All @@ -205,8 +218,17 @@ For the recommended cache-first app lifecycle, add the optional product from thi

`URLRouterPolicyProvider` depends on `URLRouter`; the reverse is not true. It
does not choose an HTTP client, remote-config vendor, or signing scheme. The
App supplies those small adapters, while the provider handles cache-first
startup, TTL, stale-cache fallback, and atomic replacement of the policy.
app supplies only where data comes from and how it is verified; the provider
handles this failure-prone lifecycle:

```text
App starts
→ restore the last verified local cache immediately
→ fetch the newest policy in the background without blocking the first screen
→ validate and atomically replace the active policy
→ save the new verified cache
→ keep the old cache after a temporary failure; fall back to local safe rules when it is too old
```

```swift
@State private var routePolicyStore = ModuleRoutePolicyStore(
Expand All @@ -222,15 +244,11 @@ func applyTrustedRemotePolicy(_ data: Data) throws {
}
```

Set `isCircuitBreakerOpen` to `true` for an immediate, release-free stop to module routing. The same document can disable individual modules, provide an allow-list, reject presentation styles, or tighten accepted contract versions.
To use it, the app gives a `ModuleRoutePolicyStore` to `moduleLinkRouting`, then calls `replaceRemotePolicy` only after receiving and validating a new policy. The next route automatically uses the new rules; no view rebuild or app release is needed. Set `isCircuitBreakerOpen` to `true` for an immediate stop to module routing. The same document can disable individual modules, provide an allow-list, reject presentation styles, or tighten accepted contract versions.

### Recommended app refresh strategy

Use this sequence: read the last verified cache first, refresh in the
background at cold start, refresh when the app becomes active after the TTL,
and keep the last verified policy during a temporary outage. If no verified
cache exists or it exceeds the hard stale limit, URLRouter keeps the App's
local safe policy.
Do not fetch the backend for every link: that is slow and unreliable. Use this sequence instead: read the last verified cache first, refresh in the background at cold start, refresh when the app becomes active after the TTL, and keep the last verified policy during a temporary outage. If no verified cache exists or it exceeds the hard stale limit, URLRouter keeps the app's local safe policy. The defaults are a 30-minute foreground refresh, one-hour normal cache, and a 24-hour hard stale limit. Use a shorter TTL or push-triggered refresh for incident-sensitive circuit breakers.

```swift
import URLRouter
Expand Down Expand Up @@ -277,19 +295,37 @@ final class AppRoutePolicySession {

Use `JSONRoutePolicyPayloadValidator` for a plain trusted JSON endpoint. For a
signed response or envelope, implement `RoutePolicyPayloadValidating`; only a
validated `ModuleRouteRemotePolicy` is cached or applied. For a normal policy,
the standard timing is a 30-minute foreground refresh, one-hour normal cache,
and 24-hour hard stale limit. Make the backend's emergency circuit-breaker
delivery more frequent or push-triggered when your incident requirements need
it.
validated `ModuleRouteRemotePolicy` is cached or applied. A corrupted response
or intercepted network payload therefore cannot replace the current working
policy with a partial value.

### Unified observability

Adopt `ModuleRouteObserving` in adapters for your logging, metrics, and tracing SDKs, then supply a `ModuleRouteObservability` instance to `moduleLinkRouting`. Each event carries a trace ID, outcome, host, module/route identity, presentation, and a stable `failureCode`; it deliberately excludes URL query values.
In plain terms, unified observability is a dashcam for routing. When a user says “the order link did nothing,” the team can see whether the route succeeded, was circuit-broken, used an unsupported version, targeted a disabled module, or was malformed. It also lets monitoring alert when one failure reason suddenly spikes.

To use it, write a small adapter that forwards an event to the logging, metrics, or tracing systems your company already uses, then pass it to `moduleLinkRouting`. `ModuleRouteObservability` can fan each event out to multiple observers: for example, one logs and one increments a metric.

```swift
@MainActor
final class AppRouteObserver: ModuleRouteObserving {
func record(_ event: ModuleRouteEvent) {
logger.notice("route outcome=\(event.outcome.rawValue) trace=\(event.traceID)")
metrics.increment("route.\(event.failureCode ?? "handled")")
}
}

let observability = ModuleRouteObservability(observers: [AppRouteObserver()])
```

Each event carries a trace ID, outcome, host, module/route identity, presentation, and a stable `failureCode`. It deliberately excludes URL query values, tokens, phone numbers, and other potentially sensitive data. Use the trace ID to correlate with your app's controlled logs when troubleshooting.

### Route contract CI

[`RouteContracts.json`](RouteContracts.json) is the source-controlled public route catalog. CI runs `Scripts/validate_route_contract.swift` before builds and rejects duplicate route IDs or path/presentation invocations, invalid presentation values, and contracts that omit required `presentation` or `version` parameters. Update the catalog, feature parser, release notes, and migration plan together whenever a public route changes.
In plain terms, route contract CI treats a URL as an interface agreement between teams. `/articles/:id?presentation=push&version=1` is not just a string: another Feature, a web page, a push notification, or an older app may depend on it. Without this check, one team can change a path or presentation and another team discovers broken links only in production.

[`RouteContracts.json`](RouteContracts.json) is the source-controlled public route catalog. Every entry states who owns a route, its path shape, permitted presentation styles, and required parameters. CI runs `Scripts/validate_route_contract.swift` before builds and rejects duplicate route IDs or path/presentation invocations, invalid presentation values, and contracts that omit required `presentation` or `version` parameters.

To use it, update four things in the same PR whenever a public link changes: the Feature URL parser, `RouteContracts.json`, the README/caller examples, and any required migration notes. CI catches structural catalog mistakes; whether an old URL can be removed and how old clients migrate must still be explicit in PR review and release notes. That distinction prevents “automatic validation” from being mistaken for “automatic compatibility.”

## Routing scenarios

Expand Down
56 changes: 49 additions & 7 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,37 @@ Swift 无法在运行时发现未链接的 Package。存在两个或更多 Featu

### 远程策略与紧急熔断

`ModuleRouteRemotePolicy` 是可 `Codable` 解码的限制文档,App Shell 可从任意已批准的远程配置服务获取。库本身不访问网络:宿主 App 必须负责鉴权、验签、缓存和回滚。远程策略只能收紧本地策略,不能越过本地授权。
先用人话说:远程策略就是后台随时能改的“路由开关表”。它解决的是“某个页面或功能出了事故,不能等 App Store 审核再修”的问题。例如文章模块有严重故障时,后台把 `content` 放进禁用列表;用户下一次打开文章链接时,App 会安全地拒绝跳转。若所有路由都存在风险,把 `isCircuitBreakerOpen` 设为 `true`,就像拉下总闸:所有模块路由立即停止,但 App 其他功能不受影响。

后台下发的是一份普通 JSON,大意可以是:

```json
{
"isCircuitBreakerOpen": false,
"disabledModuleIDs": ["content"],
"allowedPresentationStyles": ["push", "tab"],
"acceptedContractVersions": ["1"]
}
```

`ModuleRouteRemotePolicy` 负责把这份 JSON 表示为 Swift 数据。URLRouter 不会自己联网:宿主 App 负责从公司接口、Firebase 或其他配置服务获取数据,并负责鉴权、验签、缓存和回滚。远程策略只能**收紧**本地规则,不能绕过本地授权或放宽本地禁止的版本;因此即使远程配置异常,也不能把一个本来不应打开的路由放行。

需要“先读缓存、后台刷新”的 App 生命周期时,可从同一个 Package 按需引入:

```swift
.product(name: "URLRouterPolicyProvider", package: "URLRouter")
```

`URLRouterPolicyProvider` 依赖 `URLRouter`,反过来核心库不依赖它。它不绑定 HTTP 客户端、远程配置厂商或签名方案;App 只实现这些小适配层,Provider 负责缓存优先启动、TTL、旧缓存回退和策略原子替换。
`URLRouterPolicyProvider` 依赖 `URLRouter`,反过来核心库不依赖它。它不绑定 HTTP 客户端、远程配置厂商或签名方案;App 只实现“去哪里拿数据”和“如何验签”这两小部分,Provider 负责下面这套容易出错的流程:

```text
启动 App
→ 先读取上一次已验证的本地缓存,马上可用
→ 后台请求最新配置,不阻塞首屏
→ 验签、解析、检查通过后一次性替换当前策略
→ 保存新的可信缓存
→ 请求失败时继续使用旧缓存;旧缓存太久则回退到 App 内置安全规则
```

```swift
@State private var routePolicyStore = ModuleRoutePolicyStore(
Expand All @@ -217,11 +239,11 @@ func applyTrustedRemotePolicy(_ data: Data) throws {
}
```

将 `isCircuitBreakerOpen` 设为 `true`,即可不发版立即停止模块路由。该文档还可禁用指定模块、提供允许列表、拒绝某些展示方式或进一步收紧支持的协议版本。
怎么用:App 创建一个 `ModuleRoutePolicyStore` 并交给 `moduleLinkRouting`;随后只需要在拿到并验证新配置时调用 `replaceRemotePolicy`。下一次路由自动使用新规则,不需要重建界面或重新发版。将 `isCircuitBreakerOpen` 设为 `true`,即可不发版立即停止模块路由。该文档还可禁用指定模块、提供允许列表、拒绝某些展示方式或进一步收紧支持的协议版本。

### 推荐的 App 拉取策略

建议顺序是:启动先读取上一次已验证缓存,再在后台刷新;App 回到前台且超过 TTL 时按需刷新;短暂断网时继续使用最后一次可信策略。没有可信缓存,或缓存超过硬过期时间时,URLRouter 保持 App 内置的安全本地策略。
推荐策略不是“每次点链接都请求后台”,那样既慢又不可靠。建议顺序是:启动先读取上一次已验证缓存,再在后台刷新;App 回到前台且超过 TTL 时按需刷新;短暂断网时继续使用最后一次可信策略。没有可信缓存,或缓存超过硬过期时间时,URLRouter 保持 App 内置的安全本地策略。默认值是:前台 30 分钟刷新一次、普通缓存 1 小时、最多使用 24 小时的旧可信缓存;熔断要求更高的业务可缩短 TTL 或由静默推送触发立即刷新

```swift
import URLRouter
Expand Down Expand Up @@ -266,15 +288,35 @@ final class AppRoutePolicySession {
}
```

普通可信 JSON 接口可使用 `JSONRoutePolicyPayloadValidator`。如果响应有签名或信封结构,App 实现 `RoutePolicyPayloadValidating`;只有校验通过的 `ModuleRouteRemotePolicy` 才会写入缓存和生效。普通策略建议前台 30 分钟刷新、常规缓存 1 小时、硬过期 24 小时;对事故熔断,可按实际要求使用更短刷新间隔或静默推送触发刷新
普通可信 JSON 接口可使用 `JSONRoutePolicyPayloadValidator`。如果响应有签名或信封结构,App 实现 `RoutePolicyPayloadValidating`;只有校验通过的 `ModuleRouteRemotePolicy` 才会写入缓存和生效。这样即使网络被劫持、返回了损坏 JSON,当前正在使用的策略也不会被半成品覆盖

### 统一可观测性

为日志、指标、Tracing SDK 编写 `ModuleRouteObserving` 适配器,再将 `ModuleRouteObservability` 传给 `moduleLinkRouting`。每个事件包含 trace ID、结果、host、模块/路由标识、展示方式和稳定的 `failureCode`;它刻意不包含 URL query 值。
先用人话说:统一可观测性就是给路由装“行车记录仪”。当用户说“点订单链接没反应”时,团队不需要猜;可以看到这次路由是成功、被熔断、版本不支持、模块关闭,还是 URL 本身不合法。它还能让监控系统在“某个失败原因突然暴增”时报警。

怎么用:写一个很薄的适配器,把事件送到公司已有的日志、指标或追踪系统,再在根部传给 `moduleLinkRouting`。`ModuleRouteObservability` 可以同时发送给多个观察者,例如一个写日志、一个计数、一个上报 tracing。

```swift
@MainActor
final class AppRouteObserver: ModuleRouteObserving {
func record(_ event: ModuleRouteEvent) {
logger.notice("route outcome=\(event.outcome.rawValue) trace=\(event.traceID)")
metrics.increment("route.\(event.failureCode ?? "handled")")
}
}

let observability = ModuleRouteObservability(observers: [AppRouteObserver()])
```

每个事件包含 trace ID、结果、host、模块/路由标识、展示方式和稳定的 `failureCode`。它刻意不包含 URL query 值、token、手机号等可能敏感的数据;需要排障时用 trace ID 关联 App 自己的受控日志即可。

### 路由契约 CI

[`RouteContracts.json`](RouteContracts.json) 是受版本控制的公开路由目录。CI 会在构建前运行 `Scripts/validate_route_contract.swift`,拒绝重复的路由 ID 或路径/展示方式组合、非法展示方式,以及缺少 `presentation` 或 `version` 参数的契约。变更公开路由时,应同步更新目录、Feature 解析器、发布说明和迁移方案。
先用人话说:路由契约 CI 就是把 URL 当成团队之间的“接口协议”来管理。`/articles/:id?presentation=push&version=1` 不只是一个字符串;其他 Feature、网页、推送和旧 App 都可能依赖它。没有这层检查时,团队 A 改了路径或展示方式,团队 B 往往要等线上链接失效才发现。

[`RouteContracts.json`](RouteContracts.json) 是这份受版本控制的公开路由目录。每个条目声明谁拥有路由、路径长什么样、允许怎样展示、以及必须有哪些参数。CI 会在构建前运行 `Scripts/validate_route_contract.swift`,拒绝重复的路由 ID 或路径/展示方式组合、非法展示方式,以及缺少 `presentation` 或 `version` 参数的契约。

怎么用:新增或修改公开链接时,按同一个 PR 同步修改四处:Feature 的 URL 解析器、`RouteContracts.json`、README/调用方示例、以及必要的迁移说明。CI 擅长阻止目录本身的结构错误;是否可以删除旧链接、旧版本如何迁移,则仍应在 PR 审查和发布说明中明确,这是为了避免把“自动检查”误当成“自动兼容”。

## 常见路由场景

Expand Down
8 changes: 5 additions & 3 deletions URLRouterDemo/DemoViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
//

import SwiftUI
import URLRouter
import URLRouter // ModuleRouter and ModuleRoutePolicyStore drive this tab shell.
import NavigationFeature

struct DemoTabs: View {
@Bindable private var router: ModuleRouter
@Bindable private var policyStore: ModuleRoutePolicyStore
@Bindable private var router: ModuleRouter // URLRouter owns the selected tab state.
@Bindable private var policyStore: ModuleRoutePolicyStore // URLRouter reads this on the next link.
private let latestRouteEvent: ModuleRouteEvent?
private let policyStatus: String

Expand All @@ -29,6 +29,7 @@ struct DemoTabs: View {
}

var body: some View {
// Bind SwiftUI tab selection to URLRouter's tab route state.
TabView(selection: $router.selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
Expand Down Expand Up @@ -63,6 +64,7 @@ struct DemoTabs: View {
Binding(
get: { !(policyStore.remotePolicy?.isCircuitBreakerOpen ?? false) },
set: { enabled in
// Simulate the backend circuit breaker by replacing URLRouter's remote policy.
policyStore.replaceRemotePolicy(with: ModuleRouteRemotePolicy(
isCircuitBreakerOpen: !enabled
))
Expand Down
Loading