This document discusses the findings from a research spike to understand the benefits and tradeoffs of using Node.JS for native functionality instead of V8 native extensions (our current solution).
The node server would be embedded in the desktop application (either as an executable that runs as a separate process, or as a dynamic library that is loaded by the app shell).
Yes. Using a pre-built Node executable works on both Mac and Windows. Node is run as a separate process and communication happens over pipes.
We are also able to build dynamic library (dylib/dll) versions of Node for both Mac and Windows. Doing this requires a 1-line change to the GYP build files (changing the build type from "executable" to "shared_library". Build environments are easy to set up for both Mac (install xcode) and Windows (install latest Visual Studio Express and Python). These dynamic libraries would only need to be built once (and we've already built them).
Everything should work equivalently on Linux (Node works on Linux) but because we don't have any app shell solution for Linux, this is difficult to verify. It is likely that using Node will make our Linux story simpler.
I recommend using a dynamic library as a long term solution. This removes the need for IPC, which will either a.) be brittle, or b.) require a lot of implementation work to get right.
With a dynamic library solution, native functions (e.g. to create menus or show file dialogs) can be called directly from the Node extension code, rather than having to write a bridge that shuttles all arguments as strings.
There are two approaches here: using Node, and using V8 extensions. Native UI code (e.g. showing an "open" dialog) typically needs to run in a specific UI thread (e.g. the "main" thread in Mac Cocoa). This poses some interesting challenges.
In CEF3 / Chrome Content Shell, the V8 engine runs in a separate process from the main app. So, IPC is needed to drive native UI. However, CEF3 provides a wrapper for doing this IPC. If we go with a separate process Node solution (discussed above), we'll need IPC for this as well. But, we'll have to write that IPC solution ourselves.
If we a.) decide to go with a separate process Node solution, and b.) if we can be certain that Node code will never need to reach into the main process (e.g. Node code won't ever do UI stuff), then we may be able to avoid writing complicated IPC code for Node. Instead, we can use V8 extensions in CEF3 (and CEF3s IPC solution) to do our native UI work.
However, if we go with an in-process library version of Node (my current recommendation), we could do all of our UI stuff in Node. This would allow us to avoid using IPC on the V8 side. And, this would have the added architectural benefit that all native stuff (UI and otherwise) would go through the same channel. (This may be a small benefit, though.)
Ultimately, it isn't yet clear what the best solution is. We need to decide whether we'll use an in-process library version of Node or a separate-process executable version of Node.
We built a client <-> server websocket proxy that abstracts the communication layer into regular function calls on both ends. So, if existing functionality is already (correctly) written to work asynchronously, the architecture change is minimal or non-existant.
For example, to switch file i/o from V8 extensions to Node, we simply swapped out the implementation of
brackets.fs. The higher-level FS operations (in NativeFileSystem) did not have to change at all.
For things that are not currently implemented as asynchronous calls, there will need to be a change. However, it seems likely that everything that would be implemented on the Node side should be implemented asynchronously even if we don't use Node.
The main change to the programming model is that we'll need to decide where to implement features: either on the client JS side or on the server JS side. In general, the client should only implement things that we know will be roughly the same in the desktop and browser versions of Brackets. Anything unique to the desktop version should be implemented (as much as possible) on the Node side.
The main change is that there will be two JS engines. These will need to be debugged separately.
The two main areas of performance concern are startup time (because spinning up Node, an http server, and a websocket server takes time) and file i/o time (because this will be the main use of Node). There are no performance concerns around initiating native UI interactions.
All of the performance numbers below were generated on a 2010 MacBook Air with SSD.
It appears that most of the startup time will be governed by the web component (e.g. CEF) startup time. A warm launching of a simple Cocoa application with Node and a WebKit WebView component takes 250ms. This time includes starting Node and redirecting the WebView to a webpage served by Node. The timing measurement goes from the first line of
main() to the webpage calling back to Node over a websocket.
Actual startup time measurements will need to be determined after we've decided on a web component solution.
The most intensive file i/o Brackets is likely to do is a "find in files" command over a large codebase.
To test this, we created a test codebase that consisted of all the js files in the brackets repo (located in the same directory structure). This test set consists of 89743 lines of code at a size of 3.8MB. We performed a search for the regular expression
/brackets/gi which returned ~600 results in ~95 files.
To summarize, naively switching our file i/o to Node results in a 1.85x slowdown. Moving file intensive JS code to Node (which requires only minimal changes to the code) results in a 2.4x speedup.
Yes. Loading modules in Node is simpler than in Brackets client code because Node has native support for "require" and direct access to the file system. Modules can be loaded dynamically. We can use the same loading approach (i.e. looking in a directory) as we use for client extensions.
We implemented a prototype extension that adds a "grep" command to the client/server proxy for doing "find in files" on the server. The implementation was straightforward.
This does represent an additional external dependency. However, the current version of Node seems stable enough for our use, so there isn't a big concern about whether Node will remain in active development.
Furthermore, we can use the distributed (i.e. not built by us) Node executables. They are completely statically linked, so do not require any installation.
Finally, dynamic library versions of node are easy to build (see above).
The Node server maintains an http and websocket server on the user's
localhost interface. This interface is not accessible from other computers (either on the same network or over the internet).
Another process running on the user's computer could connect to this interface and use it to execute arbitrary code on the user's computer. However, this process could also execute arbitrary code itself. Brackets doesn't run with any special privilege, so this doesn't represent a large security concern.
If the user's computer were running malicious code, this malicious code could also make the localhost interface available over the internet. Again, this doesn't really represent a security concern -- the same malicious code could present a similar interface to the Internet by itself without Brackets present.
A prototype implementation is checked into the
node-proxy branches in adobe/brackets and adobe/brackets-app repos. This prototype only provides Node file i/o and websocket implementation. It does not contain any native ui implementation.
To run this code prototype:
node-proxybranch in both repos (brackets and brackets-app)
Shell prototypes (with native ui implementation) are currently checked in to the joelrbrandt/node-shell repo. (However, the code is a bit messy and there isn't any documentation beyond comments in the source.)