Skip to content

alshdavid-labs/rpc

Repository files navigation

JavaScript RPC Library

Glue for Web Workers and iFrames

This library serves to be a tiny (3kb), low level RPC implementation for JavaScript that allows for the interaction of values that exist in an external context. Think iframes and web workers.


Accesing something on the host page from a Worker
Can also work the other way around

Sample Projects

For a practical example of how this works, sample projects are available in the sample-projects folder.

These projects are also available for viewing hosted here:

Installation

NPM

Pretty standard npm installation

npm install --save @alshdavid/rpc

Then import using ES Modules or CommonJS Modules (Node 14+ allows for differential loading)

import { DataSource, Reference } from '@alshdavid/rpc'
const { DataSource, Reference } = require('@alshdavid/rpc')

CDN

You can add this directly as a <script> tag on your page with:

<script src="https://cdn.davidalsh.com/rpc/latest.js"></script>
<script>
  const { DataSource, Reference } = RPC
</script>

From Modules/Module Web Workers you can import the URL:

import { DataSource, Reference } from 'https://cdn.davidalsh.com/rpc/latest.js'

For Web Workers that are not using modules you can use

importScripts('https://cdn.davidalsh.com/rpc/latest.js')
const { DataSource, Reference } = RPC

Alternatively you can specify the version of the library in the URL:

<script src="https://cdn.davidalsh.com/rpc/latest.js"></script>
<script src="https://cdn.davidalsh.com/rpc/2.0.1.js"></script>
<script src="https://cdn.davidalsh.com/rpc/next.js"></script>

Library API

This is split into two halves:

The DataSource is the entity that has the target data in it's native context and the Reference is the entity that is consuming and interacting with that data.

Data Source

The DataSource simply exposes a variable. It can be an Object, string, function, etc.

// Swap MessagePorts
const source = new DataSource(port1, { Expose: 'This' })

Reference

The Reference acts like a cursor pointer to a segment of the data stored within the remote DataSource. It begins at the root level and can traverse the source.

// Swap MessagePorts
const ref0 = new Reference(port2)
const ref1 = ref0.property('Expose')
const value = await ref1.value() // 'This'

Methods

The Reference interacts with the source data using methods that observe, call or move the cursor.

interface IReference {
  property(...pathSegments: string[]): IReference
  set(value: any): Promise<void>
  value(): Promise<unknown>
  exec(...args: any[]): Promise<IReference>
  release(): Promise<void>
}

Different data types are eligible for different methods, for example you can not run exec() on a string as it's not a callable type, but you can use it on a function or method.

method usage
property This is used to traverse an object, it returns a new reference pointer to the path specified.

The originating reference pointer is kept, you can have multiple references.

Certain calls will result in cached values and those references must be manually released when done with them.
set This is used to set a property on an object to a certain value
value This method will convert a Reference into the value held at that reference location.

It's important to remember that only serializable types can be transferred like this.

Fortunately, not all values need to be transferred. Remote function invocation can accept Reference types which the source will convert into the native values
exec This method will invoke a method or function at the current Reference path.

It accepts simple serializable arguments like string, it accepts callback functions and Reference types as arguments.

It will return a Reference to it's return value.
release This is used to purge the local and remote caches of values relevant to the current Reference cursor.

Failing to do this will result in memory leaks in both the remote and local contexts.

Use Cases

Web Workers

A Web Worker may want access to the Window object on the host page. To do this, the host page will create a DataSource exposing the window object, then the worker will create a Reference to that, interacting with it as required.

Alternatively, if a Web Worker contains capabilities that the host page would like to interact with, then the Worker can create a DataSource, exposing the desired vales to the host page.

It's also possible for the host page and the Worker to both be consumers and data sources, cross exporting things on either side.

iFrames

Similarly to Web Workers, cross origin iframes do not have access to the host page's Window object.

Using this library, it is possible to either export functionality from an iframe, consume functionality from a host page or both simultaneously.

Catches

