feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+515 -215
View File
@@ -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 {
+312
View File
@@ -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 {
+292
View File
@@ -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
View File
@@ -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
View File
@@ -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
+42
View File
@@ -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},
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {