1use 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
36const RUNTIME_PACKAGES: &[&str] = &[
38 "@module-federation/runtime",
39 "@module-federation/enhanced/runtime",
40];
41
42const REMOTE_NAME_KEYS: &[&str] = &["name", "alias"];
45
46#[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
93fn 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#[derive(Default)]
119struct RuntimeBindings {
120 functions: FxHashMap<String, FederationRuntimeCall>,
122 namespaces: Vec<String>,
124 instances: Vec<String>,
127}
128
129impl RuntimeBindings {
130 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 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
205const fn declares_options(call: FederationRuntimeCall) -> bool {
208 matches!(
209 call,
210 FederationRuntimeCall::Init | FederationRuntimeCall::CreateInstance
211 )
212}
213
214struct 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
290fn 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
323fn 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
343fn 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
351fn 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
361fn 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}