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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion js/packages/truapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ sub.unsubscribe();
- **Generated domain clients and types** produced from the Rust API contract.
- **SCALE codec helpers** used by the generated code, also re-exported for direct use.
- **Sandbox bootstrap** (`@parity/truapi/sandbox`) that detects the host environment, builds the
matching provider, and exposes a cached client see below.
matching provider, and exposes a cached client - see below.

## Sandbox bootstrap

Expand Down Expand Up @@ -120,6 +120,27 @@ This is what makes a real host usable from an ordinary browser tab during develo
transport without the sandbox's caching and detection, `createWebSocketProvider(url)` from the
package root returns the bare `WireProvider`.

## Observability / debugging

The debugger does not live in this package, and the product transport carries no debug seam -
`@parity/truapi` is genuinely untouched by observability. The host taps every product↔host frame in
its Rust core (`truapi-server`'s `DebugSink`) and streams each one as opaque bytes to a separate
debugger app, which decodes and groups them.

- The tap: `DebugSink` in `rust/crates/truapi-server/src/host_core.rs`, unset by default. It is read
at two choke points — inbound before the frame is decoded, outbound after the product's copy is
sent — and is fire-and-forget, so an absent or slow debugger loses traces, never a session.
- Topology: the host always dials the debugger, over `ws://` on a loopback host **only**. `wss://`,
certificates, and non-loopback targets are rejected by the native dial gate
(`truapi-server/src/native_debug.rs`), which requires every resolved address to be loopback and
dials the addresses it checked.

The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value)
stays here, since it is generated from this package's contract. It is the decode source a debugger
uses to render frame values, kept off the package barrel so importing `@parity/truapi` never pulls a
decoder into a product bundle. `@parity/truapi` itself never decodes payloads — the envelope decode it
does expose (`decodeWireMessage`: `requestId`, frame id) carries no payload value.

## Wire format

Frames are SCALE encoded:
Expand Down
4 changes: 4 additions & 0 deletions js/packages/truapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"types": "./dist/generated/wire-table.d.ts",
"import": "./dist/generated/wire-table.js"
},
"./wire-decode": {
"types": "./dist/generated/wire-decode.d.ts",
Comment thread
decrypto21 marked this conversation as resolved.
"import": "./dist/generated/wire-decode.js"
},
"./playground/services": {
"types": "./dist/playground/codegen/services.d.ts",
"import": "./dist/playground/codegen/services.js"
Expand Down
1 change: 1 addition & 0 deletions js/packages/truapi/scripts/ensure-generated.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ codegen_required=(
"js/packages/truapi/src/generated/client.ts"
"js/packages/truapi/src/generated/types.ts"
"js/packages/truapi/src/generated/wire-table.ts"
"js/packages/truapi/src/generated/wire-decode.ts"
"js/packages/truapi/src/playground/codegen/services.ts"
"js/packages/truapi/src/explorer/codegen/types.ts"
"js/packages/truapi/src/explorer/versions.ts"
Expand Down
11 changes: 10 additions & 1 deletion rust/crates/truapi-codegen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,16 @@ fn main() -> Result<()> {
println!("Generated client examples in {path}");
}
if let Some(path) = &cli.rust_output {
rust::generate(&api, path)
// The Rust routing table (wire_table.rs) is version-*unfiltered* - the
// native host can route any method the crate defines - so its stamp hashes
// the full/latest table, not the client-pinned subset. Otherwise a
// `--client-version`-pinned build would route a newer #[wire(sensitive)]
// frame under an older hash that a same-pinned debugger would accept and
// decode. At the default (latest) client version this equals the TS hash.
let schema_hash =
ts::wire_schema_hash(&api, ts::latest_wire_version(&api), cli.codec_version)
.context("computing wire schema hash")?;
rust::generate(&api, path, &schema_hash)
.with_context(|| format!("writing Rust dispatcher to {}", path.display()))?;
println!("Wrote Rust dispatcher to {}", path.display());
}
Expand Down
49 changes: 36 additions & 13 deletions rust/crates/truapi-codegen/src/rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ pub use wasm_bridge::generate_wasm_bridge;
pub use wire_table::generate_wire_table;

/// Generates the Rust wire dispatcher and wire-table sources into `output_dir`.
pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> {
pub fn generate(api: &ApiDefinition, output_dir: &Path, schema_hash: &str) -> Result<()> {
fs::create_dir_all(output_dir)?;
let dispatcher = generate_dispatcher(api)?;
fs::write(output_dir.join("dispatcher.rs"), dispatcher)?;
let wire_table = generate_wire_table(api)?;
let wire_table = generate_wire_table(api, schema_hash)?;
fs::write(output_dir.join("wire_table.rs"), wire_table)?;
Ok(())
}
Expand Down Expand Up @@ -158,6 +158,7 @@ mod tests {
stop_id: None,
interrupt_id: None,
receive_id: None,
sensitive: false,
},
docs: None,
}
Expand All @@ -180,6 +181,7 @@ mod tests {
stop_id: None,
interrupt_id: None,
receive_id: None,
sensitive: false,
},
docs: None,
}
Expand All @@ -197,6 +199,7 @@ mod tests {
args: vec![],
}]),
docs: None,
codec_index: None,
}]),
docs: None,
}
Expand Down Expand Up @@ -275,9 +278,10 @@ mod tests {
}],
public_trait_order: vec!["Account".to_string()],
types: vec![],
framework_types: Vec::new(),
};

