/LiquidCore

Node.js virtual machine for Android and iOS

Primary LanguageC++MIT LicenseMIT

The LiquidCore Project

Download

NPM

LiquidCore enables Node.js virtual machines to run inside Android and iOS apps. It provides a complete runtime environment, including a virtual file system.

LiquidCore also provides a convenient way for Android developers to execute raw JavaScript inside of their apps, as iOS developers can already do natively with JavaScriptCore.

Installation

Step 1: Make sure your project is configured for use with npm

In the root directory of your project, you must have a package.json file. If you do not already have one, you can create it by running:

$ npm init

and following the steps in the wizard.

Step 2: Install LiquidCore and configure package.json

$ npm i liquidcore
$ npx liquidcore init

The init step will add some utility scripts and the liquidcore object to your package.json file. It will also create an example service called example.js, which will get packaged into your app. You can change / add files to be packaged by editing the liquidcore.entry property in your package.json.

Step 3: Configure your mobile app project

Android

iOS

$ npx liquidcore gradle-config --module=<app>

where <app> is the name of your application module (the default in Android Studio is 'app').

$ npx liquidcore pod-config --target=<target> --podfile=<podfile>
$ npx liquidcore bundler --platform=ios
$ pod install

where <target> is your XCode project target, and <podfile> is the path of your application's Podfile

Notes on iOS

LiquidCore requires the use of Cocoapods, so make sure you've set up your project to use a Podfile first. Important: Your Podfile must contain the use_frameworks! directive. If you are not yet using Cocoapods, you can create a simple one like this:

platform :ios, '11.0'
use_frameworks!

target '<TARGET>' do
end

where <TARGET> is the name of your xcodeproj (without the .xcodeproj extension).

Also, any time you add a new liquidcore.entry point in package.json, you must first run the bundler and then run pod install again. This is a quirk of how Cocoapods works with finding files. Those files must exist at installation time, or they will not be available in the pod, even if they are created later. So after adding a new entry, just do this part again:

$ npx liquidcore bundler --platform=ios
$ pod install

Automatic Bundling

One of the newest features in 0.7.0+ is the ability to automatically bundle JavaScript files in the application build process. This is configured in the gradle-config and/or pod-config steps above. The bundling options are stored in the local package.json file in the liquidcore property. A typical file liquidcore object may look something like this:

  "liquidcore": {
    "entry": [
      "example.js",
      "index.js"
    ],
    "gradle_options": {
      "module": "app"
    },
    "bundler_output": {
      "android": "app/src/main/res/raw",
      "ios": ".liquidcore/ios_bundle"
    },
    "bundler_options": {
      "minify": false
    },
    "pod_options": {
      "dev": true,
      "target": "TestApp"
    }
  }

To include a new bundle, simply put the entry point JavaScript file in the entry array property. LiquidCore will generate one bundle for each entry during the build process.

If you have a non-standard configuration for your app, you may have to change some of these values. For example, bundler_output for Android assumes that your resources directory is at <app-module>/src/main/res. This is the Android Studio default. If you have changed this to something else, you will need to update this property.

Bundling is a convenient way to test and package your JavaScript projects. The bundler uses Metro to package up all of your required node modules into a single file that can be packaged as a resource in your app. If you are running on the Android Emulator or iOS Simulator, you can run a local server on your development machine and hot-edit your JavaScript code by npx liquidcore server in your project root. If you are using the Bundle API (described below), and your app is built in debug mode, it will first attempt to get the bundles from the server. If the server is not available, it will use the automated bundle packaged at build time. In release mode, it will always use the packaged bundle.

Usage

The MicroService API

A micro service is nothing more than an independent Node.js instance whose startup code is referenced by a URI. For example:

Android Kotlin

iOS Swift

val uri = MicroService.Bundle(androidContext, "example")
val service = MicroService(androidContext, uri)
service.start()
import LiquidCore
...
let url = LCMicroService.bundle("example")
let service = LCMicroService(url: url)
service?.start()

The service URI can either refer to a server URL or a local resource. For services that are automatically bundled with your app by LiquidCore, you can use the MicroService.Bundle() or LCMicroService.bundle() methods to generate the correct URI. Any javascript entry files referenced in your package.json in liquidcore.entry will get bundled automatically with each build. By default, the initialization script creates and packages example.js, but you can easily change this.

A micro service can communicate with the host app once the Node.js environment is set up. This can be determined by adding a start listener in the constructor:

Android Kotlin

iOS Swift

val uri = MicroService.Bundle(androidContext, "example")
val startListener = MicroService.ServiceStartListener {
    // .. The environment is live, but the startup
    // JS code (from the URI) has not been executed yet.
}
val service = MicroService(androidContext, uri,
    startListener)
service.start()

Conform to LCMicroServiceDelegate

