Files
new-api/pkg/jsplugin/engine.go
T

580 lines
16 KiB
Go

package jsplugin
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/logger"
"github.com/grafana/sobek"
"github.com/grafana/sobek/parser"
)
const (
DefaultCallTimeout = 5 * time.Second
DefaultConcurrency = 8
)
var ErrCallAdmissionTimeout = errors.New("plugin call admission timed out")
const hookErrorMessageLimit = 512
// HookError reports a JavaScript exception thrown by a plugin hook. Message
// is the sanitized JS error message with engine prefixes stripped; it is safe
// to surface to API callers.
type HookError struct {
Hook string
Message string
wrapped error
}
func (e *HookError) Error() string {
if e == nil || e.wrapped == nil {
return "plugin hook failed"
}
return e.wrapped.Error()
}
func (e *HookError) Unwrap() error {
if e == nil {
return nil
}
return e.wrapped
}
func newHookError(hook, rawMessage string, wrapped error) *HookError {
var b strings.Builder
b.Grow(len(rawMessage))
count := 0
for _, r := range rawMessage {
if count >= hookErrorMessageLimit {
break
}
if r < 0x20 || (r >= 0x80 && r <= 0x9F) {
r = ' '
}
b.WriteRune(r)
count++
}
message := b.String()
if message == "" {
message = "plugin hook failed"
}
return &HookError{Hook: hook, Message: message, wrapped: wrapped}
}
func hookErrorFromException(hook string, exc *sobek.Exception, wrapped error) (hookErr *HookError) {
// Reading message/toString executes plugin getters, which can throw again
// and panic sobek. By this point the caller's recover is already consumed,
// so a second panic would crash the process; fall back to a blank message.
defer func() {
if recover() != nil {
hookErr = newHookError(hook, "", wrapped)
}
}()
raw := ""
if exc != nil {
if val := exc.Value(); val != nil && !sobek.IsUndefined(val) && !sobek.IsNull(val) {
gotMessage := false
if obj, ok := val.(*sobek.Object); ok {
if msg := obj.Get("message"); msg != nil && !sobek.IsUndefined(msg) && !sobek.IsNull(msg) {
raw = msg.String()
gotMessage = true
}
}
if !gotMessage {
if exported, ok := val.Export().(string); ok {
raw = exported
} else {
raw = val.String()
}
}
}
}
return newHookError(hook, raw, wrapped)
}
var forbiddenSyntax = regexp.MustCompile(`(?m)(^|[^A-Za-z0-9_$])(async|await|import)([^A-Za-z0-9_$]|$)`)
type Options struct {
Key string
Version string
Timeout time.Duration
Concurrency int
Now func() time.Time
Log func(string)
}
type Engine struct {
key string
version string
timeout time.Duration
now func() time.Time
log func(string)
module *sobek.SourceTextModuleRecord
pool sync.Pool
semaphore chan struct{}
}
type runtimeInstance struct {
runtime *sobek.Runtime
module sobek.ModuleInstance
logContext *runtimeLogContext
}
type runtimeLogContext struct {
context context.Context
}
// Compile performs upload-time syntax checks and compiles an ESM plugin once.
// All Sobek-specific module and runtime handling is intentionally kept here.
func Compile(source string, options Options) (*Engine, error) {
if match := forbiddenSyntax.FindString(sourceWithoutCommentsAndStrings(source)); match != "" {
return nil, fmt.Errorf("unsupported plugin syntax %q: plugins must be synchronous and cannot import modules", strings.TrimSpace(match))
}
resolve := func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
}
// Plugin source is untrusted; without this option a sourceMappingURL
// comment makes the parser read arbitrary server files via os.ReadFile.
module, err := sobek.ParseModule(options.Key+".js", source, resolve, parser.WithDisableSourceMaps)
if err != nil {
return nil, fmt.Errorf("compile plugin: %w", err)
}
if err = module.Link(); err != nil {
return nil, fmt.Errorf("link plugin: %w", err)
}
timeout := options.Timeout
if timeout <= 0 {
timeout = DefaultCallTimeout
}
concurrency := options.Concurrency
if concurrency <= 0 {
concurrency = DefaultConcurrency
}
now := options.Now
if now == nil {
now = time.Now
}
engine := &Engine{
key: options.Key,
version: options.Version,
timeout: timeout,
now: now,
log: options.Log,
module: module,
semaphore: make(chan struct{}, concurrency),
}
instance, err := engine.newRuntime(context.Background())
if err != nil {
return nil, err
}
instance.logContext.context = nil
engine.pool.Put(instance)
return engine, nil
}
// Export returns one module export without exposing Sobek values outside the
// engine boundary. It is used for declarative exports such as meta.
func (e *Engine) Export(ctx context.Context, exportName string) (result any, err error) {
select {
case e.semaphore <- struct{}{}:
defer func() { <-e.semaphore }()
case <-ctx.Done():
return nil, ctx.Err()
}
instance, err := e.getRuntime(ctx)
if err != nil {
return nil, err
}
reusable := true
defer func() {
instance.runtime.ClearInterrupt()
instance.logContext.context = nil
if reusable {
e.pool.Put(instance)
}
}()
timedOut := errors.New("plugin export timed out")
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
defer stopContext()
defer timer.Stop()
defer func() {
if recovered := recover(); recovered != nil {
reusable = false
switch value := recovered.(type) {
case *sobek.InterruptedError:
err = fmt.Errorf("plugin %s@%s export %s interrupted: %v", e.key, e.version, exportName, value.Value())
case *sobek.Exception:
err = fmt.Errorf("plugin %s@%s export %s failed: %v", e.key, e.version, exportName, value)
default:
panic(recovered)
}
}
}()
value := instance.module.GetBindingValue(exportName)
if value == nil || sobek.IsUndefined(value) {
return nil, fmt.Errorf("plugin export %q not found", exportName)
}
return value.Export(), nil
}
// HasExport reports whether a module export exists. Optional contract hooks
// should be detected with this method instead of relying on engine errors.
func (e *Engine) HasExport(ctx context.Context, exportName string) (bool, error) {
select {
case e.semaphore <- struct{}{}:
defer func() { <-e.semaphore }()
case <-ctx.Done():
return false, ctx.Err()
}
instance, err := e.getRuntime(ctx)
if err != nil {
return false, err
}
defer func() {
instance.logContext.context = nil
e.pool.Put(instance)
}()
value := instance.module.GetBindingValue(exportName)
return value != nil && !sobek.IsUndefined(value), nil
}
// HasCallablePath reports whether an exported value, or a nested member below
// it, exists and is callable.
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (found bool, err error) {
select {
case e.semaphore <- struct{}{}:
defer func() { <-e.semaphore }()
case <-ctx.Done():
return false, ctx.Err()
}
instance, err := e.getRuntime(ctx)
if err != nil {
return false, err
}
reusable := true
defer func() {
instance.runtime.ClearInterrupt()
instance.logContext.context = nil
if reusable {
e.pool.Put(instance)
}
}()
timedOut := errors.New("plugin inspection timed out")
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
defer stopContext()
defer timer.Stop()
defer func() {
if recovered := recover(); recovered != nil {
reusable = false
hookName := strings.Join(append([]string{exportName}, members...), ".")
switch value := recovered.(type) {
case *sobek.InterruptedError:
err = fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %v", e.key, e.version, hookName, value.Value())
case *sobek.Exception:
err = fmt.Errorf("plugin %s@%s hook %s inspection failed: %v", e.key, e.version, hookName, value)
default:
panic(recovered)
}
}
}()
value, _, found := resolveExportPath(instance, exportName, members)
if !found {
return false, nil
}
_, callable := sobek.AssertFunction(value)
return callable, nil
}
// Call invokes one named module export and returns its JSON-compatible value.
func (e *Engine) Call(ctx context.Context, exportName string, args ...any) (result any, err error) {
return e.call(ctx, 0, exportName, nil, args...)
}
// CallMember invokes a function stored on an exported object, such as a
// renderer in the renderers export.
func (e *Engine) CallMember(ctx context.Context, exportName, memberName string, args ...any) (result any, err error) {
return e.call(ctx, 0, exportName, []string{memberName}, args...)
}
// CallPath invokes a function nested below an exported object. It is used for
// protocol hooks such as protocols.openai_responses.renderEvents.
func (e *Engine) CallPath(ctx context.Context, exportName string, members []string, args ...any) (result any, err error) {
return e.call(ctx, 0, exportName, members, args...)
}
// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
// waiting on JavaScript capacity. Once admitted, the hook receives the
// engine's full execution timeout instead of inheriting time already spent in
// the semaphore queue.
func (e *Engine) CallPathWithAdmissionTimeout(
ctx context.Context,
admissionTimeout time.Duration,
exportName string,
members []string,
args ...any,
) (result any, err error) {
return e.call(ctx, admissionTimeout, exportName, members, args...)
}
func (e *Engine) call(
ctx context.Context,
admissionTimeout time.Duration,
exportName string,
members []string,
args ...any,
) (result any, err error) {
if err = e.acquireCallSlot(ctx, admissionTimeout); err != nil {
return nil, err
}
defer func() { <-e.semaphore }()
instance, err := e.getRuntime(ctx)
if err != nil {
return nil, err
}
reusable := true
defer func() {
instance.runtime.ClearInterrupt()
instance.logContext.context = nil
if reusable {
e.pool.Put(instance)
}
}()
hookName := strings.Join(append([]string{exportName}, members...), ".")
timedOut := errors.New("plugin call timed out")
timer := time.AfterFunc(e.timeout, func() { instance.runtime.Interrupt(timedOut) })
stopContext := context.AfterFunc(ctx, func() { instance.runtime.Interrupt(ctx.Err()) })
defer stopContext()
defer timer.Stop()
defer func() {
if recovered := recover(); recovered != nil {
reusable = false
switch value := recovered.(type) {
case *sobek.InterruptedError:
err = fmt.Errorf("plugin %s@%s hook %s interrupted: %v", e.key, e.version, hookName, value.Value())
case *sobek.Exception:
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %v", e.key, e.version, hookName, value)
err = hookErrorFromException(hookName, value, wrapped)
default:
panic(recovered)
}
}
}()
value, resolvedHookName, found := resolveExportPath(instance, exportName, members)
hookName = resolvedHookName
if !found {
if len(members) == 0 {
return nil, fmt.Errorf("plugin export %q not found", exportName)
}
return nil, fmt.Errorf("plugin hook %q not found", hookName)
}
if value == nil || sobek.IsUndefined(value) {
return nil, fmt.Errorf("plugin export %q not found", exportName)
}
callable, ok := sobek.AssertFunction(value)
if !ok {
return nil, fmt.Errorf("plugin hook %q is not a function", hookName)
}
callArgs := make([]sobek.Value, len(args))
for i, arg := range args {
callArgs[i] = instance.runtime.ToValue(arg)
}
value, err = callable(sobek.Undefined(), callArgs...)
if err != nil {
var interrupted *sobek.InterruptedError
if errors.As(err, &interrupted) {
reusable = false
return nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
}
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
var exc *sobek.Exception
if errors.As(err, &exc) {
return nil, hookErrorFromException(hookName, exc, wrapped)
}
return nil, wrapped
}
return value.Export(), nil
}
func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
if admissionTimeout <= 0 {
select {
case e.semaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
timer := time.NewTimer(admissionTimeout)
defer timer.Stop()
select {
case e.semaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
if err := ctx.Err(); err != nil {
return err
}
return fmt.Errorf("%w: plugin %s@%s", ErrCallAdmissionTimeout, e.key, e.version)
}
}
func resolveExportPath(instance *runtimeInstance, exportName string, members []string) (sobek.Value, string, bool) {
value := instance.module.GetBindingValue(exportName)
hookName := exportName
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
return nil, hookName, false
}
for _, member := range members {
hookName += "." + member
object := value.ToObject(instance.runtime)
own := false
for _, name := range object.GetOwnPropertyNames() {
if name == member {
own = true
break
}
}
if !own {
return nil, hookName, false
}
value = object.Get(member)
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
return nil, hookName, false
}
}
return value, hookName, true
}
func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
if pooled := e.pool.Get(); pooled != nil {
instance := pooled.(*runtimeInstance)
instance.logContext.context = ctx
return instance, nil
}
return e.newRuntime(ctx)
}
func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
runtime := sobek.New()
logContext := &runtimeLogContext{context: ctx}
logOutput := e.log
if logOutput == nil {
logOutput = func(message string) {
logger.LogDebug(logContext.context, "task_plugin subsystem=runtime event=console message=%q", message)
}
}
if err := injectGlobals(runtime, func() string {
return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
}, e.now, logOutput); err != nil {
return nil, fmt.Errorf("inject plugin utils: %w", err)
}
timedOut := errors.New("plugin initialization timed out")
timer := time.AfterFunc(e.timeout, func() { runtime.Interrupt(timedOut) })
defer timer.Stop()
defer func() {
if recovered := recover(); recovered != nil {
if interrupted, ok := recovered.(*sobek.InterruptedError); ok {
instance = nil
err = fmt.Errorf("initialize plugin %s@%s: %v", e.key, e.version, interrupted.Value())
return
}
panic(recovered)
}
}()
promise := runtime.CyclicModuleRecordEvaluate(e.module, func(_ interface{}, specifier string) (sobek.ModuleRecord, error) {
return nil, fmt.Errorf("plugin imports are disabled: %s", specifier)
})
if promise.State() != sobek.PromiseStateFulfilled {
return nil, fmt.Errorf("evaluate plugin: %v", promise.Result().Export())
}
return &runtimeInstance{
runtime: runtime,
module: runtime.GetModuleInstance(e.module),
logContext: logContext,
}, nil
}
func sourceWithoutCommentsAndStrings(source string) string {
var output strings.Builder
output.Grow(len(source))
quote := byte(0)
escaped := false
lineComment := false
blockComment := false
for i := 0; i < len(source); i++ {
current := source[i]
next := byte(0)
if i+1 < len(source) {
next = source[i+1]
}
if lineComment {
if current == '\n' {
lineComment = false
output.WriteByte('\n')
} else {
output.WriteByte(' ')
}
continue
}
if blockComment {
if current == '*' && next == '/' {
blockComment = false
output.WriteString(" ")
i++
} else {
output.WriteByte(' ')
}
continue
}
if quote != 0 {
output.WriteByte(' ')
if escaped {
escaped = false
} else if current == '\\' {
escaped = true
} else if current == quote {
quote = 0
}
continue
}
if current == '/' && next == '/' {
lineComment = true
output.WriteString(" ")
i++
continue
}
if current == '/' && next == '*' {
blockComment = true
output.WriteString(" ")
i++
continue
}
if current == '\'' || current == '"' || current == '`' {
quote = current
output.WriteByte(' ')
continue
}
output.WriteByte(current)
}
return output.String()
}