feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
+515
-215
@@ -2,16 +2,23 @@ package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math/big"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/grafana/sobek"
|
||||
"github.com/grafana/sobek/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -46,6 +53,21 @@ func (e *HookError) Unwrap() error {
|
||||
return e.wrapped
|
||||
}
|
||||
|
||||
// ResultError reports a hook result that CallInto could not store: one the
|
||||
// host codec cannot encode (a NaN, an infinity, a cycle) or one the target's
|
||||
// type rejects. Its message is the codec's.
|
||||
type ResultError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *ResultError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *ResultError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func newHookError(hook, rawMessage string, wrapped error) *HookError {
|
||||
var b strings.Builder
|
||||
b.Grow(len(rawMessage))
|
||||
@@ -67,37 +89,6 @@ func newHookError(hook, rawMessage string, wrapped error) *HookError {
|
||||
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 {
|
||||
@@ -115,15 +106,36 @@ type Engine struct {
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
log func(string)
|
||||
module *sobek.SourceTextModuleRecord
|
||||
module *moejs.Module
|
||||
pool chan *runtimeInstance
|
||||
semaphore chan struct{}
|
||||
// parked bounds the runtimes that lent their slot to other calls while
|
||||
// utils.fetch waits on the network; its capacity is the concurrency.
|
||||
parked chan struct{}
|
||||
hooksMu sync.RWMutex
|
||||
hooks map[hookKey]moejs.Hook
|
||||
// exports holds the exports that were not undefined once the module
|
||||
// loaded; it is written only by Compile.
|
||||
exports map[string]struct{}
|
||||
}
|
||||
|
||||
// hookKey names a cached hook handle. Contract hooks are an export and at
|
||||
// most two members (protocols.<name>.<hook>); longer paths only come from
|
||||
// fixture requests and are resolved on every call.
|
||||
type hookKey struct {
|
||||
export string
|
||||
members [2]string
|
||||
depth int
|
||||
}
|
||||
|
||||
// Fixture requests name arbitrary members, so the handle cache is bounded.
|
||||
const maxCachedHooks = 256
|
||||
|
||||
type runtimeInstance struct {
|
||||
runtime *sobek.Runtime
|
||||
module sobek.ModuleInstance
|
||||
runtime *moejs.Runtime
|
||||
logContext *runtimeLogContext
|
||||
// call is the hook call running on the runtime, nil between calls.
|
||||
call *runtimeCall
|
||||
}
|
||||
|
||||
type runtimeLogContext struct {
|
||||
@@ -131,23 +143,21 @@ type runtimeLogContext struct {
|
||||
}
|
||||
|
||||
// Compile performs upload-time syntax checks and compiles an ESM plugin once.
|
||||
// All Sobek-specific module and runtime handling is intentionally kept here.
|
||||
// All engine-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(_ any, 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)
|
||||
// The compiler never reads files, so a sourceMappingURL comment in
|
||||
// untrusted source stays inert.
|
||||
module, err := moejs.Compile(options.Key+".js", source)
|
||||
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)
|
||||
// export ... from has no import keyword; the host links no modules.
|
||||
if requests := module.Requests(); len(requests) > 0 {
|
||||
return nil, fmt.Errorf("unsupported plugin syntax: re-export from %q: plugins must be synchronous and cannot import modules", requests[0])
|
||||
}
|
||||
|
||||
timeout := options.Timeout
|
||||
@@ -171,20 +181,28 @@ func Compile(source string, options Options) (*Engine, error) {
|
||||
log: options.Log,
|
||||
module: module,
|
||||
semaphore: make(chan struct{}, concurrency),
|
||||
parked: make(chan struct{}, concurrency),
|
||||
pool: make(chan *runtimeInstance, concurrency),
|
||||
hooks: make(map[hookKey]moejs.Hook),
|
||||
exports: make(map[string]struct{}),
|
||||
}
|
||||
instance, err := engine.newRuntime(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range module.Exports() {
|
||||
if value, found := instance.runtime.Export(name); found && !value.IsUndefined() {
|
||||
engine.exports[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
instance.logContext.context = nil
|
||||
engine.putRuntime(instance)
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// Export returns one module export without exposing Sobek values outside the
|
||||
// Export returns one module export without exposing engine 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) {
|
||||
func (e *Engine) Export(ctx context.Context, exportName string) (any, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
@@ -205,58 +223,49 @@ func (e *Engine) Export(ctx context.Context, exportName string) (result any, err
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin export timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
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) {
|
||||
watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer watchdog.stop()
|
||||
value, found := instance.runtime.Export(exportName)
|
||||
if !found || value.IsUndefined() {
|
||||
return nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return value.Export(), nil
|
||||
result, err := instance.runtime.ToGo(value)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
reusable = false
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return nil, fmt.Errorf("plugin %s@%s export %s interrupted: %w", e.key, e.version, exportName, interruptCause(interrupted))
|
||||
}
|
||||
var exception *moejs.Exception
|
||||
errors.As(err, &exception)
|
||||
return nil, fmt.Errorf("plugin %s@%s export %s failed: %w%s", e.key, e.version, exportName, err, thrownAt(instance.runtime, exception))
|
||||
}
|
||||
|
||||
// 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.putRuntime(instance)
|
||||
}()
|
||||
value := instance.module.GetBindingValue(exportName)
|
||||
return value != nil && !sobek.IsUndefined(value), nil
|
||||
// HasExport reports whether a module export was defined when the plugin
|
||||
// loaded: an export that was undefined then counts as absent, and a later
|
||||
// assignment is not seen. Optional contract hooks should be detected with
|
||||
// this method instead of relying on engine errors. It runs no JavaScript, so
|
||||
// it neither waits for nor takes a call slot.
|
||||
func (e *Engine) HasExport(exportName string) bool {
|
||||
_, found := e.exports[exportName]
|
||||
return found
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members ...string) (bool, error) {
|
||||
select {
|
||||
case e.semaphore <- struct{}{}:
|
||||
defer func() { <-e.semaphore }()
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
}
|
||||
hook, err := e.hook(exportName, members)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -270,45 +279,72 @@ func (e *Engine) HasCallablePath(ctx context.Context, exportName string, members
|
||||
}
|
||||
}()
|
||||
timedOut := errors.New("plugin inspection timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
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
|
||||
watchdog := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer watchdog.stop()
|
||||
found, err := instance.runtime.Has(hook)
|
||||
if err == nil {
|
||||
return found, nil
|
||||
}
|
||||
_, callable := sobek.AssertFunction(value)
|
||||
return callable, nil
|
||||
reusable = false
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return false, fmt.Errorf("plugin %s@%s hook %s inspection interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
}
|
||||
var exception *moejs.Exception
|
||||
errors.As(err, &exception)
|
||||
return false, fmt.Errorf("plugin %s@%s hook %s inspection failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
|
||||
}
|
||||
|
||||
// RawJSON is a hook argument given as JSON text: the hook receives JSON.parse
|
||||
// of it, members in order, whether it is an argument or inside one, with no Go
|
||||
// values built in between; text nested too deep for JSON.parse is decoded with
|
||||
// the host codec instead. The engine parses the bytes in place, without the
|
||||
// copy ParseJSON makes, and strings of the call's result may share them, so
|
||||
// the caller must never change them once passed.
|
||||
type RawJSON []byte
|
||||
|
||||
// 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...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, nil, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CallInto invokes one named module export and stores its result in target as
|
||||
// the host codec decodes the encoding of the value Call returns, without that
|
||||
// round trip for plain results. A result the codec cannot encode or target's
|
||||
// type rejects is a *ResultError, and target may then hold part of it.
|
||||
func (e *Engine) CallInto(ctx context.Context, target any, exportName string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target}, exportName, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallJSONInto is CallInto with the semantics of JSON.stringify's text of the
|
||||
// result instead of the host codec's encoding: objects keep their member
|
||||
// order, which a json.RawMessage in target receives as text, members whose
|
||||
// value is undefined are left out and toJSON runs.
|
||||
func (e *Engine) CallJSONInto(ctx context.Context, target any, exportName string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target, stringify: true}, exportName, nil, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, []string{memberName}, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// 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...)
|
||||
result, _, err = e.call(ctx, callOptions{}, exportName, members, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CallPathInto is CallInto for a function nested below an exported object.
|
||||
func (e *Engine) CallPathInto(ctx context.Context, target any, exportName string, members []string, args ...any) error {
|
||||
_, _, err := e.call(ctx, callOptions{target: target}, exportName, members, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallPathWithAdmissionTimeout gives long-lived observers a separate bound for
|
||||
@@ -322,89 +358,191 @@ func (e *Engine) CallPathWithAdmissionTimeout(
|
||||
members []string,
|
||||
args ...any,
|
||||
) (result any, err error) {
|
||||
return e.call(ctx, admissionTimeout, exportName, members, args...)
|
||||
result, _, err = e.call(ctx, callOptions{admissionTimeout: admissionTimeout}, exportName, members, args...)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (e *Engine) call(
|
||||
// CallPathWithMemberJSON is CallPathWithAdmissionTimeout that also returns the
|
||||
// result's member as the text JSON.stringify writes for it, which keeps the
|
||||
// member order of its objects. The text is nil when member is empty or the
|
||||
// result has no such member.
|
||||
func (e *Engine) CallPathWithMemberJSON(
|
||||
ctx context.Context,
|
||||
admissionTimeout time.Duration,
|
||||
member string,
|
||||
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 }()
|
||||
) (result any, text json.RawMessage, err error) {
|
||||
return e.call(ctx, callOptions{admissionTimeout: admissionTimeout, jsonMember: member}, exportName, members, args...)
|
||||
}
|
||||
|
||||
// callOptions is what a hook call does besides calling: the bound on waiting
|
||||
// for a JavaScript slot, the Go value the result is stored in instead of
|
||||
// being returned (from its JSON.stringify text when stringify is set), and a
|
||||
// member of the result also returned as JSON text.
|
||||
type callOptions struct {
|
||||
admissionTimeout time.Duration
|
||||
target any
|
||||
stringify bool
|
||||
jsonMember string
|
||||
}
|
||||
|
||||
func (e *Engine) call(ctx context.Context, opts callOptions, exportName string, members []string, args ...any) (any, json.RawMessage, error) {
|
||||
if err := e.acquireCallSlot(ctx, opts.admissionTimeout); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
call := &runtimeCall{engine: e, ctx: ctx, holdsSlot: true}
|
||||
// utils.fetch may lend the slot out; return what the call holds now.
|
||||
defer call.release()
|
||||
|
||||
hook, err := e.hook(exportName, members)
|
||||
if err != nil {
|
||||
if len(members) == 0 {
|
||||
return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("plugin hook %q not found", exportName)
|
||||
}
|
||||
instance, err := e.getRuntime(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
call.runtime, call.hook = instance.runtime, hook.Name()
|
||||
call.fetcher, _ = ctx.Value(fetcherContextKey{}).(Fetcher)
|
||||
instance.call = call
|
||||
reusable := true
|
||||
defer func() {
|
||||
instance.runtime.ClearInterrupt()
|
||||
instance.logContext.context = nil
|
||||
instance.call = nil
|
||||
if reusable {
|
||||
// An idle pooled runtime must not keep this call's request
|
||||
// data alive.
|
||||
instance.runtime.ReleaseCallData()
|
||||
e.putRuntime(instance)
|
||||
}
|
||||
}()
|
||||
|
||||
hookName := strings.Join(append([]string{exportName}, members...), ".")
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
stopInterrupt := watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
defer stopInterrupt()
|
||||
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)
|
||||
callArgs := make([]moejs.Value, 0, 4)
|
||||
for index, arg := range args {
|
||||
var converted moejs.Value
|
||||
if raw, ok := arg.(RawJSON); !ok {
|
||||
value, _ := pluginValue(instance.runtime, arg, 0)
|
||||
converted, err = instance.runtime.FromGo(value)
|
||||
} else if converted, err = instance.runtime.ParseJSONString(unsafe.String(unsafe.SliceData(raw), len(raw))); err != nil {
|
||||
// JSON.parse stops at a lower nesting depth than the host codec.
|
||||
var decoded any
|
||||
if err = common.Unmarshal(raw, &decoded); err == nil {
|
||||
converted, err = instance.runtime.FromGo(decoded)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s argument %d: %w", e.key, e.version, hook.Name(), index+1, err)
|
||||
}
|
||||
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 = append(callArgs, converted)
|
||||
}
|
||||
|
||||
callArgs := make([]sobek.Value, len(args))
|
||||
for i, arg := range args {
|
||||
callArgs[i] = instance.runtime.ToValue(arg)
|
||||
}
|
||||
timedOut := errors.New("plugin call timed out")
|
||||
call.watchdog = watchRuntimeContext(instance.runtime, ctx, e.timeout, timedOut)
|
||||
// utils.fetch pauses and restarts the watchdog; stop whichever watch is
|
||||
// current when the call ends.
|
||||
defer call.watchdog.stop()
|
||||
|
||||
value, err = callable(sobek.Undefined(), callArgs...)
|
||||
if err != nil {
|
||||
var interrupted *sobek.InterruptedError
|
||||
value, err := instance.runtime.Call(hook, callArgs...)
|
||||
var interrupted *moejs.InterruptedError
|
||||
var exception *moejs.Exception
|
||||
if err == nil {
|
||||
var result any
|
||||
var memberText json.RawMessage
|
||||
switch {
|
||||
case opts.target != nil && opts.stringify:
|
||||
err = instance.runtime.Unmarshal(value, opts.target)
|
||||
case opts.target != nil:
|
||||
err = instance.runtime.ToGoInto(value, opts.target)
|
||||
default:
|
||||
result, err = instance.runtime.ToGo(value)
|
||||
}
|
||||
if err == nil && opts.jsonMember != "" {
|
||||
// After the result's Go value: AppendJSON may run a toJSON or
|
||||
// a getter, which ToGo never does.
|
||||
var member moejs.Value
|
||||
if member, err = instance.runtime.Get(value, opts.jsonMember); err == nil && !member.IsUndefined() {
|
||||
memberText, err = instance.runtime.AppendJSON(nil, member)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
return result, memberText, nil
|
||||
}
|
||||
if errors.As(err, &interrupted) {
|
||||
reusable = false
|
||||
return nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hookName, err)
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s interrupted: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
}
|
||||
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)
|
||||
// Past ToGo's errors, ToGoInto fails as json.Marshal and
|
||||
// json.Unmarshal do, which is the host codec (common/json.go), and
|
||||
// Unmarshal and AppendJSON as JSON.stringify does (a BigInt, a cycle).
|
||||
var internal *moejs.InternalError
|
||||
if (opts.target != nil || opts.jsonMember != "") && !errors.As(err, &exception) && !errors.As(err, &internal) {
|
||||
return nil, nil, &ResultError{err: err}
|
||||
}
|
||||
return nil, wrapped
|
||||
}
|
||||
return value.Export(), nil
|
||||
switch {
|
||||
case errors.Is(err, moejs.ErrHookNotFound):
|
||||
if len(members) == 0 {
|
||||
return nil, nil, fmt.Errorf("plugin export %q not found", exportName)
|
||||
}
|
||||
// Name the path up to the first missing member, as far as data
|
||||
// properties show it; getters on the path are not run again.
|
||||
hookName := exportName
|
||||
current, found := instance.runtime.Export(exportName)
|
||||
for _, member := range members {
|
||||
if !found || current.IsNullish() {
|
||||
break
|
||||
}
|
||||
hookName += "." + member
|
||||
if !current.IsObject() {
|
||||
break
|
||||
}
|
||||
current, found = current.AsObject().GetOwnDataValue(instance.runtime.Realm().KeyFromGoString(member))
|
||||
}
|
||||
return nil, nil, fmt.Errorf("plugin hook %q not found", hookName)
|
||||
case errors.Is(err, moejs.ErrNotCallable):
|
||||
return nil, nil, fmt.Errorf("plugin hook %q is not a function", hook.Name())
|
||||
case errors.As(err, &interrupted):
|
||||
reusable = false
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), interruptCause(interrupted))
|
||||
case errors.As(err, &exception):
|
||||
wrapped := fmt.Errorf("plugin %s@%s hook %s failed: %w%s", e.key, e.version, hook.Name(), err, thrownAt(instance.runtime, exception))
|
||||
return nil, nil, newHookError(hook.Name(), exception.Message(), wrapped)
|
||||
}
|
||||
reusable = false
|
||||
return nil, nil, fmt.Errorf("plugin %s@%s hook %s failed: %w", e.key, e.version, hook.Name(), err)
|
||||
}
|
||||
|
||||
// hook resolves a hook path against the module's export table. Only
|
||||
// declared exports resolve; the handle is cached, while the bindings it
|
||||
// names are read live on every call.
|
||||
func (e *Engine) hook(exportName string, members []string) (moejs.Hook, error) {
|
||||
key := hookKey{export: exportName, depth: len(members)}
|
||||
cacheable := len(members) <= len(key.members)
|
||||
if cacheable {
|
||||
copy(key.members[:], members)
|
||||
e.hooksMu.RLock()
|
||||
hook, found := e.hooks[key]
|
||||
e.hooksMu.RUnlock()
|
||||
if found {
|
||||
return hook, nil
|
||||
}
|
||||
}
|
||||
hook, err := e.module.Hook(exportName, members...)
|
||||
if err != nil || !cacheable {
|
||||
return hook, err
|
||||
}
|
||||
e.hooksMu.Lock()
|
||||
if len(e.hooks) < maxCachedHooks {
|
||||
e.hooks[key] = hook
|
||||
}
|
||||
e.hooksMu.Unlock()
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Duration) error {
|
||||
@@ -432,25 +570,146 @@ func (e *Engine) acquireCallSlot(ctx context.Context, admissionTimeout time.Dura
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// interruptCause is what stopped a runtime: the context cause
|
||||
// watchRuntimeContext interrupted it with, so errors.Is sees a caller's
|
||||
// cancellation through the host error.
|
||||
func interruptCause(interrupted *moejs.InterruptedError) error {
|
||||
if cause, ok := interrupted.Value.(error); ok {
|
||||
return cause
|
||||
}
|
||||
for _, member := range members {
|
||||
hookName += "." + member
|
||||
object := value.ToObject(instance.runtime)
|
||||
own := slices.Contains(object.GetOwnPropertyNames(), member)
|
||||
if !own {
|
||||
return nil, hookName, false
|
||||
return interrupted
|
||||
}
|
||||
|
||||
// thrownAt is the frame an Error was thrown from, as " at fn (file:line:col)",
|
||||
// for host logs; it is empty for thrown values that are not Errors and for
|
||||
// errors that are not exceptions (exception is nil).
|
||||
func thrownAt(runtime *moejs.Runtime, exception *moejs.Exception) string {
|
||||
_, frames, found := strings.Cut(runtime.StackTrace(exception), "\n at ")
|
||||
if !found {
|
||||
return ""
|
||||
}
|
||||
frame, _, _ := strings.Cut(frames, "\n")
|
||||
return " at " + frame
|
||||
}
|
||||
|
||||
// maxPluginValueDepth bounds the argument walk; deeper values, which no
|
||||
// host-built argument reaches, are passed on as they are.
|
||||
const maxPluginValueDepth = 512
|
||||
|
||||
// pluginValueContainers are the container types moejs converts; named types
|
||||
// with one of these underlying types are only retyped.
|
||||
var pluginValueContainers = []reflect.Type{
|
||||
reflect.TypeFor[map[string]any](),
|
||||
reflect.TypeFor[map[string]string](),
|
||||
reflect.TypeFor[map[string][]string](),
|
||||
reflect.TypeFor[[]any](),
|
||||
reflect.TypeFor[[]string](),
|
||||
reflect.TypeFor[[]map[string]any](),
|
||||
}
|
||||
|
||||
// pluginValue returns a hook argument in the shapes moejs converts: nil,
|
||||
// booleans, numbers, strings, JSON-shaped maps and slices and engine values.
|
||||
// A RawJSON inside the argument becomes rt's parse of it (RawJSON); other Go
|
||||
// values (structs, pointers, maps and slices of other element types) take
|
||||
// their JSON form through the configured codec, and named scalars and
|
||||
// containers become their underlying type. changed is false when v has
|
||||
// supported shapes throughout, the usual case, so an argument is passed on
|
||||
// without a copy.
|
||||
func pluginValue(rt *moejs.Runtime, v any, depth int) (converted any, changed bool) {
|
||||
if depth > maxPluginValueDepth {
|
||||
return v, false
|
||||
}
|
||||
switch typed := v.(type) {
|
||||
case nil, bool, string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64,
|
||||
float32, float64, json.Number, *big.Int, []byte, []string, map[string]string, map[string][]string,
|
||||
moejs.Value, moejs.NativeFunc:
|
||||
return v, false
|
||||
case RawJSON:
|
||||
if parsed, err := rt.ParseJSONString(unsafe.String(unsafe.SliceData(typed), len(typed))); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
value = object.Get(member)
|
||||
if value == nil || sobek.IsUndefined(value) || sobek.IsNull(value) {
|
||||
return nil, hookName, false
|
||||
// JSON.parse stops at a lower nesting depth than the host codec.
|
||||
var decoded any
|
||||
if err := common.Unmarshal(typed, &decoded); err != nil {
|
||||
return v, false
|
||||
}
|
||||
return decoded, true
|
||||
case map[string]any:
|
||||
var copied map[string]any
|
||||
for key, item := range typed {
|
||||
if item, changed = pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = maps.Clone(typed)
|
||||
}
|
||||
copied[key] = item
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
case []any:
|
||||
var copied []any
|
||||
for index, item := range typed {
|
||||
if item, changed = pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = slices.Clone(typed)
|
||||
}
|
||||
copied[index] = item
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
case []map[string]any:
|
||||
var copied []map[string]any
|
||||
for index, item := range typed {
|
||||
if next, changed := pluginValue(rt, item, depth+1); changed {
|
||||
if copied == nil {
|
||||
copied = slices.Clone(typed)
|
||||
}
|
||||
copied[index] = next.(map[string]any)
|
||||
}
|
||||
}
|
||||
if copied == nil {
|
||||
return v, false
|
||||
}
|
||||
return copied, true
|
||||
}
|
||||
_, marshaler := v.(json.Marshaler)
|
||||
_, textMarshaler := v.(encoding.TextMarshaler)
|
||||
if !marshaler && !textMarshaler {
|
||||
value := reflect.ValueOf(v)
|
||||
switch value.Kind() {
|
||||
case reflect.Bool:
|
||||
return value.Bool(), true
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return value.Int(), true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return value.Uint(), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return value.Float(), true
|
||||
case reflect.String:
|
||||
return value.String(), true
|
||||
case reflect.Map, reflect.Slice:
|
||||
for _, container := range pluginValueContainers {
|
||||
if value.Type().ConvertibleTo(container) {
|
||||
converted, _ = pluginValue(rt, value.Convert(container).Interface(), depth)
|
||||
return converted, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return value, hookName, true
|
||||
data, err := common.Marshal(v)
|
||||
if err != nil {
|
||||
return v, false
|
||||
}
|
||||
var decoded any
|
||||
if err = common.Unmarshal(data, &decoded); err != nil {
|
||||
return v, false
|
||||
}
|
||||
return decoded, true
|
||||
}
|
||||
|
||||
// Idle runtimes are bounded by the execution limit and survive GC. Start with
|
||||
@@ -472,26 +731,81 @@ func (e *Engine) getRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// A timeout callback must finish before its runtime can be reused. Merely
|
||||
// stopping a timer does not wait for an already-started Interrupt call.
|
||||
func watchRuntimeContext(runtime *sobek.Runtime, ctx context.Context, timeout time.Duration, timeoutError error) func() {
|
||||
callContext, cancel := context.WithTimeoutCause(ctx, timeout, timeoutError)
|
||||
// runtimeWatchdog interrupts a runtime with timeoutError once it has run for
|
||||
// its timeout, or with the context's cause once ctx ends. utils.fetch pauses
|
||||
// it, so the timeout counts JavaScript time only. A timeout callback must
|
||||
// finish before its runtime can be reused: merely stopping a timer does not
|
||||
// wait for an already-started Interrupt call.
|
||||
type runtimeWatchdog struct {
|
||||
runtime *moejs.Runtime
|
||||
ctx context.Context
|
||||
timeoutError error
|
||||
remaining time.Duration
|
||||
started time.Time
|
||||
watch context.Context
|
||||
stopWatch func() bool
|
||||
cancel context.CancelFunc
|
||||
interrupted chan struct{}
|
||||
paused bool
|
||||
}
|
||||
|
||||
func watchRuntimeContext(runtime *moejs.Runtime, ctx context.Context, timeout time.Duration, timeoutError error) *runtimeWatchdog {
|
||||
watchdog := &runtimeWatchdog{runtime: runtime, ctx: ctx, timeoutError: timeoutError, remaining: timeout}
|
||||
watchdog.start()
|
||||
return watchdog
|
||||
}
|
||||
|
||||
func (w *runtimeWatchdog) start() {
|
||||
watch, cancel := context.WithTimeoutCause(w.ctx, w.remaining, w.timeoutError)
|
||||
interrupted := make(chan struct{})
|
||||
stop := context.AfterFunc(callContext, func() {
|
||||
runtime.Interrupt(context.Cause(callContext))
|
||||
w.stopWatch = context.AfterFunc(watch, func() {
|
||||
w.runtime.Interrupt(context.Cause(watch))
|
||||
close(interrupted)
|
||||
})
|
||||
return func() {
|
||||
if !stop() {
|
||||
<-interrupted
|
||||
}
|
||||
cancel()
|
||||
w.watch, w.cancel, w.interrupted = watch, cancel, interrupted
|
||||
w.started, w.paused = time.Now(), false
|
||||
}
|
||||
|
||||
func (w *runtimeWatchdog) stop() {
|
||||
if w.paused {
|
||||
return
|
||||
}
|
||||
if !w.stopWatch() {
|
||||
<-w.interrupted
|
||||
}
|
||||
w.cancel()
|
||||
}
|
||||
|
||||
// pause stops the clock while a host function waits. It reports false when
|
||||
// the watchdog already fired: its interrupt is then ending the call, and the
|
||||
// host function must return instead of waiting.
|
||||
func (w *runtimeWatchdog) pause() bool {
|
||||
if !w.stopWatch() {
|
||||
<-w.interrupted
|
||||
return false
|
||||
}
|
||||
w.cancel()
|
||||
w.remaining -= time.Since(w.started)
|
||||
w.paused = true
|
||||
return true
|
||||
}
|
||||
|
||||
// resume restarts the clock with the time left at pause and the same timeout
|
||||
// cause. When the time is up or ctx ended meanwhile, it waits for the
|
||||
// interrupt that follows, so the host function can return it.
|
||||
func (w *runtimeWatchdog) resume() {
|
||||
w.start()
|
||||
if w.watch.Err() != nil {
|
||||
<-w.interrupted
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err error) {
|
||||
runtime := sobek.New()
|
||||
func (e *Engine) newRuntime(ctx context.Context) (*runtimeInstance, error) {
|
||||
// Code compiled from strings would bypass the upload-time syntax checks,
|
||||
// so eval and the Function constructors throw an EvalError.
|
||||
runtime := moejs.NewRuntime(moejs.Options{DisableDynamicCode: true})
|
||||
logContext := &runtimeLogContext{context: ctx}
|
||||
instance := &runtimeInstance{runtime: runtime, logContext: logContext}
|
||||
logOutput := e.log
|
||||
if logOutput == nil {
|
||||
logOutput = func(message string) {
|
||||
@@ -500,36 +814,22 @@ func (e *Engine) newRuntime(ctx context.Context) (instance *runtimeInstance, err
|
||||
}
|
||||
if err := injectGlobals(runtime, func() string {
|
||||
return fmt.Sprintf("[plugin:%s@%s]", e.key, e.version)
|
||||
}, e.now, logOutput); err != nil {
|
||||
}, e.now, logOutput, func() *runtimeCall { return instance.call }); err != nil {
|
||||
return nil, fmt.Errorf("inject plugin utils: %w", err)
|
||||
}
|
||||
timedOut := errors.New("plugin initialization timed out")
|
||||
stopInterrupt := watchRuntimeContext(runtime, ctx, e.timeout, timedOut)
|
||||
defer func() {
|
||||
stopInterrupt()
|
||||
runtime.ClearInterrupt()
|
||||
}()
|
||||
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(_ any, 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())
|
||||
watchdog := watchRuntimeContext(runtime, ctx, e.timeout, timedOut)
|
||||
err := runtime.Load(e.module)
|
||||
watchdog.stop()
|
||||
runtime.ClearInterrupt()
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &interrupted) {
|
||||
return nil, fmt.Errorf("initialize plugin %s@%s: %w", e.key, e.version, interruptCause(interrupted))
|
||||
}
|
||||
return &runtimeInstance{
|
||||
runtime: runtime,
|
||||
module: runtime.GetModuleInstance(e.module),
|
||||
logContext: logContext,
|
||||
}, nil
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("evaluate plugin: %w", err)
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func sourceWithoutCommentsAndStrings(source string) string {
|
||||
|
||||
Reference in new issue
Block a user