let service = LCMicroService(url:url,
                        delegate:self)
service?.start()
...
func onStart(_ service: LCMicroService) {
    // .. The environment is live, but the
    // startup JS code (from the URI) has
    // not been executed yet.
}

A micro service communicates with the host through a simple EventEmitter interface, eponymously called LiquidCore. For example, in your JavaScript startup code:

LiquidCore.emit('my_event', {foo: "hello, world", bar: 5, l337 : ['a', 'b'] })

On the app side, the host app can listen for events:

Android Kotlin

iOS Swift

val listener = MicroService.EventListener {
    service, event, payload ->
    android.util.Log.i("Event:" + event,
        payload.getString("foo"))
    // logs: I/Event:my_event: hello, world
}
service.addEventListener("my_event", listener)

Conform to LCMicroServiceEventListener

service.addEventListener("my_event", listener:self)
...
func onEvent(_ service: LCMicroService, event: String,
               payload: Any?) {
    var p = (payload as! Dictionary<String,AnyObject>)
    NSLog(format:"Event: %@: %@", args:event, p["foo"]);
    // logs: Event:my_event: hello, world
}

Similarly, the micro service can listen for events from the host:

Android Kotlin

iOS Swift

val payload = JSONObject()
payload.put("hallo", "die Weld")
service.emit("host_event", payload)
var payload = ["hallo" : "die Weld"]
service.emitObject("host_event", object:payload)

Then, in Javascript:

LiquidCore.on('host_event', function(msg) {
   console.log('Hallo, ' + msg.hallo)
})

LiquidCore creates a convenient virtual file system so that instances of micro services do not unintentionally or maliciously interfere with each other or the rest of the Android/iOS filesystem. The file system is described in detail here.

Playing with example.js

When you follow the directions above, LiquidCore will automatically bundle a file called example.js, which looks like this:

const {LiquidCore} = require('liquidcore')

// A micro service will exit when it has nothing left to do.  So to
// avoid a premature exit, set an indefinite timer.  When we
// exit() later, the timer will get invalidated.
setInterval(()=>{}, 1000)

console.log('Hello, World!')

// Listen for a request from the host for the 'ping' event
LiquidCore.on( 'ping', () => {
    // When we get the ping from the host, respond with "Hello, World!"
    // and then exit.
    LiquidCore.emit( 'pong', { message: 'Hello, World from LiquidCore!' } )
    process.exit(0)
})

// Ok, we are all set up.  Let the host know we are ready to talk
LiquidCore.emit( 'ready' )

Below is an example of how to interact with this JavaScript code from the app. Note that hello_text on Android and textBox on iOS are UI elements whose setup is not shown here.

Android Kotlin

iOS Swift

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import org.liquidplayer.service.MicroService

class MainActivity : AppCompatActivity() {

  override fun onCreate(savedInstanceState:
                        Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    val hello = findViewById<TextView>(
        R.id.hello_text)

    val readyListener = MicroService.EventListener {
      service, _, _ -> service.emit("ping")
    }
    val pongListener = MicroService.EventListener {
      _, _, jsonObject ->
      val message = jsonObject.getString("message")
      runOnUiThread { hello.text = message }
    }
    val startListener =
        MicroService.ServiceStartListener{
      service ->
      service.addEventListener("ready", readyListener)
      service.addEventListener("pong", pongListener)
    }
    val uri = MicroService.Bundle(this, "example")
    val service = MicroService(this, uri,
                               startListener)
    service.start()
  }
}
import UIKit
import LiquidCore

class ViewController: UIViewController,
  LCMicroServiceDelegate,
  LCMicroServiceEventListener {

  @IBOutlet weak var textBox: UITextField!

  override func viewDidLoad() {
    super.viewDidLoad()

    let url = LCMicroService.bundle("example")
    let service = LCMicroService(url: url,
                                delegate: self)
    service?.start()
  }

  func onStart(_ service: LCMicroService) {
    service.addEventListener("ready",listener: self)
    service.addEventListener("pong", listener: self)
  }

  func onEvent(_ service: LCMicroService,
                   event: String,
                 payload: Any?) {
    if event == "ready" {
      service.emit("ping")
    } else if event == "pong" {
      let p = (payload as! Dictionary<String,AnyObject>)
      let message = p["message"] as! String
      DispatchQueue.main.async {
        self.textBox.text = message
      }
    }
  }
}

You can use this as a guide for how to create your own services. You can use npm install to install most JS-only (non-native) modules and require them as normal. The bundler will package all of the code into a single file.

API Documentation

Android Javadocs (liquidcore-Nodejs)

Android Javadocs (liquidcore-V8)

iOS Objective-C/Swift

License

Copyright (c) 2014 - 2020 LiquidPlayer

Distributed under the MIT License. See LICENSE.md for terms and conditions.