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