Skip to main content

fallow_extract/
federation_runtime.rs

1//! Module Federation runtime calls: `registerRemotes`, `loadRemote`, `init`
2//! and `createInstance`.
3//!
4//! The Federation runtime can register a remote container and load a module
5//! from it at run time, with no static config to read. This pass reads a call
6//! only when its argument is a static literal, the same rule the config
7//! readers use. A call with any other argument is recorded with no remote, so
8//! the analysis can say that part of the file was not read. `init` and
9//! `createInstance` declare remotes under the `remotes` key of their options,
10//! in the same form as the `registerRemotes` argument. The `loadRemote` and
11//! `registerRemotes` methods of the instance they return are read too.
12//!
13//! A call counts only when the file imports the function from a Federation
14//! runtime package, by name or through a namespace import. A same-named local
15//! function registers nothing. In a Vue or Svelte file, the `<script>` blocks
16//! are read together, so an import in one block covers a call in another.
17
18use std::borrow::Cow;
19use std::path::Path;
20
21use oxc_allocator::Allocator;
22use oxc_ast::ast::{
23    Argument, ArrayExpressionElement, BindingPattern, CallExpression, Expression,
24    ImportDeclarationSpecifier, ObjectPropertyKind, Program, PropertyKey, Statement,
25    VariableDeclarator,
26};
27use oxc_ast_visit::{Visit, walk};
28use oxc_parser::Parser;
29use oxc_span::SourceType;
30use rustc_hash::FxHashMap;
31
32use fallow_types::extract::{FederationRuntimeCall, FederationRuntimeRemoteFact, SemanticFact};
33
34use crate::js_text::JsText;
35
36/// Packages whose runtime API registers and loads remotes.
37const RUNTIME_PACKAGES: &[&str] = &[
38    "@module-federation/runtime",
39    "@module-federation/enhanced/runtime",
40];
41
42/// Properties of a `registerRemotes` element that name the remote. `alias`
43/// is an import prefix of its own, so each literal one is read.
44const REMOTE_NAME_KEYS: &[&str] = &["name", "alias"];
45
46/// Read the Federation runtime calls of a JavaScript, TypeScript, Vue or
47/// Svelte file.
48///
49/// Returns an empty list without parsing when the source does not name a
50/// runtime package, which is the case for almost every file.
51#[must_use]
52pub fn extract_federation_runtime_facts(path: &Path, source: &str) -> Vec<SemanticFact> {
53    if !RUNTIME_PACKAGES
54        .iter()
55        .any(|package| source.contains(package))
56    {
57        return Vec::new();
58    }
59    let scripts = script_sources(path, source);
60    let allocators: Vec<Allocator> = scripts.iter().map(|_| Allocator::default()).collect();
61    let programs: Vec<Program<'_>> = scripts
62        .iter()
63        .zip(&allocators)
64        .map(|((text, source_type), allocator)| {
65            Parser::new(allocator, text, *source_type).parse().program
66        })
67        .collect();
68    let mut bindings = RuntimeBindings::default();
69    for program in &programs {
70        bindings.collect(program);
71    }
72    if bindings.is_empty() {
73        return Vec::new();
74    }
75    for program in &programs {
76        let mut instances = InstanceCollector {
77            bindings: &bindings,
78            instances: Vec::new(),
79        };
80        instances.visit_program(program);
81        bindings.instances.extend(instances.instances);
82    }
83    let mut collector = RuntimeCallCollector {
84        bindings: &bindings,
85        facts: Vec::new(),
86    };
87    for program in &programs {
88        collector.visit_program(program);
89    }
90    collector.facts
91}
92
93/// The script text of a file with its source type: each inline `<script>`
94/// block of a Vue or Svelte file, or the whole file otherwise.
95fn script_sources<'s>(
96    path: &Path,
97    source: &'s str,
98) -> Vec<(std::borrow::Cow<'s, str>, SourceType)> {
99    let is_sfc = path
100        .extension()
101        .and_then(|extension| extension.to_str())
102        .is_some_and(|extension| matches!(extension, "vue" | "svelte"));
103    if !is_sfc {
104        let source_type = SourceType::from_path(path).unwrap_or_default();
105        return vec![(std::borrow::Cow::Borrowed(source), source_type)];
106    }
107    crate::sfc::extract_sfc_scripts(source)
108        .into_iter()
109        .filter(|script| script.src.is_none())
110        .map(|script| {
111            let source_type = crate::sfc::source_type_for_script(&script);
112            (std::borrow::Cow::Owned(script.body), source_type)
113        })
114        .collect()
115}
116
117/// The local names under which a file imports the runtime functions.
118#[derive(Default)]
119struct RuntimeBindings {
120    /// Named imports: local name to runtime function.
121    functions: FxHashMap<String, FederationRuntimeCall>,
122    /// Local names of namespace imports of a runtime package.
123    namespaces: Vec<String>,
124    /// Local names that hold the instance an `init` or `createInstance` call
125    /// returns.
126    instances: Vec<String>,
127}
128
129impl RuntimeBindings {
130    /// Take the runtime imports of one program.
131    fn collect(&mut self, program: &Program<'_>) {
132        for statement in &program.body {
133            let Statement::ImportDeclaration(import) = statement else {
134                continue;
135            };
136            if import.import_kind.is_type()
137                || !RUNTIME_PACKAGES.contains(&&*import.source.value.text())
138            {
139                continue;
140            }
141            for specifier in import.specifiers.iter().flatten() {
142                match specifier {
143                    ImportDeclarationSpecifier::ImportSpecifier(named) => {
144                        if named.import_kind.is_type() {
145                            continue;
146                        }
147                        if let Some(call) = runtime_call(named.imported.name().as_str()) {
148                            self.functions.insert(named.local.name.to_string(), call);
149                        }
150                    }
151                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => {
152                        self.namespaces.push(namespace.local.name.to_string());
153                    }
154                    ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => {}
155                }
156            }
157        }
158    }
159
160    fn is_empty(&self) -> bool {
161        self.functions.is_empty() && self.namespaces.is_empty()
162    }
163
164    /// The runtime function a callee names, if any.
165    fn call_for(&self, callee: &Expression<'_>) -> Option<FederationRuntimeCall> {
166        match callee.without_parentheses() {
167            Expression::Identifier(identifier) => {
168                self.functions.get(identifier.name.as_str()).copied()
169            }
170            Expression::StaticMemberExpression(member) => match &member.object {
171                Expression::Identifier(object)
172                    if self
173                        .namespaces
174                        .iter()
175                        .any(|name| name == object.name.as_str()) =>
176                {
177                    runtime_call(member.property.name.as_str())
178                }
179                Expression::Identifier(object)
180                    if self
181                        .instances
182                        .iter()
183                        .any(|name| name == object.name.as_str()) =>
184                {
185                    runtime_call(member.property.name.as_str())
186                        .filter(|call| !declares_options(*call))
187                }
188                _ => None,
189            },
190            _ => None,
191        }
192    }
193}
194
195fn runtime_call(name: &str) -> Option<FederationRuntimeCall> {
196    match name {
197        "registerRemotes" => Some(FederationRuntimeCall::RegisterRemotes),
198        "loadRemote" => Some(FederationRuntimeCall::LoadRemote),
199        "init" => Some(FederationRuntimeCall::Init),
200        "createInstance" => Some(FederationRuntimeCall::CreateInstance),
201        _ => None,
202    }
203}
204
205/// Whether a runtime call takes options that declare `remotes`, and returns
206/// an instance.
207const fn declares_options(call: FederationRuntimeCall) -> bool {
208    matches!(
209        call,
210        FederationRuntimeCall::Init | FederationRuntimeCall::CreateInstance
211    )
212}
213
214/// The local names that a runtime `init` or `createInstance` call is bound
215/// to, such as `const mf = createInstance({ ... })`.
216struct InstanceCollector<'b> {
217    bindings: &'b RuntimeBindings,
218    instances: Vec<String>,
219}
220
221impl<'a> Visit<'a> for InstanceCollector<'_> {
222    fn visit_variable_declarator(&mut self, declarator: &VariableDeclarator<'a>) {
223        if let (
224            BindingPattern::BindingIdentifier(identifier),
225            Some(Expression::CallExpression(call)),
226        ) = (
227            &declarator.id,
228            declarator
229                .init
230                .as_ref()
231                .map(Expression::without_parentheses),
232        ) && self
233            .bindings
234            .call_for(&call.callee)
235            .is_some_and(declares_options)
236        {
237            self.instances.push(identifier.name.to_string());
238        }
239        walk::walk_variable_declarator(self, declarator);
240    }
241}
242
243struct RuntimeCallCollector<'b> {
244    bindings: &'b RuntimeBindings,
245    facts: Vec<SemanticFact>,
246}
247
248impl RuntimeCallCollector<'_> {
249    fn push(&mut self, call: FederationRuntimeCall, remote: Option<String>) {
250        let fact =
251            SemanticFact::FederationRuntimeRemote(FederationRuntimeRemoteFact { call, remote });
252        if !self.facts.contains(&fact) {
253            self.facts.push(fact);
254        }
255    }
256
257    fn read_call(&mut self, call: FederationRuntimeCall, arguments: &[Argument<'_>]) {
258        let remotes = arguments
259            .first()
260            .and_then(Argument::as_expression)
261            .and_then(|argument| match call {
262                FederationRuntimeCall::RegisterRemotes => registered_remotes(argument),
263                FederationRuntimeCall::LoadRemote => {
264                    loaded_remote(argument).map(|remote| vec![remote])
265                }
266                FederationRuntimeCall::Init | FederationRuntimeCall::CreateInstance => {
267                    option_remotes(argument)
268                }
269            });
270        match remotes {
271            Some(remotes) => {
272                for remote in remotes {
273                    self.push(call, Some(remote));
274                }
275            }
276            None => self.push(call, None),
277        }
278    }
279}
280
281impl<'a> Visit<'a> for RuntimeCallCollector<'_> {
282    fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
283        if let Some(runtime) = self.bindings.call_for(&call.callee) {
284            self.read_call(runtime, &call.arguments);
285        }
286        walk::walk_call_expression(self, call);
287    }
288}
289
290/// The remote names of a `registerRemotes` array, or `None` when any part of
291/// the array is not a static literal.
292fn registered_remotes(argument: &Expression<'_>) -> Option<Vec<String>> {
293    let Expression::ArrayExpression(array) = argument.without_parentheses() else {
294        return None;
295    };
296    let mut remotes = Vec::new();
297    for element in &array.elements {
298        let ArrayExpressionElement::ObjectExpression(object) = element else {
299            return None;
300        };
301        let mut named = false;
302        for property in &object.properties {
303            let ObjectPropertyKind::ObjectProperty(property) = property else {
304                return None;
305            };
306            let Some(key) = static_key(&property.key) else {
307                continue;
308            };
309            if !REMOTE_NAME_KEYS.contains(&&*key) {
310                continue;
311            }
312            let remote = static_string(&property.value).filter(|name| is_remote_name(name))?;
313            named |= key == "name";
314            remotes.push(remote);
315        }
316        if !named {
317            return None;
318        }
319    }
320    Some(remotes)
321}
322
323/// The remote names that the options of an `init` or `createInstance` call
324/// declare under `remotes`, or `None` when the options are not a static
325/// object literal or `remotes` is not a static literal array. Options with no
326/// `remotes` key declare no remote.
327fn option_remotes(argument: &Expression<'_>) -> Option<Vec<String>> {
328    let Expression::ObjectExpression(options) = argument.without_parentheses() else {
329        return None;
330    };
331    let mut remotes = Vec::new();
332    for property in &options.properties {
333        let ObjectPropertyKind::ObjectProperty(property) = property else {
334            return None;
335        };
336        if static_key(&property.key).as_deref() == Some("remotes") {
337            remotes = registered_remotes(&property.value)?;
338        }
339    }
340    Some(remotes)
341}
342
343/// The remote alias of a `loadRemote` request, or `None` when the request is
344/// not a static literal.
345fn loaded_remote(argument: &Expression<'_>) -> Option<String> {
346    let request = static_string(argument)?;
347    let alias = remote_alias(&request);
348    is_remote_name(alias).then(|| alias.to_string())
349}
350
351/// The remote part of a `remote/module` request. A scoped remote name keeps
352/// its first two segments, the same split a package specifier uses.
353fn remote_alias(request: &str) -> &str {
354    let segments = if request.starts_with('@') { 2 } else { 1 };
355    match request.match_indices('/').nth(segments - 1) {
356        Some((index, _)) => &request[..index],
357        None => request,
358    }
359}
360
361/// Whether a name can be imported as a bare specifier, which is the only form
362/// a provider rule can cover.
363fn is_remote_name(name: &str) -> bool {
364    !name.is_empty()
365        && !name.starts_with('.')
366        && !name.starts_with('/')
367        && !name.contains(':')
368        && !name.contains('\\')
369        && !name.chars().any(char::is_whitespace)
370}
371
372fn static_key<'k>(key: &'k PropertyKey<'_>) -> Option<Cow<'k, str>> {
373    match key {
374        PropertyKey::StaticIdentifier(identifier) => Some(identifier.name.as_str().into()),
375        PropertyKey::StringLiteral(literal) => Some(literal.value.text()),
376        _ => None,
377    }
378}
379
380fn static_string(expr: &Expression<'_>) -> Option<String> {
381    match expr.without_parentheses() {
382        Expression::StringLiteral(literal) => Some(literal.value.text().into_owned()),
383        Expression::TemplateLiteral(template) if template.expressions.is_empty() => template
384            .quasis
385            .first()
386            .and_then(|quasi| quasi.value.cooked)
387            .map(|cooked| cooked.text().into_owned()),
388        _ => None,
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn facts(source: &str) -> Vec<(FederationRuntimeCall, Option<String>)> {
397        extract_federation_runtime_facts(Path::new("src/index.ts"), source)
398            .into_iter()
399            .filter_map(|fact| match fact {
400                SemanticFact::FederationRuntimeRemote(fact) => Some((fact.call, fact.remote)),
401                _ => None,
402            })
403            .collect()
404    }
405
406    fn remote(call: FederationRuntimeCall, name: &str) -> (FederationRuntimeCall, Option<String>) {
407        (call, Some(name.to_string()))
408    }
409
410    #[test]
411    fn literal_calls_name_their_remotes() {
412        let found = facts(
413            r"
414            import { registerRemotes, loadRemote } from '@module-federation/runtime';
415            registerRemotes([
416                { name: 'checkout', entry: 'https://example.test/mf.js' },
417                { name: 'cart', alias: 'basket', entry: `https://example.test/cart.js` },
418            ]);
419            loadRemote('checkout/Button');
420            loadRemote(`@scope/remote/Widget`);
421            ",
422        );
423        assert_eq!(
424            found,
425            vec![
426                remote(FederationRuntimeCall::RegisterRemotes, "checkout"),
427                remote(FederationRuntimeCall::RegisterRemotes, "cart"),
428                remote(FederationRuntimeCall::RegisterRemotes, "basket"),
429                remote(FederationRuntimeCall::LoadRemote, "checkout"),
430                remote(FederationRuntimeCall::LoadRemote, "@scope/remote"),
431            ]
432        );
433    }
434
435    #[test]
436    fn a_dynamic_argument_names_no_remote() {
437        let found = facts(
438            r"
439            import * as mf from '@module-federation/enhanced/runtime';
440            mf.registerRemotes(remotes);
441            mf.registerRemotes([{ name: remoteName, entry }]);
442            mf.registerRemotes([{ entry: 'https://example.test/mf.js' }]);
443            mf.registerRemotes([...more]);
444            mf.loadRemote(`${scope}/Button`);
445            mf.loadRemote(id);
446            mf.loadRemote();
447            ",
448        );
449        assert_eq!(
450            found,
451            vec![
452                (FederationRuntimeCall::RegisterRemotes, None),
453                (FederationRuntimeCall::LoadRemote, None),
454            ]
455        );
456    }
457
458    #[test]
459    fn a_call_without_a_runtime_import_is_ignored() {
460        assert!(
461            facts(
462                r"
463                import { registerRemotes } from './local';
464                import type { loadRemote } from '@module-federation/runtime';
465                registerRemotes([{ name: 'checkout', entry: 'x' }]);
466                loadRemote('checkout/Button');
467                "
468            )
469            .is_empty()
470        );
471        assert!(
472            facts(
473                r"
474                import { loadRemote } from '@module-federation/runtime-core';
475                loadRemote('checkout/Button');
476                "
477            )
478            .is_empty()
479        );
480    }
481
482    #[test]
483    fn a_renamed_import_is_followed() {
484        assert_eq!(
485            facts(
486                r"
487                import { loadRemote as load } from '@module-federation/runtime';
488                load('checkout/Button');
489                "
490            ),
491            vec![remote(FederationRuntimeCall::LoadRemote, "checkout")]
492        );
493    }
494
495    fn facts_at(path: &str, source: &str) -> Vec<(FederationRuntimeCall, Option<String>)> {
496        extract_federation_runtime_facts(Path::new(path), source)
497            .into_iter()
498            .filter_map(|fact| match fact {
499                SemanticFact::FederationRuntimeRemote(fact) => Some((fact.call, fact.remote)),
500                _ => None,
501            })
502            .collect()
503    }
504
505    #[test]
506    fn init_and_create_instance_register_their_literal_remotes() {
507        assert_eq!(
508            facts(
509                r"
510                import { init, createInstance } from '@module-federation/enhanced/runtime';
511                init({ name: 'host', remotes: [{ name: 'checkout', entry: 'https://example.test/mf.js' }] });
512                const mf = createInstance({ name: 'host', remotes: [{ name: 'cart', alias: 'basket', entry: 'x' }] });
513                mf.loadRemote('search/Box');
514                mf.registerRemotes([{ name: 'profile', entry: 'y' }]);
515                init({ name: 'plain' });
516                "
517            ),
518            vec![
519                remote(FederationRuntimeCall::Init, "checkout"),
520                remote(FederationRuntimeCall::CreateInstance, "cart"),
521                remote(FederationRuntimeCall::CreateInstance, "basket"),
522                remote(FederationRuntimeCall::LoadRemote, "search"),
523                remote(FederationRuntimeCall::RegisterRemotes, "profile"),
524            ]
525        );
526    }
527
528    #[test]
529    fn init_with_unreadable_remotes_names_no_remote() {
530        assert_eq!(
531            facts(
532                r"
533                import { init } from '@module-federation/runtime';
534                init(options);
535                init({ name: 'host', remotes });
536                init({ name: 'host', ...rest });
537                "
538            ),
539            vec![(FederationRuntimeCall::Init, None)]
540        );
541    }
542
543    #[test]
544    fn sfc_script_blocks_are_read() {
545        let vue = r#"
546<template><Widget /></template>
547<script setup lang="ts">
548import { loadRemote } from '@module-federation/enhanced/runtime';
549const Widget = defineAsyncComponent(() => loadRemote('checkout/Widget'));
550</script>
551"#;
552        assert_eq!(
553            facts_at("src/App.vue", vue),
554            vec![remote(FederationRuntimeCall::LoadRemote, "checkout")]
555        );
556        let svelte = r"
557<script>
558  import { init } from '@module-federation/runtime';
559  init({ name: 'host', remotes: [{ name: 'cart', entry: 'x' }] });
560</script>
561<main>{name}</main>
562";
563        assert_eq!(
564            facts_at("src/App.svelte", svelte),
565            vec![remote(FederationRuntimeCall::Init, "cart")]
566        );
567        let split = r"
568<script>
569import { loadRemote } from '@module-federation/runtime';
570</script>
571<script setup>
572loadRemote('search/Box');
573</script>
574";
575        assert_eq!(
576            facts_at("src/Split.vue", split),
577            vec![remote(FederationRuntimeCall::LoadRemote, "search")]
578        );
579        let ungated = r"
580<script setup>
581import { loadRemote } from './local';
582loadRemote('checkout/Widget');
583</script>
584<!-- @module-federation/runtime -->
585";
586        assert!(facts_at("src/Local.vue", ungated).is_empty());
587    }
588}