mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-05 10:12:59 +00:00
580 lines
16 KiB
Go
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()
|
|
}
|