This is a great question. In general, there are several approaches to language inter-operation:
Running code in completely separate, isolated programs / processes, and using interprocess communication (IPC) or other networking protocols (TCP or higher level protocols built on top of TCP like HTTP, often with a REST-ful API, or some form of RPC system) to send information between the two processes that have been written in different languages.
"Transpiling" one language into the other (e.g. using the JSweet or TeaVM transpilers to convert Java code to JavaScript code), and then creating a single application / process out of the original code in one language together with the transpiled code from the other language (which is now in the same language as the other code being built into that final application).
Using a common intermediate language and low-level "native" interfaces that allow the code to interoperate. Most languages have some form of interoperation with C (because C is a common denominator supported by most operating systems). While this would not work with client-side JavaScript (though some of the principles are still be relevant with Native Client (NaCL)), with NodeJs, you can call into C code using node-gyp and cwrap. Once you are in C land, you can call into Java using the Java Native Interface (JNI) (though making it possible to call your Java code from C using JNI is probably more easily accomplished by letting SWIG autogenerate much of the boilerplate for this for you, rather than directly writing to the JNI specification).
As with all things, there are tradeoffs to the various approaches:
- Approach #1:
- Pros:
- relatively straight-forward
- works with almost any programming language
- each subsystem is fully isolated from the other
- each system can be debugged in a language idiomatic manner
- Cons:
- must define shared protocol
- can result in redundant, duplicated code
- protocols must be kept in sync
- changes must be backwards-compatible or it will break
- NOTE: protocol buffers can help with this
- serialization/deserialization overhead
- channel can add other overhead (e.g. if communicating between processes over the Internet as opposed to on the same machine via a UNIX domain socket)
- must consider security of communication mechanism
- encryption of data between subsystems
- access control of the endpoints
- Approach #2:
- Pros:
- no serialization/deserialization overhead
- can debug final system using idioms for target language
- Cons:
- not all languages can transpile from one to the other
- even if a transpiler supports the two languages:
- often supports only a subset of the language
- may need to fix/modify code to allow it to transpile
- may need to fix/modify the transpiler
- slightly different semantics in transpilation can lead to subtle, surprising bugs
- no isolation between the subsystems
- Approach #3:
- Pros:
- no serialization/deserialization overhead
- more support than approach #2
- no need to rewrite original code in either language
- Cons:
- must become an expert in esoteric tools like SWIG
- result is very difficult to debug
- stacktraces for NodeJS code suddenly contain C, JVM, Java code
- debugging tools don't easily span languages (e.g. may end up stepping through JVM code interpreting Java, rather than stepping through the actual Java code)
- ownership of objects, garbage collection across languages can lead to surprising / difficult to handle bugs if ownership semantics not correctly encoded
- different threading models between languages or other semantic mismatches between languages can make entire system buggy / difficult to debug
Having used systems with approach #1 and approach #3 (as well as hearing of systems using approach #2), I would strongly recommend using approach #1 wherever possible; only if you find the serialization overhead to be untenable (and you are not able to optimize the communication protocol / mechanism to handle that problem) would I venture into the other waters. That being said, approach #2 can be successful if the languages are very similar (like transpilation from TypeScript to JavaScript), and approach #3 can be successful if the use of this mechanism is very limited in scope (e.g. just need to expose one small but frequently called / performance-sensitive function this way).