Skip to content

econsta/remote-controller

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Remote Controller

Remote Controller is the easiest way to interact across Websockets, Webworkers and WebRTC. The library is small, dependancy free, performant and powerful.

$ npm install --save remote-controller

Introduction

Whether it is a browser talking to a NodeJs server, or a the main Javascript thread talking to a Webworker, a frightening amount of code is dedicated to solving one fundamental problem: allowing two Javascript instances to communicate with each other.

This library is an attempt to create the most seamless communication system possible within the limits of the Javascript language. It accomplishes this by allowing you to create a controller for a remote Javascript object that exists on another Javascript instance.

This controller give you access to all the properties of the remote instance, lets you call its functions with arguments, attach listeners, modify its properties or practically anything you could do with a normal object with virtually the same syntax.

Examples

Minimum Example

Here is a bare minimum example, using the built-in worker messaging system as the transport layer:

worker.js

import { createReceiver } from 'remote-controller'

// Create or chose an existing object to share
let testObj = {	num1: 5, str1: 'foo'}
createReceiver(testObj, globalThis)

index.js

import { createController } from 'remote-controller'

let worker = new Worker('worker.js', {type: 'module'})

let testObj = createController(worker)
// To access properties on the remote object they must be awaited
let num = await testObj.num1 // num = 5
// Properties can be set without awaiting
testObj.num1 = 7

Messenger Example

Here is a simple messenger that uses Remote Controller to share an array of messages over a websocket. It shows how you can use built-in functions like Array.push() or addEventListener() on remote objects. You can view a working version of this in the examples folder

index.js

import { createController } from 'remote-controller'

const input = document.getElementsByTagName('input')[0]
const button = document.getElementsByTagName('button')[0]
const messageList = document.getElementById('messages')
let randomName = (Math.random() + 1).toString(36).substring(7)

const ws = new WebSocket('ws://localhost:3000')
let myMessager = createController(ws)

ws.addEventListener('open', async () => {
	button.addEventListener('click', () => {
		const message = {
			name: randomName,
			text: input.value
		}
		myMessager.messages.push(message)
		myMessager.sentMessage(message)
	})

	myMessager.et.addEventListener('message', async (e) => {
		let allMessages = await myMessager.messages
		console.log(e.detail)
		messageList.innerHTML = ''
		allMessages.forEach((message) => {
			const messageDiv = document.createElement('div')
			messageDiv.innerHTML = `
			<div>
				<span style="color: red">${message.name}: </span>
				<span>${message.text}</span>
			</div>
			`
			messageList.appendChild(messageDiv)
		})
	})
})

server.js

import { createReceiver } from 'remote-controller'
import { WebSocketServer } from 'ws'

class MyMessager {
	messages = []
	et = new EventTarget()

	sentMessage(message) {
		this.et.dispatchEvent(new CustomEvent('message', {detail: message}))
	}
}

let myMessager = new MyMessager()

const wss = new WebSocketServer({ port: 3000 })
wss.on('connection', (ws) => {
	createReceiver(myMessager, ws)
})

In-Depth Examples

To access properties on the remote object they must be awaited

index.jsworker.js
let num = await testObj.num1
console.log(num) // 5
let testObj = {	num1: 5, str1: 'foo' }

Properties can be set using a local value without awaiting

testObj.num1 = 2
console.log(await testObj.num1) // 2
let testObj = {	num1: 5, str1: 'foo' }

Properties can be set to another remote value

testObj.num1 = testObj.num2
console.log(await testObj.num1) // 1
let testObj = {	num1: 5, num2: 1 }

Remote functions can be called using local values as arguments, results must be awaited

let res = testObj.fun1(7)
console.log(await res) // 20
let testObj = {	
	num1: 5,
	fun1(arg1) {
		return arg1 + 13 
	}
}

Remote functions can be called using remote values, or a combination of remote and local values

let val1 = 11
let res = await testObj.fun1(res, testObj.num1, val1, 7)
console.log(res) // 140
let testObj = {	
	num1: 5,
	fun1(arg1) {
		return return arg1 + arg2 + arg3 + arg4 + 100
	}
}

Objects can be awaited to receive a copy of that object

let localObj = await testObj.obj1
console.log(localObj) // { num2: 2, num3: 11}
let testObj = {	
	num1: 5,
	obj1: { num2: 2, num3: 11 }
}

local copies of objects do not affect their remote counterparts, as they are copies