let src = generate_wire_table(&api).expect("generate_wire_table");
let src = generate_wire_table(&api, "testhash").expect("generate_wire_table");
let entries = parse_entries(&src);
assert_eq!(
entries,
Expand Down Expand Up @@ -313,6 +317,7 @@ mod tests {
],
public_trait_order: vec!["StatementStore".to_string(), "Preimage".to_string()],
types: versioned_request_test_types(),
framework_types: Vec::new(),
};

let dispatcher = generate_dispatcher(&api).expect("dispatcher");
Expand All @@ -325,7 +330,7 @@ mod tests {
"dispatcher missing prefixed Preimage const:\n{dispatcher}"
);

let table = generate_wire_table(&api).expect("wire_table");
let table = generate_wire_table(&api, "testhash").expect("wire_table");
let entries = parse_entries(&table);
assert!(
entries
Expand Down Expand Up @@ -365,8 +370,10 @@ mod tests {
],
public_trait_order: vec!["Foo".to_string(), "FooBar".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("duplicate wire method name must error");
let err = generate_wire_table(&api, "testhash")
.expect_err("duplicate wire method name must error");
let msg = format!("{err}");
assert!(
msg.contains("wire method name `foo_bar_baz` reused"),
Expand Down Expand Up @@ -394,14 +401,15 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: versioned_request_test_types(),
framework_types: Vec::new(),
};

let dispatcher_a = generate_dispatcher(&api).expect("dispatcher a");
let dispatcher_b = generate_dispatcher(&api).expect("dispatcher b");
assert_eq!(dispatcher_a, dispatcher_b);

let table_a = generate_wire_table(&api).expect("wire_table a");
let table_b = generate_wire_table(&api).expect("wire_table b");
let table_a = generate_wire_table(&api, "testhash").expect("wire_table a");
let table_b = generate_wire_table(&api, "testhash").expect("wire_table b");
assert_eq!(table_a, table_b);
}

Expand All @@ -423,8 +431,9 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("duplicate ids must error");
let err = generate_wire_table(&api, "testhash").expect_err("duplicate ids must error");
let msg = format!("{err}");
assert!(
msg.contains("wire id 10 reused"),
Expand Down Expand Up @@ -486,9 +495,10 @@ mod tests {
}],
public_trait_order: vec!["Example".to_string()],
types: Vec::new(),
framework_types: Vec::new(),
};

