From 8fe49c35936d9a6f0b4a992c778d9461bf97b929 Mon Sep 17 00:00:00 2001 From: arzumify Date: Mon, 28 Oct 2024 09:47:23 +0000 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 9 + LICENSE.md | 157 +++++++++ README.md | 8 + go.mod | 8 + go.sum | 4 + main.go | 696 ++++++++++++++++++++++++++++++++++++++++ tests/client/index.html | 651 +++++++++++++++++++++++++++++++++++++ tests/client/main.go | 120 +++++++ tests/server/main.go | 89 +++++ tests/test.sh | 75 +++++ 11 files changed, 1819 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 tests/client/index.html create mode 100644 tests/client/main.go create mode 100644 tests/server/main.go create mode 100755 tests/test.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..167993b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +tests/index.html -linguist-detectable +tests/index.html linguist-vendored \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18e6a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +tests/client/main.wasm +tests/server/server +tests/server/server.crt +tests/server/server.key +tests/server/server.csr +tests/server/ca.crt +tests/server/ca.key +tests/server/ca.srl \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..969dabb --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,157 @@ +# GNU LESSER GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +## 0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the +GNU General Public License. + +"The Library" refers to a covered work governed by this License, other +than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + +The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +## 1. Exception to Section 3 of the GNU GPL. + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +## 2. Conveying Modified Versions. + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +- a) under this License, provided that you make a good faith effort + to ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or +- b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + +## 3. Object Code Incorporating Material from Library Header Files. + +The object code form of an Application may incorporate material from a +header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +- a) Give prominent notice with each copy of the object code that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the object code with a copy of the GNU GPL and this + license document. + +## 4. Combined Works. + +You may convey a Combined Work under terms of your choice that, taken +together, effectively do not restrict modification of the portions of +the Library contained in the Combined Work and reverse engineering for +debugging such modifications, if you also do each of the following: + +- a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the Combined Work with a copy of the GNU GPL and this + license document. +- c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. +- d) Do one of the following: + - 0) Convey the Minimal Corresponding Source under the terms of + this License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + - 1) Use a suitable shared library mechanism for linking with + the Library. A suitable mechanism is one that (a) uses at run + time a copy of the Library already present on the user's + computer system, and (b) will operate properly with a modified + version of the Library that is interface-compatible with the + Linked Version. +- e) Provide Installation Information, but only if you would + otherwise be required to provide such information under section 6 + of the GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the Application + with a modified version of the Linked Version. (If you use option + 4d0, the Installation Information must accompany the Minimal + Corresponding Source and Corresponding Application Code. If you + use option 4d1, you must provide the Installation Information in + the manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.) + +## 5. Combined Libraries. + +You may place library facilities that are a work based on the Library +side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +- a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities, conveyed under the terms of this License. +- b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + +## 6. Revised Versions of the GNU Lesser General Public License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +as you received it specifies that a certain numbered version of the +GNU Lesser General Public License "or any later version" applies to +it, you have the option of following the terms and conditions either +of that published version or of any later version published by the +Free Software Foundation. If the Library as you received it does not +specify a version number of the GNU Lesser General Public License, you +may choose any version of the GNU Lesser General Public License ever +published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..323ec31 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# jsFetch + +Go library to bridge net/http and the JS Fetch API, without actually importing net/http. +Made using the [jsStreams](https://git.ailur.dev/Ailur/jsStreams) library. + +[![Go Report Card](https://goreportcard.com/badge/git.ailur.dev/ailur/jsFetch)](https://goreportcard.com/report/git.ailur.dev/ailur/jsFetch) [![Go Reference](https://pkg.go.dev/badge/git.ailur.dev/ailur/jsFetch.svg)](https://pkg.go.dev/git.ailur.dev/ailur/jsFetch) + +The API is exactly the same as net/http. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d6818b3 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.ailur.dev/ailur/jsFetch + +go 1.22 + +require ( + git.ailur.dev/ailur/jsStreams v1.2.0 + github.com/go-chi/chi/v5 v5.1.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3a15a92 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +git.ailur.dev/ailur/jsStreams v1.2.0 h1:BRtLEyjkUoPKPu0Y6odUbSMlKCYNyR792TYRtujKfPw= +git.ailur.dev/ailur/jsStreams v1.2.0/go.mod h1:/ZCvbUcWkZRuKIkO7jH6b5vIjzdxIOP8ET8X0src5Go= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f8c82c9 --- /dev/null +++ b/main.go @@ -0,0 +1,696 @@ +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, +} + +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 true, 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, body io.Reader) (response *Response, err error) { + request, err := NewRequest("POST", url, body) + if err != nil { + return + } + 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 } diff --git a/tests/client/index.html b/tests/client/index.html new file mode 100644 index 0000000..9843cbd --- /dev/null +++ b/tests/client/index.html @@ -0,0 +1,651 @@ + + + + + Wasm-Tester + + + + + + \ No newline at end of file diff --git a/tests/client/main.go b/tests/client/main.go new file mode 100644 index 0000000..0ad2def --- /dev/null +++ b/tests/client/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "git.ailur.dev/ailur/jsFetch" + "io" + "net/http" + "strings" + "syscall/js" +) + +func tryGet(url string) error { + response, err := jsFetch.Get(url) + if err != nil { + return err + } + fmt.Println(response.StatusCode) + read, err := io.ReadAll(response.Body) + if err != nil { + return err + } + fmt.Println(string(read)) + return nil +} + +func tryHead(url string) error { + request, err := jsFetch.NewRequest("HEAD", url, nil) + if err != nil { + return err + } + response, err := jsFetch.Fetch.Do(request) + if err != nil { + return err + } + fmt.Println(response.StatusCode) + fmt.Println(response.Header) + return nil +} + +func tryPost(url string, message string) error { + response, err := jsFetch.Post(url, strings.NewReader(message)) + if err != nil { + return err + } + fmt.Println(response.StatusCode) + read := make([]byte, len(message)) + _, err = response.Body.Read(read) + if err != nil { + return err + } + fmt.Println(string(read)) + return nil +} + +func reportTest(fail bool, err error) { + request, _ := http.NewRequest("GET", "https://localhost:8080/reportTestResults", nil) + request.Header.Set("Success", fmt.Sprint(!fail)) + if err != nil { + request.Header.Set("Error", err.Error()) + } +} + +func main() { + js.Global().Set("tryGet", js.FuncOf(func(this js.Value, p []js.Value) interface{} { + go func() { + err := tryGet(p[0].String()) + if err != nil { + panic(err) + return + } + }() + return nil + })) + + js.Global().Set("tryHead", js.FuncOf(func(this js.Value, p []js.Value) interface{} { + go func() { + err := tryHead(p[0].String()) + if err != nil { + panic(err) + return + } + }() + return nil + })) + + js.Global().Set("tryPost", js.FuncOf(func(this js.Value, p []js.Value) interface{} { + go func() { + err := tryPost(p[0].String(), p[1].String()) + if err != nil { + panic(err) + return + } + }() + return nil + })) + + js.Global().Set("tryAll", js.FuncOf(func(this js.Value, p []js.Value) interface{} { + go func() { + err := tryGet("https://localhost:8080/hello") + if err != nil { + reportTest(true, err) + return + } + err = tryHead("https://localhost:8080/hello") + if err != nil { + reportTest(true, err) + return + } + err = tryPost("https://localhost:8080/hello", "Hello, world!") + if err != nil { + reportTest(true, err) + return + } + reportTest(false, nil) + }() + return nil + })) + + select {} +} diff --git a/tests/server/main.go b/tests/server/main.go new file mode 100644 index 0000000..db9c11f --- /dev/null +++ b/tests/server/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "fmt" + "github.com/go-chi/chi/v5" + "io" + "net/http" + "os" +) + +func main() { + r := chi.NewRouter() + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, HEAD") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Success, Error") + next.ServeHTTP(w, r) + }) + }) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + file, err := os.ReadFile("../client/index.html") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(file) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + r.Get("/main.wasm", func(w http.ResponseWriter, r *http.Request) { + file, err := os.ReadFile("../client/main.wasm") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(file) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + r.Options("/hello", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, HEAD") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Success, Error") + }) + r.Options("/reportTestResults", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, HEAD") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Success, Error") + }) + r.Head("/hello", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Success", "true") + }) + r.Get("/hello", func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte("hello")) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + r.Post("/hello", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + r.Get("/reportTestResults", func(w http.ResponseWriter, r *http.Request) { + success := r.Header.Get("Success") + if success == "true" { + fmt.Println("Test passed") + os.Exit(0) + } else { + fmt.Println("Test failed... " + r.Header.Get("Error")) + os.Exit(1) + } + }) + err := http.ListenAndServeTLS(":8080", "server.crt", "server.key", r) + if err != nil { + fmt.Println(err) + return + } +} diff --git a/tests/test.sh b/tests/test.sh new file mode 100755 index 0000000..7dff626 --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,75 @@ +#!/bin/sh + +CA_FILE=$(realpath "./server/ca.crt") +CA_KEY=$(realpath "./server/ca.key") +SRL_FILE=$(realpath "./server/ca.srl") +CSR_FILE=$(realpath "./server/server.csr") +SSL_FILE=$(realpath "./server/server.crt") +SSL_KEY=$(realpath "./server/server.key") + +superuserCommand="pkexec" +if [ -z "$(command -v pkexec)" ]; then + superuserCommand="sudo" +fi + +if [ "$1" = "-u" ] || [ "$1" = "--uninstall" ]; then + echo "Uninstalling the certificate..." + if [ -z "$(command -v p11-kit)" ]; then + $superuserCommand sh -c "rm /usr/local/share/ca-certificates/$CA_FILE && update-ca-certificates" + else + $superuserCommand sh -c "trust anchor --remove $CA_FILE" + fi + rm "$CA_FILE" "$CA_KEY" "$CSR_FILE" "$SSL_FILE" "$SSL_KEY" "$SRL_FILE" + echo "Good, you've uninstalled the certificate." + + exit 0 +fi + +if ! [ -f "$CA_FILE" ] || ! [ -f "$CA_KEY" ] || ! [ -f "$CSR_FILE" ] || ! [ -f "$SSL_FILE" ] || ! [ -f "$SSL_KEY" ]; then + echo "Warning! This will add a certificate to your system's trust store." + echo "If this self-signed certificate is ever leaked, attackers can use it to cause damage." + echo "Please only run this script if you understand the risks and trust the source of the certificate." + echo "We take no responsibility for any damage caused by the use of this certificate... though that's said in the LICENSE." + echo "Do you want to continue? (yes/no)" + read -r answer + if [ "$answer" != "yes" ]; then + echo "Aborting." + exit 1 + fi + echo "Well, you said it, not me." + COUNTRY="GB" + STATE="London" + LOCALITY="London" + ORGANIZATION="Totally Real Company Inc." + ORGANIZATIONAL_UNIT="Testing Department" + COMMON_NAME="localhost" + openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout "$CA_KEY" -out "$CA_FILE" \ + -subj "/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANIZATION/OU=$ORGANIZATIONAL_UNIT/CN=$COMMON_NAME" + openssl req -nodes -newkey rsa:2048 \ + -keyout "$SSL_KEY" -out "$CSR_FILE" \ + -subj "/C=$COUNTRY/ST=$STATE/L=$LOCALITY/O=$ORGANIZATION/OU=$ORGANIZATIONAL_UNIT/CN=$COMMON_NAME" + printf "subjectAltName = DNS:%s\nauthorityKeyIdentifier = keyid,issuer\nbasicConstraints = CA:FALSE\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage=serverAuth" $COMMON_NAME > /tmp/extfile.cnf + openssl x509 -req -in "$CSR_FILE" -CA "$CA_FILE" -CAkey "$CA_KEY" -CAcreateserial -out "$SSL_FILE" -days 365 \ + -extfile /tmp/extfile.cnf + echo "Self-signed certificate and key have been generated:" + echo "Trusting the certificate... (you may be prompted for your password)". + if [ -z "$(command -v p11-kit)" ]; then + $superuserCommand sh -c "cp $CA_FILE /usr/local/share/ca-certificates/$CA_FILE && update-ca-certificates" + else + $superuserCommand sh -c "trust anchor $CA_FILE" + fi + echo "Deleting temporary files..." + rm /tmp/extfile.cnf +fi + +echo "Building the server and client..." +go build -o server/server server/main.go +GOOS=js GOARCH=wasm go build -o client/main.wasm client/main.go +echo "Launching the client in your default browser..." +xdg-open "https://localhost:8080" +echo "Launching the server..." +cd server || exit 1 +echo "Server started. Press Ctrl+C to stop." +./server +echo "Alright, the server has stopped. If you want to remove the self-signed certificate, run ./test.sh --uninstall." \ No newline at end of file