waterui-preview-protocol 0.5.0

Shared TCP protocol for WaterUI preview
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
//! Shared TCP protocol between `water` CLI and the preview support app.

pub mod bench;
pub mod hydrolysis;

use serde::de::{Error as DeError, Visitor as DeVisitor};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};

/// Build commit hash for protocol compatibility checks.
pub const PREVIEW_PROTOCOL_COMMIT: &str = env!("WATERUI_PREVIEW_PROTOCOL_COMMIT");

#[must_use]
/// Return protocol metadata for handshake responses.
pub fn protocol_info(waterui_core_fingerprint: impl Into<String>) -> PreviewProtocolInfo {
    PreviewProtocolInfo {
        build_commit: PREVIEW_PROTOCOL_COMMIT.to_string(),
        waterui_core_fingerprint: waterui_core_fingerprint.into(),
        platform: PreviewRuntimePlatform::current(),
    }
}

/// Protocol metadata exchanged during ping/pong handshake.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewProtocolInfo {
    /// Build commit hash of the preview support app.
    pub build_commit: String,
    /// Fingerprint of the `waterui-core` package used by this preview app build.
    pub waterui_core_fingerprint: String,
    /// Runtime platform of the preview support app.
    pub platform: PreviewRuntimePlatform,
}

/// Runtime platform that owns the preview support app process.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PreviewRuntimePlatform {
    /// macOS preview support app.
    Macos,
    /// iOS Simulator preview support app.
    IosSimulator,
    /// Physical iOS preview support app.
    Ios,
    /// Android preview support app.
    Android,
    /// Other platform.
    Other,
}

impl PreviewRuntimePlatform {
    /// Return the current preview support app runtime platform.
    #[must_use]
    pub const fn current() -> Self {
        if cfg!(target_os = "macos") {
            Self::Macos
        } else if cfg!(target_os = "ios") && cfg!(target_abi = "sim") {
            Self::IosSimulator
        } else if cfg!(target_os = "ios") {
            Self::Ios
        } else if cfg!(target_os = "android") {
            Self::Android
        } else {
            Self::Other
        }
    }
}

pub mod registry {
    //! Preview support app instance registry shared between the CLI and local preview apps.

    use std::net::IpAddr;
    use std::path::PathBuf;

    use serde::{Deserialize, Serialize};

    use super::{SystemTime, UNIX_EPOCH};

    /// A registered local preview support app instance.
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct PreviewAppInstance {
        /// Operating-system process identifier of the support app.
        pub pid: u32,
        /// Host address the support app listens on.
        pub host: IpAddr,
        /// TCP port the support app listens on.
        pub port: u16,
        /// Runtime fingerprint of the support app build.
        pub waterui_core_fingerprint: String,
        /// Registration timestamp in milliseconds since the Unix epoch.
        pub registered_at_unix_ms: u64,
    }

    impl PreviewAppInstance {
        #[must_use]
        /// Create a registry entry for a support app instance.
        ///
        /// # Panics
        ///
        /// Panics if the system clock predates the Unix epoch or its millisecond timestamp does
        /// not fit into `u64`.
        pub fn new(
            pid: u32,
            host: IpAddr,
            port: u16,
            waterui_core_fingerprint: impl Into<String>,
        ) -> Self {
            Self {
                pid,
                host,
                port,
                waterui_core_fingerprint: waterui_core_fingerprint.into(),
                registered_at_unix_ms: SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .expect("system clock must not be earlier than the Unix epoch")
                    .as_millis()
                    .try_into()
                    .expect("preview registration timestamp must fit into u64"),
            }
        }
    }

    fn water_cache_dir() -> PathBuf {
        if let Some(cache_dir) = std::env::var_os("WATER_CACHE_DIR") {
            return PathBuf::from(cache_dir);
        }

        if let Some(cache_dir) = dirs::cache_dir() {
            return cache_dir.join("waterui");
        }

        std::env::temp_dir().join("waterui-cache")
    }

    #[must_use]
    /// Root cache directory for preview support assets.
    pub fn preview_cache_root_dir() -> PathBuf {
        water_cache_dir().join("preview")
    }

    #[must_use]
    /// Directory containing registered preview support app instances.
    pub fn preview_instance_registry_dir() -> PathBuf {
        preview_cache_root_dir().join("instances")
    }

