@calimero-network/mero-react 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -91,6 +91,8 @@ Props (`MeroProviderConfig & { children }`):
91
91
  | `packageVersion` | `string` | No | Specific version (defaults to latest) |
92
92
  | `registryUrl` | `string` | No | Registry URL override |
93
93
  | `timeoutMs` | `number` | No | HTTP request timeout (default 30000) |
94
+ | `allowedNodeUrls` | `string[]` | No | Origins the OAuth callback may authenticate against. The initiated node is always trusted; this allowlist additionally permits direct-callback entry. A callback `node_url` matching neither is rejected. |
95
+ | `tokenStore` | `TokenStore` | No | Pluggable access/refresh token store. Defaults to localStorage. Pass a `MemoryTokenStore` or cookie-backed store for sensitive deployments (localStorage tokens are XSS-readable). |
94
96
 
95
97
  Modes and their permissions:
96
98
 
@@ -100,7 +102,7 @@ Modes and their permissions:
100
102
  | `MultiContext` | `context:create`, `context:list`, `context:execute` | Apps managing multiple contexts |
101
103
  | `Admin` | `admin` | Admin dashboards, dev tools |
102
104
 
103
- Auth flow: when `connectToNode(url)` is called, the provider redirects to the node's auth page. After login, the node redirects back with tokens in the URL hash. The provider processes these once (StrictMode-safe via ref) and sets `isAuthenticated = true`.
105
+ Auth flow: when `connectToNode(url)` is called, the provider redirects to the node's auth page. After login, the node redirects back with tokens in the URL hash. The provider processes these once (StrictMode-safe via ref) and sets `isAuthenticated = true`. The callback's `node_url` is validated against the node login was initiated with (or `allowedNodeUrls`); a `node_url` matching neither is rejected.
104
106
 
105
107
  Online detection: the provider opens an SSE connection to the node after auth. `isOnline` reflects the SSE connection state — no polling.
106
108
 
package/dist/index.cjs CHANGED
@@ -473,6 +473,59 @@ function CalimeroLogo({
473
473
  );
474
474
  }
475
475
 
476
+ // src/utils/nodeDiscovery.ts
477
+ var DEFAULT_LOCAL_NODE_PORTS = [2428, 2429, 2528, 2529];
478
+ var LOCAL_HOST = "localhost";
479
+ var DEFAULT_PROBE_TIMEOUT_MS = 2e3;
480
+ function localNodeUrl(port) {
481
+ return `http://${LOCAL_HOST}:${port}`;
482
+ }
483
+ function nodeEndpoint(baseUrl, path) {
484
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
485
+ return new URL(path, base).toString();
486
+ }
487
+ async function probeNodeHealth(baseUrl, options = {}) {
488
+ const { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, signal } = options;
489
+ if (signal?.aborted) return false;
490
+ const controller = new AbortController();
491
+ const onAbort = () => controller.abort();
492
+ const listenerAdded = !!signal;
493
+ if (listenerAdded) signal.addEventListener("abort", onAbort, { once: true });
494
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
495
+ try {
496
+ const url = nodeEndpoint(baseUrl, "admin-api/health");
497
+ const res = await fetch(url, {
498
+ method: "GET",
499
+ signal: controller.signal
500
+ });
501
+ if (!res.ok) return false;
502
+ try {
503
+ const body = await res.json();
504
+ const status = body?.data?.status !== void 0 ? body?.data?.status : body?.status;
505
+ if (status === void 0) return true;
506
+ return typeof status === "string" && status.toLowerCase() === "alive";
507
+ } catch {
508
+ return true;
509
+ }
510
+ } catch {
511
+ return false;
512
+ } finally {
513
+ clearTimeout(timer);
514
+ if (listenerAdded) signal.removeEventListener("abort", onAbort);
515
+ }
516
+ }
517
+ async function discoverLocalNodes(options = {}) {
518
+ const { ports = DEFAULT_LOCAL_NODE_PORTS, timeoutMs, signal } = options;
519
+ const results = await Promise.all(
520
+ ports.map(async (port) => {
521
+ const url = localNodeUrl(port);
522
+ const ok = await probeNodeHealth(url, { timeoutMs, signal });
523
+ return ok ? url : null;
524
+ })
525
+ );
526
+ return results.filter((url) => url !== null);
527
+ }
528
+
476
529
  // src/theme.ts
