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 {
|
||||
|
||||
@@ -3,9 +3,12 @@ package jsplugin
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -14,6 +17,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
"weak"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
@@ -107,6 +112,72 @@ export function invalid(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineRawJSONArgumentsMatchDecodedArguments(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function describe(...values) {
|
||||
const seen = [];
|
||||
const stack = values.reverse().map((v) => ({v}));
|
||||
while (stack.length > 0) {
|
||||
const {v, key, close} = stack.pop();
|
||||
if (close) {
|
||||
seen.push(close);
|
||||
continue;
|
||||
}
|
||||
if (key !== undefined) seen.push(key);
|
||||
if (v === null || typeof v !== "object") {
|
||||
seen.push(typeof v + ":" + (Object.is(v, -0) ? "-0" : JSON.stringify(v)));
|
||||
continue;
|
||||
}
|
||||
const array = Array.isArray(v);
|
||||
seen.push(array ? "[" : Object.getPrototypeOf(v) === Object.prototype ? "{" : "{?");
|
||||
stack.push({close: array ? "]" : "}"});
|
||||
const keys = Object.keys(v);
|
||||
for (let i = keys.length - 1; i >= 0; i--) {
|
||||
stack.push({v: v[keys[i]], key: array ? undefined : JSON.stringify(keys[i])});
|
||||
}
|
||||
}
|
||||
return seen.join(" ");
|
||||
}`, Options{})
|
||||
require.NoError(t, err)
|
||||
// Hosts pass text written by common.Marshal, where both paths agree on
|
||||
// key order, numbers and strings.
|
||||
values := map[string]any{
|
||||
"object": map[string]any{
|
||||
"__proto__": map[string]any{"polluted": true},
|
||||
"10": "ten", "2": "two", "a": []any{}, "b": map[string]any{},
|
||||
"numbers": []any{0, math.Copysign(0, -1), 1e21, int64(123456789012345678), 1.5e-7, -12, math.MaxInt64, math.SmallestNonzeroFloat64},
|
||||
"strings": []any{"", "<>&
", "é中😀", "\u0000\t\"\\", "a\xffb"},
|
||||
"nested": map[string]any{"z": nil, "y": []any{true, false, map[string]any{"x": []any{[]any{1}}}}},
|
||||
},
|
||||
"array": []any{map[string]any{"id": "x", "b": 1, "a": 2}, nil, "s"},
|
||||
"string": "plain",
|
||||
"number": 42.5,
|
||||
"null": nil,
|
||||
"deep": json.RawMessage(strings.Repeat("[", 600) + strings.Repeat("]", 600)),
|
||||
}
|
||||
for name, value := range values {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
text, err := common.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
var decoded any
|
||||
require.NoError(t, common.Unmarshal(text, &decoded))
|
||||
want, err := engine.Call(t.Context(), "describe", decoded)
|
||||
require.NoError(t, err)
|
||||
got, err := engine.Call(t.Context(), "describe", RawJSON(text))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, got)
|
||||
nested, err := engine.Call(t.Context(), "describe", map[string]any{"value": RawJSON(text)})
|
||||
require.NoError(t, err)
|
||||
wantNested, err := engine.Call(t.Context(), "describe", map[string]any{"value": decoded})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantNested, nested)
|
||||
})
|
||||
}
|
||||
|
||||
_, err = engine.Call(t.Context(), "describe", "first", RawJSON(`{"a":`))
|
||||
require.EqualError(t, err, "plugin @ hook describe argument 2: unexpected end of JSON input")
|
||||
}
|
||||
|
||||
func TestJSONStateChangesAndEncodedLimits(t *testing.T) {
|
||||
const changes = `[
|
||||
[{"op":"set","path":[],"value":{"text":"<","parts":[],"nested":{"value":null}}}],
|
||||
@@ -388,6 +459,8 @@ func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
||||
"static import": `import value from "dependency"; export function run() { return value; }`,
|
||||
"dynamic import": `export function run() { return import("dependency"); }`,
|
||||
"top-level await": `const value = await work(); export function run() { return value; }`,
|
||||
"export from": `export { value as run } from "dependency";`,
|
||||
"export star": `export * from "dependency"; export function run() {}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Compile(source, Options{Key: "invalid"})
|
||||
@@ -398,6 +471,18 @@ func TestCompileRejectsAsynchronousAndImportedPlugins(t *testing.T) {
|
||||
|
||||
_, err := Compile(`export function run() { return "import async await"; }`, Options{Key: "valid"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, body := range map[string]string{
|
||||
"eval": `return eval("import('dependency')");`,
|
||||
"Function constructor": `return Function("return async function () {}")();`,
|
||||
} {
|
||||
engine, err := Compile(`export function run() { `+body+` }`, Options{Key: "dynamic"})
|
||||
require.NoError(t, err, name)
|
||||
_, err = engine.Call(context.Background(), "run")
|
||||
assert.ErrorContains(t, err, "EvalError", name)
|
||||
}
|
||||
_, err = Compile(`eval("1"); export function run() {}`, Options{Key: "dynamic"})
|
||||
assert.ErrorContains(t, err, "EvalError")
|
||||
}
|
||||
|
||||
func TestCompileIgnoresSourceMapDirectives(t *testing.T) {
|
||||
@@ -445,6 +530,22 @@ func TestEngineDoesNotReinitializeIdleRuntimeAfterGC(t *testing.T) {
|
||||
assert.Equal(t, int64(1), initialized.Load())
|
||||
}
|
||||
|
||||
func TestEngineIdleRuntimeReleasesLastCallArguments(t *testing.T) {
|
||||
engine, err := Compile(`export function run(body){return body.image.length;}`, Options{Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
image := strings.Repeat("A", 1<<20)
|
||||
released := weak.Make(unsafe.StringData(image))
|
||||
value, err := engine.Call(t.Context(), "run", map[string]any{"image": image})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1<<20), value)
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
assert.Nil(t, released.Value(), "an idle pooled runtime must not keep the last request body alive")
|
||||
value, err = engine.Call(t.Context(), "run", map[string]any{"image": "AB"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), value)
|
||||
}
|
||||
|
||||
func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
||||
entered := make(chan struct{}, 2)
|
||||
release := make(chan struct{})
|
||||
@@ -475,6 +576,193 @@ func TestEngineBoundsConcurrentCallsAndCancelsAnOccupiedRuntime(t *testing.T) {
|
||||
require.NoError(t, <-healthy)
|
||||
}
|
||||
|
||||
func TestEngineFetchContract(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function run(request) { return utils.fetch(request); }
|
||||
export function fetchBytes(url) { return Array.from(new Uint8Array(utils.fetch({url: url, responseType: "bytes"}).body)); }
|
||||
export function fetchFiveTimes() {
|
||||
const failures = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try { utils.fetch({url: "https://api.example/json"}); } catch (e) { failures.push(e.message); }
|
||||
}
|
||||
return failures;
|
||||
}`, Options{Key: "fetch"})
|
||||
require.NoError(t, err)
|
||||
var requests []FetchRequest
|
||||
ctx := WithFetcher(t.Context(), func(_ context.Context, request FetchRequest) (FetchResponse, error) {
|
||||
requests = append(requests, request)
|
||||
switch request.URL {
|
||||
case "https://api.example/json":
|
||||
return FetchResponse{Status: http.StatusOK, Headers: map[string]string{"Content-Type": "application/json"}, Body: []byte(`{"file":{"id":7}}`)}, nil
|
||||
case "https://api.example/text":
|
||||
return FetchResponse{Status: http.StatusNotFound, Body: []byte("not found")}, nil
|
||||
case "https://api.example/bytes":
|
||||
return FetchResponse{Status: http.StatusPartialContent, Body: []byte{0, 0x80, 0xff, '{'}}, nil
|
||||
case "https://api.example/large":
|
||||
return FetchResponse{Status: http.StatusOK, Body: bytes.Repeat([]byte("a"), MaxFetchBodyBytes+1)}, nil
|
||||
case "https://api.example/down?token=secret":
|
||||
return FetchResponse{}, &url.Error{Op: "Get", URL: request.URL, Err: errors.New("connection refused")}
|
||||
}
|
||||
return FetchResponse{Status: http.StatusOK, Body: []byte(`{"ignored":true}`)}, nil
|
||||
})
|
||||
|
||||
_, err = engine.Call(t.Context(), "run", map[string]any{"url": "https://api.example/json"})
|
||||
assert.ErrorContains(t, err, "utils.fetch is not available in run")
|
||||
|
||||
value, err := engine.Call(ctx, "run", map[string]any{"url": "https://api.example/json", "headers": map[string]any{"Authorization": "Bearer key"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"status": int64(http.StatusOK),
|
||||
"headers": map[string]any{"Content-Type": "application/json"},
|
||||
"body": map[string]any{"file": map[string]any{"id": int64(7)}},
|
||||
}, value)
|
||||
assert.Equal(t, []FetchRequest{{Hook: "run", URL: "https://api.example/json", Method: http.MethodGet, Headers: map[string]string{"Authorization": "Bearer key"}}}, requests)
|
||||
|
||||
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/text"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"status": int64(http.StatusNotFound), "headers": map[string]any{}, "body": "not found"}, value)
|
||||
|
||||
value, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/head", "method": "head"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", value.(map[string]any)["body"])
|
||||
assert.Equal(t, http.MethodHead, requests[len(requests)-1].Method)
|
||||
|
||||
// Bytes that are neither JSON nor UTF-8 reach the plugin unchanged.
|
||||
value, err = engine.Call(ctx, "fetchBytes", "https://api.example/bytes")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{int64(0), int64(0x80), int64(0xff), int64('{')}, value)
|
||||
|
||||
sent := len(requests)
|
||||
for name, request := range map[string]map[string]any{
|
||||
"unsupported member \"body\"": {"url": "https://api.example/json", "body": "x"},
|
||||
"only GET and HEAD": {"url": "https://api.example/json", "method": "POST"},
|
||||
`responseType must be "json" or "bytes"`: {"url": "https://api.example/json", "responseType": "text"},
|
||||
"request header \"Host\" is not": {"url": "https://api.example/json?token=secret", "headers": map[string]any{"Host": "other.example"}},
|
||||
"absolute http(s) URL": {"url": "/v1/files/retrieve"},
|
||||
"exceeds 65536 bytes": {"url": "https://api.example/" + strings.Repeat("a", 64<<10)},
|
||||
} {
|
||||
_, err = engine.Call(ctx, "run", request)
|
||||
require.Error(t, err, name)
|
||||
assert.Contains(t, err.Error(), name)
|
||||
assert.NotContains(t, err.Error(), "secret")
|
||||
}
|
||||
assert.Len(t, requests, sent, "rejected requests must not reach the fetcher")
|
||||
|
||||
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/large"})
|
||||
assert.ErrorContains(t, err, "utils.fetch response from api.example exceeds 1048576 bytes")
|
||||
|
||||
_, err = engine.Call(ctx, "run", map[string]any{"url": "https://api.example/down?token=secret"})
|
||||
var hookErr *HookError
|
||||
require.ErrorAs(t, err, &hookErr)
|
||||
assert.Equal(t, "utils.fetch to api.example failed: connection refused", hookErr.Message)
|
||||
|
||||
value, err = engine.Call(ctx, "fetchFiveTimes")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []any{"utils.fetch allows at most 4 requests per hook call"}, value)
|
||||
}
|
||||
|
||||
func TestEngineFetchLendsItsExecutionSlot(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export function fetchOnce() { return utils.fetch({url: "https://api.example/slow"}).body; }
|
||||
export function plain() { return 42; }`, Options{Key: "fetch-slot", Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusOK, Body: []byte(`"done"`)}, nil
|
||||
})
|
||||
fetched := make(chan any, 1)
|
||||
go func() {
|
||||
value, err := engine.Call(ctx, "fetchOnce")
|
||||
assert.NoError(t, err)
|
||||
fetched <- value
|
||||
}()
|
||||
<-entered
|
||||
|
||||
// The only slot is lent while the fetch waits; without that this call
|
||||
// would wait for its deadline.
|
||||
waitContext, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
value, err := engine.Call(waitContext, "plain")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(42), value)
|
||||
|
||||
close(release)
|
||||
assert.Equal(t, "done", <-fetched)
|
||||
}
|
||||
|
||||
func TestEngineFetchWaitDoesNotCountTowardCallTimeout(t *testing.T) {
|
||||
engine, err := Compile(`export function run() { return utils.fetch({url: "https://api.example/slow"}).status; }`, Options{
|
||||
Key: "fetch-clock", Timeout: 20 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx := WithFetcher(t.Context(), func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusNoContent}, nil
|
||||
})
|
||||
result := make(chan error, 1)
|
||||
var value any
|
||||
go func() {
|
||||
var err error
|
||||
value, err = engine.Call(ctx, "run")
|
||||
result <- err
|
||||
}()
|
||||
<-entered
|
||||
// Five times the JavaScript timeout: the clock is stopped while waiting.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(release)
|
||||
require.NoError(t, <-result)
|
||||
assert.Equal(t, int64(http.StatusNoContent), value)
|
||||
}
|
||||
|
||||
func TestEngineInterruptsAFetchThatCannotResume(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
let calls = 0;
|
||||
export function run(fetch) {
|
||||
calls++;
|
||||
if (!fetch) return calls;
|
||||
try { utils.fetch({url: "https://api.example/slow"}); } catch (e) { return "caught: " + e.message; }
|
||||
return "resumed";
|
||||
}`, Options{Key: "fetch-resume", Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
ctx = WithFetcher(ctx, func(context.Context, FetchRequest) (FetchResponse, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return FetchResponse{Status: http.StatusOK}, nil
|
||||
})
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := engine.Call(ctx, "run", true)
|
||||
result <- err
|
||||
}()
|
||||
<-entered
|
||||
// Another call takes the lent slot and the caller leaves before the fetch
|
||||
// returns, so the slot cannot be taken back.
|
||||
engine.semaphore <- struct{}{}
|
||||
cancel()
|
||||
close(release)
|
||||
err = <-result
|
||||
<-engine.semaphore
|
||||
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
var hookErr *HookError
|
||||
assert.False(t, errors.As(err, &hookErr), "JavaScript must not catch a failed resume")
|
||||
value, err := engine.Call(t.Context(), "run", false)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), value, "an interrupted runtime must be discarded")
|
||||
assert.Empty(t, engine.semaphore)
|
||||
assert.Empty(t, engine.parked)
|
||||
}
|
||||
|
||||
func TestEnginePreservesMutableExportAndMemberBindings(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export let run = function(){return 1;};
|
||||
@@ -495,6 +783,30 @@ export function replace(){run=function(){return 2;};native.render=function(){ret
|
||||
assert.Equal(t, int64(3), value)
|
||||
}
|
||||
|
||||
func TestEngineHasExportUsesBindingsAsLoaded(t *testing.T) {
|
||||
engine, err := Compile(`
|
||||
export let later;
|
||||
export const unset = undefined;
|
||||
export function run(){return 1;}
|
||||
export function assign(){later=function(){return 2;};}
|
||||
`, Options{Concurrency: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
engine.semaphore <- struct{}{}
|
||||
assert.True(t, engine.HasExport("run"))
|
||||
assert.False(t, engine.HasExport("later"))
|
||||
assert.False(t, engine.HasExport("unset"))
|
||||
assert.False(t, engine.HasExport("missing"))
|
||||
<-engine.semaphore
|
||||
|
||||
_, err = engine.Call(t.Context(), "assign")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, engine.HasExport("later"))
|
||||
value, err := engine.Call(t.Context(), "later")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), value)
|
||||
}
|
||||
|
||||
func TestEngineHookErrorExtractsSanitizedJSMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
)
|
||||
|
||||
// Fetcher sends one utils.fetch request for the hook call whose context
|
||||
// carries it. The host binds it to the executing channel; the engine passes a
|
||||
// context already bounded by the call's fetch budget, so the fetcher only sends.
|
||||
type Fetcher func(ctx context.Context, request FetchRequest) (FetchResponse, error)
|
||||
|
||||
type FetchRequest struct {
|
||||
Hook string // the calling hook, filled by the engine for logs and errors
|
||||
URL string
|
||||
Method string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type FetchResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// The utils.fetch limits are part of the published plugin contract
|
||||
// (docs/plugin-api/v1.md). A fetcher reads at most MaxFetchBodyBytes+1 bytes
|
||||
// so that the engine can reject a longer body.
|
||||
const (
|
||||
maxFetchesPerCall = 4
|
||||
fetchTimeout = 15 * time.Second
|
||||
maxFetchTimePerCall = 30 * time.Second
|
||||
MaxFetchBodyBytes = 1 << 20
|
||||
maxFetchURLBytes = 64 << 10
|
||||
)
|
||||
|
||||
type fetcherContextKey struct{}
|
||||
|
||||
// WithFetcher makes utils.fetch available to the hook called with ctx.
|
||||
func WithFetcher(ctx context.Context, fetcher Fetcher) context.Context {
|
||||
return context.WithValue(ctx, fetcherContextKey{}, fetcher)
|
||||
}
|
||||
|
||||
// runtimeCall is the hook call a runtime is running, as its host functions
|
||||
// see it. The engine sets it for one call and clears it when the call ends,
|
||||
// so an idle pooled runtime keeps no request data.
|
||||
type runtimeCall struct {
|
||||
engine *Engine
|
||||
runtime *moejs.Runtime
|
||||
ctx context.Context
|
||||
hook string
|
||||
fetcher Fetcher
|
||||
watchdog *runtimeWatchdog
|
||||
// holdsSlot and parked are what the call holds of the engine's
|
||||
// semaphore and parked tokens; release returns exactly that.
|
||||
holdsSlot bool
|
||||
parked bool
|
||||
fetches int
|
||||
fetchTime time.Duration
|
||||
}
|
||||
|
||||
func (c *runtimeCall) release() {
|
||||
if c.holdsSlot {
|
||||
<-c.engine.semaphore
|
||||
c.holdsSlot = false
|
||||
}
|
||||
if c.parked {
|
||||
<-c.engine.parked
|
||||
c.parked = false
|
||||
}
|
||||
}
|
||||
|
||||
// fetch is utils.fetch. While the fetcher waits on the network the call's
|
||||
// JavaScript clock is stopped and its execution slot is lent to other calls.
|
||||
// Errors name the host only: hook errors reach API callers, and paths and
|
||||
// queries can carry credentials.
|
||||
func (c *runtimeCall) fetch(r *moejs.Realm, argument moejs.Value) (moejs.Value, error) {
|
||||
if c.fetcher == nil {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch is not available in %s", c.hook)
|
||||
}
|
||||
request, host, wantBytes, err := fetchRequest(r, argument)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
request.Hook = c.hook
|
||||
if c.fetches >= maxFetchesPerCall {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch allows at most %d requests per hook call", maxFetchesPerCall)
|
||||
}
|
||||
remaining := maxFetchTimePerCall - c.fetchTime
|
||||
if remaining <= 0 {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch requests used up their %s per hook call", maxFetchTimePerCall)
|
||||
}
|
||||
c.fetches++
|
||||
timeout, timeoutCause := fetchTimeout, fmt.Errorf("utils.fetch to %s timed out after %s", host, fetchTimeout)
|
||||
if remaining < fetchTimeout {
|
||||
timeout, timeoutCause = remaining, fmt.Errorf("utils.fetch to %s ran past the %s of requests per hook call", host, maxFetchTimePerCall)
|
||||
}
|
||||
|
||||
if !c.watchdog.pause() {
|
||||
// The call timed out or its caller left; the interrupt ends it.
|
||||
return moejs.Undefined(), r.CheckInterrupt()
|
||||
}
|
||||
c.yieldSlot()
|
||||
fetchContext, cancel := context.WithTimeoutCause(c.ctx, timeout, timeoutCause)
|
||||
started := time.Now()
|
||||
response, err := c.fetcher(fetchContext, request)
|
||||
c.fetchTime += time.Since(started)
|
||||
if err != nil && fetchContext.Err() != nil {
|
||||
err = context.Cause(fetchContext)
|
||||
}
|
||||
cancel()
|
||||
if !c.reclaimSlot() {
|
||||
// JavaScript must not run without a slot, and a thrown error could be
|
||||
// caught; interrupting discards the runtime as a timeout does.
|
||||
cause := context.Cause(c.ctx)
|
||||
if cause == nil {
|
||||
cause = fmt.Errorf("plugin call could not resume after utils.fetch: %w", ErrCallAdmissionTimeout)
|
||||
}
|
||||
c.runtime.Interrupt(cause)
|
||||
return moejs.Undefined(), r.CheckInterrupt()
|
||||
}
|
||||
c.watchdog.resume()
|
||||
if interrupted := r.CheckInterrupt(); interrupted != nil {
|
||||
return moejs.Undefined(), interrupted
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
var transportErr *url.Error
|
||||
if errors.As(err, &transportErr) {
|
||||
err = transportErr.Err
|
||||
}
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch to %s failed: %v", host, err)
|
||||
}
|
||||
if len(response.Body) > MaxFetchBodyBytes {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch response from %s exceeds %d bytes", host, MaxFetchBodyBytes)
|
||||
}
|
||||
// The result is built from JavaScript objects, not a host map: a hook may
|
||||
// return it, or part of it, as it is.
|
||||
headers := r.NewObject()
|
||||
for name, value := range response.Headers {
|
||||
if err = headers.CreateDataPropertyOrThrow(r, r.KeyFromGoString(name), moejs.String(value)); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
headersValue, err := r.FromGo(headers)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
body := moejs.String("")
|
||||
if wantBytes {
|
||||
// An ArrayBuffer over the response bytes, which belong to this call.
|
||||
if response.Body == nil {
|
||||
response.Body = []byte{}
|
||||
}
|
||||
if body, err = r.FromGo(response.Body); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
} else if request.Method != http.MethodHead {
|
||||
text := string(response.Body)
|
||||
if body, err = c.runtime.ParseJSONString(text); err != nil {
|
||||
body = moejs.String(text)
|
||||
}
|
||||
}
|
||||
result := r.NewObject()
|
||||
for _, member := range []struct {
|
||||
name string
|
||||
value moejs.Value
|
||||
}{{"status", moejs.Int(int64(response.Status))}, {"headers", headersValue}, {"body", body}} {
|
||||
if err = result.CreateDataPropertyOrThrow(r, r.KeyFromGoString(member.name), member.value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
return r.FromGo(result)
|
||||
}
|
||||
|
||||
// fetchRequest reads the utils.fetch argument: {url, method?, headers?,
|
||||
// responseType?}. wantBytes is a responseType of "bytes": the body is
|
||||
// returned as an ArrayBuffer instead of parsed JSON or text.
|
||||
func fetchRequest(r *moejs.Realm, argument moejs.Value) (request FetchRequest, host string, wantBytes bool, err error) {
|
||||
if !argument.IsObject() {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request must be an object")
|
||||
}
|
||||
exported, err := r.ToGoStrict(argument)
|
||||
if err != nil {
|
||||
return FetchRequest{}, "", false, err
|
||||
}
|
||||
members, ok := exported.(map[string]any)
|
||||
if !ok {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request must be an object")
|
||||
}
|
||||
request = FetchRequest{Method: http.MethodGet}
|
||||
for name, value := range members {
|
||||
switch name {
|
||||
case "url":
|
||||
if request.URL, ok = value.(string); !ok {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url must be a string")
|
||||
}
|
||||
case "method":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
method, isString := value.(string)
|
||||
if !isString {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch method must be a string")
|
||||
}
|
||||
if method = strings.ToUpper(strings.TrimSpace(method)); method != "" {
|
||||
request.Method = method
|
||||
}
|
||||
case "headers":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
headers, isObject := value.(map[string]any)
|
||||
if !isObject {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch headers must be an object")
|
||||
}
|
||||
request.Headers = make(map[string]string, len(headers))
|
||||
for headerName, headerValue := range headers {
|
||||
text, isString := headerValue.(string)
|
||||
if !isString {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch header %q must be a string", headerName)
|
||||
}
|
||||
request.Headers[strings.TrimSpace(headerName)] = text
|
||||
}
|
||||
case "responseType":
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
responseType, isString := value.(string)
|
||||
if !isString || (responseType != "json" && responseType != "bytes") {
|
||||
return FetchRequest{}, "", false, r.TypeError(`utils.fetch responseType must be "json" or "bytes"`)
|
||||
}
|
||||
wantBytes = responseType == "bytes"
|
||||
default:
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch request has unsupported member %q", name)
|
||||
}
|
||||
}
|
||||
if request.Method != http.MethodGet && request.Method != http.MethodHead {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch supports only GET and HEAD")
|
||||
}
|
||||
if len(request.URL) > maxFetchURLBytes {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url exceeds %d bytes", maxFetchURLBytes)
|
||||
}
|
||||
parsed, err := url.Parse(request.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch url must be an absolute http(s) URL")
|
||||
}
|
||||
if err = ValidateRequestHeaders(request.Headers); err != nil {
|
||||
return FetchRequest{}, "", false, r.TypeError("utils.fetch: %s", err)
|
||||
}
|
||||
return request, parsed.Host, wantBytes, nil
|
||||
}
|
||||
|
||||
// yieldSlot lends the call's execution slot to other calls while it waits on
|
||||
// the network. Parked runtimes are bounded by the engine's parked tokens;
|
||||
// without one the call keeps its slot and waits holding it.
|
||||
func (c *runtimeCall) yieldSlot() {
|
||||
select {
|
||||
case c.engine.parked <- struct{}{}:
|
||||
default:
|
||||
return
|
||||
}
|
||||
c.parked = true
|
||||
<-c.engine.semaphore
|
||||
c.holdsSlot = false
|
||||
}
|
||||
|
||||
// reclaimSlot takes an execution slot back for the call's JavaScript, waiting
|
||||
// no longer than the caller and fetchTimeout allow.
|
||||
func (c *runtimeCall) reclaimSlot() bool {
|
||||
if c.holdsSlot {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(fetchTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case c.engine.semaphore <- struct{}{}:
|
||||
c.holdsSlot = true
|
||||
case <-c.ctx.Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
<-c.engine.parked
|
||||
c.parked = false
|
||||
return c.holdsSlot
|
||||
}
|
||||
+52
-32
@@ -4,17 +4,18 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
const (
|
||||
CapabilityJSONClone = "json-clone@1"
|
||||
CapabilitySubmitSSEDelta = "submit-sse-delta@1"
|
||||
CapabilityJSONOrder = "json-order@1"
|
||||
CapabilityDurationAuto = "duration-auto@1"
|
||||
MaxJSONToolBytes = 1 << 20
|
||||
maxJSONToolDepth = 32
|
||||
maxJSONToolNodes = 32768
|
||||
@@ -23,7 +24,7 @@ const (
|
||||
|
||||
// HasCapability describes host APIs independently of a plugin's mutable globals.
|
||||
func HasCapability(name string) bool {
|
||||
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta
|
||||
return name == CapabilityJSONClone || name == CapabilitySubmitSSEDelta || name == CapabilityJSONOrder || name == CapabilityDurationAuto
|
||||
}
|
||||
|
||||
// JSONState owns a request-local JSON result. Appended strings stay in Go and
|
||||
@@ -47,6 +48,8 @@ type jsonStateBudget struct {
|
||||
ctx context.Context
|
||||
bytes int
|
||||
nodes int
|
||||
// realm reads JavaScript values; only json.clone passes them.
|
||||
realm *engine.Realm
|
||||
}
|
||||
|
||||
func (b *jsonStateBudget) spend(bytes, nodes int) error {
|
||||
@@ -65,41 +68,58 @@ func newJSONStateNode(value any, depth int, budget *jsonStateBudget) (*jsonState
|
||||
if depth > maxJSONToolDepth {
|
||||
return nil, fmt.Errorf("JSON state exceeds depth limit")
|
||||
}
|
||||
if js, ok := value.(sobek.Value); ok {
|
||||
if sobek.IsUndefined(js) {
|
||||
if js, ok := value.(engine.Value); ok {
|
||||
if js.IsUndefined() {
|
||||
return nil, fmt.Errorf("undefined is not a JSON value")
|
||||
}
|
||||
if object, ok := js.(*sobek.Object); ok {
|
||||
switch object.ClassName() {
|
||||
case "Object":
|
||||
keys := object.Keys()
|
||||
if !js.IsObject() {
|
||||
value = budget.realm.ToGo(js)
|
||||
} else {
|
||||
// Getters and proxy traps run while the value is read; what they
|
||||
// throw is returned as it is, not as a JSON error.
|
||||
object := js.AsObject()
|
||||
switch {
|
||||
case object.ClassName() == "Object" && !object.IsCallable():
|
||||
keys, err := budget.realm.EnumerableOwnKeys(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(keys) > budget.nodes {
|
||||
return nil, fmt.Errorf("JSON object exceeds node limit")
|
||||
}
|
||||
fields := make(map[string]any, len(keys))
|
||||
for _, key := range keys {
|
||||
fields[key] = object.Get(key)
|
||||
if fields[key.GoString()], err = object.GetProp(budget.realm, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value = fields
|
||||
case "Array":
|
||||
length := object.Get("length").ToInteger()
|
||||
if length < 0 || length > int64(budget.nodes) {
|
||||
case object.ClassName() == "Array":
|
||||
length, err := budget.realm.LengthOfArrayLike(object)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length > int64(budget.nodes) {
|
||||
return nil, fmt.Errorf("JSON array exceeds node limit")
|
||||
}
|
||||
items := make([]any, int(length))
|
||||
for index := range items {
|
||||
item := object.Get(strconv.Itoa(index))
|
||||
if item == nil {
|
||||
key := engine.IndexKey(uint32(index))
|
||||
present, err := budget.realm.HasOwn(object, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !present {
|
||||
return nil, fmt.Errorf("sparse arrays are not JSON values")
|
||||
}
|
||||
items[index] = item
|
||||
if items[index], err = object.GetProp(budget.realm, key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value = items
|
||||
default:
|
||||
return nil, fmt.Errorf("json.clone accepts only plain objects, arrays and JSON scalars")
|
||||
}
|
||||
} else {
|
||||
value = js.Export()
|
||||
}
|
||||
}
|
||||
beforeBytes, beforeNodes := budget.bytes, budget.nodes
|
||||
@@ -397,32 +417,32 @@ func (n *jsonStateNode) value() any {
|
||||
return n.scalar
|
||||
}
|
||||
|
||||
// Native JS containers are essential here: wrapping Go slices in ToValue would
|
||||
// retain host-backed array mutation behavior instead of producing a JS clone.
|
||||
func (n *jsonStateNode) jsValue(runtime *sobek.Runtime) (sobek.Value, error) {
|
||||
// Native JS containers are essential here: a lazily converted Go map would
|
||||
// read as a clone, but the result must be ordinary objects a plugin owns.
|
||||
func (n *jsonStateNode) jsValue(realm *engine.Realm) (engine.Value, error) {
|
||||
if n.object != nil {
|
||||
result := runtime.NewObject()
|
||||
result := realm.NewObject()
|
||||
for key, child := range n.object {
|
||||
value, err := child.jsValue(runtime)
|
||||
value, err := child.jsValue(realm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
if err = result.DefineDataProperty(key, value, sobek.FLAG_TRUE, sobek.FLAG_TRUE, sobek.FLAG_TRUE); err != nil {
|
||||
return nil, err
|
||||
if err = result.CreateDataPropertyOrThrow(realm, realm.KeyFromGoString(key), value); err != nil {
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return engine.ObjectValue(result), nil
|
||||
}
|
||||
if n.array != nil {
|
||||
items := make([]any, len(n.array))
|
||||
items := make([]engine.Value, len(n.array))
|
||||
for index, child := range n.array {
|
||||
value, err := child.jsValue(runtime)
|
||||
value, err := child.jsValue(realm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return engine.Undefined(), err
|
||||
}
|
||||
items[index] = value
|
||||
}
|
||||
return runtime.NewArray(items...), nil
|
||||
return engine.ObjectValue(realm.NewArrayFromSlice(items)), nil
|
||||
}
|
||||
return runtime.ToValue(n.value()), nil
|
||||
return realm.FromGo(n.value())
|
||||
}
|
||||
+94
-18
@@ -100,6 +100,7 @@ type Meta struct {
|
||||
Models []string `json:"models"`
|
||||
FetchMode string `json:"fetchMode"`
|
||||
AllowedHosts []string `json:"allowedHosts"`
|
||||
Upstreams []string `json:"upstreams,omitempty"`
|
||||
Routes []Route `json:"routes"`
|
||||
Protocols []ProtocolClaim `json:"protocols"`
|
||||
UsageSchema map[string]UsageFieldSchema `json:"usageSchema,omitempty"`
|
||||
@@ -108,6 +109,47 @@ type Meta struct {
|
||||
Auth AuthMeta `json:"auth"`
|
||||
}
|
||||
|
||||
// Upstream kinds a plugin driver can address. Every driver speaks to its
|
||||
// vendor; a driver that also builds its own native-route URLs when the channel
|
||||
// points at another New API gateway declares UpstreamKindNewAPI in
|
||||
// meta.upstreams, which is what makes it bindable to a type-60 channel.
|
||||
const (
|
||||
UpstreamKindVendor = "vendor"
|
||||
UpstreamKindNewAPI = "new_api"
|
||||
)
|
||||
|
||||
// SupportsUpstream reports whether the driver handles channels of the given
|
||||
// upstream kind. The vendor kind is implied for every plugin.
|
||||
func (m Meta) SupportsUpstream(kind string) bool {
|
||||
return kind == UpstreamKindVendor || slices.Contains(m.Upstreams, kind)
|
||||
}
|
||||
|
||||
// PreservesJSONOrder reports whether the plugin declared json-order@1: its
|
||||
// hooks receive JSON request bodies with members in the order the client sent
|
||||
// them, and its JSON request bodies go upstream as JSON.stringify writes them.
|
||||
// Other plugins keep the cheaper Go-map path, whose members enumerate sorted.
|
||||
func (m Meta) PreservesJSONOrder() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityJSONOrder)
|
||||
}
|
||||
|
||||
// AcceptsAutoDuration reports whether the plugin declared duration-auto@1:
|
||||
// request validation lets a duration of -1, the vendor's "the model picks the
|
||||
// length", through to its hooks. The plugin reserves the longest duration the
|
||||
// model can produce and settles the task on the usage the vendor reports.
|
||||
func (m Meta) AcceptsAutoDuration() bool {
|
||||
return slices.Contains(m.RequiredCapabilities, CapabilityDurationAuto)
|
||||
}
|
||||
|
||||
// JSONTextMember names the member of a hook result (a decoded requestBody, a
|
||||
// request descriptor's body) that the host also takes as JSON text, for a
|
||||
// plugin that preserves JSON order; it is empty for other plugins.
|
||||
func (m Meta) JSONTextMember(member string) string {
|
||||
if !m.PreservesJSONOrder() {
|
||||
return ""
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
// UsageProfile replaces the plugin's default usage metadata for its models.
|
||||
type UsageProfile struct {
|
||||
Models []string `json:"models"`
|
||||
@@ -119,11 +161,22 @@ type UsageProfile struct {
|
||||
// must be resolved by the host first; an unknown or ambiguous model uses the
|
||||
// plugin defaults. Profile examples never inherit the default examples.
|
||||
func (m Meta) UsageForModel(model string) (map[string]UsageFieldSchema, []UsageExample) {
|
||||
folded := asciiFold(model)
|
||||
for _, profile := range m.UsageProfiles {
|
||||
for _, declared := range profile.Models {
|
||||
if asciiFold(declared) == folded {
|
||||
return profile.Schema, profile.Examples
|
||||
return m.UsageForModels(model)
|
||||
}
|
||||
|
||||
// UsageForModels returns the usage metadata of the first candidate that a
|
||||
// profile declares. Runtime callers pass the final upstream model before the
|
||||
// client-facing model, so a channel mapping that sends a declared model to a
|
||||
// vendor endpoint ID keeps the declared model's profile. When no candidate is
|
||||
// profiled, the plugin defaults apply.
|
||||
func (m Meta) UsageForModels(models ...string) (map[string]UsageFieldSchema, []UsageExample) {
|
||||
for _, model := range models {
|
||||
folded := asciiFold(model)
|
||||
for _, profile := range m.UsageProfiles {
|
||||
for _, declared := range profile.Models {
|
||||
if asciiFold(declared) == folded {
|
||||
return profile.Schema, profile.Examples
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +226,15 @@ type LoadedPlugin struct {
|
||||
Engine *Engine
|
||||
}
|
||||
|
||||
// UsesSubmitEventDelta reports whether SSE submissions go through
|
||||
// parseSubmitEventDelta: the plugin declares submit-sse-delta@1, or exports
|
||||
// parseSubmitEventDelta without parseSubmitEvent. A plugin that exports both
|
||||
// and declares nothing keeps the snapshot hook, as before.
|
||||
func UsesSubmitEventDelta(meta Meta, engine *Engine) bool {
|
||||
return slices.Contains(meta.RequiredCapabilities, CapabilitySubmitSSEDelta) ||
|
||||
engine.HasExport("parseSubmitEventDelta") && !engine.HasExport("parseSubmitEvent")
|
||||
}
|
||||
|
||||
// RegistrySnapshot is a read-only copy of the metadata currently stored in
|
||||
// each registry layer.
|
||||
type RegistrySnapshot struct {
|
||||
@@ -296,7 +358,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
engine.version = meta.Version
|
||||
requiredHooks := []string{"buildSubmitRequest", "parseSubmitResponse", "parseTaskResult"}
|
||||
if slices.Contains(meta.SubmitResponseTypes, "sse") {
|
||||
if slices.Contains(meta.RequiredCapabilities, CapabilitySubmitSSEDelta) {
|
||||
if UsesSubmitEventDelta(meta, engine) {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEventDelta")
|
||||
} else {
|
||||
requiredHooks = append(requiredHooks, "parseSubmitEvent")
|
||||
@@ -318,11 +380,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
}
|
||||
artifactHooks := make(map[string]bool, 2)
|
||||
for _, hook := range []string{"listArtifacts", "buildContentRequest"} {
|
||||
exported, exportErr := engine.HasExport(context.Background(), hook)
|
||||
if exportErr != nil {
|
||||
return nil, exportErr
|
||||
}
|
||||
if !exported {
|
||||
if !engine.HasExport(hook) {
|
||||
continue
|
||||
}
|
||||
callable, callableErr := engine.HasCallablePath(context.Background(), hook)
|
||||
@@ -470,11 +528,7 @@ func CompilePlugin(source string, options Options) (*LoadedPlugin, error) {
|
||||
}
|
||||
}
|
||||
for _, removed := range []string{"resolveRequest", "renderError", "renderers"} {
|
||||
has, e := engine.HasExport(context.Background(), removed)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if has {
|
||||
if engine.HasExport(removed) {
|
||||
return nil, fmt.Errorf("plugin %s export %q is no longer supported", meta.Key, removed)
|
||||
}
|
||||
}
|
||||
@@ -834,6 +888,7 @@ func cloneMeta(meta Meta) Meta {
|
||||
meta.ChannelTypes = append([]int(nil), meta.ChannelTypes...)
|
||||
meta.Models = append([]string(nil), meta.Models...)
|
||||
meta.AllowedHosts = append([]string(nil), meta.AllowedHosts...)
|
||||
meta.Upstreams = slices.Clone(meta.Upstreams)
|
||||
meta.Routes = append([]Route(nil), meta.Routes...)
|
||||
for index := range meta.Routes {
|
||||
meta.Routes[index].Models = append([]string(nil), meta.Routes[index].Models...)
|
||||
@@ -975,7 +1030,7 @@ func decodeMeta(value any) (Meta, error) {
|
||||
}
|
||||
for field := range object {
|
||||
switch field {
|
||||
case "requiredCapabilities", "submitResponseTypes", "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "routes", "protocols", "usageSchema", "usageExamples", "usageProfiles", "auth", "endpoints", "submitPaths", "actions":
|
||||
case "requiredCapabilities", "submitResponseTypes", "sortPriority", "website", "apiVersion", "key", "name", "icon", "description", "version", "author", "baseUrl", "channelTypes", "channelType", "compatibleChannelTypes", "models", "fetchMode", "allowedHosts", "upstreams", "routes", "protocols", "usageSchema", "usageExamples", "usageProfiles", "auth", "endpoints", "submitPaths", "actions":
|
||||
default:
|
||||
return Meta{}, &UnknownMetaFieldError{Field: field}
|
||||
}
|
||||
@@ -1071,6 +1126,10 @@ func decodeMeta(value any) (Meta, error) {
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.Upstreams, err = strictStringSlice(object, "upstreams")
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
meta.Routes, err = decodeRoutes(object["routes"])
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
@@ -1159,6 +1218,13 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
}
|
||||
seenCapabilities[name] = true
|
||||
}
|
||||
seenUpstreams := make(map[string]bool, len(meta.Upstreams))
|
||||
for _, kind := range meta.Upstreams {
|
||||
if (kind != UpstreamKindVendor && kind != UpstreamKindNewAPI) || seenUpstreams[kind] {
|
||||
return fmt.Errorf("unsupported or duplicate upstream kind %q", kind)
|
||||
}
|
||||
seenUpstreams[kind] = true
|
||||
}
|
||||
if meta.SortPriority < math.MinInt32 || meta.SortPriority > math.MaxInt32 {
|
||||
return fmt.Errorf("plugin meta sortPriority must be a signed 32-bit integer")
|
||||
}
|
||||
@@ -1256,6 +1322,9 @@ func normalizeV1Meta(meta *Meta) error {
|
||||
if channelType == constant.ChannelTypeTaskPlugin {
|
||||
return fmt.Errorf("plugin meta channelTypes must not contain the task plugin channel type")
|
||||
}
|
||||
if channelType == constant.ChannelTypeNewAPI {
|
||||
return fmt.Errorf("plugin meta channelTypes must not contain the New API channel type; bind the plugin through task_extend_plugin_keys instead")
|
||||
}
|
||||
if _, duplicate := seenChannelTypes[channelType]; duplicate {
|
||||
return fmt.Errorf("plugin meta channelTypes must be unique")
|
||||
}
|
||||
@@ -1724,7 +1793,7 @@ func decodeRoutes(value any) ([]Route, error) {
|
||||
}
|
||||
for key := range object {
|
||||
switch key {
|
||||
case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models":
|
||||
case "method", "path", "type", "action", "decode", "render", "taskIdParam", "models", "retainResult":
|
||||
default:
|
||||
return nil, fmt.Errorf("plugin meta route %d has unknown field %q", index, key)
|
||||
}
|
||||
@@ -1762,6 +1831,13 @@ func decodeRoutes(value any) ([]Route, error) {
|
||||
return nil, fmt.Errorf("plugin meta route %d models must contain at least one model", index)
|
||||
}
|
||||
}
|
||||
if value, exists := object["retainResult"]; exists {
|
||||
retain, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plugin meta route %d retainResult must be a boolean", index)
|
||||
}
|
||||
route.RetainResult = &retain
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
return routes, nil
|
||||
|
||||
@@ -589,6 +589,7 @@ func TestRegistryValidatesChannelTypes(t *testing.T) {
|
||||
{name: "zero rejected", channelTypes: []int{0}, wantErr: "positive channel types"},
|
||||
{name: "negative rejected", channelTypes: []int{-1}, wantErr: "positive channel types"},
|
||||
{name: "task plugin type rejected", channelTypes: []int{constant.ChannelTypeTaskPlugin}, wantErr: "task plugin channel type"},
|
||||
{name: "new api type rejected", channelTypes: []int{constant.ChannelTypeNewAPI}, wantErr: "New API channel type"},
|
||||
{name: "duplicates rejected", channelTypes: []int{1, 1}, wantErr: "must be unique"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
@@ -1127,6 +1128,17 @@ usageProfiles: [
|
||||
assert.Empty(t, schema)
|
||||
assert.Nil(t, examples)
|
||||
|
||||
// A channel mapping may send a declared model to an undeclared endpoint ID:
|
||||
// the first profiled candidate wins, otherwise the defaults apply.
|
||||
schema, examples = plugin.Meta.UsageForModels("ep-endpoint", "image")
|
||||
assert.Equal(t, plugin.Meta.UsageProfiles[0].Schema, schema)
|
||||
assert.Nil(t, examples)
|
||||
schema, _ = plugin.Meta.UsageForModels("video", "image")
|
||||
assert.Equal(t, plugin.Meta.UsageProfiles[1].Schema, schema)
|
||||
schema, examples = plugin.Meta.UsageForModels("ep-endpoint", "default")
|
||||
assert.Equal(t, plugin.Meta.UsageSchema, schema)
|
||||
assert.Equal(t, plugin.Meta.UsageExamples, examples)
|
||||
|
||||
snapshot := registry.Snapshot()
|
||||
require.Len(t, snapshot.Override, 1)
|
||||
profile := &snapshot.Override[0].UsageProfiles[1]
|
||||
@@ -1197,6 +1209,33 @@ usageProfiles: [{models:["image"], schema:{tokens:{type:"number", unit:"token"}}
|
||||
require.ErrorContains(t, ValidateV1Meta(plugin.Meta), "schema must be an object")
|
||||
}
|
||||
|
||||
func TestUpstreamsManifestContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, metadata string
|
||||
valid, newAPI bool
|
||||
}{
|
||||
{"omitted implies vendor", "", true, false},
|
||||
{"vendor only", `upstreams:["vendor"],`, true, false},
|
||||
{"vendor and new api", `upstreams:["vendor","new_api"],`, true, true},
|
||||
{"new api only still serves its vendor", `upstreams:["new_api"],`, true, true},
|
||||
{"unknown kind", `upstreams:["gateway"],`, false, false},
|
||||
{"duplicate", `upstreams:["new_api","new_api"],`, false, false},
|
||||
{"null", `upstreams:null,`, false, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plugin, err := CompilePlugin(routingTestPluginSource("upstreams", 0, `["model"]`, tc.metadata, ""), Options{})
|
||||
if !tc.valid {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ValidateV1Meta(plugin.Meta))
|
||||
assert.True(t, plugin.Meta.SupportsUpstream(UpstreamKindVendor))
|
||||
assert.Equal(t, tc.newAPI, plugin.Meta.SupportsUpstream(UpstreamKindNewAPI))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitResponseTypesContract(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, metadata, hook string
|
||||
@@ -1207,6 +1246,9 @@ func TestSubmitResponseTypesContract(t *testing.T) {
|
||||
{"host JSON utility", `requiredCapabilities:["json-clone@1"],`, "", true},
|
||||
{"SSE delta", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"SSE delta missing hook", `submitResponseTypes:["sse"],requiredCapabilities:["submit-sse-delta@1"],`, `export function parseSubmitEvent(){return {state:null,done:true};}`, false},
|
||||
{"SSE delta hook without declaration", `submitResponseTypes:["sse"],`, `export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"SSE snapshot and delta hooks", `submitResponseTypes:["sse"],`, `export function parseSubmitEvent(){return {state:null,done:true};}
|
||||
export function parseSubmitEventDelta(){return {changes:[],state:null,done:true};}`, true},
|
||||
{"delta requires SSE", `requiredCapabilities:["submit-sse-delta@1"],`, "", false},
|
||||
{"unsupported capability version", `requiredCapabilities:["json-clone@2"],`, "", false},
|
||||
{"duplicate capability", `requiredCapabilities:["json-clone@1","json-clone@1"],`, "", false},
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
)
|
||||
|
||||
// ValidateRequestURL prevents plugins from directing a channel credential to
|
||||
@@ -33,6 +35,30 @@ func ValidateRequestURL(requestURL, baseURL string, allowedHosts []string) error
|
||||
return fmt.Errorf("plugin request host %q is not allowed", request.Host)
|
||||
}
|
||||
|
||||
// ValidateRequestHeaders rejects plugin-chosen headers that the host
|
||||
// transport owns or that change how a request is framed or proxied, and
|
||||
// bounds their number and size.
|
||||
func ValidateRequestHeaders(headers map[string]string) error {
|
||||
if len(headers) > 64 {
|
||||
return fmt.Errorf("at most 64 request headers are allowed")
|
||||
}
|
||||
for name, value := range headers {
|
||||
name = strings.TrimSpace(name)
|
||||
if !httpguts.ValidHeaderFieldName(name) {
|
||||
return fmt.Errorf("request header name %q is invalid", name)
|
||||
}
|
||||
if !httpguts.ValidHeaderFieldValue(value) || len(value) > 8192 {
|
||||
return fmt.Errorf("request header %q has an invalid or oversized value", name)
|
||||
}
|
||||
switch strings.ToLower(name) {
|
||||
case "host", "content-length", "accept-encoding", "connection", "proxy-connection", "keep-alive",
|
||||
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade":
|
||||
return fmt.Errorf("request header %q is not allowed", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalHost(value *url.URL) string {
|
||||
host := strings.ToLower(value.Hostname())
|
||||
port := value.Port()
|
||||
|
||||
+91
-37
@@ -1,12 +1,14 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +31,11 @@ type Route struct {
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Render string `json:"render,omitempty"`
|
||||
TaskIDParam string `json:"taskIdParam,omitempty"`
|
||||
// RetainResult, when explicitly false on a submit or dynamic route, tells
|
||||
// the host not to persist the upstream snapshot of an immediate terminal
|
||||
// result and to treat the task as not found on every retrieval surface
|
||||
// afterwards. nil means the route did not declare it (retain).
|
||||
RetainResult *bool `json:"retainResult,omitempty"`
|
||||
// Models restricts this route to the listed models. The host matches the
|
||||
// canonical top-level "model" body field before any JS hook runs; empty
|
||||
// means unrestricted. Must be a subset of meta.models.
|
||||
@@ -88,8 +95,19 @@ var hostProtocols = []HostProtocolDefinition{
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
|
||||
{Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
|
||||
}},
|
||||
// The OpenAI Images API is synchronous: both operations create a task and
|
||||
// the host answers with the rendered image response once the task is
|
||||
// terminal, so there is no retrieve operation and no request modes.
|
||||
{Name: ProtocolOpenAIImage, Operations: []HostProtocolOperation{
|
||||
{Name: "generate", Methods: []string{http.MethodPost}, Path: "/v1/images/generations", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest", "render"}},
|
||||
{Name: "edit", Methods: []string{http.MethodPost}, Path: "/v1/images/edits", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest", "render"}},
|
||||
}},
|
||||
}
|
||||
|
||||
// ProtocolOpenAIImage is the host protocol that serves the OpenAI Images API
|
||||
// (`POST /v1/images/generations` and `POST /v1/images/edits`) from a plugin.
|
||||
const ProtocolOpenAIImage = "openai_image"
|
||||
|
||||
func HostProtocol(name string) (HostProtocolDefinition, bool) {
|
||||
for _, definition := range hostProtocols {
|
||||
if definition.Name == name {
|
||||
@@ -201,6 +219,10 @@ const (
|
||||
ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
|
||||
ContextKeyRouteRequest = "task_plugin_route_request"
|
||||
ContextKeyProtocolRequest = "task_plugin_protocol_request"
|
||||
// ContextKeyRequestBodyText holds, for a plugin that preserves JSON
|
||||
// order, the decoded requestBody as JSON text (json.RawMessage) beside
|
||||
// the Go value in task_request.
|
||||
ContextKeyRequestBodyText = "task_plugin_request_body_text"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
@@ -228,6 +250,38 @@ type PinnedEndpoint struct {
|
||||
Candidates []ProtocolBinding
|
||||
}
|
||||
|
||||
// FileReference returns the opaque ref of the index-th uploaded file in a
|
||||
// multipart field. The first file keeps the historical `request_file:<field>`
|
||||
// spelling; later files in the same repeated field (`image[]`, `image[]`)
|
||||
// append `#<index>` so a plugin can address each of them.
|
||||
func FileReference(field string, index int) string {
|
||||
if index <= 0 {
|
||||
return "request_file:" + field
|
||||
}
|
||||
return "request_file:" + field + "#" + strconv.Itoa(index)
|
||||
}
|
||||
|
||||
// ParseFileReference resolves a ref produced by FileReference back to the
|
||||
// multipart field and the zero-based file index within that field.
|
||||
func ParseFileReference(ref string) (field string, index int, ok bool) {
|
||||
rest, found := strings.CutPrefix(ref, "request_file:")
|
||||
if !found || rest == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
field, suffix, hasIndex := strings.Cut(rest, "#")
|
||||
if field == "" {
|
||||
return "", 0, false
|
||||
}
|
||||
if !hasIndex {
|
||||
return field, 0, true
|
||||
}
|
||||
parsed, err := strconv.Atoi(suffix)
|
||||
if err != nil || parsed < 0 || strconv.Itoa(parsed) != suffix {
|
||||
return "", 0, false
|
||||
}
|
||||
return field, parsed, true
|
||||
}
|
||||
|
||||
// RouteRequestContext is the canonical request view exposed to declarative
|
||||
// routing hooks. RequestBody contains decoded JSON or multipart text fields;
|
||||
// raw binary and multipart file bytes remain host-owned.
|
||||
@@ -239,55 +293,44 @@ type RouteRequestContext struct {
|
||||
Body any `json:"body"`
|
||||
Files []map[string]any `json:"-"`
|
||||
RequestBody any `json:"-"`
|
||||
// BodyText is the JSON body as the client sent it, which JSValueFor
|
||||
// gives to plugins that preserve JSON order; Body stays the Go value the
|
||||
// host reads. Body storage never changes these bytes, which the engine
|
||||
// parses in place.
|
||||
BodyText json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// JSValue shares the request with hooks without copying it: the engine never
|
||||
// writes JavaScript changes back into Go values, so the request must only stay
|
||||
// unchanged while a hook runs. Missing params and query still reach hooks as
|
||||
// empty objects rather than null.
|
||||
func (r RouteRequestContext) JSValue() map[string]any {
|
||||
params := make(map[string]string, len(r.Params))
|
||||
maps.Copy(params, r.Params)
|
||||
query := make(map[string][]string, len(r.Query))
|
||||
for key, values := range r.Query {
|
||||
query[key] = append([]string(nil), values...)
|
||||
params, query := r.Params, r.Query
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
if query == nil {
|
||||
query = map[string][]string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"path": r.Path,
|
||||
"method": r.Method,
|
||||
"params": params,
|
||||
"query": query,
|
||||
"body": clonePluginRequestValue(r.Body),
|
||||
"body": r.Body,
|
||||
}
|
||||
}
|
||||
|
||||
func clonePluginRequestValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case map[string][]string:
|
||||
cloned := make(map[string][]string, len(typed))
|
||||
for key, values := range typed {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
case []map[string]any:
|
||||
cloned := make([]map[string]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item).(map[string]any)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return value
|
||||
// JSValueFor is JSValue for the decode hooks of the plugin meta describes. A
|
||||
// plugin that preserves JSON order receives a JSON body as its text, which the
|
||||
// engine parses in place, members in the client's order: a decoder reads the
|
||||
// body, so it is parsed for every call.
|
||||
func (r RouteRequestContext) JSValueFor(meta Meta) map[string]any {
|
||||
value := r.JSValue()
|
||||
if len(r.BodyText) > 0 && meta.PreservesJSONOrder() {
|
||||
value["body"] = map[string]any{"kind": string(BodyJSON), "value": RawJSON(r.BodyText)}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ProtocolRequestContext struct {
|
||||
@@ -303,7 +346,15 @@ type ProtocolRequestContext struct {
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) JSValue() map[string]any {
|
||||
value := p.RouteRequestContext.JSValue()
|
||||
return p.withProtocol(p.RouteRequestContext.JSValue())
|
||||
}
|
||||
|
||||
// JSValueFor is RouteRequestContext.JSValueFor with the protocol fields.
|
||||
func (p ProtocolRequestContext) JSValueFor(meta Meta) map[string]any {
|
||||
return p.withProtocol(p.RouteRequestContext.JSValueFor(meta))
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) withProtocol(value map[string]any) map[string]any {
|
||||
value["protocol"] = p.Protocol
|
||||
value["operation"] = p.Operation
|
||||
value["model"] = p.Model
|
||||
@@ -712,6 +763,9 @@ func validateRoute(route *Route) error {
|
||||
if route.Action != "" {
|
||||
return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
|
||||
}
|
||||
if route.RetainResult != nil {
|
||||
return fmt.Errorf("query route %s %s must not declare retainResult", route.Method, route.Path)
|
||||
}
|
||||
if route.TaskIDParam == "" {
|
||||
route.TaskIDParam = "task_id"
|
||||
}
|
||||
|
||||
+129
-19
@@ -81,6 +81,7 @@ func TestRegistryIndexesSharedEndpointCandidatesForDistinctLegacyProviders(t *te
|
||||
func TestSupportsRegisteredHostProtocols(t *testing.T) {
|
||||
assert.True(t, SupportsHostProtocol("openai_responses"))
|
||||
assert.True(t, SupportsHostProtocol("openai_video"))
|
||||
assert.True(t, SupportsHostProtocol(ProtocolOpenAIImage))
|
||||
assert.False(t, SupportsHostProtocol("plugin_owned_wire"))
|
||||
}
|
||||
|
||||
@@ -89,6 +90,71 @@ func TestLookupHostProtocolOperationExcludesRetrieveWithoutModelField(t *testing
|
||||
assert.False(t, ok)
|
||||
_, _, ok = LookupHostProtocolOperation(http.MethodPost, "/v1/responses")
|
||||
assert.True(t, ok)
|
||||
for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
|
||||
protocol, operation, found := LookupHostProtocolOperation(http.MethodPost, path)
|
||||
require.True(t, found, path)
|
||||
assert.Equal(t, ProtocolOpenAIImage, protocol)
|
||||
assert.Equal(t, "model", operation.ModelField)
|
||||
assert.Empty(t, operation.Modes, "the synchronous image protocol has no request modes")
|
||||
}
|
||||
}
|
||||
|
||||
// The OpenAI Images protocol is claimed in bare-string form, binds both image
|
||||
// endpoints for every declared model, and requires the render hook that shapes
|
||||
// the synchronous response.
|
||||
func TestOpenAIImageProtocolClaimBindsBothEndpointsAndRequiresRender(t *testing.T) {
|
||||
exports := `export const protocols = {openai_image: {
|
||||
decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model, requestBody: ctx.body.value}; },
|
||||
render: function(ctx, task) { return {data: []}; }
|
||||
}};`
|
||||
registry := NewRegistry()
|
||||
plugin := mustCompileRoutingPlugin(t, "image-plugin", 17, `["image-a", "image-b"]`, `protocols: ["openai_image"],`, exports)
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{plugin}))
|
||||
for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
|
||||
for _, model := range []string{"image-a", "image-b"} {
|
||||
binding, ok := registry.Generation().LookupEndpoint(http.MethodPost, path, model)
|
||||
require.True(t, ok, path+" "+model)
|
||||
assert.Equal(t, ProtocolOpenAIImage, binding.Protocol)
|
||||
assert.Same(t, plugin, binding.Plugin)
|
||||
}
|
||||
}
|
||||
_, ok := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", "image-a")
|
||||
assert.False(t, ok, "an image claim must not bind the video endpoint")
|
||||
|
||||
scoped := mustCompileRoutingPlugin(t, "image-scoped", 17, `["image-a", "image-b"]`, `protocols: [{name: "openai_image", models: ["image-a"]}],`, exports)
|
||||
require.NoError(t, registry.ReplaceOverrides([]*LoadedPlugin{scoped}))
|
||||
_, ok = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/images/generations", "image-b")
|
||||
assert.False(t, ok, "models narrows the image endpoint bindings")
|
||||
|
||||
_, err := CompilePlugin(routingTestPluginSource("image-no-render", 17, `["image-a"]`, `protocols: ["openai_image"],`,
|
||||
`export const protocols = {openai_image: {decodeRequest: function(ctx) { return {kind: "submit", model: ctx.model}; }}};`), Options{})
|
||||
require.ErrorContains(t, err, `missing hook "render"`)
|
||||
_, err = CompilePlugin(routingTestPluginSource("image-modes", 17, `["image-a"]`, `protocols: [{name: "openai_image", supports: ["sync"]}],`, exports), Options{})
|
||||
require.ErrorContains(t, err, "does not define modes")
|
||||
}
|
||||
|
||||
func TestFileReferenceRoundTripAddressesRepeatedFieldFiles(t *testing.T) {
|
||||
assert.Equal(t, "request_file:image[]", FileReference("image[]", 0))
|
||||
assert.Equal(t, "request_file:image[]#2", FileReference("image[]", 2))
|
||||
for _, tc := range []struct {
|
||||
ref string
|
||||
field string
|
||||
index int
|
||||
ok bool
|
||||
}{
|
||||
{"request_file:input_reference", "input_reference", 0, true},
|
||||
{"request_file:image[]#1", "image[]", 1, true},
|
||||
{"request_file:image[]#01", "", 0, false},
|
||||
{"request_file:image[]#-1", "", 0, false},
|
||||
{"request_file:#1", "", 0, false},
|
||||
{"request_file:", "", 0, false},
|
||||
{"image", "", 0, false},
|
||||
} {
|
||||
field, index, ok := ParseFileReference(tc.ref)
|
||||
assert.Equal(t, tc.ok, ok, tc.ref)
|
||||
assert.Equal(t, tc.field, field, tc.ref)
|
||||
assert.Equal(t, tc.index, index, tc.ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerProtocolModelsNarrowEndpointBindings(t *testing.T) {
|
||||
@@ -348,38 +414,82 @@ func TestRouteModelsDecodeAndValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequestContextClonesFormAndMultipartValuesPerDecoder(t *testing.T) {
|
||||
func TestRouteRetainResultDecodeAndValidation(t *testing.T) {
|
||||
routeExports := `export const native = {
|
||||
decodeJob: function(ctx) { return {kind: "submit", model: "gpt-5.5", requestBody: ctx.body.value}; },
|
||||
jobCreated: function(ctx, task) { return task; },
|
||||
jobStatus: function(ctx, task) { return task; }
|
||||
};`
|
||||
falseValue := false
|
||||
tests := []struct {
|
||||
name string
|
||||
body any
|
||||
mutate func(map[string]any)
|
||||
name string
|
||||
metaFields string
|
||||
want *bool
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "form fields",
|
||||
body: map[string]any{"kind": "form", "fields": map[string][]string{"prompt": {"original"}}},
|
||||
mutate: func(value map[string]any) {
|
||||
value["body"].(map[string]any)["fields"].(map[string][]string)["prompt"][0] = "mutated"
|
||||
},
|
||||
name: "undeclared route retains",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated"}],`,
|
||||
},
|
||||
{
|
||||
name: "multipart files",
|
||||
body: map[string]any{"kind": "multipart", "files": []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}},
|
||||
mutate: func(value map[string]any) {
|
||||
value["body"].(map[string]any)["files"].([]map[string]any)[0]["filename"] = "mutated.png"
|
||||
},
|
||||
name: "submit route declines retention",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated", retainResult: false}],`,
|
||||
want: &falseValue,
|
||||
},
|
||||
{
|
||||
name: "query route rejects retainResult",
|
||||
metaFields: `routes: [{method: "GET", path: "/v1/batch/:task_id", type: "query", render: "jobStatus", retainResult: true}],`,
|
||||
errContains: "must not declare retainResult",
|
||||
},
|
||||
{
|
||||
name: "non-boolean value",
|
||||
metaFields: `routes: [{method: "POST", path: "/v1/batch", type: "submit", decode: "decodeJob", render: "jobCreated", retainResult: "no"}],`,
|
||||
errContains: "retainResult must be a boolean",
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
request := RouteRequestContext{Body: testCase.body}
|
||||
first := request.JSValue()
|
||||
testCase.mutate(first)
|
||||
second := request.JSValue()
|
||||
assert.NotEqual(t, first, second)
|
||||
plugin, err := CompilePlugin(
|
||||
routingTestPluginSource("route-retain", 0, `["gpt-5.5"]`, testCase.metaFields, routeExports),
|
||||
Options{},
|
||||
)
|
||||
if testCase.errContains != "" {
|
||||
require.ErrorContains(t, err, testCase.errContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Len(t, plugin.Meta.Routes, 1)
|
||||
assert.Equal(t, testCase.want, plugin.Meta.Routes[0].RetainResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequestContextKeepsFormAndMultipartValuesAcrossDecoderWrites(t *testing.T) {
|
||||
plugin, err := CompilePlugin(routingTestPluginSource("route-request-writes", 0, `["gpt-5.5"]`, "", `
|
||||
export function decodeJob(ctx) {
|
||||
const seen = {
|
||||
prompt: ctx.body.fields.prompt[0],
|
||||
filename: ctx.body.files[0].filename,
|
||||
params: Object.keys(ctx.params).length,
|
||||
query: Object.keys(ctx.query).length
|
||||
};
|
||||
ctx.body.fields.prompt[0] = "mutated";
|
||||
ctx.body.files[0].filename = "mutated.png";
|
||||
return seen;
|
||||
}`), Options{})
|
||||
require.NoError(t, err)
|
||||
fields := map[string][]string{"prompt": {"original"}}
|
||||
files := []map[string]any{{"ref": "request_file:image", "filename": "safe.png"}}
|
||||
request := RouteRequestContext{Body: map[string]any{"kind": "multipart", "fields": fields, "files": files}}
|
||||
for range 2 {
|
||||
seen, err := plugin.Engine.Call(context.Background(), "decodeJob", request.JSValue())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{"prompt": "original", "filename": "safe.png", "params": int64(0), "query": int64(0)}, seen)
|
||||
}
|
||||
assert.Equal(t, "original", fields["prompt"][0])
|
||||
assert.Equal(t, "safe.png", files[0]["filename"])
|
||||
}
|
||||
|
||||
func TestRegistryNoOpMutationsKeepCurrentGeneration(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
plugin := mustCompileRoutingPlugin(t, "stable-generation", 0, `["model"]`, "", "")
|
||||
|
||||
+199
-46
@@ -6,15 +6,17 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/sobek"
|
||||
)
|
||||
|
||||
type volcSignRequest struct {
|
||||
@@ -29,65 +31,216 @@ type volcSignRequest struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
func injectGlobals(runtime *sobek.Runtime, identity func() string, now func() time.Time, logOutput func(string)) error {
|
||||
// injectGlobals installs utils and console. currentCall returns the hook call
|
||||
// running on runtime, or nil outside one; utils.fetch reads its fetcher and
|
||||
// budget there, so no request data lives in the runtime's globals.
|
||||
func injectGlobals(runtime *moejs.Runtime, identity func() string, now func() time.Time, logOutput func(string), currentCall func() *runtimeCall) error {
|
||||
utils := map[string]any{
|
||||
"hasCapability": HasCapability,
|
||||
"fetch": runtime.Function("fetch", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
call := currentCall()
|
||||
if call == nil {
|
||||
return moejs.Undefined(), fmt.Errorf("utils.fetch is not available outside hooks")
|
||||
}
|
||||
return call.fetch(r, moejs.Arg(args, 0))
|
||||
}),
|
||||
"hasCapability": runtime.Function("hasCapability", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
name, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Bool(HasCapability(name)), nil
|
||||
}),
|
||||
"json": map[string]any{
|
||||
"clone": func(call sobek.FunctionCall) sobek.Value {
|
||||
if sobek.IsUndefined(call.Argument(0)) {
|
||||
panic(runtime.NewTypeError("json.clone requires a JSON value"))
|
||||
"clone": runtime.Function("clone", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
if moejs.Arg(args, 0).IsUndefined() {
|
||||
return moejs.Undefined(), r.TypeError("json.clone requires a JSON value")
|
||||
}
|
||||
budget := &jsonStateBudget{ctx: context.Background(), bytes: MaxJSONToolBytes, nodes: maxJSONToolNodes}
|
||||
value, err := newJSONStateNode(call.Argument(0), 0, budget)
|
||||
budget := &jsonStateBudget{ctx: context.Background(), bytes: MaxJSONToolBytes, nodes: maxJSONToolNodes, realm: r}
|
||||
value, err := newJSONStateNode(moejs.Arg(args, 0), 0, budget)
|
||||
if err != nil {
|
||||
panic(runtime.NewTypeError("json.clone: %s", err))
|
||||
var exception *moejs.Exception
|
||||
var interrupted *moejs.InterruptedError
|
||||
if errors.As(err, &exception) || errors.As(err, &interrupted) {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), r.TypeError("json.clone: %s", err)
|
||||
}
|
||||
cloned, err := value.jsValue(runtime)
|
||||
return value.jsValue(r)
|
||||
}),
|
||||
},
|
||||
"unixNow": runtime.Function("unixNow", 0, func(*moejs.Realm, moejs.Value, []moejs.Value) (moejs.Value, error) {
|
||||
return moejs.Int(now().Unix()), nil
|
||||
}),
|
||||
"jwtSignHS256": runtime.Function("jwtSignHS256", 2, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
var claims map[string]any
|
||||
if argument := moejs.Arg(args, 0); !argument.IsNullish() {
|
||||
exported, err := r.ToGoStrict(argument)
|
||||
if err != nil {
|
||||
panic(runtime.NewGoError(err))
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return cloned
|
||||
},
|
||||
},
|
||||
"unixNow": func() int64 { return now().Unix() },
|
||||
"jwtSignHS256": func(claims map[string]any, secret string) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
|
||||
},
|
||||
"hmacSHA256": func(message, secret string) string {
|
||||
var ok bool
|
||||
if claims, ok = exported.(map[string]any); !ok {
|
||||
return moejs.Undefined(), r.TypeError("jwtSignHS256 claims must be an object")
|
||||
}
|
||||
}
|
||||
secret, err := stringValue(r, moejs.Arg(args, 1))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)).SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(token), nil
|
||||
}),
|
||||
"hmacSHA256": runtime.Function("hmacSHA256", 2, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
message, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
secret, err := stringValue(r, moejs.Arg(args, 1))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(message))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
},
|
||||
"base64": func(value string) string { return base64.StdEncoding.EncodeToString([]byte(value)) },
|
||||
"base64URL": func(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
},
|
||||
"base64URLDecode": func(value string) (string, error) {
|
||||
return moejs.String(hex.EncodeToString(mac.Sum(nil))), nil
|
||||
}),
|
||||
"base64": runtime.Function("base64", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(base64.StdEncoding.EncodeToString([]byte(value))), nil
|
||||
}),
|
||||
"base64URL": runtime.Function("base64URL", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(base64.RawURLEncoding.EncodeToString([]byte(value))), nil
|
||||
}),
|
||||
"base64URLDecode": runtime.Function("base64URLDecode", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value, err := stringValue(r, moejs.Arg(args, 0))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return string(decoded), err
|
||||
},
|
||||
"uuid": func() string { return uuid.NewString() },
|
||||
"volcSignV4": func(request volcSignRequest) (map[string]string, error) {
|
||||
return signVolcV4(request, now)
|
||||
},
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.String(string(decoded)), nil
|
||||
}),
|
||||
"uuid": runtime.Function("uuid", 0, func(*moejs.Realm, moejs.Value, []moejs.Value) (moejs.Value, error) {
|
||||
return moejs.String(uuid.NewString()), nil
|
||||
}),
|
||||
"volcSignV4": runtime.Function("volcSignV4", 1, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
// Fields are read by their Go names, as plugins have always
|
||||
// passed them.
|
||||
var request volcSignRequest
|
||||
argument := moejs.Arg(args, 0)
|
||||
if !argument.IsNullish() && !argument.IsObject() {
|
||||
return moejs.Undefined(), r.TypeError("volcSignV4 request must be an object")
|
||||
}
|
||||
if argument.IsObject() {
|
||||
object := argument.AsObject()
|
||||
fields := []struct {
|
||||
name string
|
||||
target *string
|
||||
}{
|
||||
{"Method", &request.Method}, {"URL", &request.URL}, {"Body", &request.Body},
|
||||
{"AccessKey", &request.AccessKey}, {"SecretKey", &request.SecretKey},
|
||||
{"Region", &request.Region}, {"Service", &request.Service},
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, err := object.GetProp(r, r.KeyFromGoString(field.name))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if *field.target, err = stringValue(r, value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
headers, err := object.GetProp(r, r.KeyFromGoString("Headers"))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if headers.IsObject() {
|
||||
keys, err := r.EnumerableOwnKeys(headers.AsObject())
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
request.Headers = make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
value, err := headers.AsObject().GetProp(r, key)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if request.Headers[key.GoString()], err = stringValue(r, value); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
timestamp, err := object.GetProp(r, r.KeyFromGoString("Timestamp"))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if !timestamp.IsNullish() {
|
||||
seconds, err := r.ToNumber(timestamp)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
if !math.IsNaN(seconds) && !math.IsInf(seconds, 0) {
|
||||
request.Timestamp = int64(seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
signed, err := signVolcV4(request, now)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return r.FromGo(signed)
|
||||
}),
|
||||
}
|
||||
if err := runtime.Set("utils", utils); err != nil {
|
||||
if err := runtime.SetGlobal("utils", utils); err != nil {
|
||||
return err
|
||||
}
|
||||
console := runtime.NewObject()
|
||||
if err := console.Set("log", func(call sobek.FunctionCall) sobek.Value {
|
||||
parts := make([]string, len(call.Arguments))
|
||||
for i, argument := range call.Arguments {
|
||||
parts[i] = argument.String()
|
||||
}
|
||||
if logOutput != nil {
|
||||
logOutput(identity() + " " + strings.Join(parts, " "))
|
||||
}
|
||||
return sobek.Undefined()
|
||||
}); err != nil {
|
||||
return err
|
||||
return runtime.SetGlobal("console", map[string]any{
|
||||
"log": runtime.Function("log", 0, func(r *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
parts := make([]string, len(args))
|
||||
for i, argument := range args {
|
||||
if argument.IsSymbol() {
|
||||
parts[i] = argument.String()
|
||||
continue
|
||||
}
|
||||
text, err := r.ToString(argument)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
parts[i] = text.GoString()
|
||||
}
|
||||
if logOutput != nil {
|
||||
logOutput(identity() + " " + strings.Join(parts, " "))
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// stringValue reads a host function's string parameter: undefined and null
|
||||
// read as "", other values as JavaScript's String(value).
|
||||
func stringValue(r *moejs.Realm, value moejs.Value) (string, error) {
|
||||
if value.IsNullish() {
|
||||
return "", nil
|
||||
}
|
||||
return runtime.Set("console", console)
|
||||
if value.IsString() {
|
||||
return value.AsString().GoString(), nil
|
||||
}
|
||||
text, err := r.ToString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return text.GoString(), nil
|
||||
}
|
||||
|
||||
func signVolcV4(request volcSignRequest, now func() time.Time) (map[string]string, error) {
|
||||
|
||||
Reference in new issue
Block a user