    #[must_use]
    /// Path of the JSON registry file for a support app instance.
    pub fn preview_instance_registry_path(instance: &PreviewAppInstance) -> PathBuf {
        preview_instance_registry_dir().join(format!("{}-{}.json", instance.pid, instance.port))
    }
}

pub mod transport {
    //! Framed binary transport helpers.
    //!
    //! The preview protocol uses length-prefixed frames:
    //! `u32::to_be_bytes(len)` followed by `len` bytes of binary payload.

    use std::io;

    use futures_lite::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
    use serde::Serialize;
    use serde::de::DeserializeOwned;

    /// Length prefix size for framed messages (big-endian `u32`).
    pub const LEN_PREFIX_BYTES: usize = 4;

    /// Hard limit for a single frame to prevent OOM from malformed inputs.
    ///
    /// Override via `WATERUI_PREVIEW_MAX_FRAME_BYTES`.
    ///
    /// # Panics
    ///
    /// Panics if `WATERUI_PREVIEW_MAX_FRAME_BYTES` is not a valid UTF-8 `usize` value.
    #[must_use]
    pub fn max_frame_bytes() -> usize {
        const DEFAULT: usize = 128 * 1024 * 1024;
        match std::env::var("WATERUI_PREVIEW_MAX_FRAME_BYTES") {
            Ok(value) => value.parse::<usize>().unwrap_or_else(|error| {
                panic!("invalid WATERUI_PREVIEW_MAX_FRAME_BYTES value `{value}`: {error}")
            }),
            Err(std::env::VarError::NotPresent) => DEFAULT,
            Err(std::env::VarError::NotUnicode(_)) => {
                panic!("WATERUI_PREVIEW_MAX_FRAME_BYTES must be valid UTF-8")
            }
        }
    }

    /// Read a single length-prefixed binary frame.
    ///
    /// # Errors
    ///
    /// Returns an error when reading from the stream fails, the frame exceeds
    /// [`max_frame_bytes`], or the payload cannot be decoded.
    pub async fn read_frame<R, T>(reader: &mut R) -> io::Result<T>
    where
        R: AsyncRead + Unpin + Send,
        T: DeserializeOwned,
    {
        let mut len_buf = [0u8; LEN_PREFIX_BYTES];
        reader.read_exact(&mut len_buf).await?;
        let len = u32::from_be_bytes(len_buf) as usize;
        let max = max_frame_bytes();
        if len > max {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("preview frame too large: {len} bytes (max {max})"),
            ));
        }

        let mut buf = vec![0u8; len];
        reader.read_exact(&mut buf).await?;

        let config = bincode::config::standard();
        let (value, bytes_read): (T, usize) = bincode::serde::decode_from_slice(&buf, config)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        if bytes_read != buf.len() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "trailing bytes after preview frame payload",
            ));
        }
        Ok(value)
    }

    /// Write a single length-prefixed binary frame.
    ///
    /// # Errors
    ///
    /// Returns an error when encoding the payload fails, the encoded payload
    /// does not fit in a `u32` length prefix, or writing to the stream fails.
    pub async fn write_frame<W, T>(writer: &mut W, value: &T) -> io::Result<()>
    where
        W: AsyncWrite + Unpin + Send,
        T: Serialize + Sync,
    {
        let config = bincode::config::standard();
        let data = bincode::serde::encode_to_vec(value, config)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
        let len: u32 = data.len().try_into().map_err(|_| {
            io::Error::new(
                io::ErrorKind::InvalidData,
                "preview frame too large for u32 length",
            )
        })?;

        writer.write_all(&len.to_be_bytes()).await?;
        writer.write_all(&data).await?;
        writer.flush().await?;
        Ok(())
    }
}

pub mod tcp {
    //! TCP configuration shared by the CLI and the preview support app.

    use std::net::{IpAddr, Ipv4Addr};
    use std::ops::RangeInclusive;

    use thiserror::Error;

