1// Copyright 2018 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5//go:build js && wasm
6
7package http
8
9import (
10	"errors"
11	"fmt"
12	"io"
13	"net/http/internal/ascii"
14	"strconv"
15	"strings"
16	"syscall/js"
17)
18
19var uint8Array = js.Global().Get("Uint8Array")
20
21// jsFetchMode is a Request.Header map key that, if present,
22// signals that the map entry is actually an option to the Fetch API mode setting.
23// Valid values are: "cors", "no-cors", "same-origin", "navigate"
24// The default is "same-origin".
25//
26// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
27const jsFetchMode = "js.fetch:mode"
28
29// jsFetchCreds is a Request.Header map key that, if present,
30// signals that the map entry is actually an option to the Fetch API credentials setting.
31// Valid values are: "omit", "same-origin", "include"
32// The default is "same-origin".
33//
34// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
35const jsFetchCreds = "js.fetch:credentials"
36
37// jsFetchRedirect is a Request.Header map key that, if present,
38// signals that the map entry is actually an option to the Fetch API redirect setting.
39// Valid values are: "follow", "error", "manual"
40// The default is "follow".
41//
42// Reference: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
43const jsFetchRedirect = "js.fetch:redirect"
44
45// jsFetchMissing will be true if the Fetch API is not present in
46// the browser globals.
47var jsFetchMissing = js.Global().Get("fetch").IsUndefined()
48
49// jsFetchDisabled controls whether the use of Fetch API is disabled.
50// It's set to true when we detect we're running in Node.js, so that
51// RoundTrip ends up talking over the same fake network the HTTP servers
52// currently use in various tests and examples. See go.dev/issue/57613.
53//
54// TODO(go.dev/issue/60810): See if it's viable to test the Fetch API
55// code path.
56var jsFetchDisabled = js.Global().Get("process").Type() == js.TypeObject &&
57	strings.HasPrefix(js.Global().Get("process").Get("argv0").String(), "node")
58
59// RoundTrip implements the [RoundTripper] interface using the WHATWG Fetch API.
60func (t *Transport) RoundTrip(req *Request) (*Response, error) {
61	// The Transport has a documented contract that states that if the DialContext or
62	// DialTLSContext functions are set, they will be used to set up the connections.
63	// If they aren't set then the documented contract is to use Dial or DialTLS, even
64	// though they are deprecated. Therefore, if any of these are set, we should obey
65	// the contract and dial using the regular round-trip instead. Otherwise, we'll try
66	// to fall back on the Fetch API, unless it's not available.
67	if t.Dial != nil || t.DialContext != nil || t.DialTLS != nil || t.DialTLSContext != nil || jsFetchMissing || jsFetchDisabled {
68		return t.roundTrip(req)
69	}
70
71	ac := js.Global().Get("AbortController")
72	if !ac.IsUndefined() {
73		// Some browsers that support WASM don't necessarily support
74		// the AbortController. See
75		// https://developer.mozilla.org/en-US/docs/Web/API/AbortController#Browser_compatibility.
76		ac = ac.New()
77	}
78
79	opt := js.Global().Get("Object").New()
80	// See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
81	// for options available.
82	opt.Set("method", req.Method)
83	opt.Set("credentials", "same-origin")
84	if h := req.Header.Get(jsFetchCreds); h != "" {
85		opt.Set("credentials", h)
86		req.Header.Del(jsFetchCreds)
87	}
88	if h := req.Header.Get(jsFetchMode); h != "" {
89		opt.Set("mode", h)
90		req.Header.Del(jsFetchMode)
91	}
92	if h := req.Header.Get(jsFetchRedirect); h != "" {
93		opt.Set("redirect", h)
94		req.Header.Del(jsFetchRedirect)
95	}
96	if !ac.IsUndefined() {
97		opt.Set("signal", ac.Get("signal"))
98	}
99	headers := js.Global().Get("Headers").New()
100	for key, values := range req.Header {
101		for _, value := range values {
102			headers.Call("append", key, value)
103		}
104	}
105	opt.Set("headers", headers)
106
107	if req.Body != nil {
108		// TODO(johanbrandhorst): Stream request body when possible.
109		// See https://bugs.chromium.org/p/chromium/issues/detail?id=688906 for Blink issue.
110		// See https://bugzilla.mozilla.org/show_bug.cgi?id=1387483 for Firefox issue.
111		// See https://github.com/web-platform-tests/wpt/issues/7693 for WHATWG tests issue.
112		// See https://developer.mozilla.org/en-US/docs/Web/API/Streams_API for more details on the Streams API
113		// and browser support.
114		// NOTE(haruyama480): Ensure HTTP/1 fallback exists.
115		// See https://go.dev/issue/61889 for discussion.
116		body, err := io.ReadAll(req.Body)
117		if err != nil {
118			req.Body.Close() // RoundTrip must always close the body, including on errors.
119			return nil, err
120		}
121		req.Body.Close()
122		if len(body) != 0 {
123			buf := uint8Array.New(len(body))
124			js.CopyBytesToJS(buf, body)
125			opt.Set("body", buf)
126		}
127	}
128
129	fetchPromise := js.Global().Call("fetch", req.URL.String(), opt)
130	var (
131		respCh           = make(chan *Response, 1)
132		errCh            = make(chan error, 1)
133		success, failure js.Func
134	)
135	success = js.FuncOf(func(this js.Value, args []js.Value) any {
136		success.Release()
137		failure.Release()
138
139		result := args[0]
140		header := Header{}
141		// https://developer.mozilla.org/en-US/docs/Web/API/Headers/entries
142		headersIt := result.Get("headers").Call("entries")
143		for {
144			n := headersIt.Call("next")
145			if n.Get("done").Bool() {
146				break
147			}
148			pair := n.Get("value")
149			key, value := pair.Index(0).String(), pair.Index(1).String()
150			ck := CanonicalHeaderKey(key)
151			header[ck] = append(header[ck], value)
152		}
153
154		contentLength := int64(0)
155		clHeader := header.Get("Content-Length")
156		switch {
157		case clHeader != "":
158			cl, err := strconv.ParseInt(clHeader, 10, 64)
159			if err != nil {
160				errCh <- fmt.Errorf("net/http: ill-formed Content-Length header: %v", err)
161				return nil
162			}
163			if cl < 0 {
164				// Content-Length values less than 0 are invalid.
165				// See: https://datatracker.ietf.org/doc/html/rfc2616/#section-14.13
166				errCh <- fmt.Errorf("net/http: invalid Content-Length header: %q", clHeader)
167				return nil
168			}
169			contentLength = cl
170		default:
171			// If the response length is not declared, set it to -1.
172			contentLength = -1
173		}
174
175		b := result.Get("body")
176		var body io.ReadCloser
177		// The body is undefined when the browser does not support streaming response bodies (Firefox),
178		// and null in certain error cases, i.e. when the request is blocked because of CORS settings.
179		if !b.IsUndefined() && !b.IsNull() {
180			body = &streamReader{stream: b.Call("getReader")}
181		} else {
182			// Fall back to using ArrayBuffer
183			// https://developer.mozilla.org/en-US/docs/Web/API/Body/arrayBuffer
184			body = &arrayReader{arrayPromise: result.Call("arrayBuffer")}
185		}
186
187		code := result.Get("status").Int()
188
189		uncompressed := false
190		if ascii.EqualFold(header.Get("Content-Encoding"), "gzip") {
191			// The fetch api will decode the gzip, but Content-Encoding not be deleted.
192			header.Del("Content-Encoding")
193			header.Del("Content-Length")
194			contentLength = -1
195			uncompressed = true
196		}
197
198		respCh <- &Response{
199			Status:        fmt.Sprintf("%d %s", code, StatusText(code)),
200			StatusCode:    code,
201			Header:        header,
202			ContentLength: contentLength,
203			Uncompressed:  uncompressed,
204			Body:          body,
205			Request:       req,
206		}
207
208		return nil
209	})
210	failure = js.FuncOf(func(this js.Value, args []js.Value) any {
211		success.Release()
212		failure.Release()
213
214		err := args[0]
215		// The error is a JS Error type
216		// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error
217		// We can use the toString() method to get a string representation of the error.
218		errMsg := err.Call("toString").String()
219		// Errors can optionally contain a cause.
220		if cause := err.Get("cause"); !cause.IsUndefined() {
221			// The exact type of the cause is not defined,
222			// but if it's another error, we can call toString() on it too.
223			if !cause.Get("toString").IsUndefined() {
224				errMsg += ": " + cause.Call("toString").String()
225			} else if cause.Type() == js.TypeString {
226				errMsg += ": " + cause.String()
227			}
228		}
229		errCh <- fmt.Errorf("net/http: fetch() failed: %s", errMsg)
230		return nil
231	})
232
233	fetchPromise.Call("then", success, failure)
234	select {
235	case <-req.Context().Done():
236		if !ac.IsUndefined() {
237			// Abort the Fetch request.
238			ac.Call("abort")
239		}
240		return nil, req.Context().Err()
241	case resp := <-respCh:
242		return resp, nil
243	case err := <-errCh:
244		return nil, err
245	}
246}
247
248var errClosed = errors.New("net/http: reader is closed")
249
250// streamReader implements an io.ReadCloser wrapper for ReadableStream.
251// See https://fetch.spec.whatwg.org/#readablestream for more information.
252type streamReader struct {
253	pending []byte
254	stream  js.Value
255	err     error // sticky read error
256}
257
258func (r *streamReader) Read(p []byte) (n int, err error) {
259	if r.err != nil {
260		return 0, r.err
261	}
262	if len(r.pending) == 0 {
263		var (
264			bCh   = make(chan []byte, 1)
265			errCh = make(chan error, 1)
266		)
267		success := js.FuncOf(func(this js.Value, args []js.Value) any {
268			result := args[0]
269			if result.Get("done").Bool() {
270				errCh <- io.EOF
271				return nil
272			}
273			value := make([]byte, result.Get("value").Get("byteLength").Int())
274			js.CopyBytesToGo(value, result.Get("value"))
275			bCh <- value
276			return nil
277		})
278		defer success.Release()
279		failure := js.FuncOf(func(this js.Value, args []js.Value) any {
280			// Assumes it's a TypeError. See
281			// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError
282			// for more information on this type. See
283			// https://streams.spec.whatwg.org/#byob-reader-read for the spec on
284			// the read method.
285			errCh <- errors.New(args[0].Get("message").String())
286			return nil
287		})
288		defer failure.Release()
289		r.stream.Call("read").Call("then", success, failure)
290		select {
291		case b := <-bCh:
292			r.pending = b
293		case err := <-errCh:
294			r.err = err
295			return 0, err
296		}
297	}
298	n = copy(p, r.pending)
299	r.pending = r.pending[n:]
300	return n, nil
301}
302
303func (r *streamReader) Close() error {
304	// This ignores any error returned from cancel method. So far, I did not encounter any concrete
305	// situation where reporting the error is meaningful. Most users ignore error from resp.Body.Close().
306	// If there's a need to report error here, it can be implemented and tested when that need comes up.
307	r.stream.Call("cancel")
308	if r.err == nil {
309		r.err = errClosed
310	}
311	return nil
312}
313
314// arrayReader implements an io.ReadCloser wrapper for ArrayBuffer.
315// https://developer.mozilla.org/en-US/docs/Web/API/Body/arrayBuffer.
316type arrayReader struct {
317	arrayPromise js.Value
318	pending      []byte
319	read         bool
320	err          error // sticky read error
321}
322
323func (r *arrayReader) Read(p []byte) (n int, err error) {
324	if r.err != nil {
325		return 0, r.err
326	}
327	if !r.read {
328		r.read = true
329		var (
330			bCh   = make(chan []byte, 1)
331			errCh = make(chan error, 1)
332		)
333		success := js.FuncOf(func(this js.Value, args []js.Value) any {
334			// Wrap the input ArrayBuffer with a Uint8Array
335			uint8arrayWrapper := uint8Array.New(args[0])
336			value := make([]byte, uint8arrayWrapper.Get("byteLength").Int())
337			js.CopyBytesToGo(value, uint8arrayWrapper)
338			bCh <- value
339			return nil
340		})
341		defer success.Release()
342		failure := js.FuncOf(func(this js.Value, args []js.Value) any {
343			// Assumes it's a TypeError. See
344			// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/TypeError
345			// for more information on this type.
346			// See https://fetch.spec.whatwg.org/#concept-body-consume-body for reasons this might error.
347			errCh <- errors.New(args[0].Get("message").String())
348			return nil
349		})
350		defer failure.Release()
351		r.arrayPromise.Call("then", success, failure)
352		select {
353		case b := <-bCh:
354			r.pending = b
355		case err := <-errCh:
356			return 0, err
357		}
358	}
359	if len(r.pending) == 0 {
360		return 0, io.EOF
361	}
362	n = copy(p, r.pending)
363	r.pending = r.pending[n:]
364	return n, nil
365}
366
367func (r *arrayReader) Close() error {
368	if r.err == nil {
369		r.err = errClosed
370	}
371	return nil
372}
373