node build fixed
This commit is contained in:
419
seanime-2.9.10/internal/extension_repo/goja_plugin.go
Normal file
419
seanime-2.9.10/internal/extension_repo/goja_plugin.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package extension_repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
goja_bindings "seanime/internal/goja/goja_bindings"
|
||||
"seanime/internal/goja/goja_runtime"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/plugin"
|
||||
plugin_ui "seanime/internal/plugin/ui"
|
||||
"seanime/internal/util"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/dop251/goja/parser"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Load Plugin
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (r *Repository) loadPluginExtension(ext *extension.Extension) (err error) {
|
||||
defer util.HandlePanicInModuleWithError("extension_repo/loadPluginExtension", &err)
|
||||
|
||||
_, gojaExt, err := NewGojaPlugin(ext, ext.Language, r.logger, r.gojaRuntimeManager, r.wsEventManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add the extension to the map
|
||||
retExt := extension.NewPluginExtension(ext)
|
||||
r.extensionBank.Set(ext.ID, retExt)
|
||||
r.gojaExtensions.Set(ext.ID, gojaExt)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Plugin
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type GojaPlugin struct {
|
||||
ext *extension.Extension
|
||||
logger *zerolog.Logger
|
||||
pool *goja_runtime.Pool
|
||||
runtimeManager *goja_runtime.Manager
|
||||
store *plugin.Store[string, any]
|
||||
storage *plugin.Storage
|
||||
ui *plugin_ui.UI
|
||||
scheduler *goja_util.Scheduler
|
||||
loader *goja.Runtime
|
||||
unbindHookFuncs []func()
|
||||
interrupted bool
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
}
|
||||
|
||||
func (p *GojaPlugin) GetExtension() *extension.Extension {
|
||||
return p.ext
|
||||
}
|
||||
|
||||
func (p *GojaPlugin) PutVM(vm *goja.Runtime) {
|
||||
p.pool.Put(vm)
|
||||
}
|
||||
|
||||
// ClearInterrupt stops the UI VM and other modules.
|
||||
// It is called when the extension is unloaded.
|
||||
func (p *GojaPlugin) ClearInterrupt() {
|
||||
if p.interrupted {
|
||||
return
|
||||
}
|
||||
|
||||
p.interrupted = true
|
||||
|
||||
p.logger.Debug().Msg("plugin: Interrupting plugin")
|
||||
// Unload the UI
|
||||
if p.ui != nil {
|
||||
p.ui.Unload(false)
|
||||
}
|
||||
// Clear the interrupt
|
||||
if p.loader != nil {
|
||||
p.loader.ClearInterrupt()
|
||||
}
|
||||
// Stop the store
|
||||
if p.store != nil {
|
||||
p.store.Stop()
|
||||
}
|
||||
// Stop the storage
|
||||
if p.storage != nil {
|
||||
p.storage.Stop()
|
||||
}
|
||||
// Delete the plugin pool
|
||||
if p.runtimeManager != nil {
|
||||
p.runtimeManager.DeletePluginPool(p.ext.ID)
|
||||
}
|
||||
p.logger.Debug().Msgf("plugin: Unbinding hooks (%d)", len(p.unbindHookFuncs))
|
||||
// Unbind all hooks
|
||||
for _, unbindHookFunc := range p.unbindHookFuncs {
|
||||
unbindHookFunc()
|
||||
}
|
||||
// Run garbage collection
|
||||
// runtime.GC()
|
||||
p.logger.Debug().Msg("plugin: Interrupted plugin")
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func NewGojaPlugin(
|
||||
ext *extension.Extension,
|
||||
language extension.Language,
|
||||
mLogger *zerolog.Logger,
|
||||
runtimeManager *goja_runtime.Manager,
|
||||
wsEventManager events.WSEventManagerInterface,
|
||||
) (*GojaPlugin, GojaExtension, error) {
|
||||
logger := lo.ToPtr(mLogger.With().Str("id", ext.ID).Logger())
|
||||
defer util.HandlePanicInModuleThen("extension_repo/NewGojaPlugin", func() {
|
||||
logger.Error().Msg("extensions: Failed to create Goja plugin")
|
||||
})
|
||||
|
||||
logger.Trace().Msg("extensions: Loading plugin")
|
||||
|
||||
// 1. Create a new plugin instance
|
||||
p := &GojaPlugin{
|
||||
ext: ext,
|
||||
logger: logger,
|
||||
runtimeManager: runtimeManager,
|
||||
store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading)
|
||||
scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading)
|
||||
ui: nil, // To be initialized
|
||||
loader: goja.New(), // To be initialized
|
||||
unbindHookFuncs: []func(){},
|
||||
wsEventManager: wsEventManager,
|
||||
}
|
||||
|
||||
// 2. Create a new loader for the plugin
|
||||
// Bind shared APIs to the loader
|
||||
ShareBinds(p.loader, logger)
|
||||
BindUserConfig(p.loader, ext, logger)
|
||||
// Bind hooks to the loader
|
||||
p.bindHooks()
|
||||
|
||||
// 3. Convert the payload to JavaScript if necessary
|
||||
source := ext.Payload
|
||||
if language == extension.LanguageTypescript {
|
||||
var err error
|
||||
source, err = JSVMTypescriptToJS(ext.Payload)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("extensions: Failed to convert typescript")
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create a new pool for the plugin hooks (must be deleted when unloading)
|
||||
var err error
|
||||
p.pool, err = runtimeManager.GetOrCreatePrivatePool(ext.ID, func() *goja.Runtime {
|
||||
runtime := goja.New()
|
||||
ShareBinds(runtime, logger)
|
||||
BindUserConfig(runtime, ext, logger)
|
||||
p.BindPluginAPIs(runtime, logger)
|
||||
return runtime
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
//////// UI
|
||||
|
||||
// 5. Create a new VM for the UI (The UI uses a single VM instead of a pool in order to share state)
|
||||
// (must be interrupted when unloading)
|
||||
uiVM := goja.New()
|
||||
uiVM.SetParserOptions(parser.WithDisableSourceMaps)
|
||||
// Bind shared APIs
|
||||
ShareBinds(uiVM, logger)
|
||||
BindUserConfig(uiVM, ext, logger)
|
||||
// Bind the store to the UI VM
|
||||
p.BindPluginAPIs(uiVM, logger)
|
||||
// Create a new UI instance
|
||||
p.ui = plugin_ui.NewUI(plugin_ui.NewUIOptions{
|
||||
Extension: ext,
|
||||
Logger: logger,
|
||||
VM: uiVM,
|
||||
WSManager: wsEventManager,
|
||||
Scheduler: p.scheduler,
|
||||
})
|
||||
|
||||
go func() {
|
||||
<-p.ui.Destroyed()
|
||||
p.logger.Warn().Msg("plugin: UI interrupted, interrupting plugin")
|
||||
p.ClearInterrupt()
|
||||
}()
|
||||
|
||||
// 6. Bind the UI API to the loader so the plugin can register a new UI
|
||||
// $ui.register(callback)
|
||||
uiObj := p.loader.NewObject()
|
||||
_ = uiObj.Set("register", p.ui.Register)
|
||||
_ = p.loader.Set("$ui", uiObj)
|
||||
|
||||
// 7. Load the plugin source code in the VM (nothing will execute)
|
||||
_, err = p.loader.RunString(source)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("extensions: Failed to load plugin")
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 8. Get and call the init function to actually run the plugin
|
||||
if initFunc := p.loader.Get("init"); initFunc != nil && initFunc != goja.Undefined() {
|
||||
_, err = p.loader.RunString("init();")
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("extensions: Failed to run plugin")
|
||||
return nil, nil, fmt.Errorf("failed to run plugin: %w", err)
|
||||
}
|
||||
logger.Debug().Msg("extensions: Plugin initialized")
|
||||
}
|
||||
|
||||
return p, p, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// BindPluginAPIs adds plugin-specific APIs
|
||||
func (p *GojaPlugin) BindPluginAPIs(vm *goja.Runtime, logger *zerolog.Logger) {
|
||||
// Bind the app context
|
||||
//_ = vm.Set("$ctx", hook.GlobalHookManager.AppContext())
|
||||
|
||||
fm := FieldMapper{}
|
||||
vm.SetFieldNameMapper(fm)
|
||||
|
||||
// Bind the store
|
||||
p.store.Bind(vm, p.scheduler)
|
||||
// Bind mutable bindings
|
||||
goja_util.BindMutable(vm)
|
||||
// Bind await bindings
|
||||
goja_util.BindAwait(vm)
|
||||
// Bind console bindings
|
||||
_ = goja_bindings.BindConsoleWithWS(p.ext, vm, logger, p.wsEventManager)
|
||||
|
||||
// Bind the app context
|
||||
plugin.GlobalAppContext.BindApp(vm, logger, p.ext)
|
||||
|
||||
// Bind permission-specific APIs
|
||||
if p.ext.Plugin != nil {
|
||||
for _, permission := range p.ext.Plugin.Permissions.Scopes {
|
||||
switch permission {
|
||||
case extension.PluginPermissionStorage: // Storage
|
||||
p.storage = plugin.GlobalAppContext.BindStorage(vm, logger, p.ext, p.scheduler)
|
||||
|
||||
case extension.PluginPermissionAnilist: // Anilist
|
||||
plugin.GlobalAppContext.BindAnilist(vm, logger, p.ext)
|
||||
|
||||
case extension.PluginPermissionDatabase: // Database
|
||||
plugin.GlobalAppContext.BindDatabase(vm, logger, p.ext)
|
||||
|
||||
case extension.PluginPermissionSystem: // System
|
||||
plugin.GlobalAppContext.BindSystem(vm, logger, p.ext, p.scheduler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// bindHooks sets up hooks for the Goja runtime
|
||||
func (p *GojaPlugin) bindHooks() {
|
||||
// Create a FieldMapper instance for method name mapping
|
||||
fm := FieldMapper{}
|
||||
|
||||
// Get the type of the global hook manager
|
||||
appType := reflect.TypeOf(hook.GlobalHookManager)
|
||||
// Get the value of the global hook manager
|
||||
appValue := reflect.ValueOf(hook.GlobalHookManager)
|
||||
// Get the total number of methods in the global hook manager
|
||||
// i.e. OnGetAnime, OnGetAnimeDetails, etc.
|
||||
totalMethods := appType.NumMethod()
|
||||
// Define methods to exclude from binding
|
||||
excludeHooks := []string{"OnServe", ""}
|
||||
|
||||
// Create a new JavaScript object to hold the hooks ($app)
|
||||
appObj := p.loader.NewObject()
|
||||
|
||||
// Iterate through all methods of the global hook manager
|
||||
// i.e. OnGetAnime, OnGetAnimeDetails, etc.
|
||||
for i := 0; i < totalMethods; i++ {
|
||||
// Get the method at the current index
|
||||
method := appType.Method(i)
|
||||
|
||||
// Check that the method name starts with "On" and is not excluded
|
||||
if !strings.HasPrefix(method.Name, "On") || slices.Contains(excludeHooks, method.Name) {
|
||||
continue // Skip to the next method if not a hook or excluded
|
||||
}
|
||||
|
||||
// Map the method name to a JavaScript-friendly name
|
||||
// e.g. OnGetAnime -> onGetAnime
|
||||
jsName := fm.MethodName(appType, method)
|
||||
|
||||
// Set the method on the app object with a callback function
|
||||
// e.g. $app.onGetAnime(callback, "tag1", "tag2")
|
||||
appObj.Set(jsName, func(callback string, tags ...string) {
|
||||
// Create a wrapper JavaScript function that calls the provided callback
|
||||
// This is necessary because the callback will be called with the provided args
|
||||
callback = `function(e) { return (` + callback + `).call(undefined, e); }`
|
||||
// Compile the callback into a Goja program
|
||||
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
|
||||
|
||||
// Prepare the tags as reflect.Values for method invocation
|
||||
tagsAsValues := make([]reflect.Value, len(tags))
|
||||
for i, tag := range tags {
|
||||
tagsAsValues[i] = reflect.ValueOf(tag)
|
||||
}
|
||||
|
||||
// Get the hook function from the global hook manager and invokes it with the provided tags
|
||||
// The invokation returns a hook instance
|
||||
// i.e. OnTaggedHook(tags...) -> TaggedHook / OnHook() -> Hook
|
||||
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
|
||||
|
||||
// Get the BindFunc method from the hook instance
|
||||
hookBindFunc := hookInstance.MethodByName("BindFunc")
|
||||
unbindHookFunc := hookInstance.MethodByName("Unbind")
|
||||
|
||||
// Get the expected handler type for the hook
|
||||
// i.e. func(e *hook_resolver.Resolver) error
|
||||
handlerType := hookBindFunc.Type().In(0)
|
||||
|
||||
// Create a new handler function for the hook
|
||||
// - returns a new handler of the given handlerType that wraps the function
|
||||
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
|
||||
// Prepare arguments for the handler
|
||||
handlerArgs := make([]any, len(args))
|
||||
|
||||
// var err error
|
||||
// if p.interrupted {
|
||||
// return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||
// }
|
||||
|
||||
// Run the handler in an isolated "executor" runtime for concurrency
|
||||
err := p.runtimeManager.Run(context.Background(), p.ext.ID, func(executor *goja.Runtime) error {
|
||||
// Set the field name mapper for the executor
|
||||
executor.SetFieldNameMapper(fm)
|
||||
// Convert each argument (event property) to the appropriate type
|
||||
for i, arg := range args {
|
||||
handlerArgs[i] = arg.Interface()
|
||||
}
|
||||
// Set the global variable $ctx in the executor
|
||||
// executor.Set("$$app", plugin.GlobalAppContext)
|
||||
executor.Set("__args", handlerArgs)
|
||||
// Execute the handler program
|
||||
res, err := executor.RunProgram(pr)
|
||||
// Clear the __args variable for this executor
|
||||
executor.Set("__args", goja.Undefined())
|
||||
// executor.Set("$ctx", goja.Undefined())
|
||||
|
||||
// Check for returned Go error value
|
||||
if res != nil {
|
||||
if resErr, ok := res.Export().(error); ok {
|
||||
return resErr
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeException(err)
|
||||
})
|
||||
|
||||
// Return the error as a reflect.Value
|
||||
return []reflect.Value{reflect.ValueOf(&err).Elem()}
|
||||
})
|
||||
|
||||
// Bind the hook if the plugin is not interrupted
|
||||
if p.interrupted {
|
||||
return
|
||||
}
|
||||
|
||||
// Register the wrapped hook handler
|
||||
callRet := hookBindFunc.Call([]reflect.Value{handler})
|
||||
// Get the ID from the return value
|
||||
id, ok := callRet[0].Interface().(string)
|
||||
if ok {
|
||||
p.unbindHookFuncs = append(p.unbindHookFuncs, func() {
|
||||
p.logger.Trace().Str("id", p.ext.ID).Msgf("plugin: Unbinding hook %s", id)
|
||||
unbindHookFunc.Call([]reflect.Value{reflect.ValueOf(id)})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Set the $app object in the loader for JavaScript access
|
||||
p.loader.Set("$app", appObj)
|
||||
}
|
||||
|
||||
// normalizeException checks if the provided error is a goja.Exception
|
||||
// and attempts to return its underlying Go error.
|
||||
//
|
||||
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
|
||||
func normalizeException(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
jsException, ok := err.(*goja.Exception)
|
||||
if !ok {
|
||||
return err // no exception
|
||||
}
|
||||
|
||||
switch v := jsException.Value().Export().(type) {
|
||||
case error:
|
||||
err = v
|
||||
case map[string]any: // goja.GoError
|
||||
if vErr, ok := v["value"].(error); ok {
|
||||
err = vErr
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
Reference in New Issue
Block a user