    /// Default host the preview support app binds to.
    pub const DEFAULT_HOST: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);

    /// Default TCP port range start.
    pub const DEFAULT_PORT_START: u16 = 2106;

    /// Default number of ports to try.
    pub const DEFAULT_PORT_RANGE: u16 = 50;

    /// TCP configuration shared by the CLI and preview app.
    #[derive(Debug, Clone, Copy)]
    pub struct PreviewTcpConfig {
        /// IP address to bind/connect to (defaults to localhost).
        pub host: IpAddr,
        /// First port to try.
        pub port_start: u16,
        /// Number of consecutive ports to try.
        pub port_range: u16,
    }

    impl PreviewTcpConfig {
        #[must_use]
        /// Default localhost configuration.
        pub const fn default_localhost() -> Self {
            Self {
                host: DEFAULT_HOST,
                port_start: DEFAULT_PORT_START,
                port_range: DEFAULT_PORT_RANGE,
            }
        }

        /// Build config from environment variables.
        ///
        /// - `WATERUI_PREVIEW_HOST` (IPv4/IPv6)
        /// - `WATERUI_PREVIEW_PORT_START` (u16)
        /// - `WATERUI_PREVIEW_PORT_RANGE` (u16)
        ///
        /// Missing variables use defaults; present-but-invalid values fail fast.
        ///
        /// # Errors
        ///
        /// Returns an error when any preview TCP environment variable is present
        /// but cannot be parsed as its declared type.
        pub fn from_env() -> Result<Self, ConfigError> {
            let mut cfg = Self::default_localhost();

            if let Ok(host) = std::env::var("WATERUI_PREVIEW_HOST") {
                cfg.host = host.parse().map_err(|_| ConfigError::InvalidHost)?;
            }
            if let Ok(port_start) = std::env::var("WATERUI_PREVIEW_PORT_START") {
                cfg.port_start = port_start
                    .parse()
                    .map_err(|_| ConfigError::InvalidPortStart)?;
            }
            if let Ok(port_range) = std::env::var("WATERUI_PREVIEW_PORT_RANGE") {
                cfg.port_range = port_range
                    .parse()
                    .map_err(|_| ConfigError::InvalidPortRange)?;
            }

            Ok(cfg)
        }

        #[must_use]
        /// Inclusive port range to scan/bind.
        pub const fn ports(&self) -> RangeInclusive<u16> {
            let end = self
                .port_start
                .saturating_add(self.port_range.saturating_sub(1));
            self.port_start..=end
        }
    }

    #[derive(Debug, Error)]
    /// Errors returned by [`PreviewTcpConfig::from_env`].
    pub enum ConfigError {
        #[error("invalid WATERUI_PREVIEW_HOST")]
        /// Host env var is present but invalid.
        InvalidHost,
        #[error("invalid WATERUI_PREVIEW_PORT_START")]
        /// Port start env var is present but invalid.
        InvalidPortStart,
        #[error("invalid WATERUI_PREVIEW_PORT_RANGE")]
        /// Port range env var is present but invalid.
        InvalidPortRange,
    }
}

/// Frame size for rendering.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Size {
    /// Width in points.
    pub width: f32,
    /// Height in points.
    pub height: f32,
}

impl Size {
    /// Create a new size.
    #[must_use]
    pub const fn new(width: f32, height: f32) -> Self {
        Self { width, height }
    }
}

/// Stable identifier for a preview dylib payload.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct DylibId([u8; 32]);

impl DylibId {
    #[must_use]
    /// Create a dylib id from raw identifier bytes.
    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    #[must_use]
    /// Borrow the raw identifier bytes.
    pub const fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

impl fmt::Debug for DylibId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "DylibId({self})")
    }
}

impl fmt::Display for DylibId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", hex::encode(self.0))
    }
}

impl FromStr for DylibId {
    type Err = &'static str;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = hex::decode(s).map_err(|_| "invalid hex")?;
        let bytes: [u8; 32] = bytes.try_into().map_err(|_| "expected 32 bytes")?;
        Ok(Self(bytes))
    }
}

impl Serialize for DylibId {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        serializer.serialize_str(&hex::encode(self.0))
    }
}

impl<'de> Deserialize<'de> for DylibId {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        struct Visitor;

        impl DeVisitor<'_> for Visitor {
            type Value = DylibId;

            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                write!(f, "a 64-char hex string")
            }

            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
            where
                E: DeError,
            {
                let bytes = hex::decode(v).map_err(|_| E::custom("invalid hex"))?;
                let bytes: [u8; 32] = bytes
                    .try_into()
                    .map_err(|_| E::custom("expected 32 bytes"))?;
                Ok(DylibId(bytes))
            }
        }

        deserializer.deserialize_str(Visitor)
    }
}

