1use 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
33const RUNTIME_PACKAGES: &[&str] = &[
35 "@module-federation/runtime",
36 "@module-federation/enhanced/runtime",
37];
38
39const REMOTE_NAME_KEYS: &[&str] = &["name", "alias"];
42
43#[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
90fn 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#[derive(Default)]
116struct RuntimeBindings {
117 functions: FxHashMap<String, FederationRuntimeCall>,
119 namespaces: Vec<String>,
121 instances: Vec<String>,
124}
125
126impl RuntimeBindings {
127 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 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
202const fn declares_options(call: FederationRuntimeCall) -> bool {
205 matches!(
206 call,
207 FederationRuntimeCall::Init | FederationRuntimeCall::CreateInstance
208 )
209}
210
211struct 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
287fn 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
320fn 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
340fn 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
348fn 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
358fn 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}