Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
1 change: 1 addition & 0 deletions docs/rfcs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ created: 2026-03-13
| 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 | |
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 entropy;
pub mod jsonrpc;
pub mod local_storage;
pub mod navigation;
pub mod payment;
pub mod permissions;
pub mod preimage;
Expand All @@ -21,6 +22,7 @@ pub use chat::Chat;
pub use entropy::Entropy;
pub use jsonrpc::JsonRpc;
pub use local_storage::LocalStorage;
pub use navigation::Navigation;
pub use payment::Payment;
pub use permissions::Permissions;
pub use preimage::Preimage;
Expand All @@ -38,6 +40,7 @@ pub trait TrUApi:
+ Entropy
+ JsonRpc
+ LocalStorage
+ Navigation
+ Payment
+ Permissions
+ Preimage
Expand All @@ -58,6 +61,7 @@ impl<T> TrUApi for T where
+ Entropy
+ JsonRpc
+ LocalStorage
+ Navigation
+ Payment
+ Permissions
+ Preimage
Expand Down
105 changes: 105 additions & 0 deletions rust/crates/truapi/src/api/navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Unified [`Navigation`] trait.

use crate::versioned::navigation::{
HostNavigateToError, HostNavigateToRequest, HostNavigateToResponse, HostRouteChangedItem,
HostRouteGetError, HostRouteGetResponse, HostRouteSetError, HostRouteSetRequest,
HostRouteSetResponse,
};
use crate::wire;
use crate::{CallContext, CallError, Subscription};

/// Host navigation surface: external URL opens and the app's own route.
pub trait Navigation: Send + Sync {
/// Request the host to open a URL.
///
/// ```ts
/// import { type Client } from "@parity/truapi";
///
/// export async function navigateToDocs(truapi: Client): Promise<void> {
/// const result = await truapi.navigation.navigateTo({
/// url: "https://example.com",
/// });
///
/// if (result.isErr()) throw result.error;
/// }
/// ```
#[wire(request_id = 6)]
async fn navigate_to(
&self,
cx: &CallContext,
request: HostNavigateToRequest,
) -> Result<HostNavigateToResponse, CallError<HostNavigateToError>>;

/// 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
/// import { type Client } from "@parity/truapi";
///
/// 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 = 134)]
async fn route_get(
&self,
cx: &CallContext,
) -> 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
/// import { type Client } from "@parity/truapi";
///
/// 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 = 136)]
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 Client,
/// 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 = 138)]
async fn route_changed(&self, _cx: &CallContext) -> Subscription<HostRouteChangedItem> {
Subscription::empty()
}
}
27 changes: 3 additions & 24 deletions rust/crates/truapi/src/api/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

use crate::versioned::system::{
HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse,
HostHandshakeError, HostHandshakeRequest, HostHandshakeResponse, HostNavigateToError,
HostNavigateToRequest, HostNavigateToResponse, HostPushNotificationError,
HostHandshakeError, HostHandshakeRequest, HostHandshakeResponse, HostPushNotificationError,
HostPushNotificationRequest, HostPushNotificationResponse,
};
use crate::wire;
use crate::{CallContext, CallError};

/// General-purpose TrUAPI methods for handshake, feature detection,
/// navigation, and notifications.
/// General-purpose TrUAPI methods for handshake, feature detection, and
/// notifications.
pub trait System: Send + Sync {
/// Negotiate the wire codec version with the product.
///
Expand Down Expand Up @@ -82,24 +81,4 @@ pub trait System: Send + Sync {
cx: &CallContext,
request: HostPushNotificationRequest,
) -> Result<HostPushNotificationResponse, CallError<HostPushNotificationError>>;

/// Request the host to open a URL.
///
/// ```ts
/// import { type Client } from "@parity/truapi";
///
/// export async function navigateToDocs(truapi: Client): Promise<void> {
/// const result = await truapi.system.navigateTo({
/// url: "https://example.com",
/// });
///
/// if (result.isErr()) throw result.error;
/// }
/// ```
#[wire(request_id = 6)]
async fn navigate_to(
&self,
cx: &CallContext,
request: HostNavigateToRequest,
) -> Result<HostNavigateToResponse, CallError<HostNavigateToError>>;
}
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 common;
mod entropy;
mod jsonrpc;
mod local_storage;
mod navigation;
mod payment;
mod permissions;
mod preimage;
Expand All @@ -24,6 +25,7 @@ pub use common::*;
pub use entropy::*;
pub use jsonrpc::*;
pub use local_storage::*;
pub use navigation::*;
pub use payment::*;
pub use permissions::*;
pub use preimage::*;
Expand Down
33 changes: 33 additions & 0 deletions rust/crates/truapi/src/v01/navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use parity_scale_codec::{Decode, Encode};

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub enum HostNavigateToError {
PermissionDenied,
Unknown { reason: String },
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostNavigateToRequest {
/// URL to open.
pub url: String,
}

#[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>,
}

#[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,
}

#[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>,
}
12 changes: 0 additions & 12 deletions rust/crates/truapi/src/v01/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ pub enum HostFeatureSupportedRequest {
},
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub enum HostNavigateToError {
PermissionDenied,
Unknown { reason: String },
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostPushNotificationRequest {
/// Notification text.
Expand All @@ -42,9 +36,3 @@ pub struct HostFeatureSupportedResponse {
/// Whether the feature is supported.
pub supported: bool,
}

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct HostNavigateToRequest {
/// URL to open.
pub url: String,
}
8 changes: 5 additions & 3 deletions rust/crates/truapi/src/versioned/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ pub mod chat;
pub mod entropy;
pub mod jsonrpc;
pub mod local_storage;
pub mod navigation;
pub mod payment;
pub mod permissions;
pub mod preimage;
Expand All @@ -109,9 +110,10 @@ mod tests {

#[test]
fn unit_response_roundtrip() {
let original = super::system::HostNavigateToResponse::V1;
let decoded = super::system::HostNavigateToResponse::decode(&mut &original.encode()[..])
.expect("decode");
let original = super::navigation::HostNavigateToResponse::V1;
let decoded =
super::navigation::HostNavigateToResponse::decode(&mut &original.encode()[..])
.expect("decode");
assert_eq!(original, decoded);
}

Expand Down
Loading
Loading