let localObj = await testObj.obj1
localObj.num2 = 4
console.log(await testObj.obj1.num2) // 2
let testObj = {	
	num1: 5,
	obj1: { num2: 2, num3: 11 }
}

all of the above also works for arrays

console.log(await testObj.arr1)
let testObj = {	
	num1: 5,
	arr1: [-1, -2, -3, -4, -5],
}

nested objects and circular dependencies also work, however promises and functions on objects will be undefined

let localObj2 = await testObj.obj2
console.log(localObj2) // { str3: "I am in obj2", circular: {…}, nested: {…} }
let testObj = {	
	num1: 5,
	arr1: [-1, -2, -3, -4, -5],
}

callback functions can be sent as arguments, and if these functions are called they are run on the controller's side

let callback = (arg1) => { 
	console.log(arg1 + 11) // 18
}
await testObj.fun3(2, callback)
let testObj = {	
	fun3(arg1, funArg) {
		let newArg = arg1 + 5
		funArg(newArg)
	},
}

callback functions cannot run on the remote side, fun4 returns undefined because it attempts to get the return of a callback function

let callback2 = (arg1) => {
	let res = arg1 + 12
	return res
}
let res3 = await testObj.fun4(2, callback2)
console.log(res3) // undefined
let testObj = {	
	fun4(arg1, funArg) {
		let newArg = arg1 + 5
		let res = funArg(newArg)
		return res
	},
}

functions can be sent to run on the remote side using the fnArg function, this will allow fun4 to run

let notCallback = (arg1) => {
	let res = arg1 + 12
	return res
}
let res4 = await testObj.fun4(2, fnArg(notCallback)) 
console.log(res4) // 19
let testObj = {	
	fun4(arg1, funArg) {
		let newArg = arg1 + 5
		let res = funArg(newArg)
		return res
	},
}

If fnArgs use variables in the local scope, these can be sent with the function in order to run on the remote side

let localVar = 100
let notCallback2 = (arg1) => {
	let res = arg1 + 12 + localVar
	return res
}
let res5 = await testObj.fun4(2, fnArg(notCallback2, {localVar})) 
console.log(res5) // 119
let testObj = {	
	fun4(arg1, funArg) {
		let newArg = arg1 + 5
		let res = funArg(newArg)
		return res
	},
}

API

createController(Transport)

Creates the controller using a transport system, and returns the remote object. Use this on the local JavaScript instance.

createReceiver(Object, Transport)

Creates the receiver using a transport system, and the object you would like the Controller side to have access to. This has no return value. This is used on the remote side.

Class: Transport

This class represents a transport layer that the Controller or Receiver will be using. There are built in Transport objects for both websockets and workers, but if you would like to use something else you can declare a new Transport for Remote Controller to use.

new Transport(config)

  • config {Object}
    • adapt {Function} A callback function used to setup message handlers on the underlying transport
    • postMessage {Function} A callback function to send a message over the underlying transport
    • destroy {Function} An optional teardown method

Here is an example that adapts for Websockets

new Transport({
	adapt: transport => {
		ws.onmessage = e => {
			transport.onMessage(JSON.parse(e.data))
		}
	},
	postMessage: data => {
		ws.send(JSON.stringify(data))
	}
})

Uses

This library has potential to be used in any situation where two Javascript instances need to communicate. A major inspiration to this project was the desire to create an enhanced version of Google's Comlink so it should be a perfect fit for any project where that could be used. It is currently used in production to centrally manage a browser based peer to peer video streaming network.

The most important factor to consider when deciding to use Remote Controller is security. Because of the amount of power a controller gives over a remote it can be used freely between secured contexts, or from a secure context to control an object in an insecure context, but it should never be used to control an object in a secured context from an insecure context. For example, it is perfectly fine to use a server (secured context) to control objects on a user's browser (insecure context), or from the main Javascript thread (secure context) to a worker (secured context). However, an external user's browser (insecure context) should not be given remote control over an object on your server (secured context). Similarly, another user's browser (insecure context) should not be given control over an object in the current user's browser (secured context).

Further Work

There are two main enhancements that would be valuable for this library

  1. Type support

  2. Security

Unfortunately due to the complexity of the Remote type, current Typescript cannot accurately define all of its features. Hopefully in newer versions of Typescript this will be possible, or some brilliant developer finds a workaround (possibly a LSP extension?).

The main limitation in use for this library is that it shouldn't be used from insecure contexts, but I am fairly certain that a secured version of a Remote could exist which would allow this library to replace something like a REST API. I would really appreciate any ideas or discussions about how this could be achieved.

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published