mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
utils.fetch is a synchronous, read-only GET or HEAD to the channel Base URL host or the plugin's allowedHosts, through the channel proxy used for polling. It is available in buildSubmitRequest, the query and batch build and parse hooks, and buildContentRequest; every other hook throws. Each hook call gets at most 4 fetches, 15 s per fetch, 30 s in total and 1 MiB per body. Redirects are returned, not followed. responseType "bytes" returns an ArrayBuffer for binary data such as a media file header. While a fetch waits, the runtime gives up its execution slot and the watchdog pauses. BuildContentRequest now receives the request context, and the media proxy shares the plugin request header rules.
293 lines
9.4 KiB
Go
293 lines
9.4 KiB
Go
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
|
|
}
|