From 5faf1d6bd6cffe43c68b327e5d365c6d50b07b0a Mon Sep 17 00:00:00 2001 From: Jipeng Zhang Date: Mon, 20 Jul 2026 00:02:59 +0700 Subject: [PATCH] Reorganize documentation for URLRouter 2.4.7 --- CHANGELOG.md | 8 + README.md | 474 +++++++--------------------- README.zh-CN.md | 460 ++++++--------------------- docs/README.md | 22 ++ docs/README.zh-CN.md | 22 ++ docs/architecture.md | 127 ++++++++ docs/architecture.zh-CN.md | 115 +++++++ docs/getting-started.md | 222 +++++++++++++ docs/getting-started.zh-CN.md | 217 +++++++++++++ docs/production-governance.md | 169 ++++++++++ docs/production-governance.zh-CN.md | 157 +++++++++ 11 files changed, 1270 insertions(+), 723 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/README.zh-CN.md create mode 100644 docs/architecture.md create mode 100644 docs/architecture.zh-CN.md create mode 100644 docs/getting-started.md create mode 100644 docs/getting-started.zh-CN.md create mode 100644 docs/production-governance.md create mode 100644 docs/production-governance.zh-CN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e455b35..82b112e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes are documented here. This project follows [Semantic Versioni ## [Unreleased] +## [2.4.7] - 2026-07-19 + +### Changed + +- Reorganized the English and Chinese documentation into a concise README plus task-focused getting-started, architecture, and production-governance guides. +- Added a clear documentation map and aligned the beginner blog with the repository guides, so readers can move from the first route to production operations without duplicated or conflicting instructions. +- Refreshed repository metadata and documentation links for clearer discovery of the Swift Package, supported platforms, and practical integration path. + ## [2.4.6] - 2026-07-18 ### Added diff --git a/README.md b/README.md index ce264be..00aeade 100644 --- a/README.md +++ b/README.md @@ -1,456 +1,198 @@ # URLRouter -[🇨🇳 中文](README.zh-CN.md) +[🇨🇳 中文](README.zh-CN.md) · [Documentation](docs/README.md) · [Blog tutorial](https://zhangjipeng.com/post-urlrouter.html) -> iOS 17+ · macOS 14+ · tvOS 17+ · watchOS 10+ · Swift 6 · SwiftUI · modular `openURL` routing +> A SwiftUI routing foundation for modular Apple-platform apps. +> +> iOS 17+ · macOS 14+ · tvOS 17+ · watchOS 10+ · Swift 6 -URLRouter is a SwiftUI routing foundation for modular apps. Feature code always navigates with `openURL`; URLRouter validates the URL, finds the owning Feature Package, and applies the presentation encoded in the URL. +URLRouter gives an app one predictable front door for navigation. A button, a +push notification, a Universal Link, or another Feature can all submit the +same HTTPS URL. URLRouter validates it, lets the owning Feature resolve it, and +updates SwiftUI navigation in the presentation style declared by the URL. -## Contents +It is useful once an app has more than a few screens: callers no longer need to +know another Feature's View type, initializer, or navigation container. They +only use that Feature's documented URL contract. -1. [Install](#install) -2. [Architecture](#architecture) -3. [Universal Link setup](#universal-link-setup) -4. [Feature Package](#feature-package) -5. [App Shell](#app-shell) -6. [Production governance](#production-governance) -7. [Routing scenarios](#routing-scenarios) -8. [Demo and testing](#demo-and-testing) +## Choose your path -## Install - -Add `https://github.com/relaxfinger/URLRouter.git` in **File > Add Package Dependencies…**, then import `URLRouter`. The minimum deployment target is iOS 17, macOS 14, tvOS 17, or watchOS 10. - -### Compatibility +| If you want to… | Start here | +| --- | --- | +| Open one page from a SwiftUI button | [Five-minute quick start](docs/getting-started.md#five-minute-quick-start) | +| Add Universal Links and a modular Feature Package | [Getting started guide](docs/getting-started.md) | +| Understand ownership and URL contracts | [Architecture guide](docs/architecture.md) | +| Add remote switches, queueing, telemetry, or CI checks | [Production governance](docs/production-governance.md) | +| Follow a complete beginner-friendly walkthrough | [Technical blog](https://zhangjipeng.com/post-urlrouter.html) | -- Apple 2023 platform generation: iOS 17+, macOS 14+, tvOS 17+, and watchOS 10+ -- Swift 6 language mode -- Xcode 16 or later +The README is deliberately short. The linked guides contain the rationale, +production details, and Chinese counterparts without forcing every app to adopt +advanced features on day one. -### Package layout +## Install -The repository follows the standard Swift Package Manager layout. The library -and its tests can be built directly with SwiftPM; the Xcode project remains -only as an executable demo host. +In Xcode, choose **File → Add Package Dependencies…** and add: ```text -Sources/URLRouter/ # public library source -Sources/URLRouterPolicyProvider/ # optional cache-first policy refresh module -Tests/URLRouterTests/ # unit tests -Tests/URLRouterPolicyProviderTests/ # provider unit tests -Features/ # local feature-package examples -URLRouterDemo/ # SwiftUI demo app +https://github.com/relaxfinger/URLRouter.git ``` -## Architecture +Add `URLRouter` to the App target and to any Feature Package that declares a +`RouteModule`. -URLRouter lets Feature views navigate with one API: `openURL`. Register each Feature Package once in the App Shell, then use a complete HTTPS URL with a required `presentation` query item. Valid values are `push`, `tab`, `sheet`, and `fullScreenCover`. Production app shells can additionally enforce a versioned URL contract with `ModuleRoutePolicy`, remotely restrict routes with `ModuleRoutePolicyStore`, and emit vendor-neutral telemetry through `ModuleRouteObservability`. - -```text -https://example.com/articles/42?presentation=push&version=1 -https://example.com/favorites?presentation=tab&version=1 -https://example.com/settings?presentation=sheet&version=1 -https://example.com/sign-in?presentation=fullScreenCover&version=1 +```swift +dependencies: [ + .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.4.7") +] ``` +`URLRouterPolicyProvider` is an optional product from the same package. Add it +only when the App needs a cache-first remote route-policy lifecycle. Feature +packages should normally depend on `URLRouter` only. -## Universal Link setup - -1. Add the **Associated Domains** capability and `applinks:example.com`. -2. Host `https://example.com/.well-known/apple-app-site-association` over HTTPS without redirects. -3. Install `moduleLinkRouting` once at the `WindowGroup` root. - -Example AASA configuration (replace the team and bundle IDs): - -```json -{ - "applinks": { - "details": [{ - "appIDs": ["TEAM_ID.com.example.MyApp"], - "components": [{ "/": "/articles/*" }, { "/": "/settings" }] - }] - } -} +```swift +.product(name: "URLRouter", package: "URLRouter") +// App-shell target only, when remote policy is needed: +.product(name: "URLRouterPolicyProvider", package: "URLRouter") ``` -## Feature Package +## Five-minute quick start -A Feature Package registers its own URL grammar and destination factory. This is the only layer that knows its paths and views. +### 1. Let a Feature own its paths and destination views ```swift import SwiftUI import URLRouter enum ArticleFeature { - static let id = "articles" - static let module = RouteModule( - id: id, + id: "articles", resolve: { link in - switch link.pathComponents { - case ["articles", let articleID]: - return ModuleRoute( - moduleID: id, - routeID: "detail", - parameters: ["id": articleID] - ) - case ["articles", let articleID, "comments"]: - return ModuleRoute( - moduleID: id, - routeID: "comments", - parameters: ["id": articleID] - ) - case ["articles", "search"]: - return ModuleRoute(moduleID: id, routeID: "search") - default: + guard case ["articles", let id] = link.pathComponents, !id.isEmpty else { return nil } + return ModuleRoute( + moduleID: "articles", + routeID: "detail", + parameters: ["id": id] + ) }, destination: { route in - switch route.routeID { - case "detail": - return AnyView(ArticleView(id: route.parameters["id"] ?? "")) - case "comments": - return AnyView(CommentsView(articleID: route.parameters["id"] ?? "")) - case "search": - return AnyView(ArticleSearchView()) - default: + guard route.routeID == "detail", let id = route.parameters["id"] else { return nil } + return AnyView(ArticleDetailView(articleID: id)) } ) } ``` -Ordinary Feature views only use SwiftUI: - -```swift -struct ArticleList: View { - @Environment(\.openURL) private var openURL - - var body: some View { - Button("Open article 42") { - openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!) - } - } -} -``` - -One `RouteModule` can therefore own multiple links. In this example, the Feature owns the following public URL contracts: - -```text -https://example.com/articles/42?presentation=push&version=1 -https://example.com/articles/42/comments?presentation=sheet&version=1 -https://example.com/articles/search?presentation=tab&version=1 -``` - -The path selects the `routeID` and parameters; `presentation` selects how SwiftUI displays the resolved destination. - -## App Shell +Returning `nil` from `resolve` means “this URL is not mine.” A Feature can own +many URLs; keep their parsing and destination creation together. -The app links Feature Packages and registers them once. It never parses feature paths or chooses push/tab/sheet/full-screen presentation. +### 2. Register Feature modules once at the scene root ```swift +import SwiftUI +import URLRouter + @main -struct MyApp: App { +struct CompanyApp: App { @State private var router = ModuleRouter() - private let routePolicy = ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - ) - private let registry = ModuleRouteRegistry(modules: [ - ArticleFeature.module, - SettingsFeature.module - ]) + + private let registry = ModuleRouteRegistry(modules: [ArticleFeature.module]) var body: some Scene { WindowGroup { RouterHost(router: router) { - AppTabs(router: router) + ContentView() } destination: { route in registry.destination(for: route) } .moduleLinkRouting( router: router, registry: registry, - allowedHosts: ["example.com"], - policy: routePolicy, - onFailure: { url, error in - print("Discarded route \(url.absoluteString): \(error.localizedDescription)") - }, - onEvent: { event in - print("Route trace \(event.traceID): \(event.outcome.rawValue)") - } + allowedHosts: ["example.com"] ) } } } ``` -Swift cannot discover unlinked packages at runtime. With two or more Feature Packages, the App Shell adds each package's single `RouteModule` to this one registry. Adding a feature requires linking its package and adding that module, but never editing a central URL `switch`, path parser, or presentation mapping. - -The registry rejects duplicate module IDs, a route returned by the wrong module, and push/sheet/full-screen routes without a destination. `ModuleRoutePolicy` lets the App Shell enforce contract versions, feature flags, authorization, and permitted presentation styles without coupling Feature Packages to those systems. Use `onFailure` to log rejected URLs and `onEvent` for privacy-conscious telemetry: each event includes a trace ID, outcome, route metadata, and no query values. A router has one active modal route: a new modal replaces the previous one, while push and tab routes dismiss the active modal before navigating. Repeated pushes of the same route are idempotent. - -### When several links arrive at once - -On a busy app, a push notification, a Universal Link, and a button tap can all ask to navigate at nearly the same moment. Applying each one immediately makes the final screen depend on timing, and can make SwiftUI replace a sheet while it is still animating. +Install `RouterHost` and `moduleLinkRouting` exactly once per scene. Keep one +`ModuleRouter` per window so multi-window navigation stays independent. -`ModuleRouteCoordinator` is the scene's small waiting line. Give it the same router and registry, then install it instead of the direct handler. It gathers requests that arrive together, keeps only one copy of the exact same URL, and applies routes one at a time. This is deliberately not a login manager: the app still decides whether a route is authorized and which priority it deserves. +### 3. Navigate with the standard SwiftUI API ```swift -@State private var router: ModuleRouter -@State private var routeCoordinator: ModuleRouteCoordinator - -init() { - let router = ModuleRouter() - _router = State(initialValue: router) - _routeCoordinator = State(initialValue: ModuleRouteCoordinator( - router: router, - registry: AppModules.registry, - allowedHosts: ["example.com"] - )) -} +struct ArticleRow: View { + @Environment(\.openURL) private var openURL + let articleID: String -var body: some Scene { - WindowGroup { - RouterHost(router: router) { AppTabs(router: router) } destination: { - AppModules.registry.destination(for: $0) + var body: some View { + Button("Read article") { + openURL(URL(string: "https://example.com/articles/\(articleID)?presentation=push&version=1")!) } - .moduleLinkRouting(coordinator: routeCoordinator) } } ``` -Its default rules are intentionally easy to reason about: - -- One scene has one coordinator; separate windows do not block each other. -- The same URL already waiting or being shown is merged instead of opening twice. -- `critical` goes before `external`, then `userInitiated`, then `background`. Requests at the same level keep arrival order. -- At most ten routes wait. When full, a higher-priority route replaces the oldest lower-priority one; otherwise the new route is refused. -- A waiting route expires after 30 seconds by default. That avoids unexpectedly opening an old notification after the user has moved on. -- The route is checked once on arrival and again immediately before navigation, so a policy or circuit-breaker change while it waits is still respected. - -For app-owned calls, use `route(_:priority:expiresAt:)`; external `openURL` and Universal Link requests use `.external` automatically. Queue outcomes are sent through the same observability hooks: `queue.duplicate_merged`, `queue.full`, and `queue.request_expired`. Tune the limits with `ModuleRouteCoordinatorConfiguration`, but keep the coordinator at the scene root rather than creating one per button. - -## Production governance - -### Remote policy and emergency circuit breaker - -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: - -```swift -.product(name: "URLRouterPolicyProvider", package: "URLRouter") -``` - -`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 only where data comes from and how it is verified; the provider -handles this failure-prone lifecycle: +The URL is the public contract: ```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( - localPolicy: ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - ) -) - -func applyTrustedRemotePolicy(_ data: Data) throws { - let remotePolicy = try JSONDecoder().decode(ModuleRouteRemotePolicy.self, from: data) - routePolicyStore.replaceRemotePolicy(with: remotePolicy) -} -``` - -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 - -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 -import URLRouterPolicyProvider - -struct CompanyPolicySource: RoutePolicyRemoteSource { - func fetchPolicyData() async throws -> Data { - let url = URL(string: "https://config.example.com/mobile/route-policy")! - let (data, response) = try await URLSession.shared.data(from: url) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - throw URLError(.badServerResponse) - } - return data - } -} - -@MainActor -final class AppRoutePolicySession { - let store = ModuleRoutePolicyStore(localPolicy: ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - )) - let provider: RoutePolicyProvider - - init(cacheURL: URL) { - provider = RoutePolicyProvider( - store: store, - source: CompanyPolicySource(), - cache: FileRoutePolicyCache(url: cacheURL), - strategy: .standard // 30 min refresh, 1 h normal cache, 24 h hard stale limit - ) - } - - func start() async { - _ = await provider.bootstrap() // use verified disk cache; never waits for network - _ = await provider.refresh() // fetch the newest policy in the background - } - - func appBecameActive() async { - _ = await provider.refreshIfNeeded() - } -} -``` - -Use `JSONRoutePolicyPayloadValidator` for a plain trusted JSON endpoint. For a -signed response or envelope, implement `RoutePolicyPayloadValidating`; only a -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 - -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()]) +https://example.com/articles/42?presentation=push&version=1 ``` -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 - -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.” +`presentation` is required and may be `push`, `tab`, `sheet`, or +`fullScreenCover`. Start with one route like this. Then follow the +[getting-started guide](docs/getting-started.md) to use URL builders, +Universal Links, tabs, and a versioned route contract safely. -In addition, PR CI compares the catalog with the exact base commit. It rejects a removed route, changed path template, removed presentation style, removed required parameter, or removed supported contract version. This makes a public-link breaking change visible before merge; make such a change only in a major release with a migration plan. +## When to add the optional production features -### Public API compatibility CI +Do not build every feature at once. -In plain terms, this is the same safety net for Swift code that route-contract CI is for URLs. An app may import `URLRouter` or `URLRouterPolicyProvider` and call a public type or method for years. Removing or changing that API without a major-version migration breaks the app at its next dependency update. - -For every pull request, CI uses SwiftPM's API comparison tool to compare both public library products with the exact base commit of the PR. It rejects removed public types, changed signatures, and other source-compatible breaks before the PR can merge. Additive APIs are allowed. Intentionally breaking an API requires a major release and an explicit migration guide; do not silence the check merely to merge a minor or patch release. - -## Routing scenarios - -| Intent | Feature code | +| Need | Add | | --- | --- | -| Push detail | `openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!)` | -| Select tab | `openURL(URL(string: "https://example.com/favorites?presentation=tab&version=1")!)` | -| Show sheet | `openURL(URL(string: "https://example.com/settings?presentation=sheet&version=1")!)` | -| Full-screen flow | `openURL(URL(string: "https://example.com/sign-in?presentation=fullScreenCover&version=1")!)` | - -After asynchronous work, return to the main actor before calling `openURL`: - -```swift -Task { - let id = try await articleService.recommendedArticleID() - await MainActor.run { - openURL(URL(string: "https://example.com/articles/\(id)?presentation=push&version=1")!) - } -} -``` - -### Navigate from one Feature Package to another - -Feature A does not import Feature B or reference its views. It emits Feature B's documented URL contract: - -```swift -// Inside NavigationFeature -@Environment(\.openURL) private var openURL - -Button("Open content article") { - openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!) -} -``` +| Web links should open the same route as the app | Universal Links | +| A Feature or all routing must be remotely paused | `URLRouterPolicyProvider` and a `ModuleRoutePolicyStore` | +| A notification, link, and button can arrive together | One `ModuleRouteCoordinator` per scene | +| Support needs to explain why a link failed | `ModuleRouteObservability` | +| Marketing or web clients depend on published links | `RouteContracts.json` and contract CI | -`ContentFeature` owns `/articles/*` and supplies `ArticleView`. It can route back to `NavigationFeature` the same way: +These are opt-in. URLRouter does not choose your network client, authentication +flow, remote-config vendor, analytics vendor, or backend. The App owns those +decisions; the package provides focused routing seams. -```swift -// Inside ContentFeature -Button("Open settings") { - openURL(URL(string: "https://example.com/settings?presentation=sheet&version=1")!) -} -``` - -Both modules must be linked and included in `ModuleRouteRegistry`. The demo registers `DemoNavigationFeature` and `DemoContentFeature`, and demonstrates both directions. - -## Demo and testing - -The demo uses two real local Swift Packages: - -```text -Features/ -├── NavigationFeature/ # home, favorites, settings, sign-in -└── ContentFeature/ # article details -``` - -Both Packages depend on `URLRouter`, but they do not depend on each other. `NavigationFeature` opens article URLs owned by `ContentFeature`; `ContentFeature` opens settings URLs owned by `NavigationFeature`. - -This boundary is intentional: use URL contracts for cross-feature navigation rather than importing another Feature Package merely to access its views or route types. - -`URLRouterDemo` is an iOS 17+ reference app that demonstrates the platform-neutral `RouterHost` composition, all four URL presentation styles, cross-package navigation, strict version-1 contract enforcement, an in-app route telemetry status, and the optional `URLRouterPolicyProvider` cache-first refresh lifecycle. Its `DemoPolicySource` is intentionally local; replace it with an App-owned source in production. The same `RouterHost`, `moduleLinkRouting`, and Feature Packages are available to apps targeting macOS 14+, tvOS 17+, or watchOS 10+; SwiftUI adapts their navigation and modal presentation to each platform. Because SwiftUI does not provide `fullScreenCover` on macOS, that presentation is rendered as a sheet there. +## Demo and verification -Open `URLRouter.xcodeproj`, choose the **URLRouterDemo** scheme, select an iOS 17+ simulator, and run it. Xcode resolves both local packages automatically. - -Run tests with: +`URLRouterDemo` is an iOS 17+ reference app. It shows local Feature Packages, +all four presentation styles, cross-package navigation, a cache-first policy +lifecycle, telemetry, and coordinated concurrent routes. ```bash swift test swift Scripts/validate_route_contract.swift RouteContracts.json ``` -## License +The core library and `RouterHost` support all four listed Apple platforms. On +macOS, SwiftUI presents a `fullScreenCover` route as a sheet because macOS does +not provide `fullScreenCover`. + +## Project layout -URLRouter is released under the [MIT License](LICENSE). +```text +Sources/URLRouter/ # core routing library +Sources/URLRouterPolicyProvider/ # optional policy-refresh product +Tests/ # SwiftPM unit tests +Features/ # local Feature-package examples +URLRouterDemo/ # executable iOS reference app +docs/ # task-focused documentation +``` -## Community and maintenance +## License and community -See [CONTRIBUTING.md](CONTRIBUTING.md) for pull requests, [SUPPORT.md](SUPPORT.md) -for support channels, [SECURITY.md](SECURITY.md) for responsible disclosure, and -[MAINTENANCE.md](MAINTENANCE.md) for supported-release and compatibility policy. +URLRouter is available under the [MIT License](LICENSE). Before contributing, +read [CONTRIBUTING.md](CONTRIBUTING.md). For support, security reports, and the +maintenance policy, see [SUPPORT.md](SUPPORT.md), [SECURITY.md](SECURITY.md), +and [MAINTENANCE.md](MAINTENANCE.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 32ca80d..2c5fac8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,447 +1,193 @@ # URLRouter -[🇺🇸 English](README.md) +[🇺🇸 English](README.md) · [文档目录](docs/README.md) · [完整入门博客](https://zhangjipeng.com/post-urlrouter.html) -> iOS 17+ · macOS 14+ · tvOS 17+ · watchOS 10+ · Swift 6 · SwiftUI · 模块化 `openURL` 路由 +> 面向模块化 Apple 平台 App 的 SwiftUI 路由基础设施。 +> +> iOS 17+ · macOS 14+ · tvOS 17+ · watchOS 10+ · Swift 6 -URLRouter 是面向模块化 App 的 SwiftUI 路由基础库。Feature 页面统一使用 `openURL` 跳转;URLRouter 负责校验 URL、找到所属 Feature Package,并执行 URL 中声明的展示方式。 +URLRouter 给 App 提供一个统一、可预期的跳转入口。按钮、推送通知、 +Universal Link 或另一个 Feature,都可以提交同一条 HTTPS URL;URLRouter +会校验 URL、交给拥有它的 Feature 解析,并按 URL 声明的方式更新 SwiftUI +导航。 -## 目录 +当 App 页面变多后,调用方无需再知道另一个 Feature 的 View 类型、初始化 +参数或导航容器;它只使用该 Feature 已文档化的 URL 契约。这正是它在真实 +模块化项目中的价值。 -1. [安装](#安装) -2. [架构](#架构) -3. [配置 Universal Link](#配置-universal-link) -4. [Feature Package](#feature-package) -5. [App Shell](#app-shell) -6. [生产治理](#生产治理) -7. [常见路由场景](#常见路由场景) -8. [Demo 与测试](#demo-与测试) +## 按你的目标开始 -## 安装 - -在 Xcode 的 **File > Add Package Dependencies…** 添加 `https://github.com/relaxfinger/URLRouter.git`,随后导入 `URLRouter`。最低支持 iOS 17、macOS 14、tvOS 17 或 watchOS 10。 - -### 兼容性 +| 你想做什么 | 从这里开始 | +| --- | --- | +| 从一个 SwiftUI 按钮打开页面 | [5 分钟快速开始](docs/getting-started.zh-CN.md#5-分钟快速开始) | +| 接入 Universal Link 和模块化 Feature Package | [接入指南](docs/getting-started.zh-CN.md) | +| 理解模块边界和 URL 契约 | [架构说明](docs/architecture.zh-CN.md) | +| 接入远程开关、并发协调、埋点或 CI | [生产治理](docs/production-governance.zh-CN.md) | +| 跟着从头实践一遍 | [技术博客](https://zhangjipeng.com/post-urlrouter.html) | -- Apple 2023 年同代系统:iOS 17+、macOS 14+、tvOS 17+ 与 watchOS 10+ -- Swift 6 语言模式 -- Xcode 16 或更高版本 +README 故意保持简短。链接文档会解释为什么这样设计、生产环境如何接入,并 +提供中文内容;你不需要在第一天就引入所有高级能力。 -### 包结构 +## 安装 -仓库遵循 Swift Package Manager 的标准目录约定。库与测试可直接由 SwiftPM 构建;Xcode 工程仅作为可运行的 Demo 宿主。 +在 Xcode 选择 **File → Add Package Dependencies…**,添加: ```text -Sources/URLRouter/ # 公共库源码 -Sources/URLRouterPolicyProvider/ # 可选的缓存优先策略刷新模块 -Tests/URLRouterTests/ # 单元测试 -Tests/URLRouterPolicyProviderTests/ # Provider 单元测试 -Features/ # 本地 Feature Package 示例 -URLRouterDemo/ # SwiftUI Demo 应用 +https://github.com/relaxfinger/URLRouter.git ``` -## 架构 +将 `URLRouter` 添加到 App Target,以及每一个声明 `RouteModule` 的 Feature +Package。 -URLRouter 让 Feature 页面统一通过 `openURL` 跳转。App Shell 一次性注册各 Feature Package 后,使用完整 HTTPS URL 并携带必填 `presentation` query 即可。合法值为 `push`、`tab`、`sheet`、`fullScreenCover`。生产环境的 App Shell 还可通过 `ModuleRoutePolicy` 强制执行版本化 URL 协议,通过 `ModuleRoutePolicyStore` 接入远程限制,并通过 `ModuleRouteObservability` 输出供应商无关的遥测事件。 - -```text -https://example.com/articles/42?presentation=push&version=1 -https://example.com/favorites?presentation=tab&version=1 -https://example.com/settings?presentation=sheet&version=1 -https://example.com/sign-in?presentation=fullScreenCover&version=1 +```swift +dependencies: [ + .package(url: "https://github.com/relaxfinger/URLRouter.git", from: "2.4.7") +] ``` +`URLRouterPolicyProvider` 是同一个 Package 的可选 product。只有 App 需要 +“先读缓存、后台刷新”的远程路由策略时才引入它;通常 Feature Package 只依赖 +`URLRouter`。 -## 配置 Universal Link - -1. 在 target 添加 **Associated Domains** capability,并添加 `applinks:example.com`。 -2. 通过 HTTPS 且不重定向地部署 `https://example.com/.well-known/apple-app-site-association`。 -3. 只在 `WindowGroup` 根部安装一次 `moduleLinkRouting`。 - -AASA 示例(替换团队 ID 与 bundle ID): - -```json -{ - "applinks": { - "details": [{ - "appIDs": ["TEAM_ID.com.example.MyApp"], - "components": [{ "/": "/articles/*" }, { "/": "/settings" }] - }] - } -} +```swift +.product(name: "URLRouter", package: "URLRouter") +// 仅 App 壳层在需要远程策略时添加: +.product(name: "URLRouterPolicyProvider", package: "URLRouter") ``` -## Feature Package +## 5 分钟快速开始 -每个 Feature Package 注册自己的 URL 语法与目标 View;只有这一层知道自己的路径和页面。 +### 1. 由 Feature 负责自己的路径和目标页面 ```swift import SwiftUI import URLRouter enum ArticleFeature { - static let id = "articles" - static let module = RouteModule( - id: id, + id: "articles", resolve: { link in - switch link.pathComponents { - case ["articles", let articleID]: - return ModuleRoute( - moduleID: id, - routeID: "detail", - parameters: ["id": articleID] - ) - case ["articles", let articleID, "comments"]: - return ModuleRoute( - moduleID: id, - routeID: "comments", - parameters: ["id": articleID] - ) - case ["articles", "search"]: - return ModuleRoute(moduleID: id, routeID: "search") - default: + guard case ["articles", let id] = link.pathComponents, !id.isEmpty else { return nil } + return ModuleRoute( + moduleID: "articles", + routeID: "detail", + parameters: ["id": id] + ) }, destination: { route in - switch route.routeID { - case "detail": - return AnyView(ArticleView(id: route.parameters["id"] ?? "")) - case "comments": - return AnyView(CommentsView(articleID: route.parameters["id"] ?? "")) - case "search": - return AnyView(ArticleSearchView()) - default: + guard route.routeID == "detail", let id = route.parameters["id"] else { return nil } + return AnyView(ArticleDetailView(articleID: id)) } ) } ``` -普通 Feature 页面只需要 SwiftUI: - -```swift -struct ArticleList: View { - @Environment(\.openURL) private var openURL - - var body: some View { - Button("打开文章 42") { - openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!) - } - } -} -``` - -因此,一个 `RouteModule` 可以负责多个 link。以上 Feature 对外声明了以下 URL 协议: - -```text -https://example.com/articles/42?presentation=push&version=1 -https://example.com/articles/42/comments?presentation=sheet&version=1 -https://example.com/articles/search?presentation=tab&version=1 -``` - -路径决定 `routeID` 和参数;`presentation` 决定 SwiftUI 如何展示已解析的页面。 - -## App Shell +`resolve` 返回 `nil` 的意思就是“这条 URL 不归我”。一个 Feature 可以拥有多条 +URL;把它们的解析和目标页面创建放在一起。 -App 只链接 Feature Package 并一次性注册模块;它不解析 Feature 路径,也不选择 push/tab/sheet/全屏展示方式。 +### 2. 在场景根部只注册一次 Feature 模块 ```swift +import SwiftUI +import URLRouter + @main -struct MyApp: App { +struct CompanyApp: App { @State private var router = ModuleRouter() - private let routePolicy = ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - ) - private let registry = ModuleRouteRegistry(modules: [ - ArticleFeature.module, - SettingsFeature.module - ]) + + private let registry = ModuleRouteRegistry(modules: [ArticleFeature.module]) var body: some Scene { WindowGroup { RouterHost(router: router) { - AppTabs(router: router) + ContentView() } destination: { route in registry.destination(for: route) } .moduleLinkRouting( router: router, registry: registry, - allowedHosts: ["example.com"], - policy: routePolicy, - onFailure: { url, error in - print("Discarded route \(url.absoluteString): \(error.localizedDescription)") - }, - onEvent: { event in - print("Route trace \(event.traceID): \(event.outcome.rawValue)") - } + allowedHosts: ["example.com"] ) } } } ``` -Swift 无法在运行时发现未链接的 Package。存在两个或更多 Feature Package 时,App Shell 只需将每个 Package 唯一的 `RouteModule` 放入同一个注册表。新增 Feature 时仍要链接其 Package 并注册该模块,但永远不需要改中心化 URL `switch`、路径解析或展示方式映射。 - -注册表会拒绝重复 module ID、由错误模块返回的 route,以及没有 destination 的 push/sheet/full-screen route。`ModuleRoutePolicy` 让 App Shell 在不耦合 Feature Package 的前提下执行协议版本、Feature 开关、权限和允许的展示方式。使用 `onFailure` 记录被拒绝的 URL,使用 `onEvent` 接入隐私友好的遥测:事件包含 trace ID、处理结果和 route 元数据,不包含 query value。Router 同一时刻只保留一个模态 route:新的模态 route 会替换旧的;push 和 tab route 会先关闭当前模态展示再导航;重复 push 相同 route 是幂等的。 - -### 多条链接同时到达时怎么办 - -实际 App 很容易碰到:推送通知、Universal Link 和用户点击,几乎同时要求跳转。每一条都立刻执行,最后停在哪个页面就会看运气;SwiftUI 还可能在 sheet 动画尚未结束时又被要求换页面。 +每个场景只安装一次 `RouterHost` 与 `moduleLinkRouting`。每个窗口使用一个 +`ModuleRouter`,多窗口之间的导航状态就不会互相影响。 -`ModuleRouteCoordinator` 就是每个场景前面的“小排队员”。把它和 router、registry 放在一起,在根部安装它,协调器会先收集同一时刻到达的请求,合并完全相同的 URL,然后一条一条跳转。它**不是**登录管理器:是否有权限、哪条业务更重要,仍由 App 自己决定。 +### 3. 用标准 SwiftUI API 跳转 ```swift -@State private var router: ModuleRouter -@State private var routeCoordinator: ModuleRouteCoordinator - -init() { - let router = ModuleRouter() - _router = State(initialValue: router) - _routeCoordinator = State(initialValue: ModuleRouteCoordinator( - router: router, - registry: AppModules.registry, - allowedHosts: ["example.com"] - )) -} +struct ArticleRow: View { + @Environment(\.openURL) private var openURL + let articleID: String -var body: some Scene { - WindowGroup { - RouterHost(router: router) { AppTabs(router: router) } destination: { - AppModules.registry.destination(for: $0) + var body: some View { + Button("阅读文章") { + openURL(URL(string: "https://example.com/articles/\(articleID)?presentation=push&version=1")!) } - .moduleLinkRouting(coordinator: routeCoordinator) } } ``` -默认规则很直白: - -- 一个场景一个协调器;两个窗口互不堵塞。 -- 同一条 URL 已在等待或正在处理时,合并成一次,不会连续打开两次。 -- 优先级顺序是 `critical`、`external`、`userInitiated`、`background`;同级按到达顺序处理。 -- 最多等待 10 条。队列满时,更高优先级的新请求会挤掉最早、且优先级更低的请求;否则新请求被拒绝。 -- 默认等待超过 30 秒就过期,避免用户已经离开后又突然打开一条旧通知。 -- 入队时检查一次、真正跳转前再检查一次;所以等待期间策略或熔断开关改变,仍然会生效。 - -App 主动发起的跳转可调用 `route(_:priority:expiresAt:)`;外部 `openURL` 和 Universal Link 默认使用 `.external`。队列的结果仍走原有可观测性:`queue.duplicate_merged`、`queue.full`、`queue.request_expired`。可通过 `ModuleRouteCoordinatorConfiguration` 调整限制,但协调器应放在场景根部,不要每个按钮各建一个。 - -## 生产治理 - -### 远程策略与紧急熔断 - -先用人话说:远程策略就是后台随时能改的“路由开关表”。它解决的是“某个页面或功能出了事故,不能等 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 负责下面这套容易出错的流程: +URL 就是公开契约: ```text -启动 App - → 先读取上一次已验证的本地缓存,马上可用 - → 后台请求最新配置,不阻塞首屏 - → 验签、解析、检查通过后一次性替换当前策略 - → 保存新的可信缓存 - → 请求失败时继续使用旧缓存;旧缓存太久则回退到 App 内置安全规则 -``` - -```swift -@State private var routePolicyStore = ModuleRoutePolicyStore( - localPolicy: ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - ) -) - -func applyTrustedRemotePolicy(_ data: Data) throws { - let remotePolicy = try JSONDecoder().decode(ModuleRouteRemotePolicy.self, from: data) - routePolicyStore.replaceRemotePolicy(with: remotePolicy) -} -``` - -怎么用:App 创建一个 `ModuleRoutePolicyStore` 并交给 `moduleLinkRouting`;随后只需要在拿到并验证新配置时调用 `replaceRemotePolicy`。下一次路由自动使用新规则,不需要重建界面或重新发版。将 `isCircuitBreakerOpen` 设为 `true`,即可不发版立即停止模块路由。该文档还可禁用指定模块、提供允许列表、拒绝某些展示方式或进一步收紧支持的协议版本。 - -### 推荐的 App 拉取策略 - -推荐策略不是“每次点链接都请求后台”,那样既慢又不可靠。建议顺序是:启动先读取上一次已验证缓存,再在后台刷新;App 回到前台且超过 TTL 时按需刷新;短暂断网时继续使用最后一次可信策略。没有可信缓存,或缓存超过硬过期时间时,URLRouter 保持 App 内置的安全本地策略。默认值是:前台 30 分钟刷新一次、普通缓存 1 小时、最多使用 24 小时的旧可信缓存;熔断要求更高的业务可缩短 TTL 或由静默推送触发立即刷新。 - -```swift -import URLRouter -import URLRouterPolicyProvider - -struct CompanyPolicySource: RoutePolicyRemoteSource { - func fetchPolicyData() async throws -> Data { - let url = URL(string: "https://config.example.com/mobile/route-policy")! - let (data, response) = try await URLSession.shared.data(from: url) - guard (response as? HTTPURLResponse)?.statusCode == 200 else { - throw URLError(.badServerResponse) - } - return data - } -} - -@MainActor -final class AppRoutePolicySession { - let store = ModuleRoutePolicyStore(localPolicy: ModuleRoutePolicy( - acceptedContractVersions: ["1"], - allowsUnversionedLinks: false - )) - let provider: RoutePolicyProvider - - init(cacheURL: URL) { - provider = RoutePolicyProvider( - store: store, - source: CompanyPolicySource(), - cache: FileRoutePolicyCache(url: cacheURL), - strategy: .standard // 30 分钟刷新,1 小时常规缓存,24 小时硬过期 - ) - } - - func start() async { - _ = await provider.bootstrap() // 先使用可信磁盘缓存;不等待网络 - _ = await provider.refresh() // 在后台请求最新策略 - } - - func appBecameActive() async { - _ = await provider.refreshIfNeeded() - } -} -``` - -普通可信 JSON 接口可使用 `JSONRoutePolicyPayloadValidator`。如果响应有签名或信封结构,App 实现 `RoutePolicyPayloadValidating`;只有校验通过的 `ModuleRouteRemotePolicy` 才会写入缓存和生效。这样即使网络被劫持、返回了损坏 JSON,当前正在使用的策略也不会被半成品覆盖。 - -### 统一可观测性 - -先用人话说:统一可观测性就是给路由装“行车记录仪”。当用户说“点订单链接没反应”时,团队不需要猜;可以看到这次路由是成功、被熔断、版本不支持、模块关闭,还是 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()]) +https://example.com/articles/42?presentation=push&version=1 ``` -每个事件包含 trace ID、结果、host、模块/路由标识、展示方式和稳定的 `failureCode`。它刻意不包含 URL query 值、token、手机号等可能敏感的数据;需要排障时用 trace ID 关联 App 自己的受控日志即可。 - -### 路由契约 CI - -先用人话说:路由契约 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 审查和发布说明中明确,这是为了避免把“自动检查”误当成“自动兼容”。 +`presentation` 必填,可取 `push`、`tab`、`sheet` 或 `fullScreenCover`。 +先跑通这一条路由,再阅读[接入指南](docs/getting-started.zh-CN.md),安全地加入 +URL builder、Universal Link、Tab 和带版本的路由协议。 -此外,PR CI 会把当前目录与该 PR 的精确基线提交比较。它会拒绝删除路由、修改路径模板、移除展示方式、移除必填参数或移除支持的协议版本。这样公开链接的破坏性修改会在合并前暴露;这类修改只能在主版本发布时进行,并配套迁移方案。 +## 什么时候再加可选的生产能力 -### 公开 API 兼容性 CI +不需要一次性全做完。 -先用人话说:这就是给 Swift 代码准备的“接口不被偷偷改坏”检查,作用和路由契约 CI 对 URL 的保护一样。App 可能长期导入 `URLRouter` 或 `URLRouterPolicyProvider`,并调用某个公开类型或方法;如果库在小版本升级中删除或改掉它,App 下次升级依赖时就会编译失败。 - -每个 PR 的 CI 都会使用 SwiftPM 自带的 API 对比工具,把两个公开库产品与该 PR 的精确基线提交比较。它会在合并前拒绝删除公开类型、修改函数签名等破坏性改动;只新增 API 则可以正常通过。确实需要破坏 API 时,应发布主版本并提供明确迁移说明,而不是为了合并小版本或补丁版本而绕过这道检查。 - -## 常见路由场景 - -| 业务意图 | Feature 代码 | +| 需求 | 加入什么 | | --- | --- | -| Push 详情 | `openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!)` | -| 切换 Tab | `openURL(URL(string: "https://example.com/favorites?presentation=tab&version=1")!)` | -| 展示 Sheet | `openURL(URL(string: "https://example.com/settings?presentation=sheet&version=1")!)` | -| 全屏流程 | `openURL(URL(string: "https://example.com/sign-in?presentation=fullScreenCover&version=1")!)` | - -异步操作完成后,回到主线程再调用 `openURL`: - -```swift -Task { - let id = try await articleService.recommendedArticleID() - await MainActor.run { - openURL(URL(string: "https://example.com/articles/\(id)?presentation=push&version=1")!) - } -} -``` - -### 从一个 Feature Package 跳转到另一个 - -Feature A 不导入 Feature B,也不引用 B 的 View;它只发送 B 已公开的 URL 协议: - -```swift -// 位于 NavigationFeature -@Environment(\.openURL) private var openURL - -Button("打开内容文章") { - openURL(URL(string: "https://example.com/articles/42?presentation=push&version=1")!) -} -``` +| 网页链接也要打开同一页面 | Universal Link | +| 要远程暂停某个 Feature 或全部路由 | `URLRouterPolicyProvider` 与 `ModuleRoutePolicyStore` | +| 推送、链接、按钮可能同时到达 | 每个场景一个 `ModuleRouteCoordinator` | +| 客服需要知道链接为什么没反应 | `ModuleRouteObservability` | +| 营销或网页依赖公开链接 | `RouteContracts.json` 与契约 CI | -`ContentFeature` 负责 `/articles/*` 并提供 `ArticleView`。它也可以用相同方式跳回 `NavigationFeature`: +这些能力都是按需接入。URLRouter 不会替你选网络客户端、登录流程、远程配置 +厂商、埋点厂商或后端;这些仍是 App 的责任,Package 只提供清晰的路由边界。 -```swift -// 位于 ContentFeature -Button("打开设置") { - openURL(URL(string: "https://example.com/settings?presentation=sheet&version=1")!) -} -``` - -两个模块都必须被链接并加入 `ModuleRouteRegistry`。Demo 注册了 `DemoNavigationFeature` 和 `DemoContentFeature`,并演示双向跳转。 - -## Demo 与测试 - -Demo 使用两个真实的本地 Swift Package: - -```text -Features/ -├── NavigationFeature/ # 首页、收藏、设置、登录 -└── ContentFeature/ # 文章详情 -``` - -两个 Package 都依赖 `URLRouter`,但它们互不依赖。`NavigationFeature` 打开由 `ContentFeature` 负责的文章 URL;`ContentFeature` 打开由 `NavigationFeature` 负责的设置 URL。 - -这是刻意设计的边界:跨 Feature 跳转应使用 URL 协议,不应为了访问对方 View 或路由类型而直接导入另一个 Feature Package。 - -`URLRouterDemo` 是 iOS 17+ 的参考应用,演示了跨平台的 `RouterHost` 组合方式、四种 URL 展示方式、跨 Package 跳转、严格的 version=1 协议校验、应用内路由遥测状态,以及可选 `URLRouterPolicyProvider` 的缓存优先刷新流程。Demo 的 `DemoPolicySource` 故意使用本地数据;生产环境请替换为 App 自己的来源。面向 macOS 14+、tvOS 17+ 或 watchOS 10+ 的 App 同样可使用 `RouterHost`、`moduleLinkRouting` 与 Feature Package;SwiftUI 会按平台适配导航和模态展示。由于 SwiftUI 在 macOS 上不提供 `fullScreenCover`,该展示方式会在 macOS 中以 sheet 呈现。 +## Demo 与验证 -打开 `URLRouter.xcodeproj`,选择 **URLRouterDemo** scheme 与 iOS 17+ simulator 后运行,Xcode 会自动解析两个本地 Package。 - -运行测试: +`URLRouterDemo` 是 iOS 17+ 的参考 App,演示本地 Feature Package、四种展示 +方式、跨 Package 跳转、缓存优先策略生命周期、遥测和并发路由协调。 ```bash swift test swift Scripts/validate_route_contract.swift RouteContracts.json ``` -## 许可证 +核心库和 `RouterHost` 支持上述四个平台。macOS 的 SwiftUI 没有 +`fullScreenCover`,因此该展示方式会自动以 sheet 呈现。 + +## 项目结构 -URLRouter 使用 [MIT License](LICENSE) 发布。 +```text +Sources/URLRouter/ # 核心路由库 +Sources/URLRouterPolicyProvider/ # 可选策略刷新 product +Tests/ # SwiftPM 单元测试 +Features/ # 本地 Feature Package 示例 +URLRouterDemo/ # 可运行的 iOS 参考 App +docs/ # 按任务拆分的文档 +``` -## 社区与维护 +## 许可证与社区 -提交 PR 请阅读 [CONTRIBUTING.md](CONTRIBUTING.md),支持渠道见 -[SUPPORT.md](SUPPORT.md),安全漏洞请遵循 [SECURITY.md](SECURITY.md),支持版本与 -兼容性策略见 [MAINTENANCE.md](MAINTENANCE.md)。 +URLRouter 以 [MIT License](LICENSE) 发布。提交 PR 前请阅读 +[CONTRIBUTING.md](CONTRIBUTING.md)。支持、漏洞报告和维护策略请见 +[SUPPORT.md](SUPPORT.md)、[SECURITY.md](SECURITY.md) 与 +[MAINTENANCE.md](MAINTENANCE.md)。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..46b0c3d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# URLRouter documentation + +[中文](README.zh-CN.md) · [Repository README](../README.md) · [Beginner blog tutorial](https://zhangjipeng.com/post-urlrouter.html) + +Use the guide that matches the job in front of you: + +| Guide | Use it when you need to… | +| --- | --- | +| [Getting started](getting-started.md) | install the package, create a Feature route, host it in SwiftUI, and add Universal Links | +| [Architecture](architecture.md) | decide package ownership, URL shape, public contracts, and App-shell responsibilities | +| [Production governance](production-governance.md) | add remote restrictions, a circuit breaker, concurrent-route coordination, telemetry, and contract checks | +| [ADR 0001](adr/0001-public-compatibility-gates.md) | understand the API and route-contract compatibility gates in pull requests | + +## Recommended reading order + +1. Complete the five-minute example in the repository README. +2. Read **Getting started** while wiring the first real Feature. +3. Read **Architecture** before a second team or Feature publishes routes. +4. Adopt the relevant parts of **Production governance** only when the product needs them. + +The English and Chinese guides describe the same public behavior. Code examples +use `example.com`; replace it with an HTTPS domain controlled by your team. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md new file mode 100644 index 0000000..e0a9d5b --- /dev/null +++ b/docs/README.zh-CN.md @@ -0,0 +1,22 @@ +# URLRouter 文档 + +[English](README.md) · [仓库 README](../README.zh-CN.md) · [新手完整教程](https://zhangjipeng.com/post-urlrouter.html) + +按你当前要完成的工作选择文档: + +| 文档 | 适合什么场景 | +| --- | --- | +| [接入指南](getting-started.zh-CN.md) | 安装 Package、创建 Feature 路由、接入 SwiftUI 和 Universal Link | +| [架构说明](architecture.zh-CN.md) | 设计 Package 边界、URL 形状、公开契约和 App 壳层职责 | +| [生产治理](production-governance.zh-CN.md) | 接入远程限制、紧急熔断、并发协调、遥测和契约检查 | +| [ADR 0001](adr/0001-public-compatibility-gates.md) | 了解 PR 中的公开 API 与路由契约兼容性门禁 | + +## 推荐阅读顺序 + +1. 先完成仓库 README 的 5 分钟示例。 +2. 接第一个真实 Feature 时阅读**接入指南**。 +3. 第二个团队或 Feature 要发布路由前阅读**架构说明**。 +4. 只有产品确实需要时,再按需采用**生产治理**的能力。 + +中英文文档描述相同的公开行为。示例中的 `example.com` 只是占位符;请替换为 +团队自己控制的 HTTPS 域名。 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..f6e4dca --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,127 @@ +# URLRouter architecture and route contracts + +[中文](architecture.zh-CN.md) · [Documentation index](README.md) + +This guide explains the boundaries that keep a routing setup understandable as +the number of screens and Feature packages grows. + +## The four responsibilities + +| Part | Owns | +| --- | --- | +| URL contract | A stable, public address for a destination and its presentation style | +| Feature Package | URL parsing plus creation of the destination view | +| App shell | Linked module registration, trusted hosts, policy, and scene navigation state | +| URLRouter | URL validation, route resolution, and SwiftUI navigation state changes | + +An optional fifth piece, `URLRouterPolicyProvider`, refreshes a remote policy +into an in-memory policy store. It never performs navigation and the core +library does not depend on it. + +## Recommended package direction + +```text +App target + ├── ArticleFeature package ──> URLRouter + ├── SettingsFeature package ─> URLRouter + └── URLRouterPolicyProvider (optional, App target only) +``` + +Features do not import each other to present views. The App depends on Feature +packages and registers their public `RouteModule`s. A Feature may expose a +small URL-builder API, or a shared contracts package may expose builders when a +strict dependency graph requires it. Do not expose another Feature's view type +as a navigation API. + +## Design URLs as public contracts + +Use URLs that a web page, notification, and another Feature can all understand: + +```text +https://example.com/articles/42?presentation=push&version=1 +``` + +Keep these rules from the first release: + +- HTTPS only, with hosts controlled by your team. +- Stable IDs only; never credentials, tokens, contact data, or full JSON. +- `presentation` is required: `push`, `tab`, `sheet`, or `fullScreenCover`. +- Public or long-lived links include a contract `version`. +- The owning Feature documents and constructs its own URLs with `URLComponents`. + +A published URL has the same compatibility cost as a public Swift API. Prefer +adding a new path or version during a migration; do not quietly reinterpret or +delete an existing path. + +## Keep parsing local to the Feature + +One module can own several links. Put literal paths before parameterized paths: + +```swift +switch link.pathComponents { +case ["articles"]: + return ModuleRoute(moduleID: "articles", routeID: "list") +case ["articles", "saved"]: + return ModuleRoute(moduleID: "articles", routeID: "saved") +case ["articles", let id] where !id.isEmpty: + return ModuleRoute(moduleID: "articles", routeID: "detail", parameters: ["id": id]) +default: + return nil +} +``` + +Do not accept path suffixes that the contract does not define. A strict parser +makes a malformed external link fail safely instead of opening a surprising +screen. + +`ModuleRoute` contains a module ID, route ID, and string parameters. Keep +business objects out of it; the destination can load fresh data through its +view model or use case. That makes restoration, testing, and cross-package +boundaries simpler. + +## Keep the App shell small + +`AppRoutes.swift` should only register modules already linked into the app: + +```swift +enum AppRoutes { + static let registry = ModuleRouteRegistry(modules: [ + ArticleFeature.module, + SettingsFeature.module + ]) +} +``` + +It should not become one giant switch over every path. The registry validates +duplicate module IDs, incorrect module ownership, and missing destinations for +presented routes. + +The shell is the appropriate place for global rules: trusted hosts, supported +URL versions, feature flags, authorization decisions, and analytics adapters. +It is not the right place to hard-code an app's login implementation. Let the +app decide how to authenticate; let the routing policy say whether the current +route may open. + +## Use tabs deliberately + +`RouterHost` manages pushed and modal destinations. For tab routes it writes +`router.selectedTab`. Bind that state to the root `TabView` selection, and use +the same identifier for the URL route ID and the tab tag. This keeps a tab URL +from being “handled” without visibly switching tabs. + +## Publishing or changing a route + +For every public route change, update these items in one pull request: + +1. The Feature's parser and URL builder. +2. `RouteContracts.json`. +3. Tests and caller documentation. +4. Migration notes if an old URL remains in emails, websites, notifications, or + released apps. + +The repository CI catches structural and breaking catalog changes; it cannot +decide your product migration policy. Treat a removed or reinterpreted URL as a +breaking change and plan it accordingly. + +For rollout, remote policy, concurrency, and observability guidance, continue +to [Production governance](production-governance.md). diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md new file mode 100644 index 0000000..dff6a03 --- /dev/null +++ b/docs/architecture.zh-CN.md @@ -0,0 +1,115 @@ +# URLRouter 架构与路由契约 + +[English](architecture.md) · [文档目录](README.zh-CN.md) + +这篇说明解释项目变大后,如何仍让路由边界保持清楚、容易维护。 + +## 四个职责 + +| 部分 | 负责什么 | +| --- | --- | +| URL 契约 | 目标页面稳定、公开的地址,以及它的展示方式 | +| Feature Package | URL 解析和目标 View 的创建 | +| App 壳层 | 已链接模块的注册、可信 host、策略和场景导航状态 | +| URLRouter | URL 校验、路由解析和 SwiftUI 导航状态更新 | + +还有一个可选的第五部分:`URLRouterPolicyProvider`。它把远程策略刷新到内存 +Store 中,不负责任何页面跳转,核心库也不依赖它。 + +## 推荐的 Package 依赖方向 + +```text +App target + ├── ArticleFeature package ──> URLRouter + ├── SettingsFeature package ─> URLRouter + └── URLRouterPolicyProvider(可选,仅 App target) +``` + +Feature 不应为了展示对方页面而互相 import。App 依赖各 Feature Package,并 +注册它们公开的 `RouteModule`。Feature 可以公开一个很小的 URL builder API;依赖 +边界特别严格时,也可以由一个共享 contracts package 提供 builder。不要把另一个 +Feature 的 View 类型当成导航 API 暴露出去。 + +## 把 URL 当作公开契约来设计 + +让网页、推送和另一个 Feature 都能理解同一条 URL: + +```text +https://example.com/articles/42?presentation=push&version=1 +``` + +从第一版开始坚持这些规则: + +- 只使用 HTTPS 和团队控制的 host。 +- 只放稳定 ID;不放凭据、token、联系方式或完整 JSON。 +- `presentation` 必填:`push`、`tab`、`sheet` 或 `fullScreenCover`。 +- 对外或长期存在的链接带契约 `version`。 +- 由拥有它的 Feature 用 `URLComponents` 文档化和构造 URL。 + +一旦发布,URL 的兼容成本和公开 Swift API 一样高。迁移时优先新增路径或版本; +不要悄悄改变或删除一条已有路径的含义。 + +## 让 Feature 就地解析 + +一个 module 可以拥有多条链接。固定路径要写在带参数的路径前面: + +```swift +switch link.pathComponents { +case ["articles"]: + return ModuleRoute(moduleID: "articles", routeID: "list") +case ["articles", "saved"]: + return ModuleRoute(moduleID: "articles", routeID: "saved") +case ["articles", let id] where !id.isEmpty: + return ModuleRoute(moduleID: "articles", routeID: "detail", parameters: ["id": id]) +default: + return nil +} +``` + +契约没有定义的路径后缀不要接受。严格解析会让不合法的外部链接安全失败,而不是 +打开意外页面。 + +`ModuleRoute` 只包含 module ID、route ID 和字符串参数。不要把业务对象塞进去; +目标页面应通过 ViewModel 或 Use Case 加载最新数据。这样恢复状态、测试和跨 Package +边界都会更简单。 + +## 让 App 壳层保持很小 + +`AppRoutes.swift` 只注册已链接进 App 的模块: + +```swift +enum AppRoutes { + static let registry = ModuleRouteRegistry(modules: [ + ArticleFeature.module, + SettingsFeature.module + ]) +} +``` + +它不应变成一个解析全 App 所有路径的大 `switch`。registry 会校验重复 module ID、 +错误的模块归属,以及需要展示却缺少 destination 的路由。 + +壳层适合放全局规则:可信 host、支持的 URL 版本、Feature 开关、权限决定和埋点 +适配器;不适合写死 App 的登录实现。登录怎么做由 App 决定,路由策略只回答“当前 +这条路由是否允许进入”。 + +## 有意识地处理 Tab + +`RouterHost` 管理 push 和模态目标。收到 tab 路由时,它会写入 +`router.selectedTab`。将这个状态绑定到根部 `TabView` 的 selection,并让 URL 的 +route ID 与 tab tag 完全一致。这样路由不会出现“已处理但界面没有切换 tab”的问题。 + +## 发布或修改一条路由 + +每次修改公开路由,都在同一个 PR 中更新: + +1. Feature 的解析器和 URL builder。 +2. `RouteContracts.json`。 +3. 测试和调用方文档。 +4. 旧 URL 已存在于邮件、网页、推送或已发布 App 时的迁移说明。 + +仓库 CI 会阻止目录结构错误和破坏性契约变化,但它不会替你决定产品迁移策略。 +删除或改变已有 URL 的含义,应按破坏性变更处理并提前规划。 + +关于灰度、远程策略、并发与可观测性,请继续阅读 +[生产治理](production-governance.zh-CN.md)。 diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..d8d22df --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,222 @@ +# Getting started with URLRouter + +[中文](getting-started.zh-CN.md) · [Documentation index](README.md) + +This guide gets one production-shaped route running before you introduce any +remote configuration or queueing. The goal is simple: a button and a Universal +Link should open the same SwiftUI destination through one public URL. + +## Before you begin + +- Deploy to iOS 17+, macOS 14+, tvOS 17+, or watchOS 10+. +- Choose an HTTPS domain your team controls, such as `example.com` below. +- Add `URLRouter` to the App target and every Feature Package that declares a + `RouteModule`. + +Do not add `URLRouterPolicyProvider` yet. It is optional and belongs in the App +shell only when you need remotely managed restrictions. + +## 1. Define one stable URL contract + +Start with a complete URL, not an internal View name: + +```text +https://example.com/articles/42?presentation=push&version=1 +``` + +- `/articles/42` identifies the destination. +- `presentation=push` tells SwiftUI how to show it. +- `version=1` lets a future app support a new URL shape without guessing. + +Use HTTPS and a trusted host. Put stable IDs in URLs, never tokens, passwords, +phone numbers, or an entire JSON object. Once a link appears in a web page, +email, or notification, treat it as a public API. + +Build URLs with `URLComponents` in the owning Feature rather than copying +strings around the app: + +```swift +import Foundation + +enum ArticleLinks { + static func detail(id: String) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = "example.com" + components.path = "/articles/\(id)" + components.queryItems = [ + URLQueryItem(name: "presentation", value: "push"), + URLQueryItem(name: "version", value: "1") + ] + return components.url! + } +} +``` + +## 2. Make the owning Feature resolve it + +The Feature owns both its URL grammar and its destination views. A resolver +returns `nil` when a URL belongs to another Feature. + +```swift +import SwiftUI +import URLRouter + +enum ArticleFeature { + static let module = RouteModule( + id: "articles", + resolve: { link in + switch link.pathComponents { + case ["articles"]: + return ModuleRoute(moduleID: "articles", routeID: "list") + case ["articles", "saved"]: + return ModuleRoute(moduleID: "articles", routeID: "saved") + case ["articles", let id] where !id.isEmpty: + return ModuleRoute( + moduleID: "articles", + routeID: "detail", + parameters: ["id": id] + ) + default: + return nil + } + }, + destination: { route in + switch route.routeID { + case "list": + return AnyView(ArticleListView()) + case "saved": + return AnyView(SavedArticlesView()) + case "detail": + guard let id = route.parameters["id"] else { return nil } + return AnyView(ArticleDetailView(articleID: id)) + default: + return nil + } + } + ) +} +``` + +Put fixed paths such as `/articles/saved` before the general +`/articles/:id` case. Otherwise `saved` would be mistaken for an article ID. + +## 3. Assemble modules in the App shell + +The App shell is the only place that knows which Feature packages are linked. +It registers modules, creates navigation state, and applies app-wide rules. It +does not parse Feature paths or construct Feature views. + +```swift +import SwiftUI +import URLRouter + +@main +struct CompanyApp: App { + @State private var router = ModuleRouter() + private let registry = ModuleRouteRegistry(modules: [ArticleFeature.module]) + + var body: some Scene { + WindowGroup { + RouterHost(router: router) { + AppTabs(router: router) + } destination: { route in + registry.destination(for: route) + } + .moduleLinkRouting( + router: router, + registry: registry, + allowedHosts: ["example.com"], + policy: ModuleRoutePolicy( + acceptedContractVersions: ["1"], + allowsUnversionedLinks: false + ) + ) + } + } +} +``` + +Install `RouterHost` and `moduleLinkRouting` once per scene. Each window should +have its own `ModuleRouter`; that keeps multi-window state isolated. + +## 4. Navigate from ordinary SwiftUI code + +`moduleLinkRouting` supplies SwiftUI's standard `openURL` action. Child views +do not need a router reference: + +```swift +struct ArticleRow: View { + let id: String + @Environment(\.openURL) private var openURL + + var body: some View { + Button("Read") { + openURL(ArticleLinks.detail(id: id)) + } + } +} +``` + +After asynchronous work, return to the main actor before calling it: + +```swift +Task { + let id = try await recommendationService.nextArticleID() + await MainActor.run { + openURL(ArticleLinks.detail(id: id)) + } +} +``` + +## 5. Make tab routes change your TabView + +For a `presentation=tab` route, URLRouter updates `router.selectedTab`. Bind +your `TabView` selection to that value. Keep the tab `routeID` and the SwiftUI +tag equal, for example `favorites`. + +```swift +struct AppTabs: View { + @Bindable var router: ModuleRouter + + var body: some View { + TabView(selection: Binding( + get: { router.selectedTab?.routeID ?? "home" }, + set: { router.selectedTab = ModuleRoute(moduleID: "navigation", routeID: $0) } + )) { + HomeView().tabItem { Label("Home", systemImage: "house") }.tag("home") + FavoritesView().tabItem { Label("Favorites", systemImage: "star") }.tag("favorites") + } + } +} +``` + +## 6. Add Universal Links + +1. Add the **Associated Domains** capability to the App target. +2. Add `applinks:example.com`. +3. Serve `https://example.com/.well-known/apple-app-site-association` over + HTTPS without redirects. +4. Declare only paths that the app actually supports. + +```json +{ + "applinks": { + "details": [{ + "appIDs": ["TEAM_ID.com.example.CompanyApp"], + "components": [{ "/": "/articles/*" }] + }] + } +} +``` + +Test on a physical device. A valid domain association is an Apple platform +requirement; it is separate from URLRouter's own URL validation. + +## Next steps + +- Read [Architecture](architecture.md) before publishing routes for multiple + teams or packages. +- Read [Production governance](production-governance.md) when product needs + remote route restrictions, incident controls, telemetry, or concurrent-route + handling. diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md new file mode 100644 index 0000000..75132fa --- /dev/null +++ b/docs/getting-started.zh-CN.md @@ -0,0 +1,217 @@ +# URLRouter 接入指南 + +[English](getting-started.md) · [文档目录](README.zh-CN.md) + +本指南先让你跑通一条接近生产形态的路由,再考虑远程配置和队列。目标很简单: +一个按钮和一条 Universal Link,都通过同一条公开 URL 打开同一个 SwiftUI 页面。 + +## 开始前 + +- 部署目标为 iOS 17+、macOS 14+、tvOS 17+ 或 watchOS 10+。 +- 选择团队控制的 HTTPS 域名;下文用 `example.com` 举例。 +- 为 App Target,以及每个声明 `RouteModule` 的 Feature Package 添加 + `URLRouter`。 + +暂时不要添加 `URLRouterPolicyProvider`。它是可选能力,只有需要远程管理路由 +限制时才放到 App 壳层。 + +## 1. 定义一条稳定的 URL 契约 + +从一条完整 URL 开始,而不是从某个内部 View 名称开始: + +```text +https://example.com/articles/42?presentation=push&version=1 +``` + +- `/articles/42` 是目标页面。 +- `presentation=push` 告诉 SwiftUI 如何展示。 +- `version=1` 让未来的 App 可以支持新 URL 形状,而不是靠猜。 + +只使用 HTTPS 和可信 host。URL 中放稳定 ID,不放 token、密码、手机号或整段 +JSON。一旦链接被放进网页、邮件或推送,它就是公开 API。 + +在拥有该路由的 Feature 中用 `URLComponents` 构造 URL,而不是在全 App 复制 +字符串: + +```swift +import Foundation + +enum ArticleLinks { + static func detail(id: String) -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = "example.com" + components.path = "/articles/\(id)" + components.queryItems = [ + URLQueryItem(name: "presentation", value: "push"), + URLQueryItem(name: "version", value: "1") + ] + return components.url! + } +} +``` + +## 2. 由拥有它的 Feature 解析 URL + +Feature 同时拥有自己的 URL 语法和目标页面。解析器返回 `nil` 的意思是“这条 +URL 属于另一个 Feature”。 + +```swift +import SwiftUI +import URLRouter + +enum ArticleFeature { + static let module = RouteModule( + id: "articles", + resolve: { link in + switch link.pathComponents { + case ["articles"]: + return ModuleRoute(moduleID: "articles", routeID: "list") + case ["articles", "saved"]: + return ModuleRoute(moduleID: "articles", routeID: "saved") + case ["articles", let id] where !id.isEmpty: + return ModuleRoute( + moduleID: "articles", + routeID: "detail", + parameters: ["id": id] + ) + default: + return nil + } + }, + destination: { route in + switch route.routeID { + case "list": + return AnyView(ArticleListView()) + case "saved": + return AnyView(SavedArticlesView()) + case "detail": + guard let id = route.parameters["id"] else { return nil } + return AnyView(ArticleDetailView(articleID: id)) + default: + return nil + } + } + ) +} +``` + +固定路径,例如 `/articles/saved`,要写在通用的 `/articles/:id` 前面;否则 +`saved` 会被错误当成文章 ID。 + +## 3. 在 App 壳层组装模块 + +App 壳层是唯一知道当前链接了哪些 Feature Package 的地方。它注册模块、创建 +导航状态、执行 App 级规则;但不解析 Feature 路径,也不创建 Feature 页面。 + +```swift +import SwiftUI +import URLRouter + +@main +struct CompanyApp: App { + @State private var router = ModuleRouter() + private let registry = ModuleRouteRegistry(modules: [ArticleFeature.module]) + + var body: some Scene { + WindowGroup { + RouterHost(router: router) { + AppTabs(router: router) + } destination: { route in + registry.destination(for: route) + } + .moduleLinkRouting( + router: router, + registry: registry, + allowedHosts: ["example.com"], + policy: ModuleRoutePolicy( + acceptedContractVersions: ["1"], + allowsUnversionedLinks: false + ) + ) + } + } +} +``` + +每个场景只安装一次 `RouterHost` 和 `moduleLinkRouting`。每个窗口有自己的 +`ModuleRouter`,多窗口状态就不会互相影响。 + +## 4. 在普通 SwiftUI 代码中跳转 + +`moduleLinkRouting` 提供 SwiftUI 标准的 `openURL` action。子 View 不需要拿到 +router: + +```swift +struct ArticleRow: View { + let id: String + @Environment(\.openURL) private var openURL + + var body: some View { + Button("阅读") { + openURL(ArticleLinks.detail(id: id)) + } + } +} +``` + +异步工作结束后,回到主线程再调用: + +```swift +Task { + let id = try await recommendationService.nextArticleID() + await MainActor.run { + openURL(ArticleLinks.detail(id: id)) + } +} +``` + +## 5. 让 tab 路由真的切换 TabView + +收到 `presentation=tab` 时,URLRouter 更新 `router.selectedTab`。将 +`TabView` 的 selection 绑定到它;tab 的 `routeID` 和 SwiftUI tag 要保持一致, +例如 `favorites`。 + +```swift +struct AppTabs: View { + @Bindable var router: ModuleRouter + + var body: some View { + TabView(selection: Binding( + get: { router.selectedTab?.routeID ?? "home" }, + set: { router.selectedTab = ModuleRoute(moduleID: "navigation", routeID: $0) } + )) { + HomeView().tabItem { Label("首页", systemImage: "house") }.tag("home") + FavoritesView().tabItem { Label("收藏", systemImage: "star") }.tag("favorites") + } + } +} +``` + +## 6. 接入 Universal Link + +1. 为 App Target 添加 **Associated Domains** capability。 +2. 添加 `applinks:example.com`。 +3. 在 `https://example.com/.well-known/apple-app-site-association` 提供文件, + 必须 HTTPS 且不能重定向。 +4. 只声明 App 真实支持的路径。 + +```json +{ + "applinks": { + "details": [{ + "appIDs": ["TEAM_ID.com.example.CompanyApp"], + "components": [{ "/": "/articles/*" }] + }] + } +} +``` + +请在真机测试。域名关联是否正确是 Apple 平台能力;它与 URLRouter 自己的 URL +校验是两件事。 + +## 下一步 + +- 多团队或多 Package 发布路由前,阅读[架构说明](architecture.zh-CN.md)。 +- 产品需要远程限制、事故控制、遥测或并发路由时,阅读 + [生产治理](production-governance.zh-CN.md)。 diff --git a/docs/production-governance.md b/docs/production-governance.md new file mode 100644 index 0000000..8b36a25 --- /dev/null +++ b/docs/production-governance.md @@ -0,0 +1,169 @@ +# Production route governance + +[中文](production-governance.zh-CN.md) · [Documentation index](README.md) + +Start with the direct routing modifier. Add the capabilities below only when a +real product requirement appears. They do not bind your app to a backend, +authentication SDK, analytics service, or remote-config vendor. + +## Remote policy and circuit breaking + +A remote policy is a small route control panel. It is useful when an App Store +release would be too slow: disable a broken checkout Feature, restrict a +presentation style, or stop module routes during a serious incident. The backend +does not decide navigation; it supplies restrictions that the app applies +locally. + +`ModuleRoutePolicy` is the app's non-negotiable local baseline. Remote policy +can only tighten it; it cannot bypass local authorization or re-enable an +unsupported URL version. + +```swift +let localPolicy = ModuleRoutePolicy( + acceptedContractVersions: ["1"], + allowsUnversionedLinks: false, + isModuleEnabled: { featureFlags.isEnabled($0) }, + isAuthorized: { route, _ in + permissions.canOpen(moduleID: route.moduleID, routeID: route.routeID) + } +) +``` + +For example, a protected HTTPS endpoint can return: + +```json +{ + "isCircuitBreakerOpen": false, + "disabledModuleIDs": ["checkout"], + "allowedPresentationStyles": ["push", "tab"], + "acceptedContractVersions": ["1"] +} +``` + +The App owns authentication and, for high-impact controls, signature +verification before applying the payload. + +## Optional Provider: cache first, refresh in the background + +`URLRouterPolicyProvider` is not a backend call before every navigation. It +maintains a `ModuleRoutePolicyStore` separately, while each route reads that +in-memory store synchronously. + +```text +Cold start: restore the last verified cache immediately +After first view: fetch the latest policy in the background +App active: refresh only after the configured interval +Fetch fails: keep the last verified policy +Cache too old: fall back to the app's local safe policy +``` + +Only the App shell imports the optional product: + +```swift +import URLRouter +import URLRouterPolicyProvider + +@MainActor +final class AppRoutePolicySession { + let store: ModuleRoutePolicyStore + let provider: RoutePolicyProvider + + init(localPolicy: ModuleRoutePolicy, cacheURL: URL) { + store = ModuleRoutePolicyStore(localPolicy: localPolicy) + provider = RoutePolicyProvider( + store: store, + source: CompanyPolicySource(), + cache: FileRoutePolicyCache(url: cacheURL), + strategy: .standard + ) + } + + func start() async { + _ = await provider.bootstrap() // restore trusted cache; no network wait + _ = await provider.refresh() // refresh in the background + } + + func appBecameActive() async { + _ = await provider.refreshIfNeeded() + } +} +``` + +Put the cache in Application Support. When the Provider is used, pass +`policySession.store` to the direct modifier or coordinator. With local policy +only, pass `policy: localPolicy` instead; do not pass both. + +## Coordinate competing route requests + +Use one `ModuleRouteCoordinator` per scene if a notification, Universal Link, +and in-app action can arrive together. It gives navigation a small waiting line: + +- Exact duplicate URLs are merged. +- Priority is `critical`, `external`, `userInitiated`, then `background`. +- Equal priorities keep arrival order. +- Ten requests wait by default; requests expire after 30 seconds. +- Policy is checked again just before navigation, so a newly opened circuit + breaker still applies. + +```swift +let coordinator = ModuleRouteCoordinator( + router: router, + registry: AppRoutes.registry, + allowedHosts: ["example.com"], + policyStore: policySession.store, + configuration: ModuleRouteCoordinatorConfiguration( + maximumPendingRequests: 10, + defaultTimeToLive: 30, + transitionDelay: .milliseconds(350) + ) +) +``` + +Install it with `.moduleLinkRouting(coordinator: coordinator)`. Do not create a +coordinator in each button. App-owned requests may call +`route(_:priority:expiresAt:)`; `openURL` and Universal Links use `.external`. + +## Observe routes without collecting sensitive URLs + +`ModuleRouteEvent` is a routing breadcrumb: it reports whether a request was +handled, malformed, blocked by policy, or dropped from the queue. It includes a +trace ID, host, module ID, route ID, presentation, outcome, and stable failure +code—but never query values. + +```swift +@MainActor +final class AppRouteObserver: ModuleRouteObserving { + func record(_ event: ModuleRouteEvent) { + logger.info("route=\(event.outcome.rawValue) code=\(event.failureCode ?? "handled")") + metrics.increment("route.\(event.failureCode ?? "handled")") + } +} +``` + +`logger` and `metrics` are your app's adapters. Do not use a full URL, article +ID, token, or personal information as a metric label. Start with success rate, +top failure codes, and queue-full/expiry counts. + +## Protect published URLs with contract CI + +`RouteContracts.json` is a source-controlled catalog of public routes. In the +same pull request that changes a public route, update the Feature parser, URL +builder, catalog, tests, and any migration note. + +CI validates the catalog and compares it with the PR base commit. It rejects an +accidental removal or incompatible change to a path, presentation, required +parameter, or supported contract version. Treat an intentional break as a +major-version change with a migration plan. + +```bash +swift Scripts/validate_route_contract.swift RouteContracts.json +``` + +## A practical rollout order + +1. Ship one versioned URL and its Feature resolver. +2. Add Universal Links and tests. +3. Add `RouteContracts.json` before other teams depend on the URL. +4. Add observability when support needs diagnosis. +5. Add the Provider when operations needs safe remote restrictions. +6. Add the coordinator when route sources actually compete. diff --git a/docs/production-governance.zh-CN.md b/docs/production-governance.zh-CN.md new file mode 100644 index 0000000..6804627 --- /dev/null +++ b/docs/production-governance.zh-CN.md @@ -0,0 +1,157 @@ +# 生产环境路由治理 + +[English](production-governance.md) · [文档目录](README.zh-CN.md) + +先使用直接路由 modifier。只有真实产品需求出现时,才按需加入以下能力;它们不 +绑定后端、登录 SDK、埋点服务或远程配置厂商。 + +## 远程策略与紧急熔断 + +远程策略就是路由的“小控制台”。等 App Store 发版太慢时,它可以禁用出现故障的 +支付 Feature、收紧展示方式,或在严重事故时停止所有模块路由。后台不替 App 决定 +跳转;它只下发 App 在本地执行的限制。 + +`ModuleRoutePolicy` 是 App 不能被远程放宽的本地底线。远程策略只能收紧它,不能 +绕过本地授权,也不能重新允许不支持的 URL 版本。 + +```swift +let localPolicy = ModuleRoutePolicy( + acceptedContractVersions: ["1"], + allowsUnversionedLinks: false, + isModuleEnabled: { featureFlags.isEnabled($0) }, + isAuthorized: { route, _ in + permissions.canOpen(moduleID: route.moduleID, routeID: route.routeID) + } +) +``` + +受保护的 HTTPS 接口可以返回: + +```json +{ + "isCircuitBreakerOpen": false, + "disabledModuleIDs": ["checkout"], + "allowedPresentationStyles": ["push", "tab"], + "acceptedContractVersions": ["1"] +} +``` + +鉴权由 App 负责;熔断等高影响控制建议使用带签名的响应,校验通过后才应用。 + +## 可选 Provider:先读缓存,再后台刷新 + +`URLRouterPolicyProvider` 不是“每次跳转前都请求后台”。它独立维护 +`ModuleRoutePolicyStore`,每次路由只同步读取内存中的 store。 + +```text +冷启动: 立即恢复最后一次已验证缓存 +首屏之后: 后台拉取最新策略 +App 回到前台: 超过刷新间隔才拉取 +拉取失败: 继续使用最后一次可信策略 +缓存太旧: 回退到 App 内置的安全本地策略 +``` + +只有 App 壳层导入该可选 product: + +```swift +import URLRouter +import URLRouterPolicyProvider + +@MainActor +final class AppRoutePolicySession { + let store: ModuleRoutePolicyStore + let provider: RoutePolicyProvider + + init(localPolicy: ModuleRoutePolicy, cacheURL: URL) { + store = ModuleRoutePolicyStore(localPolicy: localPolicy) + provider = RoutePolicyProvider( + store: store, + source: CompanyPolicySource(), + cache: FileRoutePolicyCache(url: cacheURL), + strategy: .standard + ) + } + + func start() async { + _ = await provider.bootstrap() // 恢复可信缓存,不等待网络 + _ = await provider.refresh() // 后台刷新 + } + + func appBecameActive() async { + _ = await provider.refreshIfNeeded() + } +} +``` + +缓存放在 Application Support。使用 Provider 时,把 `policySession.store` 交给 +直接 modifier 或 coordinator;只使用本地策略时传 `policy: localPolicy`,两者 +不要同时传。 + +## 协调同时到达的路由请求 + +推送、Universal Link 和 App 内操作可能同时到达时,每个场景使用一个 +`ModuleRouteCoordinator`。它给导航加了一条小队列: + +- 完全相同的 URL 会合并。 +- 优先级是 `critical`、`external`、`userInitiated`、`background`。 +- 同优先级按到达顺序处理。 +- 默认最多等待 10 条,等待 30 秒后过期。 +- 真正跳转前会再次检查策略;等待期间熔断打开仍会生效。 + +```swift +let coordinator = ModuleRouteCoordinator( + router: router, + registry: AppRoutes.registry, + allowedHosts: ["example.com"], + policyStore: policySession.store, + configuration: ModuleRouteCoordinatorConfiguration( + maximumPendingRequests: 10, + defaultTimeToLive: 30, + transitionDelay: .milliseconds(350) + ) +) +``` + +用 `.moduleLinkRouting(coordinator: coordinator)` 安装它。不要每个按钮各建一个 +coordinator。App 主动请求可调用 `route(_:priority:expiresAt:)`;`openURL` 和 +Universal Link 默认使用 `.external`。 + +## 记录路由,但不要收集敏感 URL + +`ModuleRouteEvent` 是排障“面包屑”:它说明请求成功、URL 不合法、被策略拒绝, +还是从队列丢弃。它包含 trace ID、host、module ID、route ID、展示方式、结果和稳定 +错误码,但不包含 query value。 + +```swift +@MainActor +final class AppRouteObserver: ModuleRouteObserving { + func record(_ event: ModuleRouteEvent) { + logger.info("route=\(event.outcome.rawValue) code=\(event.failureCode ?? "handled")") + metrics.increment("route.\(event.failureCode ?? "handled")") + } +} +``` + +`logger` 和 `metrics` 是 App 自己的适配器。不要把完整 URL、文章 ID、token 或个人 +信息作为指标标签。先看成功率、失败原因排行、队列满和过期次数即可。 + +## 用契约 CI 保护已发布 URL + +`RouteContracts.json` 是受版本控制的公开路由目录。修改公开路由时,同一个 PR 还要 +修改 Feature 解析器、URL builder、目录、测试,以及必要的迁移说明。 + +CI 会校验目录并与 PR 基线比较,拒绝意外删除或不兼容修改路径、展示方式、必填参数 +和支持的协议版本。确实要破坏兼容性时,按主版本变更处理并提供迁移方案。 + +```bash +swift Scripts/validate_route_contract.swift RouteContracts.json +``` + +## 一个实际的接入顺序 + +1. 发布一条带版本 URL 和对应 Feature 解析器。 +2. 加入 Universal Link 和测试。 +3. 其他团队依赖 URL 前加入 `RouteContracts.json`。 +4. 客服需要排障时加入可观测性。 +5. 运营需要安全的远程限制时加入 Provider。 +6. 多个路由来源真的会竞争时加入 coordinator。