It's important to acknowledge that both entities (worker, iframe, host page) must have the RPC library installed and explicitly expose/consume capabilities from the other.

This does not give unlimited access to the DataSource context, only limited remote execution capabilities on an explicitly exported value.

Callback function arguments

Functions that accepts functions as arguments are supported by this library. So yes, you can use function over a serialized boundary.

Below are some examples with their remote implementations described

// const ref0 = () => 'Hello World'

const resultRef = await ref0.exec()
const value = await resultRef.value()

console.log(value) // 'Hello World'
// const ref0 = (value) => value

const resultRef = await ref0.exec('Hello World')
const value = await resultRef.value()

console.log(value) // 'Hello World'
// const ref0 = (callback) => callback()

await ref0.exec(() => {
  console.log('callback')
})

Function arguments that supply function arguments to the callback are also supported.

// const nextFunc = () => {}
// const ref0 = (callback) => callback(nextFunc)

await ref0.exec(async nextRef => {
  await nextRef.exec()
})

Exceptions

Errors thrown in the data source are propagated to the consumer through the reference. The errors are references to the error in the data source.

try {
  await ref0.exec()
} catch (errorRef) {
  const remoteMessage = await errorRef.reference.property('message').value()
  console.log(remoteMessage)
}

Realized values vs cursor traversal

Values are only transferred when .value(), .set() and (when arguments are supplied) .exec() are called.

Calling .property() or .release() will not transfer any values.

Generally, values are interacted with as references and are only transferred when the consumer requires the value within its own context. If an attempt to transfer a type that is not serializable is made, it will fail to send.

The library will automatically convert/send callbacks to the data source as references when passed into .exec(), but more complex types like objects with functions will not work (at least not yet). It may be required to write some small adapters/shims to facilitate smooth API access.

// Data = { foo: { bar: { foobar: 'foobar' }}}

const ref1 = await ref0.property('foo') // Create reference with path
const ref2 = await ref1.property('bar', 'foobar') // Build it up

const foobar = await ref2.value() // Now get the value there 

Memory Management

There might be some improvements I can make here with time, but for now it's important to .release() references that are cached in the DataSource.

The types of values that are cached include:

  • function return values
  • callback functions
  • callback parameters

These are associated with their Reference and must be manually purged when no longer needed to avoid memory leaks.

// const ref0 = () => 'Hello World'

const returnValueRef = await ref0.exec()

// The reference inside the DataSource for the return value
// is associated with the returned Reference on the consumer
// The consumer may use this value for as long as the value
// remains in the data source
const helloWorld = await returnValueRef.value()
console.log(helloWorld) // 'Hello World'

// Once the consumer no longer needs the value they are 
// required to manually purge values associated with their
// Reference by calling the following method
await returnValueRef.release()

// Subsequent attempts to access a released value will
// result in an undefined value being returned
const secondHelloWorld = await returnValueRef.value()
console.log(secondHelloWorld) // undefined
// const fooFn = () => {}
// const barFn = () => {}
// const ref0 = (callback) => callback(fooFn, barFn)

const returnValueRef = await ref0.exec(async (fooRef, barRef) => {
  // In situations where values are provided as callback arguments
  // the values must be released from within the callback
  console.log(await fooRef.value()) // 'foo'
  console.log(await barRef.value()) // 'bar'

  await fooRef.release()
  await barRef.release()
})

// The callback also needs to be released
// This may be changed in the future
ref0.release()

MessagePorts

The base API uses the browser MessagePort as the communication interface, so transfer your ports between your entities and you're good to go. It's usually best to send the port from the iframe to the host page rather than the other way around.

import { Reference } from '@alshdavid/rpc'

const iframe = document.createElement('iframe')
iframe.src = 'https://external-origin.com/iframe.html'

const onPort2 = new Promise(
  res => addEventListener('message', 
    e => e.data === 'PORT_TRANSFER' && res(e.ports[0])))

document.body.appendChild(iframe)
const port2 = await onPort2
port2.start()

const source = new Reference(port2, window)