477
530
  var defaultMeroTheme = Object.freeze({
478
531
  primary: "#a5ff11",
@@ -521,6 +574,8 @@ function themeToCssVars(theme) {
521
574
  function cssVar(theme, key) {
522
575
  return `var(${MERO_CSS_VARS[key]}, ${theme[key]})`;
523
576
  }
577
+ var DEFAULT_LOCAL_NODE_URL = "http://node1.127.0.0.1.nip.io";
578
+ var CUSTOM_SELECTION = "__custom__";
524
579
  function isValidUrl(urlString) {
525
580
  if (!urlString || urlString.trim() === "") {
526
581
  return false;
@@ -549,6 +604,9 @@ function isValidUrl(urlString) {
549
604
  return false;
550
605
  }
551
606
  }
607
+ function displayNodeUrl(url) {
608
+ return url.replace(/^https?:\/\//, "").replace(/\/+$/, "");
609
+ }
552
610
  function tint(color, percent) {
553
611
  return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
554
612
  }
@@ -653,10 +711,60 @@ function buildStyles(t) {
653
711
  transition: "all 0.15s ease"
654
712
  },
655
713
  radioLabelActive: {
656
- borderColor: accent,
714
+ border: `1px solid ${accent}`,
715
+ backgroundColor: accentGlow,
716
+ color: text
717
+ },
718
+ radioList: {
719
+ display: "flex",
720
+ flexDirection: "column",
721
+ gap: "0.5rem",
722
+ marginBottom: "1rem"
723
+ },
724
+ radioItem: {
725
+ display: "flex",
726
+ alignItems: "center",
727
+ gap: "0.625rem",
728
+ color: text,
729
+ cursor: "pointer",
730
+ padding: "0.75rem 1rem",
731
+ borderRadius: radius,
732
+ border: `1px solid ${border}`,
733
+ backgroundColor: bgSecondary,
734
+ transition: "all 0.15s ease",
735
+ fontSize: "0.875rem"
736
+ },
737
+ radioItemActive: {
738
+ // Override the full `border` shorthand (not just borderColor) so React
739
+ // never has to mix shorthand + longhand on the same element.
740
+ border: `1px solid ${accent}`,
657
741
  backgroundColor: accentGlow,
658
742
  color: text
659
743
  },
744
+ radioIndicator: {
745
+ flexShrink: 0,
746
+ width: "1rem",
747
+ height: "1rem",
748
+ borderRadius: "50%",
749
+ border: `2px solid ${border}`,
750
+ display: "flex",
751
+ alignItems: "center",
752
+ justifyContent: "center"
753
+ },
754
+ radioIndicatorActive: {
755
+ border: `2px solid ${accent}`
756
+ },
757
+ radioDot: {
758
+ width: "0.5rem",
759
+ height: "0.5rem",
760
+ borderRadius: "50%",
761
+ backgroundColor: accent
762
+ },
763
+ nodeMeta: {
764
+ marginLeft: "auto",
765
+ fontSize: "0.75rem",
766
+ color: textSecondary
767
+ },
660
768
  input: {
661
769
  width: "100%",
662
770
  padding: "0.75rem 1rem",
@@ -682,6 +790,29 @@ function buildStyles(t) {
682
790
  localInfoCode: {
683
791
  color: accent
684
792
  },
793
+ noNodeInfo: {
794
+ color: textSecondary,
795
+ fontSize: "0.875rem",
796
+ textAlign: "center",
797
+ padding: "0.75rem",
798
+ backgroundColor: bgSecondary,
799
+ borderRadius: radius,
800
+ marginBottom: "1rem",
801
+ border: `1px solid ${border}`
802
+ },
803
+ toolbar: {
804
+ display: "flex",
805
+ justifyContent: "center",
806
+ marginBottom: "1rem"
807
+ },
808
+ rescan: {
809
+ background: "none",
810
+ border: "none",
811
+ color: accent,
812
+ cursor: "pointer",
813
+ fontSize: "0.8125rem",
814
+ padding: "0.25rem 0.5rem"
815
+ },
685
816
  buttonGroup: {
686
817
  display: "flex",
687
818
  justifyContent: "center"
@@ -709,6 +840,15 @@ function buildStyles(t) {
709
840
  padding: "2rem",
710
841
  color: textSecondary
711
842
  },
843
+ discovering: {
844
+ display: "flex",
845
+ flexDirection: "column",
846
+ alignItems: "center",
847
+ gap: "0.75rem",
848
+ padding: "1rem",
849
+ color: textSecondary,
850
+ fontSize: "0.875rem"
851
+ },
712
852
  spinner: {
713
853
  width: "2rem",
714
854
  height: "2rem",
@@ -716,6 +856,14 @@ function buildStyles(t) {
716
856
  borderTopColor: accent,
717
857
  borderRadius: "50%",
718
858
  animation: "meroSpin 1s linear infinite"
859
+ },
860
+ spinnerSmall: {
861
+ width: "1.5rem",
862
+ height: "1.5rem",
863
+ border: `3px solid ${border}`,
864
+ borderTopColor: accent,
865
+ borderRadius: "50%",
866
+ animation: "meroSpin 1s linear infinite"
719
867
  }
720
868
  };
721
869
  }
@@ -724,11 +872,14 @@ function LoginModal({
724
872
  onClose,
725
873
  connectionType,
726
874
  isOpen,
727
- theme
875
+ theme,
876
+ localNodePorts = DEFAULT_LOCAL_NODE_PORTS
728
877
  }) {
729
878
  const [nodeType, setNodeType] = react.useState("local");
730
- const [nodeUrl, setNodeUrl2] = react.useState("");
731
- const [isValid, setIsValid] = react.useState(true);
879
+ const [selected, setSelected] = react.useState(CUSTOM_SELECTION);
880
+ const [discovered, setDiscovered] = react.useState([]);
881
+ const [discovering, setDiscovering] = react.useState(false);
882
+ const [customUrl, setCustomUrl] = react.useState("");
732
883
  const [loading, setLoading] = react.useState(false);
733
884
  const [error, setError] = react.useState(null);
734
885
  const resolved = react.useMemo(() => resolveMeroTheme(theme), [theme]);
@@ -743,7 +894,7 @@ function LoginModal({
743
894
  react.useEffect(() => {
744
895
  const savedUrl = localStorage.getItem("mero:node_url");
745
896
  if (savedUrl) {
746
- setNodeUrl2(savedUrl);
897
+ setCustomUrl(savedUrl);
747
898
  }
748
899
  }, []);
749
900
  react.useEffect(() => {
@@ -753,25 +904,56 @@ function LoginModal({
753
904
  setNodeType("remote");
754
905
  }
755
906
  }, [connectionType]);
907
+ const [scanNonce, setScanNonce] = react.useState(0);
908
+ const portsKey = react.useMemo(() => localNodePorts.join(","), [localNodePorts]);
909
+ const remoteActive = isOpen && shouldShowRemote && nodeType === "remote";
756
910
  react.useEffect(() => {
757
- if (nodeType === "remote") {
758
- setIsValid(isValidUrl(nodeUrl));
759
- } else {
760
- setIsValid(true);
911
+ if (!remoteActive) {
912
+ return;
761
913
  }
762
- }, [nodeUrl, nodeType]);
914
+ const controller = new AbortController();
915
+ let active = true;
916
+ setDiscovering(true);
917
+ setDiscovered([]);
918
+ setError(null);
919
+ discoverLocalNodes({ ports: localNodePorts, signal: controller.signal }).then((nodes) => {
920
+ if (!active) return;
921
+ setDiscovered(nodes);
922
+ setSelected(nodes.length > 0 ? nodes[0] : CUSTOM_SELECTION);
923
+ }).catch(() => {
924
+ if (active) setSelected(CUSTOM_SELECTION);
925
+ }).finally(() => {
926
+ if (active) setDiscovering(false);
927
+ });
928
+ return () => {
929
+ active = false;
930
+ controller.abort();
931
+ };
932
+ }, [remoteActive, portsKey, scanNonce]);
933
+ const isCustom = selected === CUSTOM_SELECTION;
934
+ const hasDiscovered = discovered.length > 0;
935
+ const canConnect = !loading && (nodeType === "local" ? true : discovering ? false : isCustom ? isValidUrl(customUrl) : true);
936
+ const canConnectRef = react.useRef(canConnect);
937
+ canConnectRef.current = canConnect;
763
938
  const handleConnect = react.useCallback(async () => {
764
- if (!isValid) return;
939
+ const targetUrl = nodeType === "local" ? DEFAULT_LOCAL_NODE_URL : selected === CUSTOM_SELECTION ? customUrl : selected;
940
+ const usingDiscovered = nodeType === "remote" && selected !== CUSTOM_SELECTION;
941
+ if (nodeType === "remote" && !usingDiscovered && !isValidUrl(targetUrl)) {
942
+ return;
943
+ }
944
+ const normalizedUrl = targetUrl.replace(/\/+$/, "");
945
+ if (usingDiscovered) {
946
+ onConnect(normalizedUrl);
947
+ return;
948
+ }
765
949
  setLoading(true);
766
950
  setError(null);
767
- const baseUrl = nodeType === "local" ? "http://node1.127.0.0.1.nip.io" : nodeUrl;
768
951
  try {
769
952
  const response = await fetch(
770
- new URL("admin-api/is-authed", baseUrl).toString()
953
+ nodeEndpoint(normalizedUrl, "admin-api/is-authed")
771
954
  );
772
955
  if (response.ok || response.status === 401) {
773
956
  setLoading(false);
774
- const normalizedUrl = baseUrl.replace(/\/+$/, "");
775
957
  onConnect(normalizedUrl);
776
958
  } else {
777
959
  throw new Error(`Connection failed: ${response.statusText}`);
@@ -781,10 +963,108 @@ function LoginModal({
781
963
  setError("Failed to connect. Please check the URL and try again.");
782
964
  setLoading(false);
783
965
  }
784
- }, [isValid, nodeType, nodeUrl, onConnect]);
966
+ }, [nodeType, selected, customUrl, onConnect]);
785
967
  if (!isOpen) {
786
968
  return null;
787
969
  }
970
+ const showManualInput = isCustom;
971
+ const renderRadio = (value, label, meta) => {
972
+ const active = selected === value;
973
+ return (
974
+ // Selection is driven solely by the radio input's `onChange` — clicking
975
+ // anywhere on the wrapping label forwards to the input, and keyboard
976
+ // users can tab to / arrow through the (visually hidden but focusable)
977
+ // input. A label `onClick` here would double-fire `setSelected`.
978
+ /* @__PURE__ */ jsxRuntime.jsxs(
979
+ "label",
980
+ {
981
+ "data-testid": `node-option-${value === CUSTOM_SELECTION ? "custom" : displayNodeUrl(value)}`,
982
+ style: {
983
+ ...styles.radioItem,
984
+ ...active ? styles.radioItemActive : {}
985
+ },
986
+ children: [
987
+ /* @__PURE__ */ jsxRuntime.jsx(
988
+ "input",
989
+ {
990
+ type: "radio",
991
+ name: "mero-node",
992
+ value,
993
+ checked: active,
994
+ onChange: () => setSelected(value),
995
+ style: { position: "absolute", opacity: 0 }
996
+ }
997
+ ),
998
+ /* @__PURE__ */ jsxRuntime.jsx(
999
+ "span",
1000
+ {
1001
+ style: {
1002
+ ...styles.radioIndicator,
1003
+ ...active ? styles.radioIndicatorActive : {}
1004
+ },
1005
+ children: active && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.radioDot })
1006
+ }
1007
+ ),
1008
+ label,
1009
+ meta && /* @__PURE__ */ jsxRuntime.jsx("span", { style: styles.nodeMeta, children: meta })
1010
+ ]
1011
+ },
1012
+ value
1013
+ )
1014
+ );
1015
+ };
1016
+ const renderRemoteView = () => {
1017
+ if (discovering) {
1018
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.discovering, "data-testid": "node-discovering", children: [
1019
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.spinnerSmall }),
1020
+ /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Searching for local nodes..." })
1021
+ ] });
1022
+ }
1023
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1024
+ /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles.info, children: hasDiscovered ? "Select a discovered node, or enter a node URL manually." : "No local node found. Enter a node URL to continue." }),
1025
+ hasDiscovered && /* @__PURE__ */ jsxRuntime.jsxs(
1026
+ "div",
1027
+ {
1028
+ style: styles.radioList,
1029
+ role: "radiogroup",
1030
+ "aria-label": "Available nodes",
1031
+ children: [
1032
+ discovered.map(
1033
+ (url) => renderRadio(url, displayNodeUrl(url), "local")
1034
+ ),
1035
+ renderRadio(CUSTOM_SELECTION, "Enter node URL manually")
1036
+ ]
1037
+ }
1038
+ ),
1039
+ showManualInput && /* @__PURE__ */ jsxRuntime.jsx(
1040
+ "input",
1041
+ {
1042
+ type: "text",
1043
+ value: customUrl,
1044
+ onChange: (e) => setCustomUrl(e.target.value),
1045
+ placeholder: "https://your-node-url.calimero.network",
1046
+ style: styles.input,
1047
+ "data-testid": "node-url-input",
1048
+ autoFocus: !hasDiscovered,
1049
+ onKeyDown: (e) => {
1050
+ if (e.key === "Enter" && canConnectRef.current) {
1051
+ handleConnect();
1052
+ }
1053
+ }
1054
+ }
1055
+ ),
1056
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.toolbar, children: /* @__PURE__ */ jsxRuntime.jsx(
1057
+ "button",
1058
+ {
1059
+ type: "button",
1060
+ style: styles.rescan,
1061
+ onClick: () => setScanNonce((n) => n + 1),
1062
+ "data-testid": "rescan-button",
1063
+ children: "\u21BB Rescan local nodes"
1064
+ }
1065
+ ) })
1066
+ ] });
1067
+ };
788
1068
  const modalContent = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
