(no tests) updated jsvm bindings

This commit is contained in:
Gani Georgiev 2023-07-08 13:51:16 +03:00
parent 5e37c90dde
commit 13d96e793b
6 changed files with 7176 additions and 6603 deletions

View File

@ -2,26 +2,212 @@ package jsvm
import ( import (
"encoding/json" "encoding/json"
"errors"
"reflect" "reflect"
"strings"
"github.com/dop251/goja" "github.com/dop251/goja"
validation "github.com/go-ozzo/ozzo-validation/v4" validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema" "github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tokens" "github.com/pocketbase/pocketbase/tokens"
"github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/inflector" "github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/mailer" "github.com/pocketbase/pocketbase/tools/mailer"
"github.com/pocketbase/pocketbase/tools/security" "github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/types" "github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// hooksBinds adds wrapped "on*" hook methods by reflecting on core.App.
func hooksBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
fm := FieldMapper{}
appType := reflect.TypeOf(app)
appValue := reflect.ValueOf(app)
totalMethods := appType.NumMethod()
excludeHooks := []string{"OnBeforeServe"}
for i := 0; i < totalMethods; i++ {
method := appType.Method(i)
if !strings.HasPrefix(method.Name, "On") || list.ExistInSlice(method.Name, excludeHooks) {
continue // not a hook or excluded
}
jsName := fm.MethodName(appType, method)
// register the hook to the loader
loader.Set(jsName, func(callback string, tags ...string) {
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
tagsAsValues := make([]reflect.Value, len(tags))
for i, tag := range tags {
tagsAsValues[i] = reflect.ValueOf(tag)
}
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
addFunc := hookInstance.MethodByName("Add")
handlerType := addFunc.Type().In(0)
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
handlerArgs := make([]any, len(args))
for i, arg := range args {
handlerArgs[i] = arg.Interface()
}
err := executors.run(func(executor *goja.Runtime) error {
executor.Set("__args", handlerArgs)
res, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
// check for returned hook.StopPropagation
if res != nil {
if v, ok := res.Export().(error); ok {
return v
}
}
// check for throwed hook.StopPropagation
if err != nil {
exception, ok := err.(*goja.Exception)
if ok && errors.Is(exception.Value().Export().(error), hook.StopPropagation) {
return hook.StopPropagation
}
}
return err
})
return []reflect.Value{reflect.ValueOf(&err).Elem()}
})
// register the wrapped hook handler
addFunc.Call([]reflect.Value{handler})
})
}
}
func cronBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
jobs := cron.New()
loader.Set("cronAdd", func(jobId, cronExpr, handler string) {
pr := goja.MustCompile("", "{("+handler+").apply(undefined)}", true)
err := jobs.Add(jobId, cronExpr, func() {
executors.run(func(executor *goja.Runtime) error {
_, err := executor.RunProgram(pr)
return err
})
})
if err != nil {
panic("[cronAdd] failed to register cron job " + jobId + ": " + err.Error())
}
// start the ticker (if not already)
if jobs.Total() > 0 && !jobs.HasStarted() {
jobs.Start()
}
})
loader.Set("cronRemove", func(jobId string) {
jobs.Remove(jobId)
// stop the ticker if there are no other jobs
if jobs.Total() == 0 {
jobs.Stop()
}
})
}
func routerBinds(app core.App, loader *goja.Runtime, executors *vmsPool) {
loader.Set("routerAdd", func(method string, path string, handler string, middlewares ...goja.Value) {
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
if err != nil {
panic("[routerAdd] failed to wrap middlewares: " + err.Error())
}
pr := goja.MustCompile("", "{("+handler+").apply(undefined, __args)}", true)
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.Add(strings.ToUpper(method), path, func(c echo.Context) error {
return executors.run(func(executor *goja.Runtime) error {
executor.Set("__args", []any{c})
_, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
return err
})
}, wrappedMiddlewares...)
return nil
})
})
loader.Set("routerUse", func(middlewares ...goja.Value) {
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
if err != nil {
panic("[routerUse] failed to wrap middlewares: " + err.Error())
}
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.Use(wrappedMiddlewares...)
return nil
})
})
loader.Set("routerPre", func(middlewares ...goja.Value) {
wrappedMiddlewares, err := wrapMiddlewares(executors, middlewares...)
if err != nil {
panic("[routerPre] failed to wrap middlewares: " + err.Error())
}
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
e.Router.Pre(wrappedMiddlewares...)
return nil
})
})
}
func wrapMiddlewares(executors *vmsPool, rawMiddlewares ...goja.Value) ([]echo.MiddlewareFunc, error) {
wrappedMiddlewares := make([]echo.MiddlewareFunc, len(rawMiddlewares))
for i, m := range rawMiddlewares {
switch v := m.Export().(type) {
case echo.MiddlewareFunc:
// "native" middleware - no need to wrap
wrappedMiddlewares[i] = v
case func(goja.FunctionCall) goja.Value, string:
pr := goja.MustCompile("", "{(("+m.String()+").apply(undefined, __args)).apply(undefined, __args2)}", true)
wrappedMiddlewares[i] = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return executors.run(func(executor *goja.Runtime) error {
executor.Set("__args", []any{next})
executor.Set("__args2", []any{c})
_, err := executor.RunProgram(pr)
executor.Set("__args", goja.Undefined())
executor.Set("__args2", goja.Undefined())
return err
})
}
}
default:
return nil, errors.New("unsupported goja middleware type")
}
}
return wrappedMiddlewares, nil
}
func baseBinds(vm *goja.Runtime) { func baseBinds(vm *goja.Runtime) {
vm.SetFieldNameMapper(FieldMapper{}) vm.SetFieldNameMapper(FieldMapper{})
@ -48,7 +234,7 @@ func baseBinds(vm *goja.Runtime) {
} }
`) `)
vm.Set("$arrayOf", func(model any) any { vm.Set("arrayOf", func(model any) any {
mt := reflect.TypeOf(model) mt := reflect.TypeOf(model)
st := reflect.SliceOf(mt) st := reflect.SliceOf(mt)
elem := reflect.New(st).Elem() elem := reflect.New(st).Elem()
@ -147,6 +333,8 @@ func baseBinds(vm *goja.Runtime) {
return instanceValue return instanceValue
}) })
vm.Set("$stopPropagation", hook.StopPropagation)
} }
func dbxBinds(vm *goja.Runtime) { func dbxBinds(vm *goja.Runtime) {
@ -253,11 +441,6 @@ func apisBinds(vm *goja.Runtime) {
obj := vm.NewObject() obj := vm.NewObject()
vm.Set("$apis", obj) vm.Set("$apis", obj)
vm.Set("Route", func(call goja.ConstructorCall) *goja.Object {
instance := &echo.Route{}
return structConstructor(vm, call, instance)
})
// middlewares // middlewares
obj.Set("requireRecordAuth", apis.RequireRecordAuth) obj.Set("requireRecordAuth", apis.RequireRecordAuth)
obj.Set("requireAdminAuth", apis.RequireAdminAuth) obj.Set("requireAdminAuth", apis.RequireAdminAuth)

View File

@ -38,7 +38,7 @@ func TestBaseBindsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
baseBinds(vm) baseBinds(vm)
testBindsCount(vm, "this", 14, t) testBindsCount(vm, "this", 15, t)
} }
func TestBaseBindsRecord(t *testing.T) { func TestBaseBindsRecord(t *testing.T) {
@ -682,48 +682,10 @@ func TestApisBindsCount(t *testing.T) {
vm := goja.New() vm := goja.New()
apisBinds(vm) apisBinds(vm)
testBindsCount(vm, "this", 7, t) testBindsCount(vm, "this", 6, t)
testBindsCount(vm, "$apis", 10, t) testBindsCount(vm, "$apis", 10, t)
} }
func TestApisBindsRoute(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
vm := goja.New()
baseBinds(vm)
apisBinds(vm)
_, err := vm.RunString(`
let handlerCalls = 0;
const route = new Route({
method: "test_method",
path: "test_path",
handler: () => {
handlerCalls++;
}
});
route.handler();
if (handlerCalls != 1) {
throw new Error('Expected handlerCalls 1, got ' + handlerCalls);
}
if (route.method != "test_method") {
throw new Error('Expected method "test_method", got ' + route.method);
}
if (route.path != "test_path") {
throw new Error('Expected method "test_path", got ' + route.path);
}
`)
if err != nil {
t.Fatal(err)
}
}
func TestApisBindsApiError(t *testing.T) { func TestApisBindsApiError(t *testing.T) {
app, _ := tests.NewTestApp() app, _ := tests.NewTestApp()
defer app.Cleanup() defer app.Cleanup()
@ -843,7 +805,7 @@ func TestLoadingArrayOf(t *testing.T) {
vm.Set("$app", app) vm.Set("$app", app)
_, err := vm.RunString(` _, err := vm.RunString(`
let result = $arrayOf(new DynamicModel({ let result = arrayOf(new DynamicModel({
id: "", id: "",
text: "", text: "",
})) }))

View File

@ -4,16 +4,96 @@ import (
"log" "log"
"os" "os"
"reflect" "reflect"
"strings"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm" "github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/tygoja" "github.com/pocketbase/tygoja"
) )
const heading = ` const heading = `
// -------------------------------------------------------------------
// routerBinds
// -------------------------------------------------------------------
/**
* RouterAdd registers a new route definition.
*
* Example:
*
* ` + "```" + `js
* routerAdd("GET", "/hello", (c) => {
* return c.json(200, {"message": "Hello!"})
* }, $apis.requireAdminOrRecordAuth())
* ` + "```" + `
*
* _Note that this method is available only in pb_hooks context._
*
* @group PocketBase
*/
declare function routerAdd(
method: string,
path: string,
handler: echo.HandlerFunc,
...middlewares: Array<string|echo.MiddlewareFunc>,
): void;
/**
* RouterUse registers one or more global middlewares that are executed
* along the handler middlewares after a matching route is found.
*
* Example:
*
* ` + "```" + `js
* routerUse((next) => {
* return (c) => {
* console.log(c.Path())
* return next(c)
* }
* })
* ` + "```" + `
*
* _Note that this method is available only in pb_hooks context._
*
* @group PocketBase
*/
declare function routerUse(...middlewares: Array<string|echo.MiddlewareFunc>): void;
/**
* RouterPre registers one or more global middlewares that are executed
* BEFORE the router processes the request. It is usually used for making
* changes to the request properties, for example, adding or removing
* a trailing slash or adding segments to a path so it matches a route.
*
* NB! Since the router will not have processed the request yet,
* middlewares registered at this level won't have access to any path
* related APIs from echo.Context.
*
* Example:
*
* ` + "```" + `js
* routerPre((next) => {
* return (c) => {
* console.log(c.request().url)
* return next(c)
* }
* })
* ` + "```" + `
*
* _Note that this method is available only in pb_hooks context._
*
* @group PocketBase
*/
declare function routerPre(...middlewares: Array<string|echo.MiddlewareFunc>): void;
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// baseBinds // baseBinds
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// skip on* hook methods as they are registered via the global on* method
type appWithoutHooks = Omit<pocketbase.PocketBase, ` + "`on${string}`" + `>
/** /**
* $app is the current running PocketBase instance that is globally * $app is the current running PocketBase instance that is globally
* available in each .pb.js file. * available in each .pb.js file.
@ -21,23 +101,23 @@ const heading = `
* @namespace * @namespace
* @group PocketBase * @group PocketBase
*/ */
declare var $app: pocketbase.PocketBase declare var $app: appWithoutHooks
/** /**
* $arrayOf creates a placeholder array of the specified models. * arrayOf creates a placeholder array of the specified models.
* Usually used to populate DB result into an array of models. * Usually used to populate DB result into an array of models.
* *
* Example: * Example:
* *
* ` + "```" + `js * ` + "```" + `js
* const records = $arrayOf(new Record) * const records = arrayOf(new Record)
* *
* $app.dao().recordQuery(collection).limit(10).all(records) * $app.dao().recordQuery(collection).limit(10).all(records)
* ` + "```" + ` * ` + "```" + `
* *
* @group PocketBase * @group PocketBase
*/ */
declare function $arrayOf<T>(model: T): Array<T>; declare function arrayOf<T>(model: T): Array<T>;
/** /**
* DynamicModel creates a new dynamic model with fields from the provided data shape. * DynamicModel creates a new dynamic model with fields from the provided data shape.
@ -498,27 +578,6 @@ declare class TestS3FilesystemForm implements forms.TestS3Filesystem {
// apisBinds // apisBinds
// ------------------------------------------------------------------- // -------------------------------------------------------------------
interface Route extends echo.Route{} // merge
/**
* Route specifies a new route definition.
* This is usually used when registering routes with router.addRoute().
*
* ` + "```" + `js
* const route = new Route({
* path: "/hello",
* handler: (c) => {
* c.string(200, "hello world!")
* },
* middlewares: [$apis.activityLogger($app)]
* })
* ` + "```" + `
*
* @group PocketBase
*/
declare class Route implements echo.Route {
constructor(data?: Partial<echo.Route>)
}
interface ApiError extends apis.ApiError{} // merge interface ApiError extends apis.ApiError{} // merge
/** /**
* @inheritDoc * @inheritDoc
@ -594,7 +653,7 @@ declare namespace $apis {
/** /**
* Migrate defines a single migration upgrade/downgrade action. * Migrate defines a single migration upgrade/downgrade action.
* *
* Note that this method is available only in pb_migrations context. * _Note that this method is available only in pb_migrations context._
* *
* @group PocketBase * @group PocketBase
*/ */
@ -604,8 +663,10 @@ declare function migrate(
): void; ): void;
` `
var mapper = &jsvm.FieldMapper{}
func main() { func main() {
mapper := &jsvm.FieldMapper{} declarations := heading + hooksDeclarations()
gen := tygoja.New(tygoja.Config{ gen := tygoja.New(tygoja.Config{
Packages: map[string][]string{ Packages: map[string][]string{
@ -626,7 +687,7 @@ func main() {
}, },
Indent: " ", // use only a single space to reduce slight the size Indent: " ", // use only a single space to reduce slight the size
WithPackageFunctions: true, WithPackageFunctions: true,
Heading: heading, Heading: declarations,
}) })
result, err := gen.Generate() result, err := gen.Generate()
@ -638,3 +699,45 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
} }
func hooksDeclarations() string {
var result strings.Builder
excluded := []string{"OnBeforeServe"}
appType := reflect.TypeOf(struct{ core.App }{})
totalMethods := appType.NumMethod()
for i := 0; i < totalMethods; i++ {
method := appType.Method(i)
if !strings.HasPrefix(method.Name, "On") || list.ExistInSlice(method.Name, excluded) {
continue // not a hook or excluded
}
hookType := method.Type.Out(0)
withTags := strings.HasPrefix(hookType.String(), "*hook.TaggedHook")
addMethod, ok := hookType.MethodByName("Add")
if !ok {
continue
}
addHanlder := addMethod.Type.In(1)
eventTypeName := strings.TrimPrefix(addHanlder.In(0).String(), "*")
jsName := mapper.MethodName(appType, method)
result.WriteString("/** @group PocketBase */")
result.WriteString("declare function ")
result.WriteString(jsName)
result.WriteString("(handler: (e: ")
result.WriteString(eventTypeName)
result.WriteString(") => void")
if withTags {
result.WriteString(", ...tags: string[]")
}
result.WriteString("): void")
result.WriteString("\n")
}
return result.String()
}

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
"github.com/dop251/goja_nodejs/console" "github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/eventloop"
"github.com/dop251/goja_nodejs/process" "github.com/dop251/goja_nodejs/process"
"github.com/dop251/goja_nodejs/require" "github.com/dop251/goja_nodejs/require"
"github.com/fatih/color" "github.com/fatih/color"
@ -49,6 +48,13 @@ type Config struct {
// If not set it fallbacks to a relative "pb_data/../pb_hooks" directory. // If not set it fallbacks to a relative "pb_data/../pb_hooks" directory.
HooksDir string HooksDir string
// HooksPoolSize specifies how many goja.Runtime instances to preinit
// and keep for the JS app hooks gorotines execution.
//
// Zero or negative value means that it will create a new goja.Runtime
// on every fired goroutine.
HooksPoolSize int
// MigrationsDir specifies the JS migrations directory. // MigrationsDir specifies the JS migrations directory.
// //
// If not set it fallbacks to a relative "pb_data/../pb_migrations" directory. // If not set it fallbacks to a relative "pb_data/../pb_migrations" directory.
@ -172,11 +178,10 @@ func (p *plugin) registerHooks() error {
} }
} }
registry := new(require.Registry) // this can be shared by multiple runtimes // this is safe to be shared across multiple vms
registry := new(require.Registry)
loop := eventloop.NewEventLoop() sharedBinds := func(vm *goja.Runtime) {
loop.Run(func(vm *goja.Runtime) {
registry.Enable(vm) registry.Enable(vm)
console.Enable(vm) console.Enable(vm)
process.Enable(vm) process.Enable(vm)
@ -186,11 +191,25 @@ func (p *plugin) registerHooks() error {
securityBinds(vm) securityBinds(vm)
formsBinds(vm) formsBinds(vm)
apisBinds(vm) apisBinds(vm)
vm.Set("$app", p.app) vm.Set("$app", p.app)
}
// initiliaze the executor vms
executors := newPool(p.config.HooksPoolSize, func() *goja.Runtime {
executor := goja.New()
sharedBinds(executor)
return executor
})
// initialize the loader vm
loader := goja.New()
sharedBinds(loader)
hooksBinds(p.app, loader, executors)
cronBinds(p.app, loader, executors)
routerBinds(p.app, loader, executors)
for file, content := range files { for file, content := range files {
_, err := vm.RunString(string(content)) _, err := loader.RunString(string(content))
if err != nil { if err != nil {
if p.config.HooksWatch { if p.config.HooksWatch {
color.Red("Failed to execute %s: %v", file, err) color.Red("Failed to execute %s: %v", file, err)
@ -199,13 +218,8 @@ func (p *plugin) registerHooks() error {
} }
} }
} }
})
loop.Start()
p.app.OnTerminate().Add(func(e *core.TerminateEvent) error { p.app.OnTerminate().Add(func(e *core.TerminateEvent) error {
loop.StopNoWait()
return nil return nil
}) })
@ -243,11 +257,12 @@ func (p *plugin) watchHooks() error {
// start listening for events. // start listening for events.
go func() { go func() {
defer stopDebounceTimer()
for { for {
select { select {
case event, ok := <-watcher.Events: case event, ok := <-watcher.Events:
if !ok { if !ok {
stopDebounceTimer()
return return
} }
@ -266,7 +281,6 @@ func (p *plugin) watchHooks() error {
}) })
case err, ok := <-watcher.Errors: case err, ok := <-watcher.Errors:
if !ok { if !ok {
stopDebounceTimer()
return return
} }
color.Red("Watch error:", err) color.Red("Watch error:", err)

73
plugins/jsvm/pool.go Normal file
View File

@ -0,0 +1,73 @@
package jsvm
import (
"sync"
"github.com/dop251/goja"
)
type poolItem struct {
mux sync.Mutex
busy bool
vm *goja.Runtime
}
type vmsPool struct {
mux sync.RWMutex
factory func() *goja.Runtime
items []*poolItem
}
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
pool := &vmsPool{
factory: factory,
items: make([]*poolItem, size),
}
for i := 0; i < size; i++ {
vm := pool.factory()
pool.items[i] = &poolItem{vm: vm}
}
return pool
}
// run executes "call" with a vm created from the pool
// (either from the buffer or a new one if all buffered vms are busy)
func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
p.mux.RLock()
// try to find a free item
var freeItem *poolItem
for _, item := range p.items {
item.mux.Lock()
if item.busy {
item.mux.Unlock()
continue
}
item.busy = true
item.mux.Unlock()
freeItem = item
break
}
p.mux.RUnlock()
// create a new one-off item if of all of the pool items are currently busy
//
// note: if turned out not efficient we may change this in the future
// by adding the created item in the pool with some timer for removal
if freeItem == nil {
return call(p.factory())
}
execErr := call(freeItem.vm)
// "free" the vm
freeItem.mux.Lock()
freeItem.busy = false
freeItem.mux.Unlock()
return execErr
}