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 }