Skip to main content

ssg/plugins/
rpc_schema.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Edge RPC schema emitter (issue #548 AC1 + AC4).
5//!
6//! Walks the `ssg_rpc` dispatch inventory at build time and writes a
7//! TypeScript declaration file at `dist/.ssg/rpc.d.ts`. The matching
8//! JS client (`web/rpc.js`) consumes that file via a TS users
9//! import.
10//!
11//! ## Wiring
12//!
13//! The plugin is **registered unconditionally** in `Plugins::build`,
14//! but it is a no-op when zero `#[ssg_rpc]`-annotated functions are
15//! reachable from the binary (which is the case for `ssg` itself —
16//! users add the macro in their own crates that pull `ssg-rpc`).
17//!
18//! ## Why a plugin and not a `build.rs`?
19//!
20//! Because the inventory only contains functions that are reachable
21//! from the **final binary**. `build.rs` runs at compile time when
22//! the user's RPCs are not yet linked. The plugin runs at the same
23//! lifecycle stage as the ISR manifest emitter, which means the
24//! `dist/.ssg/` directory it writes into is already created.
25
26use std::fs;
27use std::path::Path;
28
29use crate::error::SsgError;
30use crate::plugin::{Plugin, PluginContext};
31
32/// Relative path inside `dist/` where the schema is written.
33pub const RPC_DTS_RELATIVE_PATH: &str = ".ssg/rpc.d.ts";
34
35/// `after_compile` plugin that emits `dist/.ssg/rpc.d.ts`.
36///
37/// Skips silently when the dispatch inventory is empty so the
38/// behaviour is byte-identical to v0.0.43 for users who don't opt
39/// into Edge RPC.
40///
41/// # Examples
42///
43/// ```
44/// use ssg::plugin::Plugin;
45/// use ssg::rpc_schema::RpcSchemaPlugin;
46/// assert_eq!(RpcSchemaPlugin::new().name(), "rpc-schema");
47/// ```
48#[derive(Debug, Clone, Copy, Default)]
49pub struct RpcSchemaPlugin;
50
51impl RpcSchemaPlugin {
52    /// Constructs a new instance.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use ssg::rpc_schema::RpcSchemaPlugin;
58    /// let _plugin = RpcSchemaPlugin::new();
59    /// ```
60    #[must_use]
61    pub const fn new() -> Self {
62        Self
63    }
64}
65
66impl Plugin for RpcSchemaPlugin {
67    fn name(&self) -> &'static str {
68        "rpc-schema"
69    }
70
71    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
72        if ctx.dry_run {
73            return Ok(());
74        }
75
76        // No RPCs registered — nothing to emit. Stay silent so
77        // `ssg build` output looks identical for non-RPC sites.
78        if ssg_rpc::dispatch::iter_descriptors().next().is_none() {
79            return Ok(());
80        }
81
82        let opts = ssg_rpc::EmitOptions::default();
83        let ts = ssg_rpc::emit_typescript(&opts);
84
85        let out_path = ctx.site_dir.join(RPC_DTS_RELATIVE_PATH);
86        ensure_parent(&out_path)?;
87        fs::write(&out_path, ts).map_err(|e| SsgError::Io {
88            path: out_path.clone(),
89            source: e,
90        })?;
91
92        Ok(())
93    }
94}
95
96fn ensure_parent(path: &Path) -> Result<(), SsgError> {
97    if let Some(parent) = path.parent() {
98        fs::create_dir_all(parent).map_err(|e| SsgError::Io {
99            path: parent.to_path_buf(),
100            source: e,
101        })?;
102    }
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::plugin::PluginContext;
110    use schemars::JsonSchema;
111    use serde::{Deserialize, Serialize};
112    use ssg_rpc::{ssg_rpc, RpcError};
113    use tempfile::tempdir;
114
115    /// Tiny in-test RPC so the dispatch inventory has at least one
116    /// entry during `cargo test --lib`, exercising the full emit
117    /// path inside `RpcSchemaPlugin::after_compile`. Without this,
118    /// the inventory is empty and the early-return at line ~78
119    /// hides the rest of the function from coverage instrumentation.
120    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
121    struct CovInput {
122        v: u32,
123    }
124
125    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
126    struct CovOutput {
127        out: u32,
128    }
129
130    #[ssg_rpc]
131    #[doc = "Coverage probe: only exists so the inventory is non-empty."]
132    fn _ssg_rpc_schema_coverage_probe(
133        input: CovInput,
134    ) -> Result<CovOutput, RpcError> {
135        Ok(CovOutput { out: input.v + 1 })
136    }
137
138    fn ctx_for(site_dir: &Path) -> PluginContext {
139        PluginContext {
140            content_dir: site_dir.to_path_buf(),
141            build_dir: site_dir.to_path_buf(),
142            site_dir: site_dir.to_path_buf(),
143            template_dir: site_dir.to_path_buf(),
144            config: None,
145            cache: None,
146            memory_budget: None,
147            html_files: None,
148            dep_graph: None,
149            dry_run: false,
150        }
151    }
152
153    #[test]
154    fn plugin_name_is_stable() {
155        assert_eq!(RpcSchemaPlugin::new().name(), "rpc-schema");
156    }
157
158    #[test]
159    fn dry_run_short_circuits() {
160        let dir = tempdir().unwrap();
161        let mut ctx = ctx_for(dir.path());
162        ctx.dry_run = true;
163        RpcSchemaPlugin::new().after_compile(&ctx).unwrap();
164        // No file written.
165        assert!(!dir.path().join(RPC_DTS_RELATIVE_PATH).exists());
166    }
167
168    #[test]
169    fn coverage_probe_dispatches_and_increments() {
170        // Drives the `_ssg_rpc_schema_coverage_probe` body via the
171        // dispatcher so its 5 lines (signature + return expression)
172        // are covered. Without this, the probe is registered into
173        // the inventory but never executed.
174        let out = ssg_rpc::dispatch::dispatch(
175            "_ssg_rpc_schema_coverage_probe",
176            r#"{"v":41}"#,
177        )
178        .expect("dispatch");
179        assert!(out.contains("\"out\":42"));
180    }
181
182    #[test]
183    fn writes_typescript_when_inventory_nonempty() {
184        // The `_ssg_rpc_schema_coverage_probe` above registers a
185        // single descriptor, so iter_descriptors().next() returns
186        // Some(_) and the emit path executes end-to-end.
187        let dir = tempdir().unwrap();
188        let ctx = ctx_for(dir.path());
189        RpcSchemaPlugin::new().after_compile(&ctx).unwrap();
190        let path = dir.path().join(RPC_DTS_RELATIVE_PATH);
191        assert!(path.exists(), "rpc.d.ts must be written");
192        let txt = fs::read_to_string(&path).unwrap();
193        assert!(
194            txt.contains("AUTO-GENERATED"),
195            "emitted file should carry the header: {txt}"
196        );
197    }
198
199    #[test]
200    fn ensure_parent_creates_missing_directory() {
201        let dir = tempdir().unwrap();
202        let nested = dir.path().join("a/b/c/file.d.ts");
203        ensure_parent(&nested).unwrap();
204        assert!(nested.parent().unwrap().is_dir());
205    }
206
207    #[test]
208    fn ensure_parent_path_without_parent_is_ok() {
209        // `Path::new("")` has no parent — must be a no-op (Ok).
210        ensure_parent(Path::new("")).unwrap();
211    }
212
213    #[test]
214    fn after_compile_fails_when_ssg_dir_squatted_by_file() {
215        // `.ssg` exists as a file, so ensure_parent's create_dir_all
216        // fails and the Io closure fires.
217        let dir = tempdir().unwrap();
218        fs::write(dir.path().join(".ssg"), "not a dir").unwrap();
219        let ctx = ctx_for(dir.path());
220        let err = RpcSchemaPlugin::new().after_compile(&ctx).unwrap_err();
221        assert!(!format!("{err}").is_empty());
222    }
223
224    #[test]
225    fn after_compile_fails_when_dts_path_squatted_by_dir() {
226        // A directory squats `.ssg/rpc.d.ts`, so fs::write fails.
227        let dir = tempdir().unwrap();
228        fs::create_dir_all(dir.path().join(RPC_DTS_RELATIVE_PATH)).unwrap();
229        let ctx = ctx_for(dir.path());
230        let err = RpcSchemaPlugin::new().after_compile(&ctx).unwrap_err();
231        assert!(!format!("{err}").is_empty());
232    }
233}