jsFetch/main.go

701 lines
24 KiB
Go

package jsFetch
import (
"context"
"errors"
"io"
"maps"
"net"
"runtime"
"strconv"
"strings"
"sync"
"time"
"unicode"
"crypto/tls"
"mime/multipart"
"net/url"
"syscall/js"
"git.ailur.dev/ailur/jsStreams"
)
// Transport is here only for compatibility with the Go standard library.
// All fields are ignored in fetch.
type Transport struct {
// Proxy specifies a function to return a proxy for a given Request.
// It is ignored in fetch.
Proxy func(*Request) (*url.URL, error)
// OnProxyError specifies a function to handle errors that occur while fetching a proxy.
// It is ignored in fetch.
OnProxyError func(*Request, *url.URL, error)
// DialContext specifies the dial function for creating unencrypted TCP connections.
// It is ignored in fetch.
DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
// Dial specifies the dial function for creating unencrypted TCP connections.
// It is ignored in fetch.
Dial func(network, addr string) (net.Conn, error)
// DialTLSContext specifies the dial function for creating TLS connections.
// It is ignored in fetch.
DialTLSContext func(ctx context.Context, network, addr string) (net.Conn, error)
// DialTLS specifies the dial function for creating TLS connections.
// It is ignored in fetch.
DialTLS func(network, addr string) (net.Conn, error)
// TLSClientConfig specifies the TLS configuration to use with tls.Client.
// It is ignored in fetch.
TLSClientConfig *tls.Config
// TLSHandshakeTimeout specifies the maximum amount of time waiting to wait for a TLS handshake.
// It is ignored in fetch.
TLSHandshakeTimeout time.Duration
// DisableKeepAlives specifies whether to disable keep-alive connections.
// It is ignored in fetch.
DisableKeepAlives bool
// DisableCompression specifies whether to disable compression.
// It is ignored in fetch.
DisableCompression bool
// MaxIdleConns specifies the maximum number of idle (keep-alive) connections to keep.
// It is ignored in fetch.
MaxIdleConns int
// MaxIdleConnsPerHost specifies the maximum number of idle (keep-alive) connections to keep per host.
// It is ignored in fetch.
MaxIdleConnsPerHost int
// IdleConnTimeout specifies the maximum amount of time an idle (keep-alive) connection will remain idle before closing itself.
// It is ignored in fetch.
IdleConnTimeout time.Duration
// ResponseHeaderTimeout specifies the maximum amount of time to wait for a response header.
// It is ignored in fetch.
ResponseHeaderTimeout time.Duration
// ExpectContinueTimeout specifies the maximum amount of time to wait for a 100-continue response.
// It is ignored in fetch.
ExpectContinueTimeout time.Duration
// TLSNextProto specifies a function to upgrade the connection to a different protocol.
// It is ignored in fetch.
TLSNextProto map[string]func(authority string, c net.Conn) RoundTripper
// ProxyConnectHeader specifies the headers to send to proxies during CONNECT requests.
// It is ignored in fetch.
ProxyConnectHeader Header
// MaxResponseHeaderBytes specifies the maximum number of bytes to read from the server's response headers.
// It is ignored in fetch.
MaxResponseHeaderBytes int
// WriteBufferSize specifies the size of the write buffer.
// It is ignored in fetch.
WriteBufferSize int
// ReadBufferSize specifies the size of the read buffer.
// It is ignored in fetch.
ReadBufferSize int
// ForceAttemptHTTP2 specifies whether to force an attempt to use HTTP/2.
// It is ignored in fetch.
ForceAttemptHTTP2 bool
}
// RoundTrip executes a single HTTP transaction, returning a Response for the provided Request.
// In the context of fetch, it will automatically follow redirects.
// This implementation is a wrapper around the fetch API.
func (t *Transport) RoundTrip(req *Request) (resp *Response, err error) {
defer func() {
recovery := recover()
if recovery != nil {
runtimeErr, ok := recovery.(runtime.Error)
if ok {
err = runtimeErr
} else {
err = errors.New(recovery.(string))
}
}
}()
if req.GetBody != nil {
req.Body, err = req.GetBody()
if err != nil {
return nil, err
}
}
headersMapStringInterface := make(map[string]interface{})
for key, value := range req.Header {
headersMapStringInterface[key] = value
}
if req.Body != nil {
headersMapStringInterface["Content-Length"] = req.ContentLength
}
fetchArgs := map[string]interface{}{
"method": req.Method,
"headers": headersMapStringInterface,
}
// Since all supported browsers are chromium based, lets just detect for chrome.
// If we are, we can use a streamed client body
if req.Method != "GET" && req.Method != "HEAD" && req.Body != nil {
if !req.DisableStreamedClient {
if !js.Global().Get("chrome").IsUndefined() {
req.DisableStreamedClientChecks = true
}
}
var jsBody js.Value
if req.DisableStreamedClientChecks && !req.DisableStreamedClient {
jsBody = jsStreams.ReaderToReadableStream(req.Body)
fetchArgs["duplex"] = "half"
} else {
// Not today firefox, not today
// Mozilla, please add support for streamed client bodies
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
jsBody = js.Global().Get("Uint8Array").New(len(body))
js.CopyBytesToJS(jsBody, body)
}
fetchArgs["body"] = jsBody
}
promise := js.Global().Call("fetch", req.URL.String(), js.ValueOf(fetchArgs))
var waitGroup sync.WaitGroup
waitGroup.Add(1)
promise.Call("then", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
resp = new(Response)
resp.Request = req
resp.Header = make(Header)
args[0].Get("headers").Call("forEach", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
resp.Header.Set(args[0].String(), args[1].String())
return nil
}))
resp.Status = args[0].Get("statusText").String()
resp.StatusCode = args[0].Get("status").Int()
if resp.Header.Has("Content-Length") {
resp.ContentLength, err = strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
resp.ContentLength = -1
}
}
resp.Body = jsStreams.NewReadableStream(args[0].Get("body"))
// Standard-library compatibility fields
resp.Proto = "HTTP/1.1"
resp.ProtoMajor = 1
resp.ProtoMinor = 1
resp.TransferEncoding = []string{"chunked"}
resp.Trailer = make(Header)
resp.TLS = nil
resp.Close = false
resp.Uncompressed = true
waitGroup.Done()
return nil
}))
promise.Call("catch", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
err = errors.New(args[0].Get("message").String())
waitGroup.Done()
return nil
}))
waitGroup.Wait()
return
}
// Clone returns a copy of the Transport.
func (t *Transport) Clone() (newTransport *Transport) {
newTransport = new(Transport)
newTransport.Proxy = t.Proxy
newTransport.OnProxyError = t.OnProxyError
newTransport.DialContext = t.DialContext
newTransport.Dial = t.Dial
newTransport.DialTLSContext = t.DialTLSContext
newTransport.DialTLS = t.DialTLS
newTransport.TLSClientConfig = t.TLSClientConfig
newTransport.TLSHandshakeTimeout = t.TLSHandshakeTimeout
newTransport.DisableKeepAlives = t.DisableKeepAlives
newTransport.DisableCompression = t.DisableCompression
newTransport.MaxIdleConns = t.MaxIdleConns
newTransport.MaxIdleConnsPerHost = t.MaxIdleConnsPerHost
newTransport.IdleConnTimeout = t.IdleConnTimeout
newTransport.ResponseHeaderTimeout = t.ResponseHeaderTimeout
newTransport.ExpectContinueTimeout = t.ExpectContinueTimeout
newTransport.TLSNextProto = t.TLSNextProto
newTransport.ProxyConnectHeader = t.ProxyConnectHeader.Clone()
newTransport.MaxResponseHeaderBytes = t.MaxResponseHeaderBytes
newTransport.WriteBufferSize = t.WriteBufferSize
newTransport.ReadBufferSize = t.ReadBufferSize
newTransport.ForceAttemptHTTP2 = t.ForceAttemptHTTP2
return
}
// CloseIdleConnections closes any idle connections.
// This does nothing in fetch.
func (t *Transport) CloseIdleConnections() {}
// RegisterProtocol registers a new protocol with a custom RoundTripper.
// This does nothing in fetch.
func (t *Transport) RegisterProtocol(protocol string, rt RoundTripper) {}
// FetchRoundTripper is a wrapper around the fetch API. It is used to make requests.
// It is the default RoundTripper used for this subset of net/http.
var FetchRoundTripper RoundTripper = &Transport{}
// RoundTripper is an interface representing the ability to execute a single HTTP transaction.
type RoundTripper interface {
// RoundTrip executes a single HTTP transaction, returning a Response for the provided Request.
// In the context of fetch, it will automatically follow redirects.
RoundTrip(*Request) (*Response, error)
}
// Client is a fetch client. It is used to make requests.
type Client struct {
// Transport specifies the mechanism by which individual requests are made.
// If nil, FetchRoundTripper is used.
Transport RoundTripper
// Jar does nothing, but it is required for compatibility with the Go standard library.
Jar CookieJar
// Timeout specifies a time limit for requests made by this Client.
Timeout time.Duration
}
// Do sends an HTTP request and returns an HTTP response, following policy (such as redirects, cookies, auth) as configured on the client.
func (c *Client) Do(req *Request) (resp *Response, err error) {
if c.Transport == nil {
c.Transport = FetchRoundTripper
}
return c.Transport.RoundTrip(req)
}
// CookieJar does nothing, but it is required for compatibility with the Go standard library.
type CookieJar interface {
SetCookies(u *url.URL, cookies []*Cookie)
Cookies(u *url.URL) []*Cookie
}
// SameSite does nothing, but it is required for compatibility with the Go standard library.
type SameSite int
// Cookie does nothing, but it is required for compatibility with the Go standard library.
type Cookie struct {
Name string
Value string
Quoted bool
Path string
Domain string
Expires time.Time
RawExpires string
MaxAge int
Secure bool
HttpOnly bool
SameSite SameSite
Partitioned bool
Raw string
Unparsed []string
}
// Fetch is a fetch client. It is used to make requests.
// It wraps around JS fetch.
var Fetch = &Client{
Transport: FetchRoundTripper,
Timeout: 20 * time.Second,
}
// DefaultClient is an alias for Fetch.
var DefaultClient = Fetch
func CanonicalHeaderKey(key string) string {
const allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-.^_`|~"
for _, char := range []rune(key) {
if !strings.Contains(allowedCharacters, string(char)) {
return key
}
}
splitStrings := strings.Split(key, "-")
for splitString, _ := range splitStrings {
var stringBuilder strings.Builder
stringBuilder.WriteRune(unicode.ToUpper([]rune(splitStrings[splitString])[0]))
stringBuilder.WriteString(strings.ToLower(splitStrings[splitString][1:]))
splitStrings[splitString] = stringBuilder.String()
}
return strings.Join(splitStrings, "-")
}
type Header map[string]string
func (h Header) Add(key, value string) {
h[CanonicalHeaderKey(key)] = value
}
func (h Header) Clone() (newHeader Header) {
maps.Copy(h, newHeader)
return
}
func (h Header) Del(key string) {
delete(h, CanonicalHeaderKey(key))
}
func (h Header) Get(key string) (value string) {
value, _ = h[CanonicalHeaderKey(key)]
return
}
func (h Header) Set(key, value string) {
h[CanonicalHeaderKey(key)] = value
}
func (h Header) Has(key string) (has bool) {
_, has = h[CanonicalHeaderKey(key)]
return
}
func (h Header) Values() (values []string) {
for _, value := range h {
values = append(values, value)
}
return
}
func (h Header) Write(w io.Writer) error {
for key, value := range h {
_, err := w.Write([]byte(key + ": " + value + "\r\n"))
if err != nil {
return err
}
}
return nil
}
func (h Header) WriteSubset(w io.Writer, exclude map[string]bool) error {
for key, value := range h {
excludeKey, _ := exclude[key]
if excludeKey {
continue
}
_, err := w.Write([]byte(key + ": " + value + "\r\n"))
if err != nil {
return err
}
}
return nil
}
type Request struct {
// DisableStreamedClient specifies whether to use a streamed client body.
// This is set to false by default, but has checks to make sure it's running on a supported browser.
// HTTP/2 or QUIC to be enabled when using V8, which is not always the case, particularly on
// older servers or test servers.
//
// If DisableStreamedClientChecks is set to false, the client will first attempt to detect if
// the server supports HTTP/2 or QUIC and if you are running a supported JavaScript engine.
// Supported browser engines include:
// - V8 (Chrome, Edge, Opera)
// Unsupported browser engines include:
// - SpiderMonkey (Firefox)
// - JavaScriptCore (Safari)
// - Chakra (Internet Explorer)
// - KJS (Konqueror)
// - Presto (Opera Mini and ancient versions of Opera)
// Data from https://caniuse.com/mdn-api_request_request_request_body_readablestream
// TL;DR If it's chromium it'll work.
DisableStreamedClient bool
// DisableStreamedClientChecks specifies whether to disable checks for streamed clients.
// It does nothing if UseStreamedClient is set to false.
// Having it set to false may add another HEAD request to the server, which may be undesirable.
// Also, forcing it on may be useful if SpiderMonkey one day supports streamed clients bodies.
DisableStreamedClientChecks bool
// Method specifies the HTTP method (GET, POST, PUT, etc.).
Method string
// URL specifies either the URL to fetch as a string, or a URL object.
URL *url.URL
// Headers is a Headers object, allowing you to set request headers.
Header Header
// Body is an optional body to be sent with the request.
Body io.ReadCloser
// GetBody is an optional function that returns a ReadCloser for the body.
GetBody func() (io.ReadCloser, error)
// ContentLength is the length of the body. It is mostly superseded by the Content-Length header.
ContentLength int64
// The following fields are not used by fetch and exist mostly for compatibility with the Go standard library.
// They will do nothing. Do not use them.
// Proto, ProtoMajor, ProtoMinor specify the HTTP protocol version.
// This is useless, as fetch does not allow you to specify the protocol version.
Proto string
ProtoMajor int
ProtoMinor int
// Close indicates whether to close the connection after the request.
// This is useless, as fetch does not allow you to specify whether to close the connection and instead forces you to use a WebSocket.
Close bool
// Host specifies the host to perform the request to.
// This is useless, as it only makes sense in the context of the Host or :authority headers, both of which are filled-in automatically by fetch.
Host string
// TransferEncoding specifies the transfer encodings to be used.
// This is useless, as since we always use a stream, the transfer encoding is always chunked.
TransferEncoding []string
// Form, PostForm, and MultipartForm specify the parsed form data.
// This is useless, because even the Go standard library does not use it.
// Use Body instead.
Form url.Values
PostForm url.Values
MultipartForm *url.Values
// Trailer specifies additional headers that are sent after the request body.
// This is useless, as fetch does not allow you to specify trailers.
// Not to mention, nothing supports trailers.
Trailer Header
// RemoteAddr and RequestURI specify the remote address and Request-URI for the request.
// This is useless, as fetch does not allow you to specify the remote address, and you should use URL instead of RequestURI.
RemoteAddr string
RequestURI string
// TLS allows you to specify the TLS connection state.
// This is useless, as fetch does not allow you to specify the TLS connection state.
TLS *tls.ConnectionState
// Cancel is an optional channel that can be used to cancel the request.
// This isn't even in the Go standard library anymore.
Cancel <-chan struct{}
// Response is the response that caused this request to be created, usually in a redirect.
// This is useless, as fetch follows redirects automatically.
Response *Response
// Pattern is the pattern that was matched for this request.
// This is useless, as fetch does not support pattern matching.
Pattern string
}
// AddCookie does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) AddCookie(c *Cookie) {}
// BasicAuth does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) BasicAuth() (username, password string, ok bool) { return }
func (r *Request) Clone() (newRequest *Request) {
newRequest = new(Request)
newRequest.Method = r.Method
newRequest.URL = r.URL
newRequest.Header = r.Header.Clone()
newRequest.Body = r.Body
newRequest.GetBody = r.GetBody
newRequest.ContentLength = r.ContentLength
newRequest.TransferEncoding = r.TransferEncoding
newRequest.Proto = r.Proto
newRequest.ProtoMajor = r.ProtoMajor
newRequest.ProtoMinor = r.ProtoMinor
newRequest.Close = r.Close
newRequest.Host = r.Host
newRequest.Form = r.Form
newRequest.PostForm = r.PostForm
newRequest.MultipartForm = r.MultipartForm
newRequest.Trailer = r.Trailer.Clone()
newRequest.RemoteAddr = r.RemoteAddr
newRequest.RequestURI = r.RequestURI
newRequest.TLS = r.TLS
newRequest.Cancel = r.Cancel
newRequest.Response = r.Response
newRequest.Pattern = r.Pattern
return
}
// Context does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) Context() context.Context { return context.Background() }
// Cookie does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) Cookie(name string) (*Cookie, error) { return nil, nil }
// Cookies does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) Cookies() []*Cookie { return nil }
// CookiesNamed does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) CookiesNamed(name string) []*Cookie { return nil }
// FormFile does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) FormFile(key string) (multipart.File, *multipart.FileHeader, error) {
return nil, nil, nil
}
// FormValue does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) FormValue(key string) string { return "" }
// MultipartReader does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) MultipartReader() (*multipart.Reader, error) { return nil, nil }
// ParseForm does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) ParseForm() error { return nil }
// ParseMultipartForm does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) ParseMultipartForm(maxMemory int64) error { return nil }
// PostFormValue does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) PostFormValue(key string) string { return "" }
// ProtoAtLeast does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) ProtoAtLeast(major, minor int) bool { return false }
// Finally, something that does something!
// Referer returns the value of the Referer header.
func (r *Request) Referer() string {
return r.Header.Get("Referer")
}
// SetBasicAuth does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) SetBasicAuth(username, password string) {}
// UserAgent returns the value of the User-Agent header.
func (r *Request) UserAgent() string {
return r.Header.Get("User-Agent")
}
// WithContext does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) WithContext(ctx context.Context) *Request { return r }
// Write writes an HTTP/1.1 request, which is the header and body, in wire format.
// It is not yet implemented (and probably never will be, because after all, why would you need to write an HTTP request in wire format?).
func (r *Request) Write(w io.Writer) error { return errors.New("not implemented") }
// WriteProxy does nothing, but it is required for compatibility with the Go standard library.
func (r *Request) WriteProxy(w io.Writer) error { return nil }
func NewRequest(method, uri string, body io.Reader) (request *Request, err error) {
request = new(Request)
request.Method = method
request.URL, err = url.Parse(uri)
if err != nil {
return
}
request.Header = make(Header)
request.Body = io.NopCloser(body)
return
}
type Response struct {
// Status specifies the HTTP status.
// It will be an empty string if using HTTP/2.
Status string
// StatusCode specifies the HTTP status code.
StatusCode int
// Header specifies the response headers.
Header Header
// Body is the response body.
Body io.ReadCloser
// TransferEncoding specifies the transfer encodings that have been applied to the response.
TransferEncoding []string
// ContentLength specifies the length of the body.
// It is mostly superseded by the Content-Length header.
// A value of -1 indicates that the length is unknown.
ContentLength int64
// Request is the request that was made to get this response.
Request *Request
// The following fields are not used by fetch and exist mostly for compatibility with the Go standard library.
// They will do nothing. Do not use them.
// Proto, ProtoMajor, ProtoMinor specify the HTTP protocol version.
// This is useless, as fetch does not allow you to specify the protocol version.
Proto string
ProtoMajor int
ProtoMinor int
// Close indicates whether to close the connection after the request.
// This is useless, as fetch does not allow you to specify whether to close the connection and instead forces you to use a WebSocket.
Close bool
// Uncompressed specifies whether the response is uncompressed.
// This is useless, as fetch does not allow you to specify whether the response is uncompressed.
Uncompressed bool
// Trailer specifies additional headers that are sent after the request body.
// This is useless, as fetch does not allow you to specify trailers.
// Not to mention, nothing supports trailers.
Trailer Header
// TLS allows you to specify the TLS connection state.
// This is useless, as fetch does not allow you to specify the TLS connection state.
TLS *tls.ConnectionState
}
func Get(url string) (response *Response, err error) {
request, err := NewRequest("GET", url, nil)
if err != nil {
return
}
response, err = Fetch.Do(request)
return
}
func Post(url string, contentType string, body io.Reader) (response *Response, err error) {
request, err := NewRequest("POST", url, body)
if err != nil {
return
}
request.Header.Add("Content-Type", contentType)
response, err = Fetch.Do(request)
return
}
func PostForm(url string, data url.Values) (response *Response, err error) {
body := io.NopCloser(strings.NewReader(data.Encode()))
request, err := NewRequest("POST", url, body)
if err != nil {
return
}
request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
response, err = Fetch.Do(request)
return
}
// Cookies does nothing, but it is required for compatibility with the Go standard library.
func (r *Response) Cookies() []*Cookie { return nil }
// Location returns the location header (if present).
func (r *Response) Location() (string, error) {
if r.Header.Has("Location") {
return r.Header.Get("Location"), nil
} else {
return "", errors.New("http: no Location header in response")
}
}
// ProtoAtLeast does nothing, but it is required for compatibility with the Go standard library.
func (r *Response) ProtoAtLeast(major, minor int) bool { return true }
// Write writes an HTTP/1.1 response, which is the header and body, in wire format.
// It is not yet implemented (and probably never will be, because after all, why would you need to write an HTTP response in wire format?).
func (r *Response) Write(w io.Writer) error { return nil }