tauri_typed_ipc/bindings.rs
1//! TypeScript client generation.
2//!
3//! `#[procedures]` emits a [`ProcedureSet`] descriptor per trait: each
4//! procedure's wire signature as specta data types, with the receiver
5//! and any injected parameters already stripped. With the `export`
6//! feature, [`Bindings`] renders that descriptor to a TypeScript client
7//! whose calls are raw `invoke`s -- wire parity with the handler, so the
8//! generated file drops in like any hand-written client.
9//!
10//! The client rides specta's own exporter through its `framework_runtime`
11//! hook: tauri_typed_ipc contributes the command wrappers, specta owns the file
12//! header, the type definitions, and the multi-file layout's cross-file
13//! imports.
14
15use specta::Types;
16use specta::datatype::DataType;
17
18/// One procedure's wire signature, lowered to specta data types. The
19/// receiver and any injected parameter (an `AppHandle`, managed `State`)
20/// are gone: only what the client supplies remains -- the named JSON
21/// arguments, plus any streaming [`Channel`](crate::Channel) it passes.
22#[derive(Clone)]
23pub struct ProcedureType {
24 /// Wire command name -- the Rust method's identifier, verbatim.
25 pub name: &'static str,
26 /// Named wire arguments, in declaration order.
27 pub args: Vec<(&'static str, DataType)>,
28 /// Streaming `Channel<T>` parameters, each paired with the value
29 /// type `T` it sends. The client passes a `Channel`, so these stay
30 /// in the binding (rendered as `Channel<T>`), unlike injected
31 /// parameters; the inner `T` is what the descriptor carries.
32 pub channels: Vec<(&'static str, DataType)>,
33 /// Return type -- a `Result`'s success type `T`, or the plain return
34 /// (`()` for no return). The procedure resolves with this.
35 pub output: DataType,
36 /// For a procedure returning `Result<T, E>`, the error `E` it rejects
37 /// with (`E: tauri_typed_ipc::Error`); `None` for an infallible procedure.
38 pub error: Option<ErrorType>,
39}
40
41/// A wire error's shape, generated by `#[derive(tauri_typed_ipc::Error)]` and
42/// referenced by every procedure that rejects it. Each variant
43/// serializes to `{ type, message }`, so the rendered TypeScript type is
44/// a discriminated union over the tags. A built-in error with no variants (the
45/// `String` drop-in) renders inline by `name` (`string`) and emits no
46/// `export type` alias.
47#[derive(Clone)]
48pub struct ErrorType {
49 /// The TypeScript type the rejection is thrown as -- a derived error's enum
50 /// name, or a built-in scalar like `string`.
51 pub name: &'static str,
52 /// The per-variant `type` tags (lower-camel of each variant). Empty marks a
53 /// built-in inline error (`String`), rendered by `name` with no alias.
54 pub variants: Vec<&'static str>,
55}
56
57/// A trait's binding descriptor, generated by `#[procedures]` as the
58/// zero-sized `{Trait}Procedures`. Implemented on a type, not built from
59/// an instance, so a client can be rendered without standing up the
60/// procedure set.
61pub trait ProcedureSet {
62 /// Name of the generated TypeScript object -- lower-camel of the
63 /// trait (`Faders` -> `faders`), or the explicit `namespace`.
64 const OBJECT: &'static str;
65
66 /// An explicit wire namespace from `#[procedures(namespace = "x")]` (or
67 /// the taurpc alias `path`): every command becomes `x.<method>` and the
68 /// object is named `x`. `None` (default) keeps bare, raw-command wire.
69 const NAMESPACE: Option<&'static str> = None;
70
71 /// Collect each procedure's wire signature, registering any named
72 /// types it touches into `types`.
73 fn procedures(types: &mut Types) -> Vec<ProcedureType>;
74}
75
76/// A wire error's binding descriptor, generated by
77/// `#[derive(tauri_typed_ipc::Error)]` and implemented on the enum itself, so a
78/// procedure's `Result<_, E>` return can name the error it rejects with.
79pub trait ErrorSet {
80 /// The error's wire shape: its TypeScript name and variant tags.
81 fn error_type() -> ErrorType;
82}
83
84/// `Result<_, String>` is a drop-in: a `String` error rejects as a bare string
85/// on the wire (matching a raw `#[command]` and TauRPC), and the client types the
86/// rejection as the built-in `string`. So a `String`-error procedure needs no
87/// hand-written error enum -- the taurpc migration path. No variants marks it
88/// inline (see [`ErrorType`]): `@throws {string}`, no `export type` alias.
89impl ErrorSet for String {
90 fn error_type() -> ErrorType {
91 ErrorType {
92 name: "string",
93 variants: Vec::new(),
94 }
95 }
96}
97
98/// One event variant's wire signature, lowered to specta data types.
99#[derive(Clone)]
100pub struct EventType {
101 /// Variant discriminant: the TypeScript `type` tag and the wire
102 /// name's suffix.
103 pub variant: &'static str,
104 /// Wire name, `"{group}:{variant}"`.
105 pub name: &'static str,
106 /// Payload fields in declaration order (empty for unit and single
107 /// tuple-payload variants).
108 pub fields: Vec<(&'static str, DataType)>,
109 /// A single unnamed tuple payload (`V(T)`): rendered as `data: T`
110 /// instead of spread fields. `None` for unit and named variants.
111 pub data: Option<DataType>,
112}
113
114/// An event enum's binding descriptor, generated by `#[derive(Event)]`
115/// and implemented on the enum itself.
116pub trait EventSet {
117 /// The `events` object key -- lower-camel of the enum with a trailing
118 /// `Event` stripped (`FaderEvent` -> `fader`).
119 const GROUP: &'static str;
120 /// The discriminated-union type name -- the enum's name.
121 const NAME: &'static str;
122 /// Collect each variant's payload signature, registering named types.
123 fn events(types: &mut Types) -> Vec<EventType>;
124}
125
126#[cfg(feature = "export")]
127pub use render::{Bindings, BindingsError, MethodCase};
128#[cfg(feature = "export")]
129pub use specta_typescript::Layout;
130
131#[cfg(feature = "export")]
132mod render {
133 use super::{ErrorType, EventSet, EventType, ProcedureSet, ProcedureType};
134 use specta::Types;
135 use specta::datatype::DataType;
136 use specta_typescript::{Error, Exporter, FrameworkExporter, Layout, Typescript};
137 use std::borrow::Cow;
138 use std::collections::{BTreeMap, HashSet};
139 use std::path::{Path, PathBuf};
140 use std::sync::atomic::{AtomicUsize, Ordering};
141
142 /// The "do not edit" banner specta stamps at the top of every file.
143 const HEADER: &str = "// This file has been generated by tauri_typed_ipc. DO NOT EDIT.";
144 const IMPORT_INVOKE: &str = "import { invoke } from \"@tauri-apps/api/core\";\n";
145 /// `Channel` ships from the same module as `invoke`, so a client with
146 /// any streaming procedure imports both in one statement.
147 const IMPORT_INVOKE_CHANNEL: &str =
148 "import { invoke, Channel } from \"@tauri-apps/api/core\";\n";
149 const IMPORT_LISTEN: &str = "import { listen } from \"@tauri-apps/api/event\";\n";
150
151 /// How a generated client casts its TypeScript method names. The wire
152 /// command name and argument keys are always the Rust identifiers
153 /// verbatim (wire parity); argument names in the TypeScript signature
154 /// are always camelCase. Only the method name varies.
155 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
156 pub enum MethodCase {
157 /// `save_scene` -> `saveScene`. Idiomatic TypeScript, and the
158 /// default -- matching the object and event naming.
159 #[default]
160 Camel,
161 /// `save_scene` -> `save_scene`: the Rust identifier verbatim. The
162 /// taurpc drop-in -- taurpc names its proxy methods this way
163 /// (snake_case method, camelCase arguments), so a frontend
164 /// migrating off it keeps its call sites.
165 Snake,
166 }
167
168 /// Renders a TypeScript client for one or more procedure sets: a
169 /// `const` object per set whose methods are wire-parity `invoke`
170 /// calls, plus the specta type definitions they reference. One file
171 /// by default; [`Layout::Files`] splits them into a directory.
172 ///
173 /// Wire types must have a symmetric serde shape (serialize ==
174 /// deserialize). A type using an asymmetric attribute such as
175 /// `skip_serializing_if` errors at export rather than rendering a
176 /// wrong shape; per-direction rendering (args via the deserialize
177 /// phase, output via serialize) is a post-0.1 enhancement.
178 ///
179 /// BigInt-style integers (`i64`, `u64`, `usize`, `i128`, ...) also
180 /// error at export: lossless transport past 2^53 needs an upstream
181 /// tauri invoke reviver hook (responses are parsed with
182 /// `response.json()`, with no hook to add one), so it is post-0.1.
183 #[must_use]
184 pub struct Bindings {
185 types: Types,
186 sets: Vec<Set>,
187 events: Vec<EventGroup>,
188 layout: Layout,
189 method_case: MethodCase,
190 router: Option<Cow<'static, str>>,
191 /// Emit the JSON Schema contract + a pre-invoke check into the client
192 /// (the `validate` feature). Off by default.
193 #[cfg(feature = "validate")]
194 validate: bool,
195 }
196
197 #[derive(Clone)]
198 struct Set {
199 object: &'static str,
200 namespace: Option<&'static str>,
201 procedures: Vec<ProcedureType>,
202 }
203
204 #[derive(Clone)]
205 struct EventGroup {
206 group: &'static str,
207 name: &'static str,
208 variants: Vec<EventType>,
209 }
210
211 impl Bindings {
212 /// Start an empty client, single file by default.
213 pub fn new() -> Self {
214 Self {
215 types: Types::default(),
216 sets: Vec::new(),
217 events: Vec::new(),
218 layout: Layout::default(),
219 method_case: MethodCase::default(),
220 router: None,
221 #[cfg(feature = "validate")]
222 validate: false,
223 }
224 }
225
226 /// Choose the file layout. The default [`Layout::FlatFile`] is one
227 /// file; [`Layout::Files`] splits the types into a directory of
228 /// per-module files with cross-file imports -- write it with
229 /// [`export_to`](Self::export_to).
230 pub fn layout(mut self, layout: Layout) -> Self {
231 self.layout = layout;
232 self
233 }
234
235 /// Choose how generated method names are cased. Defaults to
236 /// [`MethodCase::Camel`]; [`MethodCase::Snake`] is the taurpc
237 /// drop-in. Wire names are unaffected.
238 pub fn method_case(mut self, method_case: MethodCase) -> Self {
239 self.method_case = method_case;
240 self
241 }
242
243 /// Emit a factory grouping every registered set into one nested
244 /// object: `export const {name} = () => ({ set1, set2, ... });`.
245 /// Off by default. This is the taurpc `createTauRPCProxy` drop-in:
246 /// a frontend that did `const taurpc = createTauRPCProxy()` and
247 /// called `taurpc.<set>.method()` keeps its call sites by
248 /// re-pointing that one factory at tauri_typed_ipc's output. Pair with
249 /// [`MethodCase::Snake`] to match taurpc's method names.
250 pub fn router(mut self, name: impl Into<Cow<'static, str>>) -> Self {
251 self.router = Some(name.into());
252 self
253 }
254
255 /// Emit the JSON Schema contract and a pre-invoke check into the
256 /// generated client (the `validate` feature). Off by default.
257 ///
258 /// Each method then validates its arguments before `invoke`, against
259 /// the same per-command JSON Schema the Rust
260 /// [`Validator`](crate::Validator) compiles -- so a malformed call
261 /// fails at the client boundary with a clear error, and both ends check
262 /// one contract. The check is a small self-contained function emitted
263 /// into the file; the client pulls in no runtime dependency.
264 #[cfg(feature = "validate")]
265 pub fn validate(mut self, validate: bool) -> Self {
266 self.validate = validate;
267 self
268 }
269
270 /// Add a procedure set's bindings, named by its generated
271 /// `{Trait}Procedures` handle.
272 pub fn register<P: ProcedureSet>(mut self) -> Self {
273 let procedures = P::procedures(&mut self.types);
274 self.sets.push(Set {
275 object: P::OBJECT,
276 namespace: P::NAMESPACE,
277 procedures,
278 });
279 self
280 }
281
282 /// Add an event enum's bindings, by the enum type. The generated
283 /// `events.{group}.listen` reaches its variants as a discriminated
284 /// union.
285 pub fn register_events<E: EventSet>(mut self) -> Self {
286 let variants = E::events(&mut self.types);
287 self.events.push(EventGroup {
288 group: E::GROUP,
289 name: E::NAME,
290 variants,
291 });
292 self
293 }
294
295 /// Render the client to a single TypeScript string. Errors under
296 /// [`Layout::Files`], which spans multiple files -- use
297 /// [`export_to`](Self::export_to) for that.
298 pub fn export(&self) -> Result<String, BindingsError> {
299 let block = self.validate_block()?;
300 Ok(self
301 .exporter(block)
302 .export(&self.types, specta_serde::Format)?)
303 }
304
305 /// Write the client to disk: a single file under
306 /// [`Layout::FlatFile`], or a directory of per-module files under
307 /// [`Layout::Files`].
308 pub fn export_to(&self, path: impl AsRef<Path>) -> Result<(), BindingsError> {
309 let block = self.validate_block()?;
310 self.exporter(block)
311 .export_to(path, &self.types, specta_serde::Format)?;
312 Ok(())
313 }
314
315 /// Check the rendered client against what is committed at `path`:
316 /// `Ok(())` if they match, [`BindingsError::Drift`] if they differ,
317 /// [`BindingsError::Read`] on an I/O failure (a missing file, an
318 /// unreadable directory).
319 ///
320 /// This is the consumer's CI drift guard against the tax of stale
321 /// bindings -- local `cargo` and `tsc` both pass while nothing yet
322 /// consumes a new field, so drift slips through without it. Call it
323 /// in a test; pair it with [`export_to`](Self::export_to) behind an
324 /// env var or flag to regenerate on demand. A single-file layout
325 /// compares against the file at `path`; [`Layout::Files`] renders to
326 /// a temporary directory and compares the tree against the directory
327 /// at `path`.
328 pub fn check(&self, path: impl AsRef<Path>) -> Result<(), BindingsError> {
329 let path = path.as_ref();
330 if self.layout == Layout::Files {
331 return self.check_files(path);
332 }
333 let generated = self.export()?;
334 let committed = std::fs::read_to_string(path)
335 .map_err(|source| BindingsError::read(path, source))?;
336 if generated == committed {
337 Ok(())
338 } else {
339 Err(BindingsError::Drift {
340 path: path.to_path_buf(),
341 })
342 }
343 }
344
345 /// The [`Layout::Files`] arm of [`check`](Self::check): render to a
346 /// temporary directory (specta only writes multi-file output to
347 /// disk), then compare that tree against the committed directory.
348 fn check_files(&self, path: &Path) -> Result<(), BindingsError> {
349 let temp = TempDir::new().map_err(|source| BindingsError::read(path, source))?;
350 self.export_to(temp.path())?;
351 let generated = read_tree(temp.path())
352 .map_err(|source| BindingsError::read(temp.path(), source))?;
353 let committed = read_tree(path).map_err(|source| BindingsError::read(path, source))?;
354 if generated == committed {
355 Ok(())
356 } else {
357 Err(BindingsError::Drift {
358 path: path.to_path_buf(),
359 })
360 }
361 }
362
363 /// The specta exporter that carries tauri_typed_ipc's command wrappers
364 /// (the `framework_runtime` hook) and the chosen layout.
365 fn exporter(&self, validate_block: Option<String>) -> Exporter {
366 let sets = self.sets.clone();
367 let events = self.events.clone();
368 let method_case = self.method_case;
369 let router = self.router.clone();
370 Exporter::from(Typescript::new())
371 .framework_prelude(HEADER)
372 .layout(self.layout)
373 .framework_runtime(move |exporter| {
374 render_runtime(
375 &sets,
376 &events,
377 method_case,
378 router.as_deref(),
379 validate_block.as_deref(),
380 &exporter,
381 )
382 })
383 }
384
385 /// The pre-invoke check block -- the validator function plus the
386 /// per-command JSON Schemas -- or `None` when validation is off. Built
387 /// from the same descriptor the client is rendered from, so what the
388 /// client checks against cannot drift from the types it declares.
389 fn validate_block(&self) -> Result<Option<String>, BindingsError> {
390 #[cfg(feature = "validate")]
391 if self.validate {
392 let sets: Vec<crate::validate::SchemaSet<'_>> = self
393 .sets
394 .iter()
395 .map(|set| crate::validate::SchemaSet {
396 namespace: set.namespace,
397 procedures: &set.procedures,
398 })
399 .collect();
400 let schemas = crate::validate::build_command_schemas(self.types.clone(), &sets)?;
401 return Ok(Some(render_validate_block(&schemas)));
402 }
403 Ok(None)
404 }
405 }
406
407 impl Default for Bindings {
408 fn default() -> Self {
409 Self::new()
410 }
411 }
412
413 /// The framework-runtime body: the runtime imports, one `invoke`
414 /// object per procedure set, then the event union types and the
415 /// `events` listener object. specta appends the referenced type
416 /// definitions after this.
417 fn render_runtime(
418 sets: &[Set],
419 events: &[EventGroup],
420 method_case: MethodCase,
421 router: Option<&str>,
422 validate_block: Option<&str>,
423 exporter: &FrameworkExporter,
424 ) -> Result<Cow<'static, str>, Error> {
425 let validate = validate_block.is_some();
426 let mut out = String::new();
427 if !sets.is_empty() {
428 let any_channels = sets
429 .iter()
430 .flat_map(|set| &set.procedures)
431 .any(|proc| !proc.channels.is_empty());
432 out.push_str(if any_channels {
433 IMPORT_INVOKE_CHANNEL
434 } else {
435 IMPORT_INVOKE
436 });
437 }
438 if !events.is_empty() {
439 out.push_str(IMPORT_LISTEN);
440 }
441 // The validator and the per-command schemas, once, ahead of the set
442 // objects whose methods call into them.
443 if let Some(block) = validate_block {
444 out.push('\n');
445 out.push_str(block);
446 }
447 for set in sets {
448 out.push('\n');
449 out.push_str(&render_object(set, method_case, validate, exporter)?);
450 }
451 for error in collect_errors(sets) {
452 out.push('\n');
453 out.push_str(&render_error(error));
454 }
455 for group in events {
456 out.push('\n');
457 out.push_str(&render_union(group, exporter)?);
458 }
459 if !events.is_empty() {
460 out.push('\n');
461 out.push_str(&render_events(events, exporter)?);
462 }
463 // The factory renders last so it can reference both the set objects and
464 // the `events` object by name (resolved when the factory is called).
465 if let Some(name) = router
466 && (!sets.is_empty() || !events.is_empty())
467 {
468 out.push('\n');
469 out.push_str(&render_router(name, sets, events));
470 }
471 Ok(Cow::Owned(out))
472 }
473
474 fn render_object(
475 set: &Set,
476 method_case: MethodCase,
477 validate: bool,
478 exporter: &FrameworkExporter,
479 ) -> Result<String, Error> {
480 let mut methods = Vec::with_capacity(set.procedures.len());
481 for proc in &set.procedures {
482 // Arg names are camelCase in the signature; the wire keys stay
483 // the Rust identifiers, so the invoke object maps one to the
484 // other (shorthand when they coincide -- a single-word name).
485 let count = proc.args.len() + proc.channels.len();
486 let mut params = Vec::with_capacity(count);
487 let mut fields = Vec::with_capacity(count);
488 for (wire, ty) in &proc.args {
489 let param = to_camel(wire);
490 params.push(format!("{param}: {}", render_ty(exporter, ty)?));
491 if ¶m == wire {
492 fields.push((*wire).to_string());
493 } else {
494 fields.push(format!("{wire}: {param}"));
495 }
496 }
497 // A `Channel<T>` is passed by the client like any argument
498 // (it serializes to its id on the wire), but renders as
499 // `Channel<T>`. Channels come after the plain arguments.
500 for (wire, ty) in &proc.channels {
501 let param = to_camel(wire);
502 params.push(format!("{param}: Channel<{}>", render_ty(exporter, ty)?));
503 if ¶m == wire {
504 fields.push((*wire).to_string());
505 } else {
506 fields.push(format!("{wire}: {param}"));
507 }
508 }
509 let ret = render_ty(exporter, &proc.output)?;
510 let wire = match set.namespace {
511 Some(ns) => format!("{ns}.{}", proc.name),
512 None => proc.name.to_string(),
513 };
514 // The argument object, shared by the invoke call and the
515 // pre-invoke check so both see the same wire keys.
516 let args_obj = if count == 0 {
517 "{}".to_string()
518 } else {
519 format!("{{ {} }}", fields.join(", "))
520 };
521 let call = if count == 0 {
522 format!("invoke(\"{wire}\")")
523 } else {
524 format!("invoke(\"{wire}\", {args_obj})")
525 };
526 // With validation on, check the arguments against the command's
527 // schema before the call, so a bad payload fails at the boundary
528 // rather than after a round-trip.
529 let guard = if validate {
530 format!(" __ttipcValidate(\"{wire}\", {args_obj});\n")
531 } else {
532 String::new()
533 };
534 let method = match method_case {
535 MethodCase::Camel => to_camel(proc.name),
536 MethodCase::Snake => proc.name.to_string(),
537 };
538 // A fallible procedure rejects with its typed error; the
539 // signature stays `Promise<T>` (wire parity -- it rejects),
540 // and a `@throws` ties it to the emitted error union.
541 let throws = match &proc.error {
542 Some(error) => format!(" /** @throws {{{}}} */\n", error.name),
543 None => String::new(),
544 };
545 methods.push(format!(
546 "{throws} {method}({params}): Promise<{ret}> {{\n{guard} return {call};\n }}",
547 params = params.join(", "),
548 ));
549 }
550 Ok(format!(
551 "export const {} = {{\n{},\n}};\n",
552 set.object,
553 methods.join(",\n\n"),
554 ))
555 }
556
557 /// The pre-invoke check block: the per-command JSON Schemas the client was
558 /// generated from, plus a small self-contained validator over the subset of
559 /// JSON Schema those documents use. No runtime dependency -- the client
560 /// stays dep-light. Each command's schema is self-contained (it carries the
561 /// definitions it references), so the validator needs no shared registry.
562 #[cfg(feature = "validate")]
563 fn render_validate_block(schemas: &[(String, serde_json::Value)]) -> String {
564 let mut entries = String::new();
565 for (wire, schema) in schemas {
566 // Infallible: a wire name is a string and the schema is a Value we
567 // just built, both of which always serialize to JSON.
568 let key = serde_json::to_string(wire).expect("a wire name serializes");
569 let value = serde_json::to_string(schema).expect("a schema serializes");
570 entries.push_str(" ");
571 entries.push_str(&key);
572 entries.push_str(": ");
573 entries.push_str(&value);
574 entries.push_str(",\n");
575 }
576 format!("const __ttipcSchemas: Record<string, any> = {{\n{entries}}};\n\n{VALIDATOR_TS}")
577 }
578
579 /// The self-contained argument validator emitted alongside the schemas. A
580 /// pared-down JSON Schema checker: it follows `$ref` into the embedded
581 /// definitions and enforces `type`, `properties`/`required`, `items`, and
582 /// `enum`; unions and nullable shapes are accepted as-is, so it never
583 /// rejects a valid payload, only flags a clearly wrong one.
584 #[cfg(feature = "validate")]
585 const VALIDATOR_TS: &str = r#"function __ttipcDefs(root: any): Record<string, any> {
586 return (root.$defs ?? root.definitions ?? {}) as Record<string, any>;
587}
588
589function __ttipcCheck(value: unknown, schema: any, root: any, path: string): string | null {
590 if (schema === true || schema === undefined || schema === null) return null;
591 if (typeof schema.$ref === "string") {
592 const name = schema.$ref.split("/").pop() as string;
593 return __ttipcCheck(value, __ttipcDefs(root)[name], root, path);
594 }
595 if (schema.oneOf || schema.anyOf || schema.allOf || Array.isArray(schema.type)) {
596 return null;
597 }
598 if (Array.isArray(schema.enum) && !schema.enum.some((v: unknown) => v === value)) {
599 return `${path} is not an allowed value`;
600 }
601 const type: unknown = schema.type;
602 if (type === "object" || schema.properties || schema.required) {
603 if (typeof value !== "object" || value === null || Array.isArray(value)) {
604 return `${path} must be an object`;
605 }
606 const obj = value as Record<string, unknown>;
607 for (const key of (schema.required ?? []) as string[]) {
608 if (!(key in obj)) return `${path}.${key} is required`;
609 }
610 const props = schema.properties ?? {};
611 for (const key of Object.keys(props)) {
612 if (key in obj) {
613 const err = __ttipcCheck(obj[key], props[key], root, `${path}.${key}`);
614 if (err !== null) return err;
615 }
616 }
617 return null;
618 }
619 if (type === "array") {
620 if (!Array.isArray(value)) return `${path} must be an array`;
621 if (schema.items) {
622 for (let i = 0; i < value.length; i++) {
623 const err = __ttipcCheck(value[i], schema.items, root, `${path}[${i}]`);
624 if (err !== null) return err;
625 }
626 }
627 return null;
628 }
629 if (type === "string" && typeof value !== "string") return `${path} must be a string`;
630 if ((type === "integer" || type === "number") && typeof value !== "number") {
631 return `${path} must be a number`;
632 }
633 if (type === "integer" && typeof value === "number" && !Number.isInteger(value)) {
634 return `${path} must be an integer`;
635 }
636 if (type === "boolean" && typeof value !== "boolean") return `${path} must be a boolean`;
637 if (type === "null" && value !== null) return `${path} must be null`;
638 return null;
639}
640
641function __ttipcValidate(command: string, args: unknown): void {
642 const schema = __ttipcSchemas[command];
643 if (schema === undefined) return;
644 const error = __ttipcCheck(args, schema, schema, "arguments");
645 if (error !== null) {
646 throw new Error(`invalid arguments for ${command}: ${error}`);
647 }
648}
649"#;
650
651 /// A factory grouping every registered set into one nested object --
652 /// the taurpc `createTauRPCProxy` drop-in. A frontend that did
653 /// `const x = name(); x.<set>.method()` keeps its call sites by
654 /// re-pointing that one factory at tauri_typed_ipc's output.
655 fn render_router(name: &str, sets: &[Set], events: &[EventGroup]) -> String {
656 let has_events = |ns: &str| events.iter().any(|group| group.group == ns);
657 let mut entries = Vec::with_capacity(sets.len() + events.len());
658 // A method namespace; if an event group shares its name, expose its
659 // listener at `<ns>.event.on` (the taurpc shape) beside the methods.
660 for set in sets {
661 if has_events(set.object) {
662 entries.push(format!(
663 " {obj}: {{ ...{obj}, event: {{ on: events.{obj}.listen }} }}",
664 obj = set.object,
665 ));
666 } else {
667 entries.push(format!(" {}", set.object));
668 }
669 }
670 // Event groups with no matching method set get an events-only entry.
671 for group in events {
672 if !sets.iter().any(|set| set.object == group.group) {
673 entries.push(format!(
674 " {grp}: {{ event: {{ on: events.{grp}.listen }} }}",
675 grp = group.group,
676 ));
677 }
678 }
679 format!(
680 "export const {name} = () => ({{\n{},\n}});\n",
681 entries.join(",\n")
682 )
683 }
684
685 /// A snake_case Rust identifier as camelCase: `save_scene` ->
686 /// `saveScene`, `set` -> `set`. Each `_` is dropped and the next
687 /// letter uppercased.
688 fn to_camel(snake: &str) -> String {
689 let mut out = String::with_capacity(snake.len());
690 let mut upper_next = false;
691 for ch in snake.chars() {
692 if ch == '_' {
693 upper_next = true;
694 } else if upper_next {
695 out.extend(ch.to_uppercase());
696 upper_next = false;
697 } else {
698 out.push(ch);
699 }
700 }
701 out
702 }
703
704 /// The unique error types rejected by any procedure across all sets,
705 /// in first-seen order so the output is deterministic.
706 fn collect_errors(sets: &[Set]) -> Vec<&ErrorType> {
707 let mut seen = HashSet::new();
708 let mut errors = Vec::new();
709 for set in sets {
710 for proc in &set.procedures {
711 if let Some(error) = &proc.error
712 // A built-in inline error (`string`) renders by name with no
713 // alias, so it never reaches the runtime `export type` section.
714 && !error.variants.is_empty()
715 && seen.insert(error.name)
716 {
717 errors.push(error);
718 }
719 }
720 }
721 errors
722 }
723
724 /// One error's discriminated union: `{ type: "<tag>"; message: string
725 /// }` per variant. Every variant serializes to `{ type, message }`
726 /// (the Error derive's wire shape), so only the tag varies.
727 fn render_error(error: &ErrorType) -> String {
728 let members: Vec<String> = error
729 .variants
730 .iter()
731 .map(|tag| format!("{{ type: \"{tag}\"; message: string }}"))
732 .collect();
733 format!("export type {} = {};\n", error.name, members.join(" | "))
734 }
735
736 /// Render one wire type to its TypeScript form. A top-level named
737 /// type goes through `reference` (its name, plus an import in the
738 /// multi-file layout); everything else inlines, tracking any named
739 /// types nested inside.
740 fn render_ty(exporter: &FrameworkExporter, ty: &DataType) -> Result<String, Error> {
741 match ty {
742 DataType::Reference(reference) => exporter.reference(reference),
743 other => exporter.inline(other),
744 }
745 }
746
747 /// The discriminated union for an event group: one
748 /// `{ type: "variant"; ...fields }` member per variant.
749 fn render_union(group: &EventGroup, exporter: &FrameworkExporter) -> Result<String, Error> {
750 let mut members = Vec::with_capacity(group.variants.len());
751 for variant in &group.variants {
752 let mut parts = vec![format!("type: \"{}\"", variant.variant)];
753 if let Some(data) = &variant.data {
754 parts.push(format!("data: {}", render_ty(exporter, data)?));
755 } else {
756 parts.extend(render_fields(variant, exporter)?);
757 }
758 members.push(format!("{{ {} }}", parts.join("; ")));
759 }
760 Ok(format!(
761 "export type {} = {};\n",
762 group.name,
763 members.join(" | ")
764 ))
765 }
766
767 /// The `events` object: per group a `listen` that forwards the
768 /// discriminated union. A single-variant group returns its one
769 /// subscription directly -- Tauri's `UnlistenFn` is already
770 /// `() => void`, so no wrapper is needed. A multi-variant group fans
771 /// out: it awaits every subscription, then returns one unlisten that
772 /// drops them all.
773 fn render_events(events: &[EventGroup], exporter: &FrameworkExporter) -> Result<String, Error> {
774 let mut groups = Vec::with_capacity(events.len());
775 for group in events {
776 let mut listeners = Vec::with_capacity(group.variants.len());
777 for variant in &group.variants {
778 let payload = render_payload(variant, exporter)?;
779 let forward = if variant.data.is_some() {
780 format!(
781 "(event) => callback({{ type: \"{}\", data: event.payload }})",
782 variant.variant,
783 )
784 } else if variant.fields.is_empty() {
785 format!("() => callback({{ type: \"{}\" }})", variant.variant)
786 } else {
787 format!(
788 "(event) => callback({{ type: \"{}\", ...event.payload }})",
789 variant.variant,
790 )
791 };
792 listeners.push(format!(
793 "listen<{payload}>(\"{name}\", {forward})",
794 name = variant.name,
795 ));
796 }
797 let body = if let [only] = listeners.as_slice() {
798 format!(
799 " listen(callback: (event: {name}) => void): \
800 Promise<() => void> {{\n return {only};\n }},",
801 name = group.name,
802 )
803 } else {
804 let calls = listeners
805 .iter()
806 .map(|call| format!(" {call},"))
807 .collect::<Vec<_>>()
808 .join("\n");
809 format!(
810 " async listen(callback: (event: {name}) => void): \
811 Promise<() => void> {{\n const unlisten = await Promise.all([\n{calls}\n \
812 ]);\n return () => unlisten.forEach((un) => un());\n }},",
813 name = group.name,
814 )
815 };
816 groups.push(format!(" {group}: {{\n{body}\n }}", group = group.group));
817 }
818 Ok(format!(
819 "export const events = {{\n{},\n}};\n",
820 groups.join(",\n\n"),
821 ))
822 }
823
824 /// A variant's TypeScript field list: `["channel: number", ...]`.
825 fn render_fields(
826 variant: &EventType,
827 exporter: &FrameworkExporter,
828 ) -> Result<Vec<String>, Error> {
829 variant
830 .fields
831 .iter()
832 .map(|(name, ty)| Ok(format!("{name}: {}", render_ty(exporter, ty)?)))
833 .collect()
834 }
835
836 /// A variant's wire payload type: an object of its fields, or `null`
837 /// for a unit variant.
838 fn render_payload(variant: &EventType, exporter: &FrameworkExporter) -> Result<String, Error> {
839 if let Some(data) = &variant.data {
840 return render_ty(exporter, data);
841 }
842 let fields = render_fields(variant, exporter)?;
843 if fields.is_empty() {
844 Ok("null".to_string())
845 } else {
846 Ok(format!("{{ {} }}", fields.join("; ")))
847 }
848 }
849
850 /// Failure rendering or checking the TypeScript client.
851 #[derive(Debug, thiserror::Error)]
852 pub enum BindingsError {
853 /// specta-typescript could not render a type.
854 #[error("rendering TypeScript failed: {0}")]
855 Render(#[from] Error),
856 /// Building the JSON Schema contract for the pre-invoke check failed
857 /// (the `validate` feature).
858 #[cfg(feature = "validate")]
859 #[error(transparent)]
860 Validate(#[from] crate::validate::ValidatorError),
861 /// An I/O failure during [`check`](Bindings::check) -- a missing or
862 /// unreadable committed client, or the temporary render it compares
863 /// against under [`Layout::Files`].
864 #[error("checking the client at {path:?} failed: {source}")]
865 Read {
866 /// The path involved in the failure.
867 path: PathBuf,
868 /// The underlying I/O error.
869 #[source]
870 source: std::io::Error,
871 },
872 /// [`check`](Bindings::check) found the committed client out of date.
873 #[error("the committed client at {path:?} is out of date; regenerate it")]
874 Drift {
875 /// The path whose contents drifted from the generator.
876 path: PathBuf,
877 },
878 }
879
880 impl BindingsError {
881 /// An I/O failure touching `path`.
882 fn read(path: &Path, source: std::io::Error) -> Self {
883 Self::Read {
884 path: path.to_path_buf(),
885 source,
886 }
887 }
888 }
889
890 /// A uniquely-named temporary directory, removed on drop. Used by
891 /// [`Bindings::check`] to render a [`Layout::Files`] client somewhere
892 /// disposable before comparing it against the committed tree.
893 struct TempDir(PathBuf);
894
895 impl TempDir {
896 fn new() -> std::io::Result<Self> {
897 static COUNTER: AtomicUsize = AtomicUsize::new(0);
898 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
899 let dir = std::env::temp_dir().join(format!(
900 "tauri_typed_ipc-check-{}-{unique}",
901 std::process::id()
902 ));
903 std::fs::create_dir_all(&dir)?;
904 Ok(Self(dir))
905 }
906
907 fn path(&self) -> &Path {
908 &self.0
909 }
910 }
911
912 impl Drop for TempDir {
913 fn drop(&mut self) {
914 let _ = std::fs::remove_dir_all(&self.0);
915 }
916 }
917
918 /// Every file under `root`, keyed by its path relative to `root`, so two
919 /// rendered trees compare by structure and content regardless of where
920 /// each is rooted.
921 fn read_tree(root: &Path) -> std::io::Result<BTreeMap<PathBuf, String>> {
922 let mut files = BTreeMap::new();
923 let mut stack = vec![root.to_path_buf()];
924 while let Some(dir) = stack.pop() {
925 for entry in std::fs::read_dir(&dir)? {
926 let path = entry?.path();
927 if path.is_dir() {
928 stack.push(path);
929 } else {
930 let rel = path
931 .strip_prefix(root)
932 .expect("entry is under root")
933 .to_path_buf();
934 files.insert(rel, std::fs::read_to_string(&path)?);
935 }
936 }
937 }
938 Ok(files)
939 }
940}