789
1069
  /* @__PURE__ */ jsxRuntime.jsx("style", { children: `
790
1070
  @keyframes meroSpin { to { transform: rotate(360deg); } }
@@ -792,7 +1072,7 @@ function LoginModal({
792
1072
  @keyframes meroSlideIn { from { transform: translateY(-12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
793
1073
  ` }),
794
1074
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { ...themeVars, ...styles.overlay }, onClick: onClose, children: /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.content, onClick: (e) => e.stopPropagation(), children: [
795
- /* @__PURE__ */ jsxRuntime.jsx("button", { style: styles.closeButton, onClick: onClose, children: "\xD7" }),
1075
+ /* @__PURE__ */ jsxRuntime.jsx("button", { style: styles.closeButton, onClick: onClose, "aria-label": "Close", children: "\xD7" }),
796
1076
  /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.header, children: [
797
1077
  /* @__PURE__ */ jsxRuntime.jsx(CalimeroLogo, { size: 44, color: cssVar(resolved, "primary") }),
798
1078
  /* @__PURE__ */ jsxRuntime.jsx("h1", { style: styles.title, children: "Connect to Calimero" })
@@ -801,12 +1081,13 @@ function LoginModal({
801
1081
  /* @__PURE__ */ jsxRuntime.jsx("p", { children: "Connecting to node..." }),
802
1082
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.spinner })
803
1083
  ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
804
- /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles.info, children: shouldShowRadioGroup ? "Select your Calimero node type to continue." : connectionType === "local" /* Local */ ? "Connect to your local Calimero node." : "Enter your remote Calimero node URL." }),
1084
+ shouldShowRadioGroup && /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles.info, children: "Select your Calimero node type to continue." }),
805
1085
  error && /* @__PURE__ */ jsxRuntime.jsx("p", { style: styles.error, children: error }),
