Skip to main content

truss/adapters/
wasm.rs

1//! Browser and WebAssembly adapter support.
2//!
3//! This module keeps the browser-facing contract separate from the core Rust types so the
4//! GitHub Pages demo can exchange simple JSON-like objects with JavaScript while still
5//! reusing the shared transformation pipeline.
6
7use crate::{
8    Artifact, MediaType, RawArtifact, Rgba8, Rotation, TransformError, TransformOptions,
9    TransformRequest, TransformResult, sniff_artifact, transform_raster,
10};
11use serde::{Deserialize, Serialize};
12use std::str::FromStr;
13
14#[cfg(feature = "svg")]
15use crate::transform_svg;
16#[cfg(feature = "wasm")]
17use wasm_bindgen::prelude::*;
18
19/// Browser-facing transform options accepted by the WASM adapter.
20///
21/// The fields intentionally use strings for enum-like values so JavaScript callers do not
22/// need to understand the Rust enum layout. The adapter validates and converts these fields
23/// before calling the shared Core transformation pipeline.
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct WasmTransformOptions {
27    /// The requested output width in pixels.
28    pub width: Option<u32>,
29    /// The requested output height in pixels.
30    pub height: Option<u32>,
31    /// The resize fit mode (`contain`, `cover`, `fill`, or `inside`).
32    pub fit: Option<String>,
33    /// The crop anchor (`center`, `top-left`, and so on).
34    pub position: Option<String>,
35    /// The requested output format (`jpeg`, `png`, `webp`, `avif`, `bmp`, or `svg`).
36    pub format: Option<String>,
37    /// The requested lossy quality from 1 to 100.
38    pub quality: Option<u8>,
39    /// Optional background color as `RRGGBB` or `RRGGBBAA`.
40    pub background: Option<String>,
41    /// Optional clockwise rotation in degrees. Supported values are `0`, `90`, `180`, `270`.
42    pub rotate: Option<u16>,
43    /// Whether EXIF auto-orientation should run. Defaults to `true`.
44    pub auto_orient: Option<bool>,
45    /// Whether all supported metadata should be retained when possible.
46    pub keep_metadata: Option<bool>,
47    /// Whether only EXIF metadata should be retained.
48    pub preserve_exif: Option<bool>,
49}
50
51/// Build-time capabilities exposed by the WASM adapter.
52///
53/// The GitHub Pages UI uses this to disable controls for features that are intentionally
54/// absent from the browser build, such as SVG processing or lossy WebP encoding.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct WasmCapabilities {
58    /// Whether SVG input and SVG output processing are available in this build.
59    pub svg: bool,
60    /// Whether quality-controlled lossy WebP encoding is available in this build.
61    pub webp_lossy: bool,
62}
63
64/// Serializable metadata about an inspected or transformed artifact.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct WasmArtifactInfo {
68    /// Canonical media type name such as `png` or `jpeg`.
69    pub media_type: String,
70    /// MIME type string such as `image/png`.
71    pub mime_type: String,
72    /// Rendered width in pixels when known.
73    pub width: Option<u32>,
74    /// Rendered height in pixels when known.
75    pub height: Option<u32>,
76    /// Frame count for the artifact.
77    pub frame_count: u32,
78    /// Whether the artifact contains alpha when known.
79    pub has_alpha: Option<bool>,
80}
81
82/// Response payload returned by [`inspect_browser_artifact`].
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct WasmInspectResponse {
86    /// Inspected metadata for the supplied artifact.
87    pub artifact: WasmArtifactInfo,
88}
89
90/// Response payload returned by [`transform_browser_artifact`].
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct WasmTransformResponse {
94    /// Transformed output bytes. JavaScript receives this as a `Uint8Array`.
95    pub bytes: Vec<u8>,
96    /// Metadata describing the transformed artifact.
97    pub artifact: WasmArtifactInfo,
98    /// Non-fatal warnings emitted by the transform pipeline.
99    pub warnings: Vec<String>,
100    /// Suggested output extension derived from the output media type.
101    pub suggested_extension: String,
102}
103
104#[cfg(feature = "wasm")]
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107struct WasmErrorPayload {
108    kind: &'static str,
109    message: String,
110}
111
112/// Returns the compile-time capabilities of the current WASM build.
113pub fn browser_capabilities() -> WasmCapabilities {
114    WasmCapabilities {
115        svg: cfg!(feature = "svg"),
116        webp_lossy: cfg!(feature = "webp-lossy"),
117    }
118}
119
120/// Inspects browser-provided bytes and returns metadata suitable for JavaScript callers.
121///
122/// `declared_media_type` may be omitted. When present, it is validated against the detected
123/// signature in the same way as the CLI and HTTP server adapters.
124///
125/// # Errors
126///
127/// Returns [`TransformError::InvalidInput`] when the declared media type conflicts with the
128/// detected bytes, [`TransformError::UnsupportedInputMediaType`] when the bytes are not a
129/// supported image format, and [`TransformError::DecodeFailed`] when the image structure is
130/// malformed.
131pub fn inspect_browser_artifact(
132    input_bytes: Vec<u8>,
133    declared_media_type: Option<&str>,
134) -> Result<WasmInspectResponse, TransformError> {
135    let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
136
137    Ok(WasmInspectResponse {
138        artifact: artifact_info(&artifact),
139    })
140}
141
142/// Transforms browser-provided bytes using JavaScript-friendly transform options.
143///
144/// This adapter intentionally excludes runtime-specific features such as local filesystem
145/// paths, server-side URL fetches, and secret-backed authentication. It only accepts raw
146/// input bytes and explicit transform options supplied by the browser application.
147///
148/// # Errors
149///
150/// Returns the same validation and execution errors as the shared transformation pipeline,
151/// plus [`TransformError::CapabilityMissing`] when a requested browser feature was compiled out
152/// of the current build, such as SVG processing or lossy WebP encoding.
153pub fn transform_browser_artifact(
154    input_bytes: Vec<u8>,
155    declared_media_type: Option<&str>,
156    options: WasmTransformOptions,
157) -> Result<WasmTransformResponse, TransformError> {
158    let artifact = sniff_browser_artifact(input_bytes, declared_media_type)?;
159    let options = parse_wasm_options(options)?;
160    let output = dispatch_browser_transform(artifact, options)?;
161    let TransformResult { artifact, warnings } = output;
162    let artifact_info = artifact_info(&artifact);
163    let suggested_extension = output_extension(artifact.media_type).to_string();
164
165    Ok(WasmTransformResponse {
166        bytes: artifact.bytes,
167        artifact: artifact_info,
168        warnings: warnings
169            .into_iter()
170            .map(|warning| warning.to_string())
171            .collect(),
172        suggested_extension,
173    })
174}
175
176fn sniff_browser_artifact(
177    input_bytes: Vec<u8>,
178    declared_media_type: Option<&str>,
179) -> Result<Artifact, TransformError> {
180    let declared_media_type = declared_media_type
181        .map(|value| parse_media_type(value, "declaredMediaType"))
182        .transpose()?;
183
184    sniff_artifact(RawArtifact::new(input_bytes, declared_media_type))
185}
186
187fn parse_wasm_options(options: WasmTransformOptions) -> Result<TransformOptions, TransformError> {
188    let (strip_metadata, preserve_exif) =
189        crate::core::resolve_metadata_flags(None, options.keep_metadata, options.preserve_exif)?;
190
191    let fit = parse_optional_enum(options.fit, "fit")?;
192    let position = parse_optional_enum(options.position, "position")?;
193    let format = options
194        .format
195        .as_deref()
196        .map(|value| parse_media_type(value, "format"))
197        .transpose()?;
198    let background = options
199        .background
200        .as_deref()
201        .map(|value| {
202            Rgba8::from_hex(value).map_err(|reason| {
203                TransformError::InvalidOptions(format!("background is invalid: {reason}"))
204            })
205        })
206        .transpose()?;
207
208    Ok(TransformOptions {
209        width: options.width,
210        height: options.height,
211        fit,
212        position,
213        format,
214        quality: options.quality,
215        background,
216        rotate: parse_rotation(options.rotate)?,
217        auto_orient: options.auto_orient.unwrap_or(true),
218        strip_metadata,
219        preserve_exif,
220        deadline: None,
221    })
222}
223
224fn parse_optional_enum<T>(value: Option<String>, field: &str) -> Result<Option<T>, TransformError>
225where
226    T: FromStr<Err = String>,
227{
228    value
229        .map(|value| {
230            T::from_str(&value).map_err(|reason| {
231                TransformError::InvalidOptions(format!("{field} is invalid: {reason}"))
232            })
233        })
234        .transpose()
235}
236
237fn parse_media_type(value: &str, field: &str) -> Result<MediaType, TransformError> {
238    MediaType::from_str(value)
239        .map_err(|reason| TransformError::InvalidOptions(format!("{field} is invalid: {reason}")))
240}
241
242fn parse_rotation(value: Option<u16>) -> Result<Rotation, TransformError> {
243    match value.unwrap_or(0) {
244        0 => Ok(Rotation::Deg0),
245        90 => Ok(Rotation::Deg90),
246        180 => Ok(Rotation::Deg180),
247        270 => Ok(Rotation::Deg270),
248        other => Err(TransformError::InvalidOptions(format!(
249            "rotate is invalid: unsupported rotation `{other}`"
250        ))),
251    }
252}
253
254fn dispatch_browser_transform(
255    artifact: Artifact,
256    options: TransformOptions,
257) -> Result<TransformResult, TransformError> {
258    if artifact.media_type != MediaType::Svg && options.format == Some(MediaType::Svg) {
259        return Err(TransformError::UnsupportedOutputMediaType(MediaType::Svg));
260    }
261
262    if artifact.media_type == MediaType::Svg {
263        #[cfg(feature = "svg")]
264        {
265            return transform_svg(TransformRequest::new(artifact, options));
266        }
267        #[cfg(not(feature = "svg"))]
268        {
269            let _ = options;
270            return Err(TransformError::CapabilityMissing(
271                "SVG processing is not enabled in this build".to_string(),
272            ));
273        }
274    }
275
276    transform_raster(TransformRequest::new(artifact, options))
277}
278
279fn artifact_info(artifact: &Artifact) -> WasmArtifactInfo {
280    WasmArtifactInfo {
281        media_type: artifact.media_type.as_name().to_string(),
282        mime_type: artifact.media_type.as_mime().to_string(),
283        width: artifact.metadata.width,
284        height: artifact.metadata.height,
285        frame_count: artifact.metadata.frame_count,
286        has_alpha: artifact.metadata.has_alpha,
287    }
288}
289
290fn output_extension(media_type: MediaType) -> &'static str {
291    match media_type {
292        MediaType::Jpeg => "jpg",
293        MediaType::Png => "png",
294        MediaType::Webp => "webp",
295        MediaType::Avif => "avif",
296        MediaType::Svg => "svg",
297        MediaType::Bmp => "bmp",
298    }
299}
300
301#[cfg(feature = "wasm")]
302fn error_kind(error: &TransformError) -> &'static str {
303    match error {
304        TransformError::InvalidInput(_) => "invalidInput",
305        TransformError::InvalidOptions(_) => "invalidOptions",
306        TransformError::UnsupportedInputMediaType(_) => "unsupportedInputMediaType",
307        TransformError::UnsupportedOutputMediaType(_) => "unsupportedOutputMediaType",
308        TransformError::DecodeFailed(_) => "decodeFailed",
309        TransformError::EncodeFailed(_) => "encodeFailed",
310        TransformError::CapabilityMissing(_) => "capabilityMissing",
311        TransformError::LimitExceeded(_) => "limitExceeded",
312    }
313}
314
315#[cfg(feature = "wasm")]
316fn serialize_json<T: Serialize>(value: &T) -> Result<String, JsValue> {
317    serde_json::to_string(value)
318        .map_err(|error| JsValue::from_str(&format!("failed to serialize WASM response: {error}")))
319}
320
321#[cfg(feature = "wasm")]
322fn transform_error_to_js(error: TransformError) -> JsValue {
323    let payload = WasmErrorPayload {
324        kind: error_kind(&error),
325        message: error.to_string(),
326    };
327
328    serialize_json(&payload)
329        .map(JsValue::from)
330        .unwrap_or_else(|_| JsValue::from_str(&format!("{}: {}", payload.kind, payload.message)))
331}
332
333/// Browser-facing transform output returned by [`transform_image`].
334///
335/// JavaScript callers receive the transformed bytes separately from the JSON metadata so the
336/// output can be downloaded or previewed without reparsing large byte arrays through JSON.
337#[cfg(feature = "wasm")]
338#[wasm_bindgen]
339pub struct WasmTransformOutput {
340    bytes: Vec<u8>,
341    response_json: String,
342}
343
344#[cfg(feature = "wasm")]
345#[wasm_bindgen]
346impl WasmTransformOutput {
347    /// Returns the transformed output bytes.
348    #[wasm_bindgen(getter)]
349    pub fn bytes(&self) -> Vec<u8> {
350        self.bytes.clone()
351    }
352
353    /// Returns JSON metadata describing the transformed output.
354    #[wasm_bindgen(js_name = responseJson, getter)]
355    pub fn response_json(&self) -> String {
356        self.response_json.clone()
357    }
358}
359
360/// Returns build-time capabilities to JavaScript callers as a JSON string.
361#[cfg(feature = "wasm")]
362#[wasm_bindgen(js_name = getCapabilitiesJson)]
363pub fn get_capabilities_json() -> Result<String, JsValue> {
364    serialize_json(&browser_capabilities())
365}
366
367/// Inspects image bytes supplied by JavaScript and returns structured metadata as JSON.
368///
369/// The returned object contains the canonical media type, MIME type, dimensions, frame count,
370/// and alpha information when available.
371#[cfg(feature = "wasm")]
372#[wasm_bindgen(js_name = inspectImageJson)]
373pub fn inspect_image_json(
374    input_bytes: &[u8],
375    declared_media_type: Option<String>,
376) -> Result<String, JsValue> {
377    let response = inspect_browser_artifact(input_bytes.to_vec(), declared_media_type.as_deref())
378        .map_err(transform_error_to_js)?;
379
380    serialize_json(&response)
381}
382
383/// Transforms image bytes supplied by JavaScript and returns output bytes plus metadata.
384///
385/// `options_json` must match the JSON shape of [`WasmTransformOptions`]. On success, the
386/// returned object contains output bytes plus a JSON metadata payload describing the artifact,
387/// warnings, and suggested file extension for download flows.
388#[cfg(feature = "wasm")]
389#[wasm_bindgen(js_name = transformImage)]
390pub fn transform_image(
391    input_bytes: &[u8],
392    declared_media_type: Option<String>,
393    options_json: &str,
394) -> Result<WasmTransformOutput, JsValue> {
395    let options = serde_json::from_str::<WasmTransformOptions>(options_json).map_err(|error| {
396        transform_error_to_js(TransformError::InvalidOptions(format!(
397            "failed to parse transform options: {error}"
398        )))
399    })?;
400    let response = transform_browser_artifact(
401        input_bytes.to_vec(),
402        declared_media_type.as_deref(),
403        options,
404    )
405    .map_err(transform_error_to_js)?;
406    let response_json = serialize_json(&response)?;
407
408    Ok(WasmTransformOutput {
409        bytes: response.bytes,
410        response_json,
411    })
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use image::codecs::png::PngEncoder;
418    use image::{ColorType, ImageEncoder, Rgba, RgbaImage};
419
420    fn png_bytes(width: u32, height: u32) -> Vec<u8> {
421        let image = RgbaImage::from_pixel(width, height, Rgba([10, 20, 30, 255]));
422        let mut bytes = Vec::new();
423        PngEncoder::new(&mut bytes)
424            .write_image(&image, width, height, ColorType::Rgba8.into())
425            .expect("encode png");
426        bytes
427    }
428
429    #[test]
430    fn browser_capabilities_reflect_compile_time_features() {
431        let capabilities = browser_capabilities();
432
433        assert_eq!(capabilities.svg, cfg!(feature = "svg"));
434        assert_eq!(capabilities.webp_lossy, cfg!(feature = "webp-lossy"));
435    }
436
437    #[test]
438    fn inspect_browser_artifact_reports_png_metadata() {
439        let response =
440            inspect_browser_artifact(png_bytes(4, 3), Some("png")).expect("inspect png artifact");
441
442        assert_eq!(response.artifact.media_type, "png");
443        assert_eq!(response.artifact.mime_type, "image/png");
444        assert_eq!(response.artifact.width, Some(4));
445        assert_eq!(response.artifact.height, Some(3));
446        assert_eq!(response.artifact.has_alpha, Some(true));
447    }
448
449    #[test]
450    fn transform_browser_artifact_converts_png_to_jpeg() {
451        let response = transform_browser_artifact(
452            png_bytes(4, 3),
453            Some("png"),
454            WasmTransformOptions {
455                format: Some("jpeg".to_string()),
456                width: Some(2),
457                ..WasmTransformOptions::default()
458            },
459        )
460        .expect("transform png to jpeg");
461
462        assert_eq!(response.artifact.media_type, "jpeg");
463        assert_eq!(response.artifact.mime_type, "image/jpeg");
464        assert_eq!(response.artifact.width, Some(2));
465        assert_eq!(response.artifact.height, Some(2));
466        assert_eq!(response.suggested_extension, "jpg");
467        assert!(response.bytes.starts_with(&[0xFF, 0xD8]));
468    }
469
470    #[test]
471    fn parse_wasm_options_rejects_conflicting_metadata_flags() {
472        let error = parse_wasm_options(WasmTransformOptions {
473            keep_metadata: Some(true),
474            preserve_exif: Some(true),
475            ..WasmTransformOptions::default()
476        })
477        .expect_err("conflicting metadata flags should fail");
478
479        assert_eq!(
480            error,
481            TransformError::InvalidOptions(
482                "keepMetadata and preserveExif cannot both be true".to_string()
483            )
484        );
485    }
486
487    #[test]
488    fn raster_input_cannot_request_svg_output() {
489        let error = transform_browser_artifact(
490            png_bytes(4, 3),
491            Some("png"),
492            WasmTransformOptions {
493                format: Some("svg".to_string()),
494                ..WasmTransformOptions::default()
495            },
496        )
497        .expect_err("raster input should not produce svg output");
498
499        assert_eq!(
500            error,
501            TransformError::UnsupportedOutputMediaType(MediaType::Svg)
502        );
503    }
504}