Ctrl K
ComunidadGuías…

pkg/jsplugin/fetch.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}