File size: 7,052 Bytes
6a7089a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
package instance

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	"github.com/pinchtab/pinchtab/internal/bridge"
)

// BridgeClient makes HTTP calls to a bridge instance.
// Each method targets a specific bridge endpoint.
type BridgeClient struct {
	client *http.Client
}

// NewBridgeClient creates a BridgeClient.
func NewBridgeClient() *BridgeClient {
	return &BridgeClient{
		client: &http.Client{Timeout: 60 * time.Second},
	}
}

// FetchTabs implements TabFetcher by querying a bridge's /tabs endpoint.
func (bc *BridgeClient) FetchTabs(instanceURL string) ([]bridge.InstanceTab, error) {
	resp, err := bc.client.Get(instanceURL + "/tabs")
	if err != nil {
		return nil, fmt.Errorf("fetch tabs: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("fetch tabs: status %d", resp.StatusCode)
	}

	// Bridge returns {"tabs": [...]}
	var wrapper struct {
		Tabs []bridge.InstanceTab `json:"tabs"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
		return nil, fmt.Errorf("decode tabs: %w", err)
	}
	return wrapper.Tabs, nil
}

// CreateTab creates a new tab on a bridge instance. Returns the tab ID.
func (bc *BridgeClient) CreateTab(ctx context.Context, port, url string) (string, error) {
	// Create blank tab first to avoid waitFor issues
	body := `{"action":"new","url":"about:blank"}`
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, bridgeURL(port, "/tab"), strings.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("create tab request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := bc.client.Do(req)
	if err != nil {
		return "", fmt.Errorf("create tab: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(resp.Body)
		return "", fmt.Errorf("create tab: status %d: %s", resp.StatusCode, respBody)
	}

	var result struct {
		TabID string `json:"tabId"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return "", fmt.Errorf("decode create tab response: %w", err)
	}

	// If URL provided and not about:blank, navigate to it
	if url != "" && url != "about:blank" {
		if err := bc.NavigateTab(ctx, port, result.TabID, url); err != nil {
			return "", fmt.Errorf("navigate after create: %w", err)
		}
	}

	return result.TabID, nil
}

// NavigateTab navigates an existing tab to a URL
func (bc *BridgeClient) NavigateTab(ctx context.Context, port, tabID, url string) error {
	body := fmt.Sprintf(`{"url":%q,"waitFor":"dom"}`, url)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, bridgeURL(port, "/tabs/"+tabID+"/navigate"), strings.NewReader(body))
	if err != nil {
		return fmt.Errorf("navigate request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := bc.client.Do(req)
	if err != nil {
		return fmt.Errorf("navigate: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		respBody, _ := io.ReadAll(resp.Body)
		return fmt.Errorf("navigate: status %d: %s", resp.StatusCode, respBody)
	}

	return nil
}

// CloseTab closes a tab on a bridge instance.
func (bc *BridgeClient) CloseTab(ctx context.Context, port, tabID string) error {
	body := fmt.Sprintf(`{"action":"close","tabId":%q}`, tabID)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, bridgeURL(port, "/tab"), strings.NewReader(body))
	if err != nil {
		return fmt.Errorf("close tab request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := bc.client.Do(req)
	if err != nil {
		return fmt.Errorf("close tab: %w", err)
	}
	defer func() { _ = resp.Body.Close() }()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("close tab: status %d", resp.StatusCode)
	}
	return nil
}

// SnapshotTab calls GET /tabs/{tabID}/snapshot on the bridge to populate
// the snapshot cache. The response body is discarded.
func (bc *BridgeClient) SnapshotTab(ctx context.Context, port, tabID string) {
	url := bridgeURL(port, "/tabs/"+tabID+"/snapshot")
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return
	}
	resp, err := bc.client.Do(req)
	if err != nil {
		return
	}
	_ = resp.Body.Close()
}

// ProxyWithTabID proxies a request to a bridge shorthand endpoint (e.g. /find),
// injecting the tabId into the JSON request body so the bridge knows which tab
// to operate on. Used for endpoints that don't support /tabs/{id}/... paths.
func (bc *BridgeClient) ProxyWithTabID(w http.ResponseWriter, r *http.Request, port, tabID, path string) {
	// Read original body and inject tabId.
	var body map[string]any
	if r.Body != nil {
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			body = map[string]any{}
		}
	} else {
		body = map[string]any{}
	}
	body["tabId"] = tabID

	encoded, err := json.Marshal(body)
	if err != nil {
		http.Error(w, fmt.Sprintf("encode body: %s", err), http.StatusInternalServerError)
		return
	}

	targetURL := bridgeURL(port, path)
	proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, strings.NewReader(string(encoded)))
	if err != nil {
		http.Error(w, fmt.Sprintf("proxy request: %s", err), http.StatusInternalServerError)
		return
	}
	proxyReq.Header.Set("Content-Type", "application/json")

	resp, err := bc.client.Do(proxyReq)
	if err != nil {
		http.Error(w, fmt.Sprintf("proxy failed: %s", err), http.StatusBadGateway)
		return
	}
	defer func() { _ = resp.Body.Close() }()

	for key, values := range resp.Header {
		for _, v := range values {
			w.Header().Add(key, v)
		}
	}
	w.WriteHeader(resp.StatusCode)
	_, _ = io.Copy(w, resp.Body)
}

// ProxyToTab forwards an HTTP request to a specific bridge tab endpoint.
// It builds the URL as http://localhost:{port}/tabs/{tabID}/{suffix} and
// copies the request method, body, and headers.
func (bc *BridgeClient) ProxyToTab(w http.ResponseWriter, r *http.Request, port, tabID, suffix string) {
	targetURL := bridgeURL(port, "/tabs/"+tabID+suffix)
	if r.URL.RawQuery != "" {
		targetURL += "?" + r.URL.RawQuery
	}

	proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body)
	if err != nil {
		http.Error(w, fmt.Sprintf("proxy request: %s", err), http.StatusInternalServerError)
		return
	}

	for key, values := range r.Header {
		switch key {
		case "Host", "Connection", "Keep-Alive", "Proxy-Authenticate",
			"Proxy-Authorization", "Te", "Trailers", "Transfer-Encoding", "Upgrade":
		default:
			for _, v := range values {
				proxyReq.Header.Add(key, v)
			}
		}
	}

	resp, err := bc.client.Do(proxyReq)
	if err != nil {
		http.Error(w, fmt.Sprintf("proxy failed: %s", err), http.StatusBadGateway)
		return
	}
	defer func() { _ = resp.Body.Close() }()

	for key, values := range resp.Header {
		for _, v := range values {
			w.Header().Add(key, v)
		}
	}
	w.WriteHeader(resp.StatusCode)
	_, _ = io.Copy(w, resp.Body)
}

func bridgeURL(port, path string) string {
	return "http://localhost:" + port + path
}