/// How to provide the dylib used for rendering.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DylibSource {
    /// Inline dylib bytes (used when the app doesn't have `id` yet).
    ///
    /// The CLI must use a fresh `id` whenever the payload changes.
    Bytes {
        /// Dylib payload identifier.
        id: DylibId,
        /// Raw dylib bytes.
        bytes: Vec<u8>,
    },
    /// Reuse a previously loaded dylib by id.
    Cached {
        /// Dylib payload identifier.
        id: DylibId,
    },
    /// Absolute local dylib path on the same host as the preview support app.
    ///
    /// This avoids retransmitting large dylibs over TCP when the CLI and support app share
    /// a filesystem, such as macOS local preview.
    LocalPath {
        /// Dylib payload identifier.
        id: DylibId,
        /// Absolute local dylib path.
        path: std::path::PathBuf,
    },
}

/// Request from CLI to preview support app.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PreviewRequest {
    /// Fast liveness probe.
    ///
    /// Used by the CLI to confirm the app is responsive (not just accepting TCP).
    Ping,
    /// Ask whether a dylib id is present in the app cache.
    HasDylib {
        /// Dylib id to query.
        id: DylibId,
    },
    /// Render a view function.
    Render {
        /// Dylib source to use for rendering.
        dylib: DylibSource,
        /// Symbol name (e.g. `waterui_preview_my_crate_sidebar`).
        symbol: String,
        /// Frame size for rendering.
        frame: Size,
    },
    /// Shutdown the preview app.
    Shutdown,
}

/// Successful render output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PreviewOutput {
    /// PNG image bytes.
    pub png_data: Vec<u8>,
    /// Support-app timing breakdown for this render request.
    pub timings: PreviewRenderTimings,
}

/// Timing breakdown for loading a preview dylib into the support app process.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct PreviewDylibLoadTimings {
    /// Time spent materializing the cached dylib file on disk.
    pub cache_file_ms: u64,
    /// Total time spent loading the library from disk.
    pub load_library_ms: u64,
    /// Time spent in the initial `dlopen`.
    pub initial_dlopen_ms: u64,
    /// Time spent verifying an existing code signature, if the initial `dlopen` failed.
    pub codesign_verify_ms: Option<u64>,
    /// Time spent codesigning the dylib, if needed.
    pub codesign_ms: Option<u64>,
    /// Time spent reloading the dylib after codesigning, if needed.
    pub reload_after_codesign_ms: Option<u64>,
}

/// Timing breakdown for a single preview render request inside the support app.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct PreviewRenderTimings {
    /// Time spent ensuring the requested dylib is cached and loaded.
    pub ensure_dylib_cached_ms: u64,
    /// Detailed dylib-load timings when this request had to load a new dylib.
    pub dylib_load: Option<PreviewDylibLoadTimings>,
    /// Time spent resolving the requested preview symbol into a view.
    pub load_view_ms: u64,
    /// Time spent rendering the view to an in-memory image.
    pub render_ms: u64,
    /// Time spent encoding the rendered image to PNG.
    pub png_encode_ms: u64,
    /// Total request time inside the support app.
    pub total_ms: u64,
}

/// Errors that can occur during preview rendering.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum PreviewError {
    /// Requested dylib id is not loaded.
    #[error("Unknown dylib id: {0}")]
    UnknownDylibId(DylibId),
    /// Failed to load dylib.
    #[error("Failed to load dylib: {0}")]
    DylibLoad(String),
    /// Symbol not found in dylib.
    #[error("Symbol not found: {0}")]
    SymbolNotFound(String),
    /// Rendering failed.
    #[error("Render failed: {0}")]
    RenderFailed(String),
}

/// Response from preview support app.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PreviewResponse {
    /// Response to [`PreviewRequest::Ping`].
    Pong {
        /// Protocol metadata for compatibility handshake.
        protocol: PreviewProtocolInfo,
    },
    /// Response to [`PreviewRequest::HasDylib`].
    HasDylib {
        /// Whether the dylib id is present.
        present: bool,
    },
    /// Response to [`PreviewRequest::Render`].
    Render {
        /// Render result or error.
        result: Result<PreviewOutput, PreviewError>,
    },
    /// Response to [`PreviewRequest::Shutdown`].
    Shutdown,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dylib_id_roundtrip_hex() {
        let id = DylibId::from_bytes([0xAB; 32]);
        let json = serde_json::to_string(&id).unwrap();
        let de: DylibId = serde_json::from_str(&json).unwrap();
        assert_eq!(id, de);
    }
}