let error = generate_wire_table(&api)
let error = generate_wire_table(&api, "testhash")
.expect_err(&format!("{method_name} must not allocate wire id 255"));
let message = error.to_string();
assert!(
Expand Down Expand Up @@ -545,8 +555,10 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("request kind + start_id must error");
let err =
generate_wire_table(&api, "testhash").expect_err("request kind + start_id must error");
let msg = format!("{err}");
assert!(
msg.contains("must not use subscription wire ids"),
Expand All @@ -568,8 +580,10 @@ mod tests {
}],
public_trait_order: vec!["Account".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("subscription kind + request_id must error");
let err = generate_wire_table(&api, "testhash")
.expect_err("subscription kind + request_id must error");
let msg = format!("{err}");
assert!(
msg.contains("must not use request wire ids"),
Expand All @@ -592,8 +606,10 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("missing request_id annotation must error");
let err = generate_wire_table(&api, "testhash")
.expect_err("missing request_id annotation must error");
let msg = format!("{err}");
assert!(
msg.contains("missing #[wire(request_id"),
Expand All @@ -615,8 +631,10 @@ mod tests {
}],
public_trait_order: vec!["Account".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_wire_table(&api).expect_err("missing start_id annotation must error");
let err = generate_wire_table(&api, "testhash")
.expect_err("missing start_id annotation must error");
let msg = format!("{err}");
assert!(
msg.contains("missing #[wire(start_id"),
Expand Down Expand Up @@ -646,6 +664,7 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_dispatcher(&api).expect_err("two-param method must error");
let msg = format!("{err}");
Expand Down Expand Up @@ -679,6 +698,7 @@ mod tests {
}],
public_trait_order: vec!["Permissions".to_string()],
types: vec![],
framework_types: Vec::new(),
};
let err = generate_dispatcher(&api).expect_err("primitive response must error");
let msg = format!("{err}");
Expand Down Expand Up @@ -716,6 +736,7 @@ mod tests {
versioned_test_type("ReqWrapper"),
versioned_test_type("RespWrapper"),
],
framework_types: Vec::new(),
};

let err = generate_dispatcher(&api).expect_err("raw error wrapper must error");
Expand Down Expand Up @@ -745,6 +766,7 @@ mod tests {
versioned_test_type("RespWrapper"),
versioned_test_type("ErrWrapper"),
],
framework_types: Vec::new(),
};

let err = generate_dispatcher(&api).expect_err("missing target version must error");
Expand Down Expand Up @@ -781,6 +803,7 @@ mod tests {
}],
public_trait_order: vec!["Account".to_string()],
types: vec![versioned_test_type("ItemWrapper")],
framework_types: Vec::new(),
};

let err = generate_dispatcher(&api).expect_err("raw result subscription error must error");
Expand Down
22 changes: 18 additions & 4 deletions rust/crates/truapi-codegen/src/rust/wire_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ enum MethodEntry {
Subscription(SubEntry),
}

/// Emit the contents of `wire_table.rs`.
pub fn generate_wire_table(api: &ApiDefinition) -> Result<String> {
/// Emit the contents of `wire_table.rs`. `schema_hash` is the wire-contract
/// fingerprint emitted as `TRUAPI_WIRE_SCHEMA_HASH`, identical to the TS client's.
pub fn generate_wire_table(api: &ApiDefinition, schema_hash: &str) -> Result<String> {
let mut method_entries: Vec<(String, MethodEntry)> = Vec::new();
let mut seen = BTreeMap::from([(
RESERVED_PROTOCOL_ERROR_ID,
Expand Down Expand Up @@ -72,7 +73,7 @@ pub fn generate_wire_table(api: &ApiDefinition) -> Result<String> {
MethodEntry::Subscription(SubEntry { start_id, .. }) => *start_id,
});

render(&method_entries)
render(&method_entries, schema_hash)
}

fn method_entry(trait_def: &TraitDef, method: &MethodDef) -> Result<MethodEntry> {
Expand Down Expand Up @@ -173,7 +174,7 @@ fn insert_entry(
Ok(())
}

fn render(methods: &[(String, MethodEntry)]) -> Result<String> {
fn render(methods: &[(String, MethodEntry)], schema_hash: &str) -> Result<String> {
let mut out = String::new();
writedoc!(
out,
Expand Down Expand Up @@ -229,6 +230,19 @@ fn render(methods: &[(String, MethodEntry)]) -> Result<String> {
)
.unwrap();

writedoc!(
out,
r#"
/// Fingerprint of this build's wire contract: frame ids, method legs,
/// sensitivity, and codec version, identical to the TS client's
/// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so
/// the debugger refuses to decode a frame whose contract differs from
/// its own, even when the coarse handshake codec version is unchanged.
pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "{schema_hash}";
"#
)
.unwrap();

// Per-method consts: the single source of truth for each method's ids.
for (name, entry) in methods {
let konst = const_name(name);
Expand Down
Loading