Skip to main content

fiftyone_javascript_builder/
element.rs

1/* *********************************************************************
2 * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
3 * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
4 * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
5 *
6 * This Original Work is licensed under the European Union Public Licence
7 * (EUPL) v.1.2 and is subject to its terms as set out below.
8 *
9 * If a copy of the EUPL was not distributed with this file, You can obtain
10 * one at https://opensource.org/licenses/EUPL-1.2.
11 *
12 * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
13 * amended by the European Commission) shall be deemed incompatible for
14 * the purposes of the Work and the provisions of the compatibility
15 * clause in Article 5 of the EUPL shall not apply.
16 *
17 * If using the Work as, or as part of, a network application, by
18 * including the attribution notice(s) required under Article 5 of the EUPL
19 * in the end user terms of the application under an appropriate heading,
20 * such notice(s) shall fulfill the requirements of that article.
21 * ********************************************************************* */
22
23//! The JavaScript builder flow element.
24
25use std::collections::BTreeMap;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use fiftyone_json_builder::JSON_BUILDER_DATA_KEY;
29use fiftyone_pipeline_core::constants::{
30    EVIDENCE_PROTOCOL_KEY, EVIDENCE_QUERY_PREFIX, EVIDENCE_SEPARATOR,
31};
32use fiftyone_pipeline_core::{
33    EvidenceKeyFilter, EvidenceKeyFilterWhitelist, FlowData, FlowElement, PropertyMetaData,
34    PropertyValue, PropertyValueType, Result,
35};
36use fiftyone_pipeline_engines_fiftyone::constants::{EVIDENCE_SEQUENCE, EVIDENCE_SESSIONID};
37use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
38
39use crate::constants::{
40    DELAY_EXECUTION_MARKER, EVIDENCE_ENABLE_COOKIES, EVIDENCE_HOST_KEY, EVIDENCE_OBJECT_NAME,
41    FALLBACK_PROTOCOL, FETCH_PROPERTY, JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
42    JAVASCRIPT_PROPERTY_KEY, PROMISE_FULL_VALUE, PROMISE_PROPERTY,
43};
44use crate::data::{JavaScriptBuilderElementData, JAVASCRIPT_BUILDER_DATA_KEY};
45use crate::minify::minify;
46use crate::mustache::Template;
47use crate::template_data::JavaScriptResource;
48use crate::JavaScriptBuilderElementBuilder;
49
50/// The Mustache template, embedded at compile time.
51const TEMPLATE_SOURCE: &str = include_str!("../assets/JavaScriptResource.mustache");
52
53/// The `application/x-www-form-urlencoded` percent-encoding set used for the
54/// request parameter keys and values.
55///
56/// The set percent-encodes everything except the
57/// unreserved characters `A-Z a-z 0-9 - _ . *` (and encodes a space as `%20`,
58/// not `+`, because each key and value is encoded individually before being
59/// placed into the JSON object). It starts from
60/// "encode all non-alphanumerics" and adds back the four exceptions.
61const URL_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
62    .remove(b'-')
63    .remove(b'_')
64    .remove(b'.')
65    .remove(b'*');
66
67/// Generates a JavaScript include to be run on the client device.
68///
69/// The element renders the bundled Mustache template with the JSON payload
70/// produced by the JSON builder, the request's session and sequence evidence,
71/// a callback URL and the request parameters, then optionally minifies the
72/// result. The generated JavaScript is stored on the flow data under the
73/// [`crate::JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY`] element data key. It implements
74/// the
75/// [javascript-builder specification](https://github.com/51Degrees/specifications/blob/main/pipeline-specification/pipeline-elements/javascript-builder.md).
76///
77/// # Derivation rules
78///
79/// - **protocol**: the configured protocol if set, else the `header.protocol`
80///   evidence, else `https`.
81/// - **host**: the configured host if set, else the `header.host` evidence.
82/// - **object name**: the `query.fod-js-object-name` evidence if present, else
83///   the configured object name (default `fod`).
84/// - **enable cookies**: the `query.fod-js-enable-cookies` evidence parsed as a
85///   boolean if present, else the configured default (true).
86/// - **callback URL**: built only when protocol, host and endpoint are all
87///   present, normalizing the single slash between host and endpoint. When a URL
88///   is built the background-update mechanism is enabled.
89/// - **parameters**: every `query.*` evidence entry except the session id and
90///   sequence, with the prefix stripped and the key and value URL-encoded,
91///   serialized as a JSON object.
92/// - **has delayed properties**: true when the JSON payload contains the
93///   `delayexecution` marker.
94/// - **supports promises**: true when the device-detection `Promise` property is
95///   `Full`. The check is latched off once the property proves unavailable.
96/// - **supports fetch**: true when the device-detection `Fetch` property is
97///   true. The check is latched off once the property proves unavailable.
98///
99/// # Example
100///
101/// ```
102/// use std::sync::Arc;
103/// use fiftyone_pipeline_core::{Evidence, Pipeline};
104/// use fiftyone_json_builder::JsonBuilderElement;
105/// use fiftyone_javascript_builder::{JavaScriptBuilderElement, JAVASCRIPT_BUILDER_DATA_KEY};
106///
107/// let pipeline = Pipeline::builder()
108///     .add_element(Arc::new(JsonBuilderElement::new()))
109///     .add_element(Arc::new(JavaScriptBuilderElement::new()))
110///     .build()
111///     .unwrap();
112///
113/// let mut data = pipeline.create_flow_data_with(
114///     Evidence::builder()
115///         .add("header.host", "localhost")
116///         .add("query.sequence", "1")
117///         .build(),
118/// );
119/// data.process().unwrap();
120///
121/// let js = data.get(JAVASCRIPT_BUILDER_DATA_KEY).unwrap().javascript().to_owned();
122/// assert!(js.contains("fiftyoneDegreesManager"));
123/// ```
124pub struct JavaScriptBuilderElement {
125    evidence_key_filter: EvidenceKeyFilterWhitelist,
126    properties: Vec<PropertyMetaData>,
127    template: Template,
128
129    host: String,
130    endpoint: String,
131    protocol: String,
132    object_name: String,
133    enable_cookies: bool,
134    minify: bool,
135
136    /// Latches that short-circuit the Promise/Fetch property lookups once they
137    /// have proved unavailable. Stored atomically because
138    /// `FlowElement::process` takes `&self` and the element is shared across
139    /// threads.
140    promise_property_available: AtomicBool,
141    fetch_property_available: AtomicBool,
142}
143
144impl JavaScriptBuilderElement {
145    /// Create a JavaScript builder with the default configuration.
146    pub fn new() -> Self {
147        JavaScriptBuilderElementBuilder::new().build()
148    }
149
150    /// Start configuring a JavaScript builder.
151    pub fn builder() -> JavaScriptBuilderElementBuilder {
152        JavaScriptBuilderElementBuilder::new()
153    }
154
155    /// Internal constructor used by the builder.
156    pub(crate) fn from_parts(
157        host: String,
158        endpoint: String,
159        protocol: String,
160        object_name: String,
161        enable_cookies: bool,
162        minify: bool,
163    ) -> Self {
164        // The template is parsed once at construction. The embedded source is a
165        // valid Mustache template, so parsing it cannot fail in practice. The
166        // expect documents that invariant.
167        let template = Template::parse(TEMPLATE_SOURCE)
168            .expect("the embedded JavaScriptResource template is valid Mustache");
169
170        JavaScriptBuilderElement {
171            evidence_key_filter: EvidenceKeyFilterWhitelist::new([
172                EVIDENCE_HOST_KEY,
173                EVIDENCE_PROTOCOL_KEY,
174                EVIDENCE_OBJECT_NAME,
175                EVIDENCE_ENABLE_COOKIES,
176            ]),
177            properties: vec![PropertyMetaData::new(
178                JAVASCRIPT_PROPERTY_KEY,
179                JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY,
180                PropertyValueType::String,
181            )],
182            template,
183            host,
184            endpoint,
185            protocol,
186            object_name,
187            enable_cookies,
188            minify,
189            promise_property_available: AtomicBool::new(true),
190            fetch_property_available: AtomicBool::new(true),
191        }
192    }
193
194    /// Resolve the protocol: configured value, else `header.protocol` evidence,
195    /// else the `https` fallback.
196    fn resolve_protocol(&self, data: &FlowData) -> String {
197        if !self.protocol.is_empty() {
198            return self.protocol.clone();
199        }
200        if let Some(protocol) = data.evidence().get(EVIDENCE_PROTOCOL_KEY) {
201            if !protocol.is_empty() {
202                return protocol.to_owned();
203            }
204        }
205        FALLBACK_PROTOCOL.to_owned()
206    }
207
208    /// Resolve the host: configured value, else `header.host` evidence, else an
209    /// empty string.
210    fn resolve_host(&self, data: &FlowData) -> String {
211        if !self.host.is_empty() {
212            return self.host.clone();
213        }
214        data.evidence()
215            .get(EVIDENCE_HOST_KEY)
216            .unwrap_or("")
217            .to_owned()
218    }
219
220    /// Resolve the object name: `query.fod-js-object-name` evidence if present,
221    /// else the configured object name.
222    fn resolve_object_name(&self, data: &FlowData) -> String {
223        match data.evidence().get(EVIDENCE_OBJECT_NAME) {
224            Some(name) => name.to_owned(),
225            None => self.object_name.clone(),
226        }
227    }
228
229    /// Resolve the enable-cookies flag: `query.fod-js-enable-cookies` evidence
230    /// parsed as a boolean if present and parseable, else the configured
231    /// default.
232    fn resolve_enable_cookies(&self, data: &FlowData) -> bool {
233        match data.evidence().get(EVIDENCE_ENABLE_COOKIES) {
234            Some(value) => value.trim().parse::<bool>().unwrap_or(self.enable_cookies),
235            None => self.enable_cookies,
236        }
237    }
238
239    /// Build the callback URL, or `None` if protocol, host or endpoint is
240    /// missing.
241    ///
242    /// The single slash between host and endpoint is normalized: a slash is
243    /// added when neither side has one, and a duplicate is removed when both
244    /// sides have one.
245    fn build_url(protocol: &str, host: &str, endpoint: &str) -> Option<String> {
246        if protocol.trim().is_empty() || host.trim().is_empty() || endpoint.trim().is_empty() {
247            return None;
248        }
249
250        let endpoint_has_slash = endpoint.starts_with('/');
251        let host_has_slash = host.ends_with('/');
252
253        let normalised_endpoint = if !endpoint_has_slash && !host_has_slash {
254            // No slash on either side: add one.
255            format!("/{endpoint}")
256        } else if endpoint_has_slash && host_has_slash {
257            // A slash on both sides: drop the leading one from the endpoint.
258            endpoint[1..].to_owned()
259        } else {
260            endpoint.to_owned()
261        };
262
263        Some(format!("{protocol}://{host}{normalised_endpoint}"))
264    }
265
266    /// Read the session id evidence, or an empty string if absent.
267    fn session_id(data: &FlowData) -> String {
268        data.evidence()
269            .get(EVIDENCE_SESSIONID)
270            .unwrap_or("")
271            .to_owned()
272    }
273
274    /// Read the sequence evidence as an integer, defaulting to `1` when absent
275    /// or unparseable.
276    fn sequence(data: &FlowData) -> i32 {
277        data.evidence()
278            .get(EVIDENCE_SEQUENCE)
279            .and_then(|s| s.trim().parse::<i32>().ok())
280            .unwrap_or(1)
281    }
282
283    /// Build the request-parameters JSON object.
284    ///
285    /// Every `query.*` evidence entry except the session id and sequence is
286    /// included. The `query.` prefix is stripped, then the key and value are
287    /// URL-encoded. The result is serialized as a JSON object. A `BTreeMap` is
288    /// used so the key order, and therefore the serialized JSON, is
289    /// deterministic.
290    fn build_parameters(data: &FlowData) -> String {
291        let query_prefix = format!("{EVIDENCE_QUERY_PREFIX}{EVIDENCE_SEPARATOR}");
292        let mut parameters: BTreeMap<String, String> = BTreeMap::new();
293
294        for (key, value) in data.evidence().iter() {
295            if !key.starts_with(&query_prefix) {
296                continue;
297            }
298            // Evidence keys are lowercased; the excluded keys are too.
299            if key.eq_ignore_ascii_case(EVIDENCE_SESSIONID)
300                || key.eq_ignore_ascii_case(EVIDENCE_SEQUENCE)
301            {
302                continue;
303            }
304            let field = &key[query_prefix.len()..];
305            let encoded_key = utf8_percent_encode(field, URL_ENCODE_SET).to_string();
306            let encoded_value = utf8_percent_encode(value, URL_ENCODE_SET).to_string();
307            parameters.insert(encoded_key, encoded_value);
308        }
309
310        serde_json::to_string(&parameters).unwrap_or_else(|_| "{}".to_owned())
311    }
312
313    /// Determine whether a latched device-detection property satisfies a
314    /// predicate.
315    ///
316    /// Returns `false` immediately when the latch has already been cleared. The
317    /// property is otherwise looked up and the predicate applied to its value.
318    /// Once the property proves unavailable the latch is cleared so later
319    /// requests skip the lookup.
320    fn supports_property(
321        &self,
322        latch: &AtomicBool,
323        data: &FlowData,
324        property: &str,
325        predicate: impl Fn(&PropertyValue) -> bool,
326    ) -> bool {
327        if !latch.load(Ordering::Relaxed) {
328            return false;
329        }
330        match data.get_evidence_or_property(property) {
331            Ok(value) => predicate(&value),
332            Err(_) => {
333                // The property is not available in this pipeline; latch off so
334                // we do not keep looking.
335                latch.store(false, Ordering::Relaxed);
336                false
337            }
338        }
339    }
340
341    /// Determine whether the client supports promises.
342    ///
343    /// Returns `true` only when the device-detection `Promise` property resolves
344    /// to `Full`. Once the property proves unavailable the latch is cleared so
345    /// later requests skip the lookup.
346    fn supports_promises(&self, data: &FlowData) -> bool {
347        self.supports_property(
348            &self.promise_property_available,
349            data,
350            PROMISE_PROPERTY,
351            |value| value.as_str() == Some(PROMISE_FULL_VALUE),
352        )
353    }
354
355    /// Determine whether the client supports the fetch API.
356    ///
357    /// Returns `true` only when the device-detection `Fetch` property resolves to
358    /// true. Once the property proves unavailable the latch is cleared so later
359    /// requests skip the lookup.
360    fn supports_fetch(&self, data: &FlowData) -> bool {
361        self.supports_property(
362            &self.fetch_property_available,
363            data,
364            FETCH_PROPERTY,
365            |value| value.as_bool() == Some(true),
366        )
367    }
368
369    /// Read the JSON payload from the JSON builder's element data, or an empty
370    /// string if the JSON builder did not run.
371    fn json_object(data: &FlowData) -> String {
372        data.get(JSON_BUILDER_DATA_KEY)
373            .map(|json| json.json().to_owned())
374            .unwrap_or_default()
375    }
376
377    /// Render and (optionally) minify the JavaScript for this request, returning
378    /// the content to store and whether minification flagged an error.
379    fn build_javascript(&self, data: &FlowData) -> (String, bool) {
380        let protocol = self.resolve_protocol(data);
381        let host = self.resolve_host(data);
382        let object_name = self.resolve_object_name(data);
383        let enable_cookies = self.resolve_enable_cookies(data);
384
385        let supports_promises = self.supports_promises(data);
386        let supports_fetch = self.supports_fetch(data);
387
388        let json_object = Self::json_object(data);
389        let parameters = Self::build_parameters(data);
390        let session_id = Self::session_id(data);
391        let sequence = Self::sequence(data);
392
393        let url = Self::build_url(&protocol, &host, &self.endpoint);
394        let update_enabled = url.as_ref().is_some_and(|u| !u.is_empty());
395
396        let has_delayed_properties = json_object.contains(DELAY_EXECUTION_MARKER);
397
398        let resource = JavaScriptResource::new(
399            object_name,
400            json_object,
401            session_id,
402            sequence,
403            supports_promises,
404            supports_fetch,
405            url.unwrap_or_default(),
406            parameters,
407            enable_cookies,
408            update_enabled,
409            has_delayed_properties,
410        );
411
412        let content = resource.render(&self.template);
413
414        let outcome = if self.minify {
415            minify(content)
416        } else {
417            crate::minify::MinifyOutcome {
418                content,
419                had_error: false,
420            }
421        };
422
423        (outcome.content, outcome.had_error)
424    }
425}
426
427impl Default for JavaScriptBuilderElement {
428    fn default() -> Self {
429        JavaScriptBuilderElement::new()
430    }
431}
432
433impl FlowElement for JavaScriptBuilderElement {
434    fn process(&self, data: &mut FlowData) -> Result<()> {
435        // build_javascript already substitutes the unminified script when
436        // minification fails, so the had_error flag needs no further handling
437        // here. The content is the correct script to serve either way.
438        let (content, _had_error) = self.build_javascript(data);
439
440        let result = data.get_or_add(
441            JAVASCRIPT_BUILDER_DATA_KEY,
442            JavaScriptBuilderElementData::new,
443        );
444        match result {
445            Ok(element_data) => {
446                element_data.set_javascript(content);
447                Ok(())
448            }
449            Err(error) => Err(error),
450        }
451    }
452
453    fn data_key(&self) -> &str {
454        JAVASCRIPT_BUILDER_ELEMENT_DATA_KEY
455    }
456
457    fn evidence_key_filter(&self) -> &dyn EvidenceKeyFilter {
458        &self.evidence_key_filter
459    }
460
461    fn properties(&self) -> &[PropertyMetaData] {
462        &self.properties
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use fiftyone_pipeline_core::{Evidence, Pipeline};
470    use std::sync::Arc;
471
472    /// Build a flow data carrying the supplied evidence on a pipeline whose only
473    /// element is the JavaScript builder, without processing it. This lets the
474    /// derivation helpers be exercised against real evidence.
475    fn flow_data_with(pairs: &[(&str, &str)]) -> FlowData {
476        let mut builder = Evidence::builder();
477        for (key, value) in pairs {
478            builder = builder.add(*key, *value);
479        }
480        let pipeline = Pipeline::builder()
481            .add_element(Arc::new(JavaScriptBuilderElement::new()))
482            .build()
483            .expect("pipeline builds");
484        pipeline.create_flow_data_with(builder.build())
485    }
486
487    #[test]
488    fn build_url_adds_single_slash() {
489        let url = JavaScriptBuilderElement::build_url("https", "example.com", "51dpipeline/json");
490        assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
491    }
492
493    #[test]
494    fn build_url_keeps_single_slash_on_endpoint() {
495        let url = JavaScriptBuilderElement::build_url("https", "example.com", "/51dpipeline/json");
496        assert_eq!(url.as_deref(), Some("https://example.com/51dpipeline/json"));
497    }
498
499    #[test]
500    fn build_url_collapses_double_slash() {
501        let url = JavaScriptBuilderElement::build_url("http", "example.com/", "/json");
502        assert_eq!(url.as_deref(), Some("http://example.com/json"));
503    }
504
505    #[test]
506    fn build_url_none_when_host_missing() {
507        assert!(JavaScriptBuilderElement::build_url("https", "", "/json").is_none());
508        assert!(JavaScriptBuilderElement::build_url("", "example.com", "/json").is_none());
509        assert!(JavaScriptBuilderElement::build_url("https", "example.com", "").is_none());
510    }
511
512    #[test]
513    fn parameters_exclude_session_and_sequence() {
514        let data = flow_data_with(&[
515            ("query.session-id", "abc"),
516            ("query.sequence", "3"),
517            ("query.user-agent", "test agent"),
518            ("query.fod-js-object-name", "myObj"),
519            ("header.host", "ignored"),
520        ]);
521        let json = JavaScriptBuilderElement::build_parameters(&data);
522        let value: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
523        let object = value.as_object().expect("an object");
524
525        // Session id and sequence are excluded.
526        assert!(!object.contains_key("session-id"));
527        assert!(!object.contains_key("sequence"));
528        // header.* evidence is excluded (only query.* is taken).
529        assert!(!object.values().any(|v| v == "ignored"));
530        // The remaining query.* keys are present with the prefix stripped, and
531        // the space in the value is URL-encoded as %20 (not '+').
532        assert_eq!(
533            object.get("user-agent").and_then(|v| v.as_str()),
534            Some("test%20agent")
535        );
536        assert_eq!(
537            object.get("fod-js-object-name").and_then(|v| v.as_str()),
538            Some("myObj")
539        );
540    }
541
542    #[test]
543    fn parameters_are_deterministic() {
544        let data = flow_data_with(&[("query.b", "2"), ("query.a", "1"), ("query.c", "3")]);
545        let first = JavaScriptBuilderElement::build_parameters(&data);
546        let second = JavaScriptBuilderElement::build_parameters(&data);
547        assert_eq!(first, second);
548        // Keys are sorted, so 'a' precedes 'b' precedes 'c'.
549        let a = first.find("\"a\"").unwrap();
550        let b = first.find("\"b\"").unwrap();
551        let c = first.find("\"c\"").unwrap();
552        assert!(a < b && b < c);
553    }
554
555    #[test]
556    fn protocol_falls_back_to_https() {
557        let element = JavaScriptBuilderElement::new();
558        let data = flow_data_with(&[("header.host", "example.com")]);
559        assert_eq!(element.resolve_protocol(&data), "https");
560    }
561
562    #[test]
563    fn protocol_taken_from_evidence_when_not_configured() {
564        let element = JavaScriptBuilderElement::new();
565        let data = flow_data_with(&[("header.protocol", "http")]);
566        assert_eq!(element.resolve_protocol(&data), "http");
567    }
568
569    #[test]
570    fn object_name_overridden_by_evidence() {
571        let element = JavaScriptBuilderElement::new();
572        let data = flow_data_with(&[("query.fod-js-object-name", "custom")]);
573        assert_eq!(element.resolve_object_name(&data), "custom");
574        let data = flow_data_with(&[]);
575        assert_eq!(element.resolve_object_name(&data), "fod");
576    }
577
578    #[test]
579    fn enable_cookies_overridden_by_evidence() {
580        let element = JavaScriptBuilderElement::new();
581        let data = flow_data_with(&[("query.fod-js-enable-cookies", "false")]);
582        assert!(!element.resolve_enable_cookies(&data));
583        // Default is true when no evidence is supplied.
584        let data = flow_data_with(&[]);
585        assert!(element.resolve_enable_cookies(&data));
586    }
587
588    #[test]
589    fn promise_and_fetch_latch_off_when_unavailable() {
590        let element = JavaScriptBuilderElement::new();
591        let data = flow_data_with(&[]);
592        // No device element present, so neither property resolves.
593        assert!(!element.supports_promises(&data));
594        assert!(!element.supports_fetch(&data));
595        // The latches have been cleared so subsequent calls skip the lookup.
596        assert!(!element.promise_property_available.load(Ordering::Relaxed));
597        assert!(!element.fetch_property_available.load(Ordering::Relaxed));
598        // A second call still reports no support.
599        assert!(!element.supports_promises(&data));
600        assert!(!element.supports_fetch(&data));
601    }
602}