Skip to main content

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 &param == 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 &param == 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}