806
1086
  shouldShowRadioGroup && /* @__PURE__ */ jsxRuntime.jsxs("div", { style: styles.radioGroup, children: [
807
1087
  /* @__PURE__ */ jsxRuntime.jsxs(
808
1088
  "label",
809
1089
  {
1090
+ "data-testid": "node-type-local",
810
1091
  style: {
811
1092
  ...styles.radioLabel,
812
1093
  ...nodeType === "local" ? styles.radioLabelActive : {}
@@ -830,6 +1111,7 @@ function LoginModal({
830
1111
  /* @__PURE__ */ jsxRuntime.jsxs(
831
1112
  "label",
832
1113
  {
1114
+ "data-testid": "node-type-remote",
833
1115
  style: {
834
1116
  ...styles.radioLabel,
835
1117
  ...nodeType === "remote" ? styles.radioLabelActive : {}
@@ -851,34 +1133,22 @@ function LoginModal({
851
1133
  }
852
1134
  )
853
1135
  ] }),
854
- /* @__PURE__ */ jsxRuntime.jsx("div", { children: shouldShowRemote && nodeType === "remote" ? /* @__PURE__ */ jsxRuntime.jsx(
855
- "input",
856
- {
857
- type: "text",
858
- value: nodeUrl,
859
- onChange: (e) => setNodeUrl2(e.target.value),
860
- placeholder: "https://your-node-url.calimero.network",
861
- style: styles.input,
862
- onKeyDown: (e) => {
863
- if (e.key === "Enter" && isValid) {
864
- handleConnect();
865
- }
866
- }
867
- }
868
- ) : shouldShowLocal ? /* @__PURE__ */ jsxRuntime.jsxs("p", { style: styles.localInfo, children: [
1136
+ nodeType === "local" && shouldShowLocal && /* @__PURE__ */ jsxRuntime.jsxs("p", { style: styles.localInfo, children: [
869
1137
  "Using default local node: ",
870
1138
  /* @__PURE__ */ jsxRuntime.jsx("br", {}),
871
- /* @__PURE__ */ jsxRuntime.jsx("code", { style: styles.localInfoCode, children: "http://node1.127.0.0.1.nip.io" })
872
- ] }) : null }),
1139
+ /* @__PURE__ */ jsxRuntime.jsx("code", { style: styles.localInfoCode, children: DEFAULT_LOCAL_NODE_URL })
1140
+ ] }),
1141
+ nodeType === "remote" && shouldShowRemote && renderRemoteView(),
873
1142
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: styles.buttonGroup, children: /* @__PURE__ */ jsxRuntime.jsx(
874
1143
  "button",
875
1144
  {
876
1145
  onClick: handleConnect,
877
- disabled: !isValid || loading,
1146
+ disabled: !canConnect,
878
1147
  style: {
879
1148
  ...styles.button,
880
- ...!isValid || loading ? styles.buttonDisabled : {}
1149
+ ...!canConnect ? styles.buttonDisabled : {}
881
1150
  },
1151
+ "data-testid": "connect-button",
882
1152
  children: "Connect"
883
1153
  }
884
1154
  ) })
@@ -2670,6 +2940,7 @@ exports.AppMode = AppMode;
2670
2940
  exports.CalimeroLogo = CalimeroLogo;
2671
2941
  exports.ConnectButton = ConnectButton;
2672
2942
  exports.ConnectionType = ConnectionType;
2943
+ exports.DEFAULT_LOCAL_NODE_PORTS = DEFAULT_LOCAL_NODE_PORTS;
2673
2944
  exports.LoginModal = LoginModal;
2674
2945
  exports.MERO_CSS_VARS = MERO_CSS_VARS;
2675
2946
  exports.MeroContext = MeroContext;
@@ -2683,11 +2954,15 @@ exports.clearContextIdentity = clearContextIdentity;
2683
2954
  exports.clearNodeUrl = clearNodeUrl;
2684
2955
  exports.cssVar = cssVar;
2685
2956
  exports.defaultMeroTheme = defaultMeroTheme;
2957
+ exports.discoverLocalNodes = discoverLocalNodes;
2686
2958
  exports.getApplicationId = getApplicationId;
2687
2959
  exports.getContextId = getContextId;
2688
2960
  exports.getContextIdentity = getContextIdentity;
2689
2961
  exports.getNodeUrl = getNodeUrl;
2962
+ exports.localNodeUrl = localNodeUrl;
2690
2963
  exports.localStorageTokenStorage = localStorageTokenStorage;
2964
+ exports.nodeEndpoint = nodeEndpoint;
2965
+ exports.probeNodeHealth = probeNodeHealth;
2691
2966
  exports.resolveMeroTheme = resolveMeroTheme;
2692
2967
  exports.setApplicationId = setApplicationId;
2693
2968
  exports.setContextId = setContextId;