How to support custom Javascript scripting in Go Applications
- 🏷 javascript
- 🏷 golang
- 🏷 parser
Why will someone need a Javascript Parser, written natively in Go? Isn’t it a crazy, Architecture Astronauts solution that is looking for a problem? Not necessarily.
There was a time when applications allowed some kind of scripting to extend them and to make them fit into any workflow. For example VBScript for Microsoft office products. However, very few Web applications have the infrastructure to allow custom scripts inside them. There are a few that does support; one example is Google Apps Script.
Of course, most web platforms support REST APIs. However, it requires running a separate server that runs your custom scripts that can call the APIs. There are IFTTTs and Zapiers that can do integration with other similar services. However, none of those solutions can match the seamless integration possible by allowing custom scripting inside the web app itself.
Imagine you have an email service like Gmail or Hey. Let’s say your users want more control over their email workflow by using their own scripting. For example :
myemail.RegisterHook("onEmailReceived", iGotEmail)
function iGotEmail(newEmail)
{
if(newEmail.subject.startsWith("URGENT:"))
{
newEmail.setPriority(5)
newEmail.reply("Hello,\n Received your email. We will respond on priority basis. \n\nThanks\n")
return
}
else if(newEmail.to.includes("sales@website"))
{
newEmail.moveTo("Sales")
return
}
}
If you have a Javascript interpreter that allows limited operations running in a sandbox, implementing scriptability will be much easier. That is where a parser for native Go becomes helpful.
Why Javascript?
The scripting language could be any of the many available. Why choose Javascript? Javascript is one of the most popular languages, has detailed documentation, books, StackOverflow answers, has very rich tooling (Translate from ES6, Typescript to ES5). So, you don’t have to teach the language syntax to your users.
Go packages for running Javascript
One option is to use V8 bindings for Go. There are a few packages available.
- GitHub - behrsin/go-v8: V8 Bindings for Go
- GitHub - augustoroman/v8: A Go API for the V8 javascript engine.
- GitHub - rogchap/v8go: Execute JavaScript from Go
Looking for pure go implementation takes you to otto and goja
I would go with the native implementation if the requirements of Javascript support does not require full language support. GoJa has Full ECMAScript 5.1 and has unit tests for almost all ES5.1 features. That makes it easy to verify the language features quickly.
Step 1: Running simple Javascript Code from Go
Let’s try getting familiar with the goja library. We run Javascript from Go
package mod2
import (
"fmt"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
)
func SimpleJS() {
vm := goja.New()
new(require.Registry).Enable(vm)
console.Enable(vm)
script := `
console.log("Hello world - from Javascript inside Go! ")
`
fmt.Println("Compiling ... ")
prog, err := goja.Compile("", script, true)
if err != nil {
fmt.Printf("Error compiling the script %v ", err)
return
}
fmt.Println("Running ... \n ")
_, err = vm.RunProgram(prog)
}
Mapping values from Go, to Javascript and vis-versa
goja has a Value interface defined that is used to convert values to and from Go
type Value interface {
ToInteger() int64
ToString() Value
String() string
ToFloat() float64
ToNumber() Value
ToBoolean() bool
ToObject(*Runtime) *Object
SameAs(Value) bool
Equals(Value) bool
StrictEquals(Value) bool
Export() interface{}
ExportType() reflect.Type
// contains filtered or unexported methods
}
goja.Runtime has a function ToValue() that will convert from native Go type to internal representation for the Javascript VM.
For example, vm.ToValue("a string value")
converts from go string.
Similarly, the values returned from the RunTime are also of Value type. You can convert to Go type using the Value
interface functions like value.ToBoolean()
or value.ToString()
.
Sometimes, it is more convenient to export to a provided variable. In such cases, use value.ExportTo()
You have to provide a pointer of the appropriate type as the second parameter.
Call a Javascript function from Go
Next, let’s try calling a Javascript function from Go
import (
"fmt"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
)
func CallJSFunction() {
vm := goja.New()
new(require.Registry).Enable(vm)
console.Enable(vm)
script := `
function myFunction(param)
{
console.log("myFunction running ...")
console.log("Param = ", param)
return "Nice meeting you, Go"
}
`
prog, err := goja.Compile("", script, true)
if err != nil {
fmt.Printf("Error compiling the script %v ", err)
return
}
_, err = vm.RunProgram(prog)
var myJSFunc goja.Callable
err = vm.ExportTo(vm.Get("myFunction"), &myJSFunc)
if err != nil {
fmt.Printf("Error exporting the function %v", err)
return
}
res, err := myJSFunc(goja.Undefined(), vm.ToValue("message from go"))
if err != nil {
fmt.Printf("Error calling function %v", err)
return
}
fmt.Printf("Returned value from JS function\n%s \n", res.ToString())
}
goja.Runtime Get() function gets values from the global object. myFunction
is defined in the global scope. So you can get the function calling: vm.Get("myFunction")
. This returns the Value interface for the function. Then we use the ExportTo
function to get the function to a variable of the right type.
The first parameter to the function is the “this” of the Javascript function. Since this function is not meant to use “this” we can pass Undefined()
. The second parameter of the Go function is the first parameter of the Javascript function - a simple string. We can convert from Go string to Value interface type by calling RunTime.ToValue()
function.
The return value from the function is also of Value
type. Since we know that the return value is string, just calling res.ToString()
works just fine.
Receiving a Javascript Object back in Go
Making a specialized object available in Javascript
Let’s come back to our original intention of making your App scriptable. For the custom script to be useful, we have to expose parts of the App to the script. For example, in order to write custom script for Google Sheets, you have a SpreadsheetApp
object that provides that access. For example, SpreadsheetApp.getActiveSheet()
gets the active sheet. Then you can access the rows by calling sheet.getDataRange().getValues()
.
Now let’s review the sample code in the beginning of this article.
myemail.RegisterHook("onEmailReceived", iGotEmail)
Our scripting environment requires a myemail
object through which the user can register a hook that will get called whenever an email is received.
We can create an Object in Go and add it to the globals
of the Javascript RunTime.
import (
"fmt"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
)
type Hooks struct {
OnNewEmail []*goja.Value
BeforeSendingEmail []func(e *Email)
}
type Email struct {
Subject string
Body string
}
func (h Hooks) Init() {
h.OnNewEmail = make([]*goja.Value, 0)
h.BeforeSendingEmail = make([]func(e *Email), 0)
}
func (h *Hooks) TriggerNewEmailEvent(email *Email, vm *goja.Runtime) {
for _, newEmail := range h.OnNewEmail {
var newEmailCallBack func(e *Email)
vm.ExportTo(*newEmail, &newEmailCallBack)
newEmailCallBack(email)
}
}
func WorkWithHooks() {
var hooks Hooks
hooks.Init()
vm := goja.New()
new(require.Registry).Enable(vm)
console.Enable(vm)
obj := vm.NewObject()
obj.Set("RegisterHook", func(hook string, fn goja.Value) {
hooks.OnNewEmail = append(hooks.OnNewEmail, &fn)
fmt.Println("Registered the Hook ")
})
vm.Set("myemail", obj)
script := `
console.log("JS code started ")
myemail.RegisterHook("onEmailReceived", iGotEmail)
function iGotEmail(newEmail)
{
console.log("New Email callback received. \n",newEmail.Subject,"\n", newEmail.Body)
}
`
prg, err := goja.Compile("", script, true)
if err != nil {
fmt.Printf("Error compiling the script %v ", err)
return
}
_, err = vm.RunProgram(prg)
email := &Email{
Subject: "Your order for blue widgets",
Body: "Will be delivered in 1.3 micro seconds",
}
fmt.Println("Triggering the event ")
hooks.TriggerNewEmailEvent(email, vm)
}
First we add a method to the custom object that makes it possible for the users to register their “hooks”.
obj.Set("RegisterHook", func(hook string, fn goja.Value) {
hooks.OnNewEmail = append(hooks.OnNewEmail, &fn)
fmt.Println("Registered the Hook ")
})
Hooks are pointers to functions inside the script.
We keep the hooks in a table and call the hooks when an event occurs.
Note that we are getting the Value
object for the callback from the RunTime and keeping it in the hooks
table.
In the TriggerNewEmailEvent
function, we convert the Value
object to function and call it passing the Email object.
func (h *Hooks) TriggerNewEmailEvent(email *Email, vm *goja.Runtime) {
for _, newEmail := range h.OnNewEmail {
var newEmailCallBack func(e *Email)
vm.ExportTo(*newEmail, &newEmailCallBack)
newEmailCallBack(email)
}
}
goja can convert from go struct to Javascript object. This is useful in simple cases such as passing value. For more closer integration, we will have to make a custom Object
like that of the myemail
object.
Exposing more Programmable objects to the script
The sample script at the beginning of this article has one more level of exposure. It calls newEmail.reply()
and also newEmail.moveTo()
to move the email to another folder. We have to build another goja.Object for the email object.
func makeEmailJSObject(vm *goja.Runtime, email *Email) *goja.Object {
obj := vm.NewObject()
obj.Set("subject", email.Subject)
obj.Set("body", email.Body)
obj.Set("to", email.To)
obj.Set("reply", func(body string) {
fmt.Printf("Replying:\n%s\n", body)
})
obj.Set("setPriority", func(p int) {
fmt.Printf("Set email priority to %d\n", p)
})
obj.Set("moveTo", func(folder string) {
fmt.Printf("Moving email to folder %s\n", folder)
})
return obj
}
We can now update our original code to use this function and make use of the new Email object.
import (
"fmt"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
)
type Hooks struct {
OnNewEmail []*goja.Value
BeforeSendingEmail []func(e *Email)
}
type Email struct {
Subject string
Body string
Priority int
To []string
}
func (h Hooks) Init() {
h.OnNewEmail = make([]*goja.Value, 0)
h.BeforeSendingEmail = make([]func(e *Email), 0)
}
func (h *Hooks) TriggerNewEmailEvent(email *Email, vm *goja.Runtime) {
eobj := makeEmailJSObject(vm, email)
for _, newEmail := range h.OnNewEmail {
var newEmailCallBack func(*goja.Object)
vm.ExportTo(*newEmail, &newEmailCallBack)
newEmailCallBack(eobj)
}
}
func makeEmailJSObject(vm *goja.Runtime, email *Email) *goja.Object {
obj := vm.NewObject()
obj.Set("subject", email.Subject)
obj.Set("body", email.Body)
obj.Set("to", email.To)
obj.Set("reply", func(body string) {
fmt.Printf("Replying:\n%s\n", body)
})
obj.Set("setPriority", func(p int) {
fmt.Printf("Set email priority to %d\n", p)
})
obj.Set("moveTo", func(folder string) {
fmt.Printf("Moving email to folder %s\n", folder)
})
return obj
}
func WorkWithHooks() {
var hooks Hooks
hooks.Init()
vm := goja.New()
new(require.Registry).Enable(vm)
console.Enable(vm)
obj := vm.NewObject()
obj.Set("RegisterHook", func(hook string, fn goja.Value) {
switch hook {
case "onEmailReceived":
hooks.OnNewEmail = append(hooks.OnNewEmail, &fn)
fmt.Println("Registered onEmailReceived Hook ")
}
})
vm.Set("myemail", obj)
script := `
console.log("JS code started ")
myemail.RegisterHook("onEmailReceived", iGotEmail)
function iGotEmail(newEmail)
{
console.log("newEmail, subject %s ", newEmail.subject, newEmail.to)
if(newEmail.subject.startsWith("URGENT:"))
{
newEmail.setPriority(5)
newEmail.reply("Hello,\n Received your email. We will respond on priority basis. \n\nThanks\n")
return
}
else if(newEmail.to.includes("sales@website"))
{
newEmail.moveTo("Sales")
return
}
}
`
prg, err := goja.Compile("", script, true)
if err != nil {
fmt.Printf("Error compiling the script %v ", err)
return
}
_, err = vm.RunProgram(prg)
email1 := &Email{
Subject: "URGENT: Systems down!",
Body: "5 of your systems are down at the moment",
To: []string{"some@one.cc"},
}
fmt.Println("Triggering the urgent email event ")
hooks.TriggerNewEmailEvent(email1, vm)
email2 := &Email{
Subject: "New order received!",
Body: "You got new order for 1k blue widgets!",
To: []string{"sales@website"},
}
fmt.Println("Triggering the sales email event ")
hooks.TriggerNewEmailEvent(email2, vm)
}
We first trigger the “Urgent” email event and then trigger the “sales” email event. The custom script directs the flow through the two cases handled in the script. That is the beauty of making the system scriptable. Now the user has the power to change and enhance the system behavior which was otherwise not possible.