/
t3
/
cli
Обзор
Документация
Войти
/
t3
/
cli
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
internal/engine/bridge/http_client.go
287 строк
8 KB
Ivan
metrics
09 июл 2026, 15:05
09 июл 2026, 15:05
c490f2e
Код
Авторство
О чём код?
package bridge import ( "crypto/tls" "encoding/json" "fmt" "io" "net/http" "net/http/cookiejar" "strings" "time" "github.com/dop251/goja" ) // HTTPClientConfig — конфигурация HTTP-клиента, создаваемого в t3.http.newClient() type HTTPClientConfig struct { BaseURL string Headers map[string]string InsecureSkipVerify bool TLSCert string // PEM-строка клиентского сертификата TLSKey string // PEM-строка клиентского ключа TimeoutMs int64 // таймаут запроса в мс (0 = без таймаута) } // httpClient — Go-обертка HTTP-клиента с cookie jar и замером времени type httpClient struct { client *http.Client baseURL string headers map[string]string } // httpResponse — Go-обертка HTTP-ответа с ленивым чтением тела type httpResponse struct { status int statusText string headers map[string]string body []byte timing responseTiming } type responseTiming struct { TTFB float64 // ms до первого байта заголовков Total float64 // ms до последнего байта тела } // FetchOptions — опции метода fetch() type FetchOptions struct { Method string Headers map[string]string Body string } // newHTTPClient создает http.Client с заданной конфигурацией func newHTTPClient(cfg *HTTPClientConfig, sharedTransport *http.Transport) (*httpClient, error) { var transport *http.Transport if cfg.TLSCert != "" && cfg.TLSKey != "" { // mTLS: клонируем транспорт и добавляем клиентский сертификат cert, err := tls.X509KeyPair([]byte(cfg.TLSCert), []byte(cfg.TLSKey)) if err != nil { return nil, fmt.Errorf("invalid TLS certificate/key pair: %w", err) } transport = sharedTransport.Clone() if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{} } transport.TLSClientConfig.Certificates = []tls.Certificate{cert} transport.TLSClientConfig.InsecureSkipVerify = cfg.InsecureSkipVerify } else if cfg.InsecureSkipVerify { transport = sharedTransport.Clone() if transport.TLSClientConfig == nil { transport.TLSClientConfig = &tls.Config{} } transport.TLSClientConfig.InsecureSkipVerify = true } else { transport = sharedTransport } // In-memory cookie jar — уникальный для каждого клиента jar, err := cookiejar.New(nil) if err != nil { return nil, fmt.Errorf("cannot create cookie jar: %w", err) } timeout := time.Duration(0) if cfg.TimeoutMs > 0 { timeout = time.Duration(cfg.TimeoutMs) * time.Millisecond } client := &http.Client{ Transport: transport, Jar: jar, Timeout: timeout, } return &httpClient{ client: client, baseURL: cfg.BaseURL, headers: cfg.Headers, }, nil } // fetch выполняет HTTP-запрос и возвращает httpResponse с замерами времени func (c *httpClient) fetch(urlOrPath string, opts *FetchOptions) (*httpResponse, error) { // Резолвим URL reqURL := resolveURL(c.baseURL, urlOrPath) // Определяем метод method := "GET" if opts != nil && opts.Method != "" { method = strings.ToUpper(opts.Method) } // Создаем тело запроса var bodyReader io.Reader if opts != nil && opts.Body != "" { bodyReader = strings.NewReader(opts.Body) } // Создаем request req, err := http.NewRequest(method, reqURL, bodyReader) if err != nil { return nil, fmt.Errorf("cannot create request: %w", err) } // Применяем общие заголовки клиента for k, v := range c.headers { req.Header.Set(k, v) } // Применяем заголовки запроса (перезаписывают общие) if opts != nil { for k, v := range opts.Headers { req.Header.Set(k, v) } } // Замеряем время start := time.Now() // Выполняем запрос resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() ttfb := time.Since(start).Seconds() * 1000 // Читаем тело полностью — это гарантирует, что resp.Body.Close() освободит ресурсы bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("cannot read response body: %w", err) } total := time.Since(start).Seconds() * 1000 // Собираем заголовки ответа respHeaders := make(map[string]string, len(resp.Header)) for k, v := range resp.Header { if len(v) > 0 { respHeaders[strings.ToLower(k)] = v[0] } } return &httpResponse{ status: resp.StatusCode, statusText: resp.Status, headers: respHeaders, body: bodyBytes, timing: responseTiming{ TTFB: ttfb, Total: total, }, }, nil } // resolveURL превращает относительный путь в абсолютный URL func resolveURL(baseURL, urlStr string) string { if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") { return urlStr } if baseURL == "" { return urlStr } base := strings.TrimRight(baseURL, "/") path := strings.TrimLeft(urlStr, "/") return base + "/" + path } // resolveStringArg принимает goja.Value (строку или File из S3) и возвращает строку. // Если передан File, вызывает .text() на нём. func resolveStringArg(vm *goja.Runtime, val goja.Value) (string, bool) { if val == nil || goja.IsUndefined(val) || goja.IsNull(val) { return "", false } // Если это строка — возвращаем как есть if goja.IsString(val) { return val.String(), true } // Если это объект с методом text() (File из S3) if obj, ok := val.(*goja.Object); ok { textVal := obj.Get("text") if textFn, ok := goja.AssertFunction(textVal); ok { result, err := textFn(obj) if err == nil && result != nil && !goja.IsUndefined(result) { return result.String(), true } } } return "", false } // parseFetchOptions разбирает goja.Value опций в FetchOptions func parseFetchOptions(vm *goja.Runtime, optsVal goja.Value) *FetchOptions { if optsVal == nil || goja.IsUndefined(optsVal) || goja.IsNull(optsVal) { return nil } optsObj := optsVal.ToObject(vm) opts := &FetchOptions{} if m := optsObj.Get("method"); m != nil && !goja.IsUndefined(m) { opts.Method = m.String() } if b := optsObj.Get("body"); b != nil && !goja.IsUndefined(b) { opts.Body = b.String() } if h := optsObj.Get("headers"); h != nil && !goja.IsUndefined(h) { headersObj := h.ToObject(vm) headerNames := headersObj.Keys() opts.Headers = make(map[string]string, len(headerNames)) for _, key := range headerNames { if v := headersObj.Get(key); v != nil && !goja.IsUndefined(v) { opts.Headers[key] = v.String() } } } return opts } // newResponseObject создаёт JS-объект HTTPResponse из httpResponse. // Тело ответа ВСЕГДА прочитано в Go-коде (в fetch()). // Даже если JS-сценарий не вызывает text()/json() — утечки нет, // resp.Body.Close() уже вызван через defer. func newResponseObject(vm *goja.Runtime, resp *httpResponse) *goja.Object { obj := vm.NewObject() obj.Set("status", resp.status) obj.Set("statusText", resp.statusText) obj.Set("headers", vm.ToValue(resp.headers)) // time — объект с замерами времени timingObj := vm.NewObject() timingObj.Set("ttfb", resp.timing.TTFB) timingObj.Set("total", resp.timing.Total) obj.Set("time", timingObj) // text() obj.Set("text", func(goja.FunctionCall) goja.Value { return vm.ToValue(string(resp.body)) }) // json() obj.Set("json", func(goja.FunctionCall) goja.Value { if resp.body == nil || len(resp.body) == 0 { return vm.ToValue(nil) } var data interface{} if err := json.Unmarshal(resp.body, &data); err != nil { panic(vm.ToValue("httpResponse.json(): " + err.Error())) } return vm.ToValue(data) }) // body как ArrayBuffer obj.Set("arrayBuffer", func(goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer(resp.body)) }) return obj }