Skip to content

Commit 98df470

Browse files
authored
Initial implementation of JavaScript Filter (#47)
Signed-off-by: Takeshi Yoneda <t.y.mathetake@gmail.com>
1 parent 4711818 commit 98df470

9 files changed

Lines changed: 547 additions & 1 deletion

File tree

.github/workflows/commit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jobs:
9797
~/go/bin
9898
key: go-test-${{ hashFiles('**/go.mod', '**/go.sum') }}
9999

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

go/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ require (
4646
github.com/daixiang0/gci v0.13.5 // indirect
4747
github.com/davecgh/go-spew v1.1.1 // indirect
4848
github.com/denis-tingaikin/go-header v0.5.0 // indirect
49+
github.com/dlclark/regexp2 v1.11.4 // indirect
50+
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 // indirect
4951
github.com/ettle/strcase v0.2.0 // indirect
5052
github.com/fatih/color v1.18.0 // indirect
5153
github.com/fatih/structtag v1.2.0 // indirect
@@ -54,6 +56,7 @@ require (
5456
github.com/fzipp/gocyclo v0.6.0 // indirect
5557
github.com/ghostiam/protogetter v0.3.9 // indirect
5658
github.com/go-critic/go-critic v0.12.0 // indirect
59+
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
5760
github.com/go-toolsmith/astcast v1.1.0 // indirect
5861
github.com/go-toolsmith/astcopy v1.1.0 // indirect
5962
github.com/go-toolsmith/astequal v1.2.0 // indirect
@@ -75,6 +78,7 @@ require (
7578
github.com/golangci/revgrep v0.8.0 // indirect
7679
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
7780
github.com/google/go-cmp v0.7.0 // indirect
81+
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
7882
github.com/gordonklaus/ineffassign v0.1.0 // indirect
7983
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
8084
github.com/gostaticanalysis/comment v1.5.0 // indirect

go/go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42
134134
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
135135
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
136136
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
137+
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
138+
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
139+
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM=
140+
github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
137141
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
138142
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
139143
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -169,6 +173,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
169173
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
170174
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
171175
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
176+
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
177+
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
172178
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
173179
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
174180
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=

go/javascript.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"log"
7+
"math/rand"
8+
"os"
9+
"sync"
10+
11+
"github.com/dop251/goja"
12+
"github.com/envoyproxy/dynamic-modules-examples/go/gosdk"
13+
)
14+
15+
const (
16+
javaScriptExportedSymbolOnConfig = "OnConfigure"
17+
javaScriptExportedSymbolOnRequestHeaders = "OnRequestHeaders"
18+
javaScriptExportedSymbolOnResponseHeaders = "OnResponseHeaders"
19+
20+
functionDeclTemplate = `globalThis.%[1]s = %[1]s`
21+
numberOfVMPool = 24
22+
)
23+
24+
type (
25+
// javaScriptFilterConfig implements [gosdk.HttpFilterConfig].
26+
javaScriptFilterConfig struct {
27+
vms [numberOfVMPool]*javaScriptVM
28+
}
29+
// javaScriptFilter implements [gosdk.HttpFilter].
30+
javaScriptFilter struct {
31+
vm *javaScriptVM
32+
requestHeaders map[string]string
33+
responseHeaders map[string]string
34+
}
35+
javaScriptVM struct {
36+
*goja.Runtime
37+
mux sync.Mutex
38+
onRequestHeaders goja.Callable
39+
onResponseHeaders goja.Callable
40+
}
41+
)
42+
43+
func newJavaScriptFilterConfig(userCode string) gosdk.HttpFilterConfig {
44+
c := &javaScriptFilterConfig{}
45+
46+
for i := range numberOfVMPool {
47+
vm, err := newJavaScriptVM(userCode, os.Stdout)
48+
if err != nil {
49+
log.Printf("failed to create JavaScript VM: %v", err)
50+
return nil
51+
}
52+
c.vms[i] = vm
53+
}
54+
return c
55+
}
56+
57+
func newJavaScriptVM(script string, w io.Writer) (*javaScriptVM, error) {
58+
vm := goja.New()
59+
console := vm.NewObject()
60+
err := console.Set("log", func(call goja.FunctionCall) goja.Value {
61+
args := make([]interface{}, 0, len(call.Arguments))
62+
for _, a := range call.Arguments {
63+
args = append(args, a.Export())
64+
}
65+
_, _ = fmt.Fprint(w, args...)
66+
return goja.Undefined()
67+
})
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to set console.log: %w", err)
70+
}
71+
err = vm.Set("console", console)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to set console: %w", err)
74+
}
75+
76+
_, err = vm.RunString(script)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to run script: %w", err)
79+
}
80+
81+
// Call OnConfigure.
82+
onConfigure, ok := goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnConfig))
83+
if !ok {
84+
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnConfig)
85+
}
86+
_, err = onConfigure(goja.Undefined())
87+
if err != nil {
88+
return nil, fmt.Errorf("failed to call %s function: %w", javaScriptExportedSymbolOnConfig, err)
89+
}
90+
91+
ret := &javaScriptVM{Runtime: vm}
92+
// Check two exported functions.
93+
ret.onRequestHeaders, ok = goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnRequestHeaders))
94+
if !ok {
95+
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnRequestHeaders)
96+
}
97+
ret.onResponseHeaders, ok = goja.AssertFunction(vm.GlobalObject().Get(javaScriptExportedSymbolOnResponseHeaders))
98+
if !ok {
99+
return nil, fmt.Errorf("failed to get %s function", javaScriptExportedSymbolOnResponseHeaders)
100+
}
101+
return ret, nil
102+
}
103+
104+
// NewFilter implements [gosdk.HttpFilterConfig].
105+
func (p *javaScriptFilterConfig) NewFilter() gosdk.HttpFilter {
106+
vm := p.vms[rand.Intn(numberOfVMPool)]
107+
return &javaScriptFilter{vm: vm, requestHeaders: make(map[string]string), responseHeaders: make(map[string]string)}
108+
}
109+
110+
// RequestHeaders implements [gosdk.HttpFilter].
111+
func (p *javaScriptFilter) RequestHeaders(e gosdk.EnvoyHttpFilter, _ bool) gosdk.RequestHeadersStatus {
112+
headers := e.GetRequestHeaders()
113+
for k, vs := range headers {
114+
p.requestHeaders[k] = vs[0]
115+
}
116+
p.vm.mux.Lock()
117+
defer p.vm.mux.Unlock()
118+
vm := p.vm
119+
obj := vm.NewObject()
120+
_ = obj.Set("getRequestHeader", func(call goja.FunctionCall) goja.Value {
121+
if len(call.Arguments) < 1 {
122+
return vm.ToValue("")
123+
}
124+
key := call.Argument(0).String()
125+
return vm.ToValue(p.requestHeaders[key])
126+
})
127+
_ = obj.Set("setRequestHeader", func(call goja.FunctionCall) goja.Value {
128+
if len(call.Arguments) < 2 {
129+
return goja.Undefined()
130+
}
131+
key := call.Argument(0).String()
132+
value := call.Argument(1).String()
133+
p.requestHeaders[key] = value
134+
e.SetRequestHeader(key, []byte(value))
135+
return goja.Undefined()
136+
})
137+
if _, err := vm.onRequestHeaders(goja.Undefined(), obj); err != nil {
138+
log.Printf("failed to call %s: %v", javaScriptExportedSymbolOnRequestHeaders, err)
139+
return gosdk.RequestHeadersStatusStopIteration
140+
}
141+
return gosdk.RequestHeadersStatusContinue
142+
}
143+
144+
// ResponseHeaders implements [gosdk.HttpFilter].
145+
func (p *javaScriptFilter) ResponseHeaders(e gosdk.EnvoyHttpFilter, _ bool) gosdk.ResponseHeadersStatus {
146+
headers := e.GetResponseHeaders()
147+
for k, vs := range headers {
148+
p.responseHeaders[k] = vs[0]
149+
}
150+
p.vm.mux.Lock()
151+
defer p.vm.mux.Unlock()
152+
vm := p.vm
153+
obj := vm.NewObject()
154+
_ = obj.Set("getRequestHeader", func(call goja.FunctionCall) goja.Value {
155+
if len(call.Arguments) < 1 {
156+
return vm.ToValue("")
157+
}
158+
key := call.Argument(0).String()
159+
return vm.ToValue(p.requestHeaders[key])
160+
})
161+
162+
// Setting request header in response phase is not allowed.
163+
164+
_ = obj.Set("getResponseHeader", func(call goja.FunctionCall) goja.Value {
165+
if len(call.Arguments) < 1 {
166+
return vm.ToValue("")
167+
}
168+
key := call.Argument(0).String()
169+
return vm.ToValue(p.responseHeaders[key])
170+
})
171+
_ = obj.Set("setResponseHeader", func(call goja.FunctionCall) goja.Value {
172+
if len(call.Arguments) < 2 {
173+
return goja.Undefined()
174+
}
175+
key := call.Argument(0).String()
176+
value := call.Argument(1).String()
177+
p.responseHeaders[key] = value
178+
e.SetResponseHeader(key, []byte(value))
179+
return goja.Undefined()
180+
})
181+
if _, err := vm.onResponseHeaders(goja.Undefined(), obj); err != nil {
182+
log.Printf("failed to call %s: %v", javaScriptExportedSymbolOnResponseHeaders, err)
183+
return gosdk.ResponseHeadersStatusStopIteration
184+
}
185+
return gosdk.ResponseHeadersStatusContinue
186+
}
187+
188+
// Destroy implements [gosdk.HttpFilterConfig].
189+
func (p *javaScriptFilterConfig) Destroy() {}
190+
191+
// Scheduled implements gosdk.HttpFilter.
192+
func (p *javaScriptFilter) Scheduled(gosdk.EnvoyHttpFilter, uint64) {}
193+
194+
// Destroy implements [gosdk.HttpFilter].
195+
func (p *javaScriptFilter) Destroy() {}
196+
197+
// RequestBody implements [gosdk.HttpFilter].
198+
func (p *javaScriptFilter) RequestBody(gosdk.EnvoyHttpFilter, bool) gosdk.RequestBodyStatus {
199+
return gosdk.RequestBodyStatusContinue
200+
}
201+
202+
// ResponseBody implements [gosdk.HttpFilter].
203+
func (p *javaScriptFilter) ResponseBody(gosdk.EnvoyHttpFilter, bool) gosdk.ResponseBodyStatus {
204+
return gosdk.ResponseBodyStatusContinue
205+
}

