Files
CaIon 29509776ef feat(jsplugin): let driver hooks read upstream data with utils.fetch
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.
2026-10-10 13:39:55 +08:00

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
}