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
|
|
}
|