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 }