go/javascript_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/envoyproxy/dynamic-modules-examples/go/gosdk"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func Test_newJavaScriptFilterConfig(t *testing.T) {
12+
f := newJavaScriptFilterConfig(`
13+
function OnConfigure () {}
14+
function OnRequestHeaders(ctx) {}
15+
function OnResponseHeaders(ctx) {}
16+
`)
17+
require.NotNil(t, f)
18+
}
19+
20+
func Test_newJavasScriptVM(t *testing.T) {
21+
for _, tc := range []struct {
22+
name string
23+
script string
24+
expOut string
25+
expErr string
26+
}{
27+
{
28+
name: "valid script with all functions",
29+
expOut: `OnConfigure called`,
30+
script: `
31+
function OnConfigure () {
32+
console.log("OnConfigure called");
33+
}
34+
function OnRequestHeaders(ctx) {
35+
console.log("OnRequestHeader called");
36+
}
37+
function OnResponseHeaders(ctx) {
38+
console.log("OnResponseHeader called");
39+
}
40+
`,
41+
},
42+
{
43+
name: "invalid script with missing functions",
44+
script: `
45+
function OnConfigure () {
46+
console.log("OnConfigure called");
47+
}
48+
`,
49+
expErr: `failed to get OnRequestHeaders function`,
50+
},
51+
{
52+
name: "invalid script",
53+
script: `invalid`,
54+
expErr: `failed to run script: ReferenceError: invalid is not defined at <eval>:1:1(0)`,
55+
},
56+
} {
57+
t.Run(tc.name, func(t *testing.T) {
58+
logout := &bytes.Buffer{}
59+
_, err := newJavaScriptVM(tc.script, logout)
60+
if tc.expErr == "" {
61+
require.Equal(t, tc.expOut, logout.String())
62+
require.NoError(t, err)
63+
} else {
64+
require.ErrorContains(t, err, tc.expErr)
65+
}
66+
})
67+
}
68+
}
69+
70+
func Test_javaScriptFilter_RequestHeaders(t *testing.T) {
71+
logout := &bytes.Buffer{}
72+
vm, err := newJavaScriptVM(
73+
`function OnConfigure () {}
74+
function OnRequestHeaders(ctx) {
75+
ctx.setRequestHeader("x-hello", "world");
76+
let reqId = ctx.getRequestHeader("x-request-id");
77+
console.log("Request ID: ", reqId);
78+
}
79+
function OnResponseHeaders(ctx) {}`, logout)
80+
require.NoError(t, err)
81+
82+
f := &javaScriptFilter{vm: vm, requestHeaders: map[string]string{
83+
"x-request-id": "12345",
84+
}}
85+
called := false
86+
m := &mockEnvoyHttpFilter{
87+
getRequestHeaders: func() map[string][]string { return map[string][]string{"x-request-id": {"12345"}} },
88+
setRequestHeader: func(key string, value []byte) bool {
89+
require.Equal(t, "x-hello", key)
90+
require.Equal(t, "world", string(value))
91+
called = true
92+
return true
93+
},
94+
}
95+
96+
status := f.RequestHeaders(m, false)
97+
require.Equal(t, gosdk.RequestHeadersStatusContinue, status)
98+
require.True(t, called)
99+
100+
require.Contains(t, logout.String(), "Request ID: 12345")
101+
}
102+
103+
func Test_javaScriptFilter_ResponseHeaders(t *testing.T) {
104+
logout := &bytes.Buffer{}
105+
vm, err := newJavaScriptVM(
106+
`function OnConfigure () {}
107+
function OnRequestHeaders(ctx) {}
108+
function OnResponseHeaders(ctx) {
109+
ctx.setResponseHeader("x-hello", "world");
110+
let status = ctx.getResponseHeader(":status");
111+
console.log("Response status: ", status);
112+
}`, logout)
113+
require.NoError(t, err)
114+
115+
f := &javaScriptFilter{vm: vm, responseHeaders: map[string]string{
116+
":status": "200",
117+
}}
118+
called := false
119+
m := &mockEnvoyHttpFilter{
120+
getResponseHeaders: func() map[string][]string { return map[string][]string{":status": {"200"}} },
121+
setResponseHeader: func(key string, value []byte) bool {
122+
require.Equal(t, "x-hello", key)
123+
require.Equal(t, "world", string(value))
124+
called = true
125+
return true
126+
},
127+
}
128+
129+
status := f.ResponseHeaders(m, false)
130+
require.Equal(t, gosdk.ResponseHeadersStatusContinue, status)
131+
require.True(t, called)
132+
133+
require.Contains(t, logout.String(), "Response status: 200")
134+
}

0 commit comments

Comments
 (0)