Skip to content
103 changes: 103 additions & 0 deletions docs/rfcs/0021-route-relay.md
Original file line number Diff line number Diff line change
@@ -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/<app>` 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<HostRouteGetResponse, GenericErr>

struct HostRouteGetResponse {
/// The current route the Host holds for this app.
/// `None` if no route is set (app's home).
route: Option<String>,
}
```

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<HostRouteChangedEvent, GenericErr>

struct HostRouteChangedEvent {
/// New route. `None` when the user is at the app's home.
route: Option<String>,
}
```

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.
28 changes: 14 additions & 14 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
2 changes: 1 addition & 1 deletion hosts/dotli
Submodule dotli updated from 80bedc to 8f5d28
4 changes: 4 additions & 0 deletions rust/crates/truapi/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -40,6 +42,7 @@ pub trait TrUApi:
+ CoinPayment
+ Entropy
+ LocalStorage
+ Navigation
+ Notifications
+ Payment
+ Permissions
Expand All @@ -61,6 +64,7 @@ impl<T> TrUApi for T where
+ CoinPayment
+ Entropy
+ LocalStorage
+ Navigation
+ Notifications
+ Payment
+ Permissions
Expand Down
80 changes: 80 additions & 0 deletions rust/crates/truapi/src/api/navigation.rs
Original file line number Diff line number Diff line change
@@ -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<string | null> {
/// 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<HostRouteGetResponse, CallError<HostRouteGetError>>;

/// 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<void> {
/// 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<HostRouteSetResponse, CallError<HostRouteSetError>>;

/// 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<HostRouteChangedItem> {
Subscription::empty()
}
}
2 changes: 2 additions & 0 deletions rust/crates/truapi/src/v01/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod coin_payment;
mod common;
mod entropy;
mod local_storage;
mod navigation;
mod notifications;
mod payment;
mod permissions;
Expand All @@ -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::*;
Expand Down
24 changes: 24 additions & 0 deletions rust/crates/truapi/src/v01/navigation.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<String>,
}
1 change: 1 addition & 0 deletions rust/crates/truapi/src/versioned/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions rust/crates/truapi/src/versioned/navigation.rs
Original file line number Diff line number Diff line change
@@ -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 }
}