Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/commit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ jobs:
~/go/bin
key: go-test-${{ hashFiles('**/go.mod', '**/go.sum') }}

- run: go test ./... -v
- run: CGO_ENABLED=0 go test ./... -v
- run: go build -buildmode=c-shared -o main.so
- run: go tool golangci-lint run

Expand Down
4 changes: 4 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ require (
github.com/daixiang0/gci v0.13.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
Expand All @@ -54,6 +56,7 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.9 // indirect
github.com/go-critic/go-critic v0.12.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
github.com/go-toolsmith/astcopy v1.1.0 // indirect
github.com/go-toolsmith/astequal v1.2.0 // indirect
Expand All @@ -75,6 +78,7 @@ require (
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
Expand Down
6 changes: 6 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
Expand Down Expand Up @@ -169,6 +173,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
Expand Down
205 changes: 205 additions & 0 deletions go/javascript.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package main

import (
"fmt"
"io"
"log"
"math/rand"
"os"
"sync"

"github.com/dop251/goja"
"github.com/envoyproxy/dynamic-modules-examples/go/gosdk"
)

const (
javaScriptExportedSymbolOnConfig = "OnConfigure"
javaScriptExportedSymbolOnRequestHeaders = "OnRequestHeaders"
javaScriptExportedSymbolOnResponseHeaders = "OnResponseHeaders"

functionDeclTemplate = `globalThis.%[1]s = %[1]s`
numberOfVMPool = 24
)

type (
// javaScriptFilterConfig implements [gosdk.HttpFilterConfig].
javaScriptFilterConfig struct {
vms [numberOfVMPool]*javaScriptVM
}
// javaScriptFilter implements [gosdk.HttpFilter].
javaScriptFilter struct {
vm *javaScriptVM
requestHeaders map[string]string
responseHeaders map[string]string
}
javaScriptVM struct {
*goja.Runtime
mux sync.Mutex
onRequestHeaders goja.Callable
onResponseHeaders goja.Callable
}
)

func newJavaScriptFilterConfig(userCode string) gosdk.HttpFilterConfig {
c := &javaScriptFilterConfig{}

for i := range numberOfVMPool {
vm, err := newJavaScriptVM(userCode, os.Stdout)
if err != nil {
log.Printf("failed to create JavaScript VM: %v", err)
return nil
}
c.vms[i] = vm
}
return c
}

func newJavaScriptVM(script string, w io.Writer) (*javaScriptVM, error) {
vm := goja.New()
console := vm.NewObject()
err := console.Set("log", func(call goja.FunctionCall) goja.Value {
args := make([]interface{}, 0, len(call.Arguments))
for _, a := range call.Arguments {
args = append(args, a.Export())
}
_, _ = fmt.Fprint(w, args...)
return goja.Undefined()
})
if err != nil {
return nil, fmt.Errorf("failed to set console.log: %w", err)
}
err = vm.Set("console", console)
if err != nil {
return nil, fmt.Errorf("failed to set console: %w", err)
}

_, err = vm.RunString(script)
if err != nil {
return nil, fmt.Errorf("failed to run script: %w", err)
}

// Call OnConfigure.
onConfigure, ok := goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnConfig))
if !ok {
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnConfig)
}
_, err = onConfigure(goja.Undefined())
if err != nil {
return nil, fmt.Errorf("failed to call %s function: %w", javaScriptExportedSymbolOnConfig, err)
}

ret := &javaScriptVM{Runtime: vm}
// Check two exported functions.
ret.onRequestHeaders, ok = goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnRequestHeaders))
if !ok {
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnRequestHeaders)
}
ret.onResponseHeaders, ok = goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnResponseHeaders))
if !ok {
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnResponseHeaders)
}
return ret, nil
}

// NewFilter implements [gosdk.HttpFilterConfig].
func (p *javaScriptFilterConfig) NewFilter() gosdk.HttpFilter {
vm := p.vms[rand.Intn(numberOfVMPool)]
return &javaScriptFilter{vm: vm, requestHeaders: make(map[string]string), responseHeaders: make(map[string]string)}
}

// RequestHeaders implements [gosdk.HttpFilter].
func (p *javaScriptFilter) RequestHeaders(e gosdk.EnvoyHttpFilter, _ bool) gosdk.RequestHeadersStatus {
headers := e.GetRequestHeaders()
for k, vs := range headers {
p.requestHeaders[k] = vs[0]
}
p.vm.mux.Lock()
defer p.vm.mux.Unlock()
vm := p.vm
obj := vm.NewObject()
_ = obj.Set("getRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
key := call.Argument(0).String()
return vm.ToValue(p.requestHeaders[key])
})
_ = obj.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return goja.Undefined()
}
key := call.Argument(0).String()
value := call.Argument(1).String()
p.requestHeaders[key] = value
e.SetRequestHeader(key, []byte(value))
return goja.Undefined()
})
if _, err := vm.onRequestHeaders(goja.Undefined(), obj); err != nil {
log.Printf("failed to call %s: %v", javaScriptExportedSymbolOnRequestHeaders, err)
return gosdk.RequestHeadersStatusStopIteration
}
return gosdk.RequestHeadersStatusContinue
}

// ResponseHeaders implements [gosdk.HttpFilter].
func (p *javaScriptFilter) ResponseHeaders(e gosdk.EnvoyHttpFilter, _ bool) gosdk.ResponseHeadersStatus {
headers := e.GetResponseHeaders()
for k, vs := range headers {
p.responseHeaders[k] = vs[0]
}
p.vm.mux.Lock()
defer p.vm.mux.Unlock()
vm := p.vm
obj := vm.NewObject()
_ = obj.Set("getRequestHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
key := call.Argument(0).String()
return vm.ToValue(p.requestHeaders[key])
})

