diff --git a/docs/rfcs/0021-route-relay.md b/docs/rfcs/0021-route-relay.md new file mode 100644 index 00000000..045d747b --- /dev/null +++ b/docs/rfcs/0021-route-relay.md @@ -0,0 +1,103 @@ +# RFC-0021: Route Relay + +| | | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Start Date** | 2026-05-15 | +| **Description** | Host API surface that lets an app publish its internal route to the Host's address bar, read the current route, and observe back/forward navigation | +| **Authors** | @pgherveou | + +## Summary + +Add three host calls (`host_route_get`, `host_route_set`, `host_route_changed`) that relay an opaque per-app route string between the embedded app and the Host shell. This makes in-app navigation deep-linkable, shareable, and reload-stable, and lets the app react to Host back/forward, without giving the app any access to the Host's URL bar. + +## Motivation + +Apps that run inside a Web host are loaded in a webview / iframe. The visible address bar belongs to the Host, not to the app. Today this means: + +- An app can call `history.pushState` / mutate `window.location.hash` internally, but those changes are invisible to the user, are not shareable, and do not survive a reload — the Host re-launches the wrapper at `https://dot.li/` with no fragment preserved. +- At bootstrap the app cannot tell which sub-route the user intended to open. There is no way to deep-link into, say, a specific method in the TrUAPI Playground, a specific chat in a messenger app, or a specific item in a marketplace app. + We need a small, symmetric channel: the app owns its route format, the Host owns the address bar, and the two stay in sync. + +## Stakeholders + +- **Product developers** (consumers): want shareable deep links and reload-stable routes without re-implementing routing per host. +- **Host implementors**: own the address bar, history stack, and how routes are rendered to the user (path, fragment, query, etc.). +- **End users**: copy / share / reload URLs and expect them to land where they were. + +## Explanation + +### `host_route_get` + +```rust +fn host_route_get() -> Result + +struct HostRouteGetResponse { + /// The current route the Host holds for this app. + /// `None` if no route is set (app's home). + route: Option, +} +``` + +Returns the current route the Host holds for this app. At bootstrap this is the route the Host was launched with (e.g. `Permissions/host_device_permission`); afterwards it reflects the most recent `host_route_set` and any Host-driven changes (back/forward, pasted URL). The Host does not interpret the string; the app defines its own format. + +Typical use is one call at bootstrap to restore deep-linked state. + +### `host_route_set` + +```rust +fn host_route_set(req: HostRouteSetRequest) -> Result<(), GenericErr> + +struct HostRouteSetRequest { + /// Opaque route segment defined by the app. + route: String, + /// `true` replaces the current history entry (analog of `history.replaceState`). + /// `false` pushes a new entry (analog of `history.pushState`). + replace: bool, +} +``` + +Called whenever the app navigates internally. The Host renders `route` as part of the user-visible URL so it can be copied, shared, and reloaded. The exact rendering (path segment, fragment, query parameter) is the Host's choice; the protocol does not constrain it. + +Setting `route` to the empty string clears the route (app's "home"). + +### `host_route_changed` + +```rust +fn host_route_changed() -> Stream + +struct HostRouteChangedEvent { + /// New route. `None` when the user is at the app's home. + route: Option, +} +``` + +Emits when the route changes from outside the app: Host back/forward, or a pasted URL while the app is running. The Host MUST NOT emit for changes that originated from `host_route_set` in this app session (no echo loop). The stream does not emit the initial value; the app reads that from `host_route_get`. + +### Lifecycle + +1. App starts → calls `host_route_get` → restores deep-linked state. +2. App subscribes to `host_route_changed` → handles back/forward and pasted URLs. +3. On every internal navigation → calls `host_route_set` with `replace=false` (or `true` for redirects / non-history-worthy transitions). + +### Semantics + +- **Opaque.** The Host treats `route` as an opaque byte string and does not parse it. Apps define their own grammar. +- **Length / charset.** Routes MUST be valid UTF-8. Hosts MAY impose a maximum length (recommended: at least 2048 bytes) and MUST return `GenericErr` for over-long routes; apps should avoid stuffing application state into the route. +- **Permissioning.** No permission prompt. The route is information the app already has; relaying it to the address bar does not disclose anything new to the user. (The Host MAY still rate-limit `host_route_set` to mitigate history-stack abuse.) + +### Web-host shim + +A Web host MAY monkey-patch `history.pushState`, `history.replaceState`, and the `popstate` / `hashchange` events on the iframe's `window` to call these TrUAPI methods underneath. With that shim in place, apps written against the standard web History API "just work" — their existing router (Next.js, React Router, vanilla `pushState`, etc.) drives the Host address bar with no TrUAPI-specific code. The shim is a Host implementation detail, not part of the protocol; non-web hosts implement these methods natively. + +## Drawbacks + +- Hosts must implement the no-echo rule on `host_route_changed` correctly, or naive apps will loop. + +## Compatibility + +Purely additive. + +## Future Directions + +- `host_route_set_title` for per-route titles in the Host chrome. +- Fold `host_route_get` into the connection handshake to save a bootstrap round-trip. diff --git a/docs/rfcs/_index.md b/docs/rfcs/_index.md index 3bac6f9a..06fb119c 100644 --- a/docs/rfcs/_index.md +++ b/docs/rfcs/_index.md @@ -8,17 +8,17 @@ created: 2026-03-13 # RFCs -| Number | Title | Status | Author | PR | -| ------ | ------------------------------------------------------------------------ | ------------------ | ------------- | --------------------------------------------------------------- | -| 0001 | [RFC Title](0001-template.md) | accepted | @ownerhandle | — | -| 0002 | [Permission Model for Host API](0002-permission-model.md) | accepted | @johnthecat | [#66](https://github.com/paritytech/triangle-js-sdks/pull/66) | -| 0006 | [Payment Host API](0006-payments.md) | accepted | Valentin Sergeev | [#94](https://github.com/paritytech/triangle-js-sdks/pull/94) | -| 0007 | [Deterministic Entropy Derivation for Products](0007-derive-entropy.md) | accepted | Valentin Sergeev | [#95](https://github.com/paritytech/triangle-js-sdks/pull/95) | -| 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | draft | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) | -| 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | Filippo Vecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) | -| 0010 | [W3S Allowance Management in TrUAPI](0010-allowance.md) | accepted | Valentin Sergeev | — | -| 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | accepted | Valentin Sergeev | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) | -| 0017 | [Coinage Payment User Agent API](0017-coinage-payment.md) | accepted | @replghost | — | -| 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | accepted | @johnthecat | — | -| 0020 | [Remove `context` from `create_transaction` and mirror in Accounts Protocol](0020-create-transaction.md) | accepted | Valentin Sergeev | — | -| 0021 | [Add Coins variant to PaymentTopUpSource](0021-payment-topup-coins.md) | accepted | @filippovecchiato | — | +| Number | Title | Status | Author | PR | +| ------ | ------------------------------------------------------------------------ | -------- | ------------- | --------------------------------------------------------------- | +| 0001 | [RFC Template](0001-template.md) | — | — | — | +| 0002 | [Permission Model for Host API](0002-permission-model.md) | accepted | @johnthecat | [#66](https://github.com/paritytech/triangle-js-sdks/pull/66) | +| 0006 | [Payment Host API](0006-payments.md) | accepted | @valentunn | [#94](https://github.com/paritytech/triangle-js-sdks/pull/94) | +| 0007 | [Deterministic Entropy Derivation](0007-derive-entropy.md) | accepted | @valentunn | [#95](https://github.com/paritytech/triangle-js-sdks/pull/95) | +| 0008 | [Statement Store Host API v0.2](0008-statement-store.md) | accepted | @johnthecat | [#118](https://github.com/paritytech/triangle-js-sdks/pull/118) | +| 0009 | [Unauthenticated Product Access](0009-unauthenticated-product-access.md) | accepted | @filvecchiato | [#128](https://github.com/paritytech/triangle-js-sdks/pull/128) | +| 0010 | [Root account access Host API](0010-get-root-account.md) | accepted | @johnthecat | [#126](https://github.com/paritytech/triangle-js-sdks/pull/126) | +| 0010 | [W3S Allowance Management](0010-allowance.md) | draft | @valentunn | — | +| 0011 | [Simple Group Chat](0011-simple-group-chat.md) | draft | @filvecchiato | [#131](https://github.com/paritytech/triangle-js-sdks/pull/131) | +| 0015 | [Get User Primary DotNS Name](0015-get-user-id.md) | draft | @valentunn | [#144](https://github.com/paritytech/triangle-js-sdks/pull/144) | +| 0019 | [Scheduled Push Notifications](0019-scheduled-notifications.md) | draft | @johnthecat | | +| 0021 | [Route Relay](0021-route-relay.md) | draft | @pgherveou | | diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..8f5d281c 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit 8f5d281cda255e2f45efab2ea3bc8ffbf563c814 diff --git a/rust/crates/truapi/src/api/mod.rs b/rust/crates/truapi/src/api/mod.rs index 957509e4..267f9197 100644 --- a/rust/crates/truapi/src/api/mod.rs +++ b/rust/crates/truapi/src/api/mod.rs @@ -6,6 +6,7 @@ pub mod chat; pub mod coin_payment; pub mod entropy; pub mod local_storage; +pub mod navigation; pub mod notifications; pub mod payment; pub mod permissions; @@ -22,6 +23,7 @@ pub use chat::Chat; pub use coin_payment::CoinPayment; pub use entropy::Entropy; pub use local_storage::LocalStorage; +pub use navigation::Navigation; pub use notifications::Notifications; pub use payment::Payment; pub use permissions::Permissions; @@ -40,6 +42,7 @@ pub trait TrUApi: + CoinPayment + Entropy + LocalStorage + + Navigation + Notifications + Payment + Permissions @@ -61,6 +64,7 @@ impl TrUApi for T where + CoinPayment + Entropy + LocalStorage + + Navigation + Notifications + Payment + Permissions diff --git a/rust/crates/truapi/src/api/navigation.rs b/rust/crates/truapi/src/api/navigation.rs new file mode 100644 index 00000000..4c3f233a --- /dev/null +++ b/rust/crates/truapi/src/api/navigation.rs @@ -0,0 +1,80 @@ +//! Unified [`Navigation`] trait. + +use crate::versioned::navigation::{ + HostRouteChangedItem, HostRouteGetError, HostRouteGetRequest, HostRouteGetResponse, + HostRouteSetError, HostRouteSetRequest, HostRouteSetResponse, +}; +use crate::wire; +use crate::{CallContext, CallError, Subscription}; + +/// Host route relay: read, write, and subscribe to the app's in-host route. +pub trait Navigation: Send + Sync { + /// Read the route the host currently holds for this app. + /// + /// At bootstrap this returns the route the host was launched with, so the + /// app can restore deep-linked state. Returns `None` when the app is at + /// its home. + /// + /// ```ts + /// export async function bootstrapRoute(truapi: Client): Promise { + /// const result = await truapi.navigation.routeGet(); + /// + /// if (result.isErr()) throw result.error; + /// return result.value.route ?? null; + /// } + /// ``` + #[wire(request_id = 168)] + async fn route_get( + &self, + cx: &CallContext, + request: HostRouteGetRequest, + ) -> Result>; + + /// Publish the app's current route to the host's address bar. + /// + /// The host renders `route` as part of the user-visible URL so it can be + /// copied, shared, and reloaded. The host treats the route as opaque. + /// + /// ```ts + /// export async function pushRoute(truapi: Client): Promise { + /// const result = await truapi.navigation.routeSet({ + /// route: "Permissions/host_device_permission", + /// replace: false, + /// }); + /// + /// if (result.isErr()) throw result.error; + /// } + /// ``` + #[wire(request_id = 170)] + async fn route_set( + &self, + cx: &CallContext, + request: HostRouteSetRequest, + ) -> Result>; + + /// Subscribe to route changes that originated outside the app. + /// + /// Emits on host back/forward and pasted-URL navigation. The host MUST + /// NOT emit for changes that originated from `route_set` in this app + /// session. The stream does not emit the initial value; the app reads + /// that from `route_get`. + /// + /// ```ts + /// import { + /// type Subscription, + /// type HostRouteChangedItem, + /// } from "@parity/truapi"; + /// + /// export function watchRoute(truapi: Client): Subscription { + /// return truapi.navigation.routeChanged().subscribe({ + /// next: (event: HostRouteChangedItem) => console.log(event.route), + /// error: (error: Error) => console.error(error), + /// complete: () => console.log("completed"), + /// }); + /// } + /// ``` + #[wire(start_id = 172)] + async fn route_changed(&self, _cx: &CallContext) -> Subscription { + Subscription::empty() + } +} diff --git a/rust/crates/truapi/src/v01/mod.rs b/rust/crates/truapi/src/v01/mod.rs index 8b34df5a..f8d6048e 100644 --- a/rust/crates/truapi/src/v01/mod.rs +++ b/rust/crates/truapi/src/v01/mod.rs @@ -7,6 +7,7 @@ mod coin_payment; mod common; mod entropy; mod local_storage; +mod navigation; mod notifications; mod payment; mod permissions; @@ -25,6 +26,7 @@ pub use coin_payment::*; pub use common::*; pub use entropy::*; pub use local_storage::*; +pub use navigation::*; pub use notifications::*; pub use payment::*; pub use permissions::*; diff --git a/rust/crates/truapi/src/v01/navigation.rs b/rust/crates/truapi/src/v01/navigation.rs new file mode 100644 index 00000000..da9dd0a5 --- /dev/null +++ b/rust/crates/truapi/src/v01/navigation.rs @@ -0,0 +1,24 @@ +use parity_scale_codec::{Decode, Encode}; + +/// Response containing the app's current route as held by the host. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostRouteGetResponse { + /// Current route the host holds for this app, or `None` when the app is at its home. + pub route: Option, +} + +/// Request to publish the app's current route to the host's address bar. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostRouteSetRequest { + /// Opaque route segment defined by the app. + pub route: String, + /// `true` replaces the current history entry; `false` pushes a new one. + pub replace: bool, +} + +/// Subscription item emitted when the route changes from outside the app. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostRouteChangedItem { + /// New route, or `None` when the user is at the app's home. + pub route: Option, +} diff --git a/rust/crates/truapi/src/versioned/mod.rs b/rust/crates/truapi/src/versioned/mod.rs index 9da72067..57265970 100644 --- a/rust/crates/truapi/src/versioned/mod.rs +++ b/rust/crates/truapi/src/versioned/mod.rs @@ -36,6 +36,7 @@ pub mod chat; pub mod coin_payment; pub mod entropy; pub mod local_storage; +pub mod navigation; pub mod notifications; pub mod payment; pub mod permissions; diff --git a/rust/crates/truapi/src/versioned/navigation.rs b/rust/crates/truapi/src/versioned/navigation.rs new file mode 100644 index 00000000..ae06a63d --- /dev/null +++ b/rust/crates/truapi/src/versioned/navigation.rs @@ -0,0 +1,13 @@ +//! Versioned wrappers for [`Navigation`](crate::api::Navigation) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostRouteGetRequest { V1 } + pub enum HostRouteGetResponse { V1 => v01::HostRouteGetResponse } + pub enum HostRouteGetError { V1 => v01::GenericError } + pub enum HostRouteSetRequest { V1 => v01::HostRouteSetRequest } + pub enum HostRouteSetResponse { V1 } + pub enum HostRouteSetError { V1 => v01::GenericError } + pub enum HostRouteChangedItem { V1 => v01::HostRouteChangedItem } +}