// Setting request header in response phase is not allowed.

_ = obj.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return vm.ToValue("")
}
key := call.Argument(0).String()
return vm.ToValue(p.responseHeaders[key])
})
_ = obj.Set("setResponseHeader", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 2 {
return goja.Undefined()
}
key := call.Argument(0).String()
value := call.Argument(1).String()
p.responseHeaders[key] = value
e.SetResponseHeader(key, []byte(value))
return goja.Undefined()
})
if _, err := vm.onResponseHeaders(goja.Undefined(), obj); err != nil {
log.Printf("failed to call %s: %v", javaScriptExportedSymbolOnResponseHeaders, err)
return gosdk.ResponseHeadersStatusStopIteration
}
return gosdk.ResponseHeadersStatusContinue
}

// Destroy implements [gosdk.HttpFilterConfig].
func (p *javaScriptFilterConfig) Destroy() {}

// Scheduled implements gosdk.HttpFilter.
func (p *javaScriptFilter) Scheduled(gosdk.EnvoyHttpFilter, uint64) {}

// Destroy implements [gosdk.HttpFilter].
func (p *javaScriptFilter) Destroy() {}

// RequestBody implements [gosdk.HttpFilter].
func (p *javaScriptFilter) RequestBody(gosdk.EnvoyHttpFilter, bool) gosdk.RequestBodyStatus {
return gosdk.RequestBodyStatusContinue
}

// ResponseBody implements [gosdk.HttpFilter].
func (p *javaScriptFilter) ResponseBody(gosdk.EnvoyHttpFilter, bool) gosdk.ResponseBodyStatus {
return gosdk.ResponseBodyStatusContinue
}
134 changes: 134 additions & 0 deletions go/javascript_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"bytes"
"testing"

"github.com/envoyproxy/dynamic-modules-examples/go/gosdk"
"github.com/stretchr/testify/require"
)

func Test_newJavaScriptFilterConfig(t *testing.T) {
f := newJavaScriptFilterConfig(`
function OnConfigure () {}
function OnRequestHeaders(ctx) {}
function OnResponseHeaders(ctx) {}
`)
require.NotNil(t, f)
}

func Test_newJavasScriptVM(t *testing.T) {
for _, tc := range []struct {
name string
script string
expOut string
expErr string
}{
{
name: "valid script with all functions",
expOut: `OnConfigure called`,
script: `
function OnConfigure () {
console.log("OnConfigure called");
}
function OnRequestHeaders(ctx) {
console.log("OnRequestHeader called");
}
function OnResponseHeaders(ctx) {
console.log("OnResponseHeader called");
}
`,
},
{
name: "invalid script with missing functions",
script: `
function OnConfigure () {
console.log("OnConfigure called");
}
`,
expErr: `failed to get OnRequestHeaders function`,
},
{
name: "invalid script",
script: `invalid`,
expErr: `failed to run script: ReferenceError: invalid is not defined at <eval>:1:1(0)`,
},
} {
t.Run(tc.name, func(t *testing.T) {
logout := &bytes.Buffer{}
_, err := newJavaScriptVM(tc.script, logout)
if tc.expErr == "" {
require.Equal(t, tc.expOut, logout.String())
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tc.expErr)
}
})
}
}

func Test_javaScriptFilter_RequestHeaders(t *testing.T) {
logout := &bytes.Buffer{}
vm, err := newJavaScriptVM(
`function OnConfigure () {}
function OnRequestHeaders(ctx) {
ctx.setRequestHeader("x-hello", "world");
let reqId = ctx.getRequestHeader("x-request-id");
console.log("Request ID: ", reqId);
}
function OnResponseHeaders(ctx) {}`, logout)
require.NoError(t, err)

f := &javaScriptFilter{vm: vm, requestHeaders: map[string]string{
"x-request-id": "12345",
}}
called := false
m := &mockEnvoyHttpFilter{
getRequestHeaders: func() map[string][]string { return map[string][]string{"x-request-id": {"12345"}} },
setRequestHeader: func(key string, value []byte) bool {
require.Equal(t, "x-hello", key)
require.Equal(t, "world", string(value))
called = true
return true
},
}

status := f.RequestHeaders(m, false)
require.Equal(t, gosdk.RequestHeadersStatusContinue, status)
require.True(t, called)

require.Contains(t, logout.String(), "Request ID: 12345")
}

func Test_javaScriptFilter_ResponseHeaders(t *testing.T) {
logout := &bytes.Buffer{}
vm, err := newJavaScriptVM(
`function OnConfigure () {}
function OnRequestHeaders(ctx) {}
function OnResponseHeaders(ctx) {
ctx.setResponseHeader("x-hello", "world");
let status = ctx.getResponseHeader(":status");
console.log("Response status: ", status);
}`, logout)
require.NoError(t, err)

f := &javaScriptFilter{vm: vm, responseHeaders: map[string]string{
":status": "200",
}}
called := false
m := &mockEnvoyHttpFilter{
getResponseHeaders: func() map[string][]string { return map[string][]string{":status": {"200"}} },
setResponseHeader: func(key string, value []byte) bool {
require.Equal(t, "x-hello", key)
require.Equal(t, "world", string(value))
called = true
return true
},
}

status := f.ResponseHeaders(m, false)
require.Equal(t, gosdk.ResponseHeadersStatusContinue, status)
require.True(t, called)

require.Contains(t, logout.String(), "Response status: 200")
}
Loading
Loading