From ef994b4d6189ee0cdb3ad6e002816e0d77c7db48 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 11:51:38 -0400 Subject: [PATCH 01/11] feat: add browser-scoped session client Bind browser subresource calls to a browser session's base_url and expose raw HTTP through a standard http.Client so metro-routed access feels like normal Go networking. Made-with: Cursor --- browser_session.go | 126 ++++++++++++++++ browser_session_httpclient_test.go | 57 +++++++ browser_session_services.go | 223 ++++++++++++++++++++++++++++ browser_session_test.go | 80 ++++++++++ lib/browserscope/jwt.go | 25 ++++ lib/browserscope/jwt_test.go | 21 +++ lib/browserscope/middleware.go | 41 +++++ lib/browserscope/middleware_test.go | 57 +++++++ lib/browserscope/rawcurl.go | 63 ++++++++ lib/browserscope/rawcurl_test.go | 54 +++++++ lib/browserscope/ref.go | 43 ++++++ 11 files changed, 790 insertions(+) create mode 100644 browser_session.go create mode 100644 browser_session_httpclient_test.go create mode 100644 browser_session_services.go create mode 100644 browser_session_test.go create mode 100644 lib/browserscope/jwt.go create mode 100644 lib/browserscope/jwt_test.go create mode 100644 lib/browserscope/middleware.go create mode 100644 lib/browserscope/middleware_test.go create mode 100644 lib/browserscope/rawcurl.go create mode 100644 lib/browserscope/rawcurl_test.go create mode 100644 lib/browserscope/ref.go diff --git a/browser_session.go b/browser_session.go new file mode 100644 index 0000000..5ec2409 --- /dev/null +++ b/browser_session.go @@ -0,0 +1,126 @@ +package kernel + +import ( + "fmt" + "net/http" + "slices" + + "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +// BrowserSessionClient is a metro-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string + + Replays BrowserSessionReplayService + Fs BrowserSessionFService + Process BrowserSessionProcessService + Logs BrowserSessionLogService + Computer BrowserSessionComputerService + Playwright BrowserSessionPlaywrightService +} + +// SessionID returns the control-plane browser session id. +func (b *BrowserSessionClient) SessionID() string { return b.sessionID } + +// HTTPClient returns an [http.Client] that performs egress HTTP through the +// browser's Chrome network stack via the metro kernel /curl/raw proxy. Each +// request must use an absolute http(s) URL; it is not rewritten to expose +// /curl/raw in the public API. +func (b *BrowserSessionClient) HTTPClient() *http.Client { + cfg, err := requestconfig.PreRequestOptions(b.opts...) + if err != nil { + return &http.Client{ + Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, nil), + } + } + underlying := cfg.HTTPClient + if underlying == nil { + underlying = http.DefaultClient + } + rt := underlying.Transport + if rt == nil { + rt = http.DefaultTransport + } + return &http.Client{ + Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, rt), + Timeout: underlying.Timeout, + } +} + +// ForBrowser returns a [BrowserSessionClient] for the given browser value. +// Supported types are [browserscope.Ref], [*BrowserNewResponse], [*BrowserGetResponse], +// [*BrowserListResponse], and [*BrowserUpdateResponse]. +func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessionClient, error) { + ref, err := browserSessionRefFrom(v) + if err != nil { + return nil, err + } + norm, err := ref.Normalize() + if err != nil { + return nil, err + } + + scoped := slices.Concat( + c.Options, + []option.RequestOption{ + option.WithBaseURL(norm.BaseURL), + option.WithMiddleware(browserscope.MetroKernelMiddleware(norm.SessionID, norm.JWT)), + }, + opts, + ) + + innerFs := NewBrowserFService(scoped...) + return &BrowserSessionClient{ + sessionID: norm.SessionID, + opts: scoped, + kernelBase: norm.BaseURL, + jwt: norm.JWT, + Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: norm.SessionID}, + Fs: BrowserSessionFService{ + inner: innerFs, + id: norm.SessionID, + Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: norm.SessionID}, + }, + Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: norm.SessionID}, + Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: norm.SessionID}, + Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: norm.SessionID}, + Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: norm.SessionID}, + }, nil +} + +func browserSessionRefFrom(v any) (browserscope.Ref, error) { + switch t := v.(type) { + case browserscope.Ref: + return t, nil + case *BrowserNewResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserNewResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserGetResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserGetResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserListResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserListResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserUpdateResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserUpdateResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + default: + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: unsupported type %T", v) + } +} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go new file mode 100644 index 0000000..99fb422 --- /dev/null +++ b/browser_session_httpclient_test.go @@ -0,0 +1,57 @@ +package kernel + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/kernel-go-sdk/option" +) + +func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { + var sawRaw string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/browser/kernel/curl/raw" { + http.NotFound(w, r) + return + } + sawRaw = r.URL.RawQuery + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("proxied")) + })) + defer srv.Close() + + c := NewClient( + option.WithBaseURL("https://api.example/"), + option.WithAPIKey("sk"), + option.WithHTTPClient(srv.Client()), + ) + + sess, err := c.ForBrowser(&BrowserGetResponse{ + SessionID: "sid", + BaseURL: srv.URL + "/browser/kernel", + CdpWsURL: "wss://x/browser/cdp?jwt=j1", + }) + if err != nil { + t.Fatal(err) + } + + hc := sess.HTTPClient() + req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/get", nil) + if err != nil { + t.Fatal(err) + } + res, err := hc.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + if string(body) != "proxied" { + t.Fatalf("body %q", body) + } + if sawRaw == "" { + t.Fatal("expected raw query on curl/raw") + } +} diff --git a/browser_session_services.go b/browser_session_services.go new file mode 100644 index 0000000..814a2b5 --- /dev/null +++ b/browser_session_services.go @@ -0,0 +1,223 @@ +package kernel + +import ( + "context" + "io" + "net/http" + + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" +) + +// BrowserSessionProcessService exposes process APIs without passing browser id. +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} + +func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (*BrowserProcessExecResponse, error) { + return s.inner.Exec(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (*BrowserProcessKillResponse, error) { + params.ID = s.id + return s.inner.Kill(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (*BrowserProcessResizeResponse, error) { + params.ID = s.id + return s.inner.Resize(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (*BrowserProcessSpawnResponse, error) { + return s.inner.Spawn(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (*BrowserProcessStatusResponse, error) { + query.ID = s.id + return s.inner.Status(ctx, processID, query, opts...) +} + +func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (*BrowserProcessStdinResponse, error) { + params.ID = s.id + return s.inner.Stdin(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[BrowserProcessStdoutStreamResponse] { + query.ID = s.id + return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) +} + +// BrowserSessionComputerService exposes computer APIs without passing browser id. +type BrowserSessionComputerService struct { + inner BrowserComputerService + id string +} + +func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) error { + return s.inner.Batch(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) error { + return s.inner.ClickMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) error { + return s.inner.DragMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerGetMousePositionResponse, error) { + return s.inner.GetMousePosition(ctx, s.id, opts...) +} + +func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) error { + return s.inner.MoveMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) error { + return s.inner.PressKey(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerReadClipboardResponse, error) { + return s.inner.ReadClipboard(ctx, s.id, opts...) +} + +func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) error { + return s.inner.Scroll(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (*BrowserComputerSetCursorVisibilityResponse, error) { + return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) error { + return s.inner.TypeText(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) error { + return s.inner.WriteClipboard(ctx, s.id, body, opts...) +} + +// BrowserSessionFService exposes filesystem APIs without passing browser id. +type BrowserSessionFService struct { + inner BrowserFService + id string + Watch BrowserSessionFWatchService +} + +func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) error { + return s.inner.NewDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error { + return s.inner.DeleteDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) error { + return s.inner.DeleteFile(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.DownloadDirZip(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (*BrowserFFileInfoResponse, error) { + return s.inner.FileInfo(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (*[]BrowserFListFilesResponse, error) { + return s.inner.ListFiles(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) error { + return s.inner.Move(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.ReadFile(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error { + return s.inner.SetFilePermissions(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) error { + return s.inner.Upload(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) error { + return s.inner.UploadZip(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) error { + return s.inner.WriteFile(ctx, s.id, contents, params, opts...) +} + +// BrowserSessionFWatchService exposes fs watch APIs without passing browser id. +type BrowserSessionFWatchService struct { + inner BrowserFWatchService + id string +} + +func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) *ssestream.Stream[BrowserFWatchEventsResponse] { + query.ID = s.id + return s.inner.EventsStreaming(ctx, watchID, query, opts...) +} + +func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (*BrowserFWatchStartResponse, error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) error { + body.ID = s.id + return s.inner.Stop(ctx, watchID, body, opts...) +} + +// BrowserSessionReplayService exposes replay APIs without passing browser id. +type BrowserSessionReplayService struct { + inner BrowserReplayService + id string +} + +func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (*[]BrowserReplayListResponse, error) { + return s.inner.List(ctx, s.id, opts...) +} + +func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + query.ID = s.id + return s.inner.Download(ctx, replayID, query, opts...) +} + +func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (*BrowserReplayStartResponse, error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) error { + body.ID = s.id + return s.inner.Stop(ctx, replayID, body, opts...) +} + +// BrowserSessionLogService exposes log streaming without passing browser id. +type BrowserSessionLogService struct { + inner BrowserLogService + id string +} + +func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] { + return s.inner.StreamStreaming(ctx, s.id, query, opts...) +} + +// BrowserSessionPlaywrightService exposes Playwright execution without passing browser id. +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} + +func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (*BrowserPlaywrightExecuteResponse, error) { + return s.inner.Execute(ctx, s.id, body, opts...) +} diff --git a/browser_session_test.go b/browser_session_test.go new file mode 100644 index 0000000..0678017 --- /dev/null +++ b/browser_session_test.go @@ -0,0 +1,80 @@ +package kernel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestForBrowserRewritesToKernelPaths(t *testing.T) { + var gotPath string + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if r.URL.Query().Get("jwt") == "" { + http.Error(w, "jwt", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "duration_ms": 1, + "exit_code": 0, + "stderr_b64": "", + "stdout_b64": "", + }) + })) + defer srv.Close() + + c := NewClient( + option.WithBaseURL("https://api.example/"), + option.WithAPIKey("sk_test"), + option.WithHTTPClient(srv.Client()), + ) + + b := &BrowserGetResponse{ + SessionID: "sid-1", + BaseURL: srv.URL + "/browser/kernel", + CdpWsURL: "wss://x/browser/cdp?jwt=metro-jwt", + } + + sess, err := c.ForBrowser(b) + if err != nil { + t.Fatal(err) + } + if sess.SessionID() != "sid-1" { + t.Fatalf("session id: %s", sess.SessionID()) + } + + _, err = sess.Process.Exec(context.Background(), BrowserProcessExecParams{Command: "true"}) + if err != nil { + t.Fatal(err) + } + + if gotPath != "/browser/kernel/process/exec" { + t.Fatalf("path: got %q", gotPath) + } + if gotAuth != "" { + t.Fatalf("authorization should be empty, got %q", gotAuth) + } +} + +func TestForBrowserRefNormalize(t *testing.T) { + ref := browserscope.Ref{ + SessionID: "s", + BaseURL: "https://x/browser/kernel", + JWT: "direct", + } + norm, err := ref.Normalize() + if err != nil { + t.Fatal(err) + } + if norm.JWT != "direct" { + t.Fatalf("jwt: %q", norm.JWT) + } +} diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go new file mode 100644 index 0000000..6600231 --- /dev/null +++ b/lib/browserscope/jwt.go @@ -0,0 +1,25 @@ +package browserscope + +import ( + "fmt" + "net/url" + "strings" +) + +// JWTFromWebSocketURL extracts the session jwt query parameter from a Kernel +// metro websocket URL (for example cdp_ws_url or webdriver_ws_url). +func JWTFromWebSocketURL(wsURL string) (string, error) { + wsURL = strings.TrimSpace(wsURL) + if wsURL == "" { + return "", fmt.Errorf("browserscope: empty websocket url") + } + u, err := url.Parse(wsURL) + if err != nil { + return "", fmt.Errorf("browserscope: parse websocket url: %w", err) + } + jwt := u.Query().Get("jwt") + if jwt == "" { + return "", fmt.Errorf("browserscope: missing jwt query parameter") + } + return jwt, nil +} diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go new file mode 100644 index 0000000..2364373 --- /dev/null +++ b/lib/browserscope/jwt_test.go @@ -0,0 +1,21 @@ +package browserscope + +import "testing" + +func TestJWTFromWebSocketURL(t *testing.T) { + const u = "wss://metro.example/browser/cdp?jwt=abc123&foo=bar" + j, err := JWTFromWebSocketURL(u) + if err != nil { + t.Fatal(err) + } + if j != "abc123" { + t.Fatalf("jwt: got %q want abc123", j) + } +} + +func TestJWTFromWebSocketURLMissing(t *testing.T) { + _, err := JWTFromWebSocketURL("wss://metro.example/browser/cdp") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/lib/browserscope/middleware.go b/lib/browserscope/middleware.go new file mode 100644 index 0000000..cca3232 --- /dev/null +++ b/lib/browserscope/middleware.go @@ -0,0 +1,41 @@ +package browserscope + +import ( + "net/http" + "strings" + + "github.com/kernel/kernel-go-sdk/option" +) + +const kernelHTTPPrefix = "/browser/kernel" + +// MetroKernelMiddleware prepares requests for metro-api's /browser/kernel proxy: +// it strips the control-plane browsers/{session_id} path prefix, attaches jwt, +// and removes Authorization so the Kernel API key is not forwarded to the VM. +func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { + prefix := kernelHTTPPrefix + "/browsers/" + sessionID + "/" + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.Header.Del("Authorization") + + if jwt != "" { + q := req.URL.Query() + if q.Get("jwt") == "" { + q.Set("jwt", jwt) + req.URL.RawQuery = q.Encode() + } + } + + if sessionID != "" && strings.HasPrefix(req.URL.Path, prefix) { + rest := strings.TrimPrefix(req.URL.Path, prefix) + if rest == "" { + rest = "/" + } + if !strings.HasPrefix(rest, "/") { + rest = "/" + rest + } + req.URL.Path = kernelHTTPPrefix + rest + } + + return next(req) + } +} diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go new file mode 100644 index 0000000..a18a2f6 --- /dev/null +++ b/lib/browserscope/middleware_test.go @@ -0,0 +1,57 @@ +package browserscope + +import ( + "net/http" + "net/url" + "testing" + + "github.com/kernel/kernel-go-sdk/option" +) + +func TestMetroKernelMiddleware(t *testing.T) { + mw := MetroKernelMiddleware("sess1", "tok") + var final *http.Request + next := func(req *http.Request) (*http.Response, error) { + final = req + return nil, nil + } + + u, err := url.Parse("https://host/browser/kernel/browsers/sess1/process/exec?x=1") + if err != nil { + t.Fatal(err) + } + u.Path = "/browser/kernel/browsers/sess1/process/exec" + req := &http.Request{URL: u, Header: http.Header{"Authorization": {"Bearer sk"}}} + _, _ = mw(req, next) + + if final.Header.Get("Authorization") != "" { + t.Fatal("authorization should be stripped") + } + if final.URL.Query().Get("jwt") != "tok" { + t.Fatalf("jwt query: got %q", final.URL.Query().Get("jwt")) + } + if final.URL.Path != "/browser/kernel/process/exec" { + t.Fatalf("path rewrite: got %s", final.URL.Path) + } +} + +func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { + mw := MetroKernelMiddleware("sess1", "tok") + var final *http.Request + next := func(req *http.Request) (*http.Response, error) { + final = req + return nil, nil + } + + u, _ := url.Parse("https://host/browser/kernel/browsers/sess1/fs/list_files?jwt=already") + u.Path = "/browser/kernel/browsers/sess1/fs/list_files" + req := &http.Request{URL: u} + _, _ = mw(req, next) + if final.URL.Query().Get("jwt") != "already" { + t.Fatalf("jwt: got %q want already", final.URL.Query().Get("jwt")) + } +} + +func TestMetroKernelMiddlewareType(t *testing.T) { + var _ option.Middleware = MetroKernelMiddleware("a", "b") +} diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go new file mode 100644 index 0000000..32b9a28 --- /dev/null +++ b/lib/browserscope/rawcurl.go @@ -0,0 +1,63 @@ +package browserscope + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// RawCURLRoundTripper implements browser-egress HTTP by tunneling through the +// kernel-images /curl/raw endpoint on the metro kernel base URL. +type RawCURLRoundTripper struct { + kernelBaseURL string + jwt string + underlying http.RoundTripper +} + +// NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// to {kernelBase}/curl/raw?jwt=...&url=, preserving method, +// headers, and body. The caller's request URL must be an absolute http(s) URL. +func NewRawCURLRoundTripper(kernelBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { + if underlying == nil { + underlying = http.DefaultTransport + } + return &RawCURLRoundTripper{ + kernelBaseURL: strings.TrimRight(strings.TrimSpace(kernelBaseURL), "/"), + jwt: strings.TrimSpace(jwt), + underlying: underlying, + } +} + +// RoundTrip implements [http.RoundTripper]. +func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL == nil || !req.URL.IsAbs() { + return nil, fmt.Errorf("browserscope: raw curl requires an absolute request URL (got %q)", req.URL) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") + } + if t.kernelBaseURL == "" { + return nil, fmt.Errorf("browserscope: kernel base url is required") + } + if t.jwt == "" { + return nil, fmt.Errorf("browserscope: jwt is required for raw curl") + } + + target := req.URL.String() + proxyURL, err := url.Parse(t.kernelBaseURL + "/curl/raw") + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("jwt", t.jwt) + q.Set("url", target) + proxyURL.RawQuery = q.Encode() + + out := req.Clone(req.Context()) + out.URL = proxyURL + out.Host = proxyURL.Host + out.RequestURI = "" + + return t.underlying.RoundTrip(out) +} diff --git a/lib/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go new file mode 100644 index 0000000..42e66cd --- /dev/null +++ b/lib/browserscope/rawcurl_test.go @@ -0,0 +1,54 @@ +package browserscope + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRawCURLRoundTripper(t *testing.T) { + var sawPath, sawRaw string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawPath = r.URL.Path + sawRaw = r.URL.RawQuery + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("ok")) + })) + defer up.Close() + + rt := NewRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) + + client := &http.Client{Transport: rt} + req, err := http.NewRequest(http.MethodGet, "https://example.org/foo?bar=1", nil) + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusTeapot { + t.Fatalf("status: %d", res.StatusCode) + } + b, _ := io.ReadAll(res.Body) + if string(b) != "ok" { + t.Fatalf("body: %q", b) + } + if sawPath != "/browser/kernel/curl/raw" { + t.Fatalf("path: %s", sawPath) + } + if sawRaw == "" { + t.Fatal("expected query") + } +} + +func TestRawCURLRoundTripperRelativeURL(t *testing.T) { + rt := NewRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) + req, _ := http.NewRequest(http.MethodGet, "/relative", nil) + _, err := rt.RoundTrip(req) + if err == nil { + t.Fatal("expected error for relative url") + } +} diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go new file mode 100644 index 0000000..3a41027 --- /dev/null +++ b/lib/browserscope/ref.go @@ -0,0 +1,43 @@ +package browserscope + +import ( + "fmt" + "strings" +) + +// Ref identifies a browser session for metro-scoped HTTP calls. SessionID is +// reserved for future client-side routing; metro requests rewrite +// /browser/kernel/browsers/{SessionID}/ to /browser/kernel/. +type Ref struct { + SessionID string + BaseURL string + JWT string + CdpWsURL string +} + +// Normalize validates fields and fills JWT from CdpWsURL when JWT is empty. +func (r Ref) Normalize() (Ref, error) { + if strings.TrimSpace(r.BaseURL) == "" { + return Ref{}, fmt.Errorf("browserscope: base_url is required") + } + if strings.TrimSpace(r.SessionID) == "" { + return Ref{}, fmt.Errorf("browserscope: session_id is required") + } + out := r + out.BaseURL = strings.TrimSpace(r.BaseURL) + out.SessionID = strings.TrimSpace(r.SessionID) + out.JWT = strings.TrimSpace(r.JWT) + out.CdpWsURL = strings.TrimSpace(r.CdpWsURL) + if out.JWT == "" { + src := out.CdpWsURL + if src == "" { + return Ref{}, fmt.Errorf("browserscope: jwt or cdp_ws_url is required") + } + jwt, err := JWTFromWebSocketURL(src) + if err != nil { + return Ref{}, err + } + out.JWT = jwt + } + return out, nil +} From 64f7f811a2f3d7c657ad56a1f34a8bea0206e585 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:09:21 -0400 Subject: [PATCH 02/11] fix: align browser-scoped routing with base_url Use the browser session base_url directly for path rewriting, preserve custom HTTP clients in HTTPClient(), and add an env-gated integration test for browser-scoped routing. Made-with: Cursor --- browser_session.go | 6 +-- browser_session_test.go | 2 +- lib/browserscope/integration_test.go | 76 ++++++++++++++++++++++++++++ lib/browserscope/jwt.go | 4 +- lib/browserscope/jwt_test.go | 4 +- lib/browserscope/middleware.go | 28 +++++----- lib/browserscope/middleware_test.go | 12 ++--- lib/browserscope/rawcurl.go | 24 ++++----- lib/browserscope/ref.go | 6 +-- option/requestoption.go | 2 +- 10 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 lib/browserscope/integration_test.go diff --git a/browser_session.go b/browser_session.go index 5ec2409..d22eb1c 100644 --- a/browser_session.go +++ b/browser_session.go @@ -10,7 +10,7 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -// BrowserSessionClient is a metro-scoped view of a browser session. Subresources +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources // use the session base_url and do not repeat the browser id in method // signatures. SessionID is exposed for future routing extensions. type BrowserSessionClient struct { @@ -31,7 +31,7 @@ type BrowserSessionClient struct { func (b *BrowserSessionClient) SessionID() string { return b.sessionID } // HTTPClient returns an [http.Client] that performs egress HTTP through the -// browser's Chrome network stack via the metro kernel /curl/raw proxy. Each +// browser's Chrome network stack via the browser session /curl/raw proxy. Each // request must use an absolute http(s) URL; it is not rewritten to expose // /curl/raw in the public API. func (b *BrowserSessionClient) HTTPClient() *http.Client { @@ -72,7 +72,7 @@ func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessio c.Options, []option.RequestOption{ option.WithBaseURL(norm.BaseURL), - option.WithMiddleware(browserscope.MetroKernelMiddleware(norm.SessionID, norm.JWT)), + option.WithMiddleware(browserscope.BrowserSessionMiddleware(norm.SessionID, norm.JWT)), }, opts, ) diff --git a/browser_session_test.go b/browser_session_test.go index 0678017..5ca48d0 100644 --- a/browser_session_test.go +++ b/browser_session_test.go @@ -40,7 +40,7 @@ func TestForBrowserRewritesToKernelPaths(t *testing.T) { b := &BrowserGetResponse{ SessionID: "sid-1", BaseURL: srv.URL + "/browser/kernel", - CdpWsURL: "wss://x/browser/cdp?jwt=metro-jwt", + CdpWsURL: "wss://x/browser/cdp?jwt=session-jwt", } sess, err := c.ForBrowser(b) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go new file mode 100644 index 0000000..a1727a6 --- /dev/null +++ b/lib/browserscope/integration_test.go @@ -0,0 +1,76 @@ +package browserscope_test + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + "time" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestIntegrationBrowserSessionClient(t *testing.T) { + apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) + baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) + if apiKey == "" || baseURL == "" { + t.Skip("set KERNEL_API_KEY and KERNEL_BASE_URL to run integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := kernel.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + ) + + browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ + Headless: kernel.Bool(true), + TimeoutSeconds: kernel.Int(60), + }) + if err != nil { + t.Fatalf("create browser: %v", err) + } + t.Cleanup(func() { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + }) + + if browser.BaseURL == "" { + t.Fatal("expected browser base_url to be set") + } + if !strings.Contains(browser.BaseURL, "/browser/kernel") { + t.Fatalf("expected browser base_url to include /browser/kernel, got %q", browser.BaseURL) + } + + scoped, err := client.ForBrowser(browser) + if err != nil { + t.Fatalf("for browser: %v", err) + } + + execRes, err := scoped.Process.Exec(ctx, kernel.BrowserProcessExecParams{ + Command: "echo", + Args: []string{"hello"}, + }) + if err != nil { + t.Fatalf("process exec: %v", err) + } + if execRes.ExitCode != 0 { + t.Fatalf("expected process exit code 0, got %d", execRes.ExitCode) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := scoped.HTTPClient().Do(req) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + t.Fatalf("expected successful browser http response, got %d", resp.StatusCode) + } +} diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go index 6600231..dd023f8 100644 --- a/lib/browserscope/jwt.go +++ b/lib/browserscope/jwt.go @@ -6,8 +6,8 @@ import ( "strings" ) -// JWTFromWebSocketURL extracts the session jwt query parameter from a Kernel -// metro websocket URL (for example cdp_ws_url or webdriver_ws_url). +// JWTFromWebSocketURL extracts the session jwt query parameter from a browser +// websocket URL (for example cdp_ws_url or webdriver_ws_url). func JWTFromWebSocketURL(wsURL string) (string, error) { wsURL = strings.TrimSpace(wsURL) if wsURL == "" { diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go index 2364373..4798071 100644 --- a/lib/browserscope/jwt_test.go +++ b/lib/browserscope/jwt_test.go @@ -3,7 +3,7 @@ package browserscope import "testing" func TestJWTFromWebSocketURL(t *testing.T) { - const u = "wss://metro.example/browser/cdp?jwt=abc123&foo=bar" + const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" j, err := JWTFromWebSocketURL(u) if err != nil { t.Fatal(err) @@ -14,7 +14,7 @@ func TestJWTFromWebSocketURL(t *testing.T) { } func TestJWTFromWebSocketURLMissing(t *testing.T) { - _, err := JWTFromWebSocketURL("wss://metro.example/browser/cdp") + _, err := JWTFromWebSocketURL("wss://browser.example/browser/cdp") if err == nil { t.Fatal("expected error") } diff --git a/lib/browserscope/middleware.go b/lib/browserscope/middleware.go index cca3232..8f9f7bf 100644 --- a/lib/browserscope/middleware.go +++ b/lib/browserscope/middleware.go @@ -7,13 +7,11 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -const kernelHTTPPrefix = "/browser/kernel" - -// MetroKernelMiddleware prepares requests for metro-api's /browser/kernel proxy: +// BrowserSessionMiddleware prepares requests for a browser session base_url: // it strips the control-plane browsers/{session_id} path prefix, attaches jwt, -// and removes Authorization so the Kernel API key is not forwarded to the VM. -func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { - prefix := kernelHTTPPrefix + "/browsers/" + sessionID + "/" +// and removes Authorization so the Kernel API key is not forwarded to the browser. +func BrowserSessionMiddleware(sessionID, jwt string) option.Middleware { + marker := "/browsers/" + sessionID + "/" return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { req.Header.Del("Authorization") @@ -25,15 +23,17 @@ func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { } } - if sessionID != "" && strings.HasPrefix(req.URL.Path, prefix) { - rest := strings.TrimPrefix(req.URL.Path, prefix) - if rest == "" { - rest = "/" - } - if !strings.HasPrefix(rest, "/") { - rest = "/" + rest + if sessionID != "" { + if idx := strings.Index(req.URL.Path, marker); idx >= 0 { + prefix := strings.TrimRight(req.URL.Path[:idx], "/") + rest := strings.TrimPrefix(req.URL.Path[idx+len(marker):], "/") + if rest == "" { + rest = "/" + } else { + rest = "/" + rest + } + req.URL.Path = prefix + rest } - req.URL.Path = kernelHTTPPrefix + rest } return next(req) diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go index a18a2f6..afd1efe 100644 --- a/lib/browserscope/middleware_test.go +++ b/lib/browserscope/middleware_test.go @@ -8,8 +8,8 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -func TestMetroKernelMiddleware(t *testing.T) { - mw := MetroKernelMiddleware("sess1", "tok") +func TestBrowserSessionMiddleware(t *testing.T) { + mw := BrowserSessionMiddleware("sess1", "tok") var final *http.Request next := func(req *http.Request) (*http.Response, error) { final = req @@ -35,8 +35,8 @@ func TestMetroKernelMiddleware(t *testing.T) { } } -func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { - mw := MetroKernelMiddleware("sess1", "tok") +func TestBrowserSessionMiddlewarePreservesExistingJWT(t *testing.T) { + mw := BrowserSessionMiddleware("sess1", "tok") var final *http.Request next := func(req *http.Request) (*http.Response, error) { final = req @@ -52,6 +52,6 @@ func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { } } -func TestMetroKernelMiddlewareType(t *testing.T) { - var _ option.Middleware = MetroKernelMiddleware("a", "b") +func TestBrowserSessionMiddlewareType(t *testing.T) { + var _ option.Middleware = BrowserSessionMiddleware("a", "b") } diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 32b9a28..9370638 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -8,24 +8,24 @@ import ( ) // RawCURLRoundTripper implements browser-egress HTTP by tunneling through the -// kernel-images /curl/raw endpoint on the metro kernel base URL. +// browser session base_url /curl/raw endpoint. type RawCURLRoundTripper struct { - kernelBaseURL string - jwt string - underlying http.RoundTripper + browserBaseURL string + jwt string + underlying http.RoundTripper } // NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request -// to {kernelBase}/curl/raw?jwt=...&url=, preserving method, +// to {base_url}/curl/raw?jwt=...&url=, preserving method, // headers, and body. The caller's request URL must be an absolute http(s) URL. -func NewRawCURLRoundTripper(kernelBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { +func NewRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { if underlying == nil { underlying = http.DefaultTransport } return &RawCURLRoundTripper{ - kernelBaseURL: strings.TrimRight(strings.TrimSpace(kernelBaseURL), "/"), - jwt: strings.TrimSpace(jwt), - underlying: underlying, + browserBaseURL: strings.TrimRight(strings.TrimSpace(browserBaseURL), "/"), + jwt: strings.TrimSpace(jwt), + underlying: underlying, } } @@ -37,15 +37,15 @@ func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") } - if t.kernelBaseURL == "" { - return nil, fmt.Errorf("browserscope: kernel base url is required") + if t.browserBaseURL == "" { + return nil, fmt.Errorf("browserscope: browser base_url is required") } if t.jwt == "" { return nil, fmt.Errorf("browserscope: jwt is required for raw curl") } target := req.URL.String() - proxyURL, err := url.Parse(t.kernelBaseURL + "/curl/raw") + proxyURL, err := url.Parse(t.browserBaseURL + "/curl/raw") if err != nil { return nil, err } diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 3a41027..60c3586 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -5,9 +5,9 @@ import ( "strings" ) -// Ref identifies a browser session for metro-scoped HTTP calls. SessionID is -// reserved for future client-side routing; metro requests rewrite -// /browser/kernel/browsers/{SessionID}/ to /browser/kernel/. +// Ref identifies a browser session for browser-scoped HTTP calls. SessionID is +// reserved for future client-side routing; browser-scoped requests rewrite +// the /browsers/{SessionID}/ path segment against the returned base_url. type Ref struct { SessionID string BaseURL string diff --git a/option/requestoption.go b/option/requestoption.go index fd632db..82b0927 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -56,7 +56,7 @@ type HTTPClient interface { // For custom uses cases, it is recommended to provide an [*http.Client] with a custom // [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. func WithHTTPClient(client HTTPClient) RequestOption { - return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + return requestconfig.PreRequestOptionFunc(func(r *requestconfig.RequestConfig) error { if client == nil { return fmt.Errorf("requestoption: custom http client cannot be nil") } From 3e3e33f031d8460662da8a368467a3dcc6a0ec3f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:33:33 -0400 Subject: [PATCH 03/11] fix: tighten browser-scoped helper surface Avoid depending on base_url path details in the integration test, keep the JWT helper package-private, and make round-tripper conformance explicit while preserving browser-scoped routing behavior. Made-with: Cursor --- lib/browserscope/integration_test.go | 14 +++++++++++--- lib/browserscope/jwt.go | 4 ++-- lib/browserscope/jwt_test.go | 4 ++-- lib/browserscope/rawcurl.go | 2 ++ lib/browserscope/ref.go | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index a1727a6..6feef68 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -2,6 +2,7 @@ package browserscope_test import ( "context" + "io" "net/http" "os" "strings" @@ -41,9 +42,6 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { if browser.BaseURL == "" { t.Fatal("expected browser base_url to be set") } - if !strings.Contains(browser.BaseURL, "/browser/kernel") { - t.Fatalf("expected browser base_url to include /browser/kernel, got %q", browser.BaseURL) - } scoped, err := client.ForBrowser(browser) if err != nil { @@ -73,4 +71,14 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { if resp.StatusCode < 200 || resp.StatusCode >= 400 { t.Fatalf("expected successful browser http response, got %d", resp.StatusCode) } + if got := resp.Header.Get("Content-Type"); got == "" { + t.Fatal("expected raw browser response to include content type") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read raw browser response: %v", err) + } + if len(body) == 0 { + t.Fatal("expected raw browser response body to be non-empty") + } } diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go index dd023f8..5e1f857 100644 --- a/lib/browserscope/jwt.go +++ b/lib/browserscope/jwt.go @@ -6,9 +6,9 @@ import ( "strings" ) -// JWTFromWebSocketURL extracts the session jwt query parameter from a browser +// jwtFromWebSocketURL extracts the session jwt query parameter from a browser // websocket URL (for example cdp_ws_url or webdriver_ws_url). -func JWTFromWebSocketURL(wsURL string) (string, error) { +func jwtFromWebSocketURL(wsURL string) (string, error) { wsURL = strings.TrimSpace(wsURL) if wsURL == "" { return "", fmt.Errorf("browserscope: empty websocket url") diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go index 4798071..220ce77 100644 --- a/lib/browserscope/jwt_test.go +++ b/lib/browserscope/jwt_test.go @@ -4,7 +4,7 @@ import "testing" func TestJWTFromWebSocketURL(t *testing.T) { const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" - j, err := JWTFromWebSocketURL(u) + j, err := jwtFromWebSocketURL(u) if err != nil { t.Fatal(err) } @@ -14,7 +14,7 @@ func TestJWTFromWebSocketURL(t *testing.T) { } func TestJWTFromWebSocketURLMissing(t *testing.T) { - _, err := JWTFromWebSocketURL("wss://browser.example/browser/cdp") + _, err := jwtFromWebSocketURL("wss://browser.example/browser/cdp") if err == nil { t.Fatal("expected error") } diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 9370638..25e37fe 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -15,6 +15,8 @@ type RawCURLRoundTripper struct { underlying http.RoundTripper } +var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) + // NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request // to {base_url}/curl/raw?jwt=...&url=, preserving method, // headers, and body. The caller's request URL must be an absolute http(s) URL. diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 60c3586..603a94b 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -33,7 +33,7 @@ func (r Ref) Normalize() (Ref, error) { if src == "" { return Ref{}, fmt.Errorf("browserscope: jwt or cdp_ws_url is required") } - jwt, err := JWTFromWebSocketURL(src) + jwt, err := jwtFromWebSocketURL(src) if err != nil { return Ref{}, err } From 0ac61ef0532e6682eed740765975775767e28a10 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 15:58:01 -0400 Subject: [PATCH 04/11] refactor: narrow browser-scoped helper exports Keep the raw round-tripper constructor package-private, remove defensive middleware branches that imply unsupported empty inputs, and retain the browser-scoped integration coverage without baking in base_url path details. Made-with: Cursor --- browser_session.go | 17 ++--------------- lib/browserscope/rawcurl.go | 20 ++++++++++++++++++-- lib/browserscope/rawcurl_test.go | 4 ++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/browser_session.go b/browser_session.go index d22eb1c..e8bf476 100644 --- a/browser_session.go +++ b/browser_session.go @@ -37,22 +37,9 @@ func (b *BrowserSessionClient) SessionID() string { return b.sessionID } func (b *BrowserSessionClient) HTTPClient() *http.Client { cfg, err := requestconfig.PreRequestOptions(b.opts...) if err != nil { - return &http.Client{ - Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, nil), - } - } - underlying := cfg.HTTPClient - if underlying == nil { - underlying = http.DefaultClient - } - rt := underlying.Transport - if rt == nil { - rt = http.DefaultTransport - } - return &http.Client{ - Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, rt), - Timeout: underlying.Timeout, + return browserscope.HTTPClient(b.kernelBase, b.jwt, nil) } + return browserscope.HTTPClient(b.kernelBase, b.jwt, cfg.HTTPClient) } // ForBrowser returns a [BrowserSessionClient] for the given browser value. diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 25e37fe..a53fc04 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -17,10 +17,26 @@ type RawCURLRoundTripper struct { var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) -// NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// HTTPClient returns an [http.Client] that performs browser egress HTTP via the +// browser session base_url and internal /curl/raw path. +func HTTPClient(browserBaseURL, jwt string, underlying *http.Client) *http.Client { + if underlying == nil { + underlying = http.DefaultClient + } + rt := underlying.Transport + if rt == nil { + rt = http.DefaultTransport + } + return &http.Client{ + Transport: newRawCURLRoundTripper(browserBaseURL, jwt, rt), + Timeout: underlying.Timeout, + } +} + +// newRawCURLRoundTripper returns an [http.RoundTripper] that maps each request // to {base_url}/curl/raw?jwt=...&url=, preserving method, // headers, and body. The caller's request URL must be an absolute http(s) URL. -func NewRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { +func newRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { if underlying == nil { underlying = http.DefaultTransport } diff --git a/lib/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go index 42e66cd..f39a622 100644 --- a/lib/browserscope/rawcurl_test.go +++ b/lib/browserscope/rawcurl_test.go @@ -17,7 +17,7 @@ func TestRawCURLRoundTripper(t *testing.T) { })) defer up.Close() - rt := NewRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) + rt := newRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) client := &http.Client{Transport: rt} req, err := http.NewRequest(http.MethodGet, "https://example.org/foo?bar=1", nil) @@ -45,7 +45,7 @@ func TestRawCURLRoundTripper(t *testing.T) { } func TestRawCURLRoundTripperRelativeURL(t *testing.T) { - rt := NewRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) + rt := newRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) req, _ := http.NewRequest(http.MethodGet, "/relative", nil) _, err := rt.RoundTrip(req) if err == nil { From b6a77bc656e6480e1bee0932fcc3b5beb40bcb10 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:27:02 -0400 Subject: [PATCH 05/11] feat: generate browser-scoped service bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the handwritten browser-scoped Go service façade with deterministic generated bindings derived from the generated browser service graph, and enforce regeneration in lint. Made-with: Cursor --- browser_session.go | 37 +- ...ices.go => browser_session_services_gen.go | 228 ++++--- go.mod | 5 +- go.sum | 8 + .../generate_test.go | 63 ++ internal/genbrowsersessionservices/main.go | 623 ++++++++++++++++++ scripts/generate-browser-session | 7 + scripts/lint | 6 + 8 files changed, 847 insertions(+), 130 deletions(-) rename browser_session_services.go => browser_session_services_gen.go (62%) create mode 100644 internal/genbrowsersessionservices/generate_test.go create mode 100644 internal/genbrowsersessionservices/main.go create mode 100755 scripts/generate-browser-session diff --git a/browser_session.go b/browser_session.go index e8bf476..c541f01 100644 --- a/browser_session.go +++ b/browser_session.go @@ -1,5 +1,7 @@ package kernel +//go:generate go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go + import ( "fmt" "net/http" @@ -10,23 +12,6 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string - - Replays BrowserSessionReplayService - Fs BrowserSessionFService - Process BrowserSessionProcessService - Logs BrowserSessionLogService - Computer BrowserSessionComputerService - Playwright BrowserSessionPlaywrightService -} - // SessionID returns the control-plane browser session id. func (b *BrowserSessionClient) SessionID() string { return b.sessionID } @@ -64,23 +49,7 @@ func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessio opts, ) - innerFs := NewBrowserFService(scoped...) - return &BrowserSessionClient{ - sessionID: norm.SessionID, - opts: scoped, - kernelBase: norm.BaseURL, - jwt: norm.JWT, - Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: norm.SessionID}, - Fs: BrowserSessionFService{ - inner: innerFs, - id: norm.SessionID, - Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: norm.SessionID}, - }, - Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: norm.SessionID}, - Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: norm.SessionID}, - Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: norm.SessionID}, - Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: norm.SessionID}, - }, nil + return newBrowserSessionClient(norm.SessionID, norm.BaseURL, norm.JWT, scoped), nil } func browserSessionRefFrom(v any) (browserscope.Ref, error) { diff --git a/browser_session_services.go b/browser_session_services_gen.go similarity index 62% rename from browser_session_services.go rename to browser_session_services_gen.go index 814a2b5..be7053b 100644 --- a/browser_session_services.go +++ b/browser_session_services_gen.go @@ -1,3 +1,5 @@ +// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. + package kernel import ( @@ -10,214 +12,250 @@ import ( "github.com/kernel/kernel-go-sdk/shared" ) -// BrowserSessionProcessService exposes process APIs without passing browser id. -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} - -func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (*BrowserProcessExecResponse, error) { - return s.inner.Exec(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (*BrowserProcessKillResponse, error) { - params.ID = s.id - return s.inner.Kill(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (*BrowserProcessResizeResponse, error) { - params.ID = s.id - return s.inner.Resize(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (*BrowserProcessSpawnResponse, error) { - return s.inner.Spawn(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (*BrowserProcessStatusResponse, error) { - query.ID = s.id - return s.inner.Status(ctx, processID, query, opts...) -} - -func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (*BrowserProcessStdinResponse, error) { - params.ID = s.id - return s.inner.Stdin(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[BrowserProcessStdoutStreamResponse] { - query.ID = s.id - return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) -} - -// BrowserSessionComputerService exposes computer APIs without passing browser id. +// BrowserSessionComputerService exposes browser session APIs without passing browser id. type BrowserSessionComputerService struct { inner BrowserComputerService id string } -func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) (err error) { return s.inner.Batch(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) { return s.inner.ClickMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) { return s.inner.DragMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerGetMousePositionResponse, error) { +func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerGetMousePositionResponse, err error) { return s.inner.GetMousePosition(ctx, s.id, opts...) } -func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) { return s.inner.MoveMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) { return s.inner.PressKey(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerReadClipboardResponse, error) { +func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerReadClipboardResponse, err error) { return s.inner.ReadClipboard(ctx, s.id, opts...) } -func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) (err error) { return s.inner.Scroll(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (*BrowserComputerSetCursorVisibilityResponse, error) { +func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *BrowserComputerSetCursorVisibilityResponse, err error) { return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) { return s.inner.TypeText(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) { return s.inner.WriteClipboard(ctx, s.id, body, opts...) } -// BrowserSessionFService exposes filesystem APIs without passing browser id. +// BrowserSessionFService exposes browser session APIs without passing browser id. type BrowserSessionFService struct { inner BrowserFService id string Watch BrowserSessionFWatchService } -func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) error { - return s.inner.NewDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) { return s.inner.DeleteDirectory(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) { return s.inner.DeleteFile(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.DownloadDirZip(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (*BrowserFFileInfoResponse, error) { +func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (res *BrowserFFileInfoResponse, err error) { return s.inner.FileInfo(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (*[]BrowserFListFilesResponse, error) { +func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (res *[]BrowserFListFilesResponse, err error) { return s.inner.ListFiles(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) (err error) { return s.inner.Move(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) { + return s.inner.NewDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.ReadFile(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) { return s.inner.SetFilePermissions(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) (err error) { return s.inner.Upload(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) (err error) { return s.inner.UploadZip(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) (err error) { return s.inner.WriteFile(ctx, s.id, contents, params, opts...) } -// BrowserSessionFWatchService exposes fs watch APIs without passing browser id. +// BrowserSessionFWatchService exposes browser session APIs without passing browser id. type BrowserSessionFWatchService struct { inner BrowserFWatchService id string } -func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) *ssestream.Stream[BrowserFWatchEventsResponse] { +func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserFWatchEventsResponse]) { query.ID = s.id return s.inner.EventsStreaming(ctx, watchID, query, opts...) } -func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (*BrowserFWatchStartResponse, error) { +func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (res *BrowserFWatchStartResponse, err error) { return s.inner.Start(ctx, s.id, body, opts...) } -func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) error { +func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) (err error) { body.ID = s.id return s.inner.Stop(ctx, watchID, body, opts...) } -// BrowserSessionReplayService exposes replay APIs without passing browser id. -type BrowserSessionReplayService struct { - inner BrowserReplayService +// BrowserSessionLogService exposes browser session APIs without passing browser id. +type BrowserSessionLogService struct { + inner BrowserLogService id string } -func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (*[]BrowserReplayListResponse, error) { - return s.inner.List(ctx, s.id, opts...) +func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) { + return s.inner.StreamStreaming(ctx, s.id, query, opts...) } -func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { +// BrowserSessionPlaywrightService exposes browser session APIs without passing browser id. +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} + +func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *BrowserPlaywrightExecuteResponse, err error) { + return s.inner.Execute(ctx, s.id, body, opts...) +} + +// BrowserSessionProcessService exposes browser session APIs without passing browser id. +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} + +func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (res *BrowserProcessExecResponse, err error) { + return s.inner.Exec(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (res *BrowserProcessKillResponse, err error) { + params.ID = s.id + return s.inner.Kill(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (res *BrowserProcessResizeResponse, err error) { + params.ID = s.id + return s.inner.Resize(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (res *BrowserProcessSpawnResponse, err error) { + return s.inner.Spawn(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (res *BrowserProcessStatusResponse, err error) { query.ID = s.id - return s.inner.Download(ctx, replayID, query, opts...) + return s.inner.Status(ctx, processID, query, opts...) } -func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (*BrowserReplayStartResponse, error) { - return s.inner.Start(ctx, s.id, body, opts...) +func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (res *BrowserProcessStdinResponse, err error) { + params.ID = s.id + return s.inner.Stdin(ctx, processID, params, opts...) } -func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) error { - body.ID = s.id - return s.inner.Stop(ctx, replayID, body, opts...) +func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserProcessStdoutStreamResponse]) { + query.ID = s.id + return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) } -// BrowserSessionLogService exposes log streaming without passing browser id. -type BrowserSessionLogService struct { - inner BrowserLogService +// BrowserSessionReplayService exposes browser session APIs without passing browser id. +type BrowserSessionReplayService struct { + inner BrowserReplayService id string } -func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] { - return s.inner.StreamStreaming(ctx, s.id, query, opts...) +func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) { + query.ID = s.id + return s.inner.Download(ctx, replayID, query, opts...) } -// BrowserSessionPlaywrightService exposes Playwright execution without passing browser id. -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string +func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (res *[]BrowserReplayListResponse, err error) { + return s.inner.List(ctx, s.id, opts...) } -func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (*BrowserPlaywrightExecuteResponse, error) { - return s.inner.Execute(ctx, s.id, body, opts...) +func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (res *BrowserReplayStartResponse, err error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) (err error) { + body.ID = s.id + return s.inner.Stop(ctx, replayID, body, opts...) +} + +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string + Replays BrowserSessionReplayService + Fs BrowserSessionFService + Process BrowserSessionProcessService + Logs BrowserSessionLogService + Computer BrowserSessionComputerService + Playwright BrowserSessionPlaywrightService +} + +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { + innerFs := NewBrowserFService(scoped...) + return &BrowserSessionClient{ + sessionID: sessionID, + opts: scoped, + kernelBase: kernelBase, + jwt: jwt, + Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: sessionID}, + Fs: BrowserSessionFService{ + inner: innerFs, + id: sessionID, + Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID}, + }, + Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: sessionID}, + Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: sessionID}, + Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: sessionID}, + Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: sessionID}, + } } diff --git a/go.mod b/go.mod index e5d1e20..cd5b1e5 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,16 @@ module github.com/kernel/kernel-go-sdk -go 1.22 +go 1.23.0 require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 + golang.org/x/tools v0.31.0 ) require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sync v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 32ba293..abdea0d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -8,3 +10,9 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= diff --git a/internal/genbrowsersessionservices/generate_test.go b/internal/genbrowsersessionservices/generate_test.go new file mode 100644 index 0000000..9499f40 --- /dev/null +++ b/internal/genbrowsersessionservices/generate_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGenerateDeterministic(t *testing.T) { + root := moduleRoot(t) + a, err := Generate(root) + if err != nil { + t.Fatal(err) + } + b, err := Generate(root) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatal("Generate is not deterministic for identical inputs") + } +} + +func TestGenerateIncludesExpectedServices(t *testing.T) { + root := moduleRoot(t) + src, err := Generate(root) + if err != nil { + t.Fatal(err) + } + text := string(src) + for _, want := range []string{ + "type BrowserSessionClient struct", + "func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient", + "\tReplays BrowserSessionReplayService", + "\tinnerFs := NewBrowserFService(scoped...)", + "type BrowserSessionComputerService struct", + "type BrowserSessionFService struct", + "type BrowserSessionFWatchService struct", + "type BrowserSessionLogService struct", + "type BrowserSessionPlaywrightService struct", + "type BrowserSessionProcessService struct", + "type BrowserSessionReplayService struct", + "func (s BrowserSessionProcessService) Kill(", + "params.ID = s.id", + "func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption)", + } { + if !strings.Contains(text, want) { + t.Fatalf("generated source missing %q", want) + } + } +} + +func moduleRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + // internal/genbrowsersessionservices/generate_test.go -> repo root + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} diff --git a/internal/genbrowsersessionservices/main.go b/internal/genbrowsersessionservices/main.go new file mode 100644 index 0000000..796fcbf --- /dev/null +++ b/internal/genbrowsersessionservices/main.go @@ -0,0 +1,623 @@ +// Command genbrowsersessionservices emits browser_session_services_gen.go by +// analyzing BrowserService and nested *Browser*Service types in this module. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "go/types" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +// browserSessionGenLoadStub provides placeholder BrowserSession* service types so +// the kernel package type-checks while this file is regenerated (see Overlay in Generate). +const browserSessionGenLoadStub = `package kernel + +import "github.com/kernel/kernel-go-sdk/option" + +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string +} + +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { + return &BrowserSessionClient{sessionID: sessionID, opts: scoped, kernelBase: kernelBase, jwt: jwt} +} + +type BrowserSessionReplayService struct { + inner BrowserReplayService + id string +} +type BrowserSessionFService struct { + inner BrowserFService + id string + Watch BrowserSessionFWatchService +} +type BrowserSessionFWatchService struct { + inner BrowserFWatchService + id string +} +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} +type BrowserSessionLogService struct { + inner BrowserLogService + id string +} +type BrowserSessionComputerService struct { + inner BrowserComputerService + id string +} +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} +` + +func main() { + outPath := flag.String("output", "browser_session_services_gen.go", "path to write generated file (relative to -dir)") + dir := flag.String("dir", "", "module root (default: working directory)") + flag.Parse() + + root := *dir + if root == "" { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + root = wd + } + + src, err := Generate(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + path := *outPath + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + if err := os.WriteFile(path, src, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// Generate returns formatted Go source for browser session service wrappers. +func Generate(moduleRoot string) ([]byte, error) { + genPath := filepath.Join(moduleRoot, "browser_session_services_gen.go") + genAbs, err := filepath.Abs(genPath) + if err != nil { + return nil, err + } + cfg := &packages.Config{ + Mode: packages.NeedTypes | packages.NeedImports | packages.NeedName | packages.NeedModule, + Dir: moduleRoot, + Overlay: map[string][]byte{ + // Placeholder types so browser_session.go type-checks while the real file is regenerated. + genAbs: []byte(browserSessionGenLoadStub), + }, + } + pkgs, err := packages.Load(cfg, ".") + if err != nil { + return nil, err + } + if len(pkgs) != 1 { + return nil, fmt.Errorf("expected 1 package in %s, got %d", moduleRoot, len(pkgs)) + } + pkg := pkgs[0] + if len(pkg.Errors) > 0 { + return nil, fmt.Errorf("package load errors: %v", pkg.Errors) + } + if pkg.Name != "kernel" { + return nil, fmt.Errorf("expected package kernel, got %s", pkg.Name) + } + + services, err := discoverBrowserSessionServices(pkg) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + buf.WriteString(`// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. + +package kernel + +import ( + "context" + "io" + "net/http" + + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" +) +`) + + for _, s := range services { + emitService(&buf, pkg, s) + } + + topFields, err := discoverBrowserSessionClientFields(pkg) + if err != nil { + return nil, err + } + emitBrowserSessionClient(&buf, topFields) + + out := buf.Bytes() + formatted, err := format.Source(out) + if err != nil { + return nil, fmt.Errorf("format: %w\n%s", err, string(out)) + } + return formatted, nil +} + +type serviceInfo struct { + innerName string + sessionName string + named *types.Named + nested []nestedFieldInfo +} + +type nestedFieldInfo struct { + fieldName string + inner *types.Named + session string +} + +// clientTopField is one exported subresource field on [BrowserSessionClient], +// aligned with a [BrowserService] service field (same name and inner type). +type clientTopField struct { + fieldName string // e.g. Replays, Fs + innerName string // e.g. BrowserReplayService +} + +func discoverBrowserSessionClientFields(pkg *packages.Package) ([]clientTopField, error) { + obj := pkg.Types.Scope().Lookup("BrowserService") + if obj == nil { + return nil, fmt.Errorf("BrowserService not found") + } + named, ok := obj.Type().(*types.Named) + if !ok { + return nil, fmt.Errorf("BrowserService is not a named type") + } + st, ok := named.Underlying().(*types.Struct) + if !ok { + return nil, fmt.Errorf("BrowserService underlying is not a struct") + } + var out []clientTopField + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + name := nn.Obj().Name() + if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { + continue + } + out = append(out, clientTopField{fieldName: f.Name(), innerName: name}) + } + if len(out) == 0 { + return nil, fmt.Errorf("no browser session subresource fields found on BrowserService") + } + return out, nil +} + +func emitBrowserSessionClient(buf *bytes.Buffer, top []clientTopField) { + buf.WriteString(` + +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string +`) + for _, f := range top { + buf.WriteByte('\t') + buf.WriteString(f.fieldName) + buf.WriteByte(' ') + buf.WriteString(sessionWrapperName(f.innerName)) + buf.WriteByte('\n') + } + buf.WriteString("}\n") + + buf.WriteString(` +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { +`) + + hasFS := false + for _, f := range top { + if f.innerName == "BrowserFService" { + hasFS = true + break + } + } + if hasFS { + buf.WriteString("\tinnerFs := NewBrowserFService(scoped...)\n") + } + + buf.WriteString("\treturn &BrowserSessionClient{\n") + buf.WriteString("\t\tsessionID: sessionID,\n") + buf.WriteString("\t\topts: scoped,\n") + buf.WriteString("\t\tkernelBase: kernelBase,\n") + buf.WriteString("\t\tjwt: jwt,\n") + + for _, f := range top { + if f.innerName == "BrowserFService" { + buf.WriteString("\t\tFs: BrowserSessionFService{\n") + buf.WriteString("\t\t\tinner: innerFs,\n") + buf.WriteString("\t\t\tid: sessionID,\n") + buf.WriteString("\t\t\tWatch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID},\n") + buf.WriteString("\t\t},\n") + continue + } + wrap := sessionWrapperName(f.innerName) + newFn := "New" + f.innerName + buf.WriteByte('\t') + buf.WriteByte('\t') + buf.WriteString(f.fieldName) + buf.WriteString(": ") + buf.WriteString(wrap) + buf.WriteString("{inner: ") + buf.WriteString(newFn) + buf.WriteString("(scoped...), id: sessionID},\n") + } + buf.WriteString("\t}\n}\n") +} + +func discoverBrowserSessionServices(pkg *packages.Package) ([]serviceInfo, error) { + obj := pkg.Types.Scope().Lookup("BrowserService") + if obj == nil { + return nil, fmt.Errorf("BrowserService not found") + } + named, ok := obj.Type().(*types.Named) + if !ok { + return nil, fmt.Errorf("BrowserService is not a named type") + } + + seen := map[string]*types.Named{} + var walk func(*types.Named) + walk = func(n *types.Named) { + name := n.Obj().Name() + if name == "BrowserService" { + // descend without recording + } else { + if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { + return + } + if _, dup := seen[name]; dup { + return + } + seen[name] = n + } + st, ok := n.Underlying().(*types.Struct) + if !ok { + return + } + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Embedded() { + continue + } + if f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + walk(nn) + } + } + walk(named) + + var names []string + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + + var out []serviceInfo + for _, name := range names { + n := seen[name] + st := n.Underlying().(*types.Struct) + var nested []nestedFieldInfo + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Embedded() || f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + fn := nn.Obj().Name() + if !strings.HasPrefix(fn, "Browser") || !strings.HasSuffix(fn, "Service") { + continue + } + if _, ok := seen[fn]; !ok { + continue + } + nested = append(nested, nestedFieldInfo{ + fieldName: f.Name(), + inner: nn, + session: sessionWrapperName(fn), + }) + } + sort.Slice(nested, func(i, j int) bool { return nested[i].fieldName < nested[j].fieldName }) + out = append(out, serviceInfo{ + innerName: name, + sessionName: sessionWrapperName(name), + named: n, + nested: nested, + }) + } + return out, nil +} + +func sessionWrapperName(innerService string) string { + mid := strings.TrimSuffix(strings.TrimPrefix(innerService, "Browser"), "Service") + return "BrowserSession" + mid + "Service" +} + +func derefNamed(t types.Type) *types.Named { + for { + switch u := t.(type) { + case *types.Pointer: + t = u.Elem() + default: + n, ok := t.(*types.Named) + if !ok { + return nil + } + return n + } + } +} + +func emitService(buf *bytes.Buffer, pkg *packages.Package, s serviceInfo) { + buf.WriteString("\n// ") + buf.WriteString(s.sessionName) + buf.WriteString(" exposes browser session APIs without passing browser id.\n") + buf.WriteString("type ") + buf.WriteString(s.sessionName) + buf.WriteString(" struct {\n") + buf.WriteString("\tinner ") + buf.WriteString(s.innerName) + buf.WriteString("\n") + buf.WriteString("\tid string\n") + for _, nf := range s.nested { + buf.WriteString("\t") + buf.WriteString(nf.fieldName) + buf.WriteString(" ") + buf.WriteString(nf.session) + buf.WriteString("\n") + } + buf.WriteString("}\n") + + ms := types.NewMethodSet(types.NewPointer(s.named)) + var methods []*types.Func + for i := 0; i < ms.Len(); i++ { + sel := ms.At(i) + fn, ok := sel.Obj().(*types.Func) + if !ok || !fn.Exported() { + continue + } + methods = append(methods, fn) + } + sort.Slice(methods, func(i, j int) bool { return methods[i].Name() < methods[j].Name() }) + + for _, fn := range methods { + sig := fn.Type().(*types.Signature) + emitMethod(buf, pkg, s.sessionName, sig, fn.Name()) + } +} + +// writeWrapperSignature writes "(params) results" for a browser session wrapper method. +// go/types.WriteSignature encodes final variadic parameters as slices; callers expect +// "opts ...option.RequestOption" like the underlying Stainless services. +func writeWrapperSignature(buf *bytes.Buffer, sig *types.Signature, q types.Qualifier) { + buf.WriteByte('(') + params := sig.Params() + for i := 0; i < params.Len(); i++ { + if i > 0 { + buf.WriteString(", ") + } + p := params.At(i) + if p.Name() != "" { + buf.WriteString(p.Name()) + buf.WriteByte(' ') + } + if sig.Variadic() && i == params.Len()-1 { + slice := p.Type().(*types.Slice) + buf.WriteString("...") + buf.WriteString(types.TypeString(slice.Elem(), q)) + continue + } + buf.WriteString(types.TypeString(p.Type(), q)) + } + buf.WriteByte(')') + writeResults(buf, sig.Results(), q) +} + +func writeResults(buf *bytes.Buffer, results *types.Tuple, q types.Qualifier) { + switch results.Len() { + case 0: + return + case 1: + r := results.At(0) + if r.Name() == "" { + buf.WriteByte(' ') + buf.WriteString(types.TypeString(r.Type(), q)) + return + } + fallthrough + default: + buf.WriteString(" (") + for i := 0; i < results.Len(); i++ { + if i > 0 { + buf.WriteString(", ") + } + r := results.At(i) + if r.Name() != "" { + buf.WriteString(r.Name()) + buf.WriteByte(' ') + } + buf.WriteString(types.TypeString(r.Type(), q)) + } + buf.WriteByte(')') + } +} + +func emitMethod(buf *bytes.Buffer, pkg *packages.Package, sessionName string, innerSig *types.Signature, methodName string) { + params := innerSig.Params() + if params.Len() < 1 || !isContext(params.At(0).Type()) { + return + } + + qual := qualifier(pkg.Types) + wrapperSig := types.NewSignature(nil, wrapperParamTuple(innerSig), innerSig.Results(), innerSig.Variadic()) + + var preStmts []string + var callArgs []string + callArgs = append(callArgs, "ctx") + + for i := 1; i < params.Len(); i++ { + p := params.At(i) + pname := paramName(p, i) + if isVariadicOpts(innerSig, i) { + callArgs = append(callArgs, pname+"...") + break + } + if p.Name() == "id" && isStringType(p.Type()) { + callArgs = append(callArgs, "s.id") + continue + } + if hasStringIDField(p.Type()) { + preStmts = append(preStmts, fmt.Sprintf("\t%s.ID = s.id", pname)) + callArgs = append(callArgs, pname) + continue + } + callArgs = append(callArgs, pname) + } + + fmt.Fprintf(buf, "\nfunc (s %s) %s", sessionName, methodName) + writeWrapperSignature(buf, wrapperSig, qual) + buf.WriteString(" {\n") + for _, ln := range preStmts { + buf.WriteString(ln) + buf.WriteByte('\n') + } + innerCall := fmt.Sprintf("s.inner.%s(%s)", methodName, strings.Join(callArgs, ", ")) + if innerSig.Results().Len() == 0 { + fmt.Fprintf(buf, "\t%s\n", innerCall) + } else { + fmt.Fprintf(buf, "\treturn %s\n", innerCall) + } + buf.WriteString("}\n") +} + +// wrapperParamTuple is the inner API signature with the browser id path parameter removed. +func wrapperParamTuple(sig *types.Signature) *types.Tuple { + params := sig.Params() + var vars []*types.Var + vars = append(vars, params.At(0)) + last := params.Len() - 1 + for i := 1; i < params.Len(); i++ { + p := params.At(i) + if sig.Variadic() && i == last { + vars = append(vars, p) + break + } + if p.Name() == "id" && isStringType(p.Type()) { + continue + } + vars = append(vars, p) + } + return types.NewTuple(vars...) +} + +func paramName(p *types.Var, idx int) string { + if p.Name() != "" { + return p.Name() + } + return fmt.Sprintf("arg%d", idx) +} + +func isVariadicOpts(sig *types.Signature, idx int) bool { + if !sig.Variadic() || idx != sig.Params().Len()-1 { + return false + } + _, ok := sig.Params().At(idx).Type().(*types.Slice) + return ok +} + +func isContext(t types.Type) bool { + named, ok := t.(*types.Named) + if !ok { + return false + } + return named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "context" && named.Obj().Name() == "Context" +} + +func isStringType(t types.Type) bool { + b, ok := t.Underlying().(*types.Basic) + return ok && b.Kind() == types.String +} + +func hasStringIDField(t types.Type) bool { + str, ok := derefStruct(t) + if !ok { + return false + } + for i := 0; i < str.NumFields(); i++ { + f := str.Field(i) + if f.Name() == "ID" && isStringType(f.Type()) { + return true + } + } + return false +} + +func derefStruct(t types.Type) (*types.Struct, bool) { + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + switch u := t.(type) { + case *types.Named: + s, ok := u.Underlying().(*types.Struct) + return s, ok + case *types.Struct: + return u, true + default: + return nil, false + } +} + +func qualifier(pkg *types.Package) types.Qualifier { + return func(other *types.Package) string { + if other == pkg { + return "" + } + return other.Name() + } +} diff --git a/scripts/generate-browser-session b/scripts/generate-browser-session new file mode 100755 index 0000000..5d5479a --- /dev/null +++ b/scripts/generate-browser-session @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go diff --git a/scripts/lint b/scripts/lint index 7e03a7b..e577c0d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,6 +4,12 @@ set -e cd "$(dirname "$0")/.." +echo "==> Regenerating browser-scoped bindings" +./scripts/generate-browser-session + +echo "==> Verifying generated browser-scoped bindings are committed" +git diff --exit-code -- browser_session_services_gen.go + echo "==> Running Go build" go build ./... From 92dc96e99e14eeb9eb6881115f330369bb3c7542 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 21 Apr 2026 16:24:57 -0400 Subject: [PATCH 06/11] docs: add browser-scoped raw http example Show the browser-scoped HTTPClient flow explicitly so the /curl/raw-backed public API is discoverable from a runnable Go example. Made-with: Cursor --- examples/browser-scoped/main.go | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 examples/browser-scoped/main.go diff --git a/examples/browser-scoped/main.go b/examples/browser-scoped/main.go new file mode 100644 index 0000000..9f34f1e --- /dev/null +++ b/examples/browser-scoped/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func main() { + ctx := context.Background() + client := kernel.NewClient() + + browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ + Headless: kernel.Bool(true), + }) + if err != nil { + panic(err) + } + defer func() { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + }() + + scoped, err := client.ForBrowser(browser) + if err != nil { + panic(err) + } + + httpClient := scoped.HTTPClient() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + panic(err) + } + + resp, err := httpClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Fprintln(os.Stdout, "status", resp.StatusCode) +} From 3452e537737576fdb645a4e5de0d9147e50cb33b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 10:58:03 -0400 Subject: [PATCH 07/11] refactor: remove browser session wrapper layer Route browser subresources and raw HTTP through the shared browser route cache so the SDK no longer needs the generated browser-scoped client surface. Made-with: Cursor --- browser.go | 37 ++ browser_routing.go | 71 ++ browser_routing_test.go | 113 ++++ browser_session.go | 82 --- browser_session_httpclient_test.go | 26 +- browser_session_services_gen.go | 261 -------- browser_session_test.go | 80 --- client.go | 12 +- examples/browser-scoped/main.go | 11 +- .../generate_test.go | 63 -- internal/genbrowsersessionservices/main.go | 623 ------------------ lib/browserscope/integration_test.go | 19 +- lib/browserscope/route_cache.go | 153 +++++ scripts/generate-browser-session | 7 - scripts/lint | 6 - 15 files changed, 426 insertions(+), 1138 deletions(-) create mode 100644 browser_routing.go create mode 100644 browser_routing_test.go delete mode 100644 browser_session.go delete mode 100644 browser_session_services_gen.go delete mode 100644 browser_session_test.go delete mode 100644 internal/genbrowsersessionservices/generate_test.go delete mode 100644 internal/genbrowsersessionservices/main.go create mode 100644 lib/browserscope/route_cache.go delete mode 100755 scripts/generate-browser-session diff --git a/browser.go b/browser.go index 4533fc1..1423df6 100644 --- a/browser.go +++ b/browser.go @@ -19,6 +19,7 @@ import ( "github.com/kernel/kernel-go-sdk/internal/apijson" "github.com/kernel/kernel-go-sdk/internal/apiquery" "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/param" @@ -69,6 +70,9 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts .. opts = slices.Concat(r.Options, opts) path := "browsers" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) + if err == nil && res != nil { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -81,6 +85,9 @@ func (r *BrowserService) Get(ctx context.Context, id string, query BrowserGetPar } path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) + if err == nil && res != nil { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -93,6 +100,9 @@ func (r *BrowserService) Update(ctx context.Context, id string, body BrowserUpda } path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) + if err == nil && res != nil { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -112,6 +122,9 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts return nil, err } res.SetPageConfig(cfg, raw) + for _, item := range res.Items { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + } return res, nil } @@ -147,6 +160,25 @@ func (r *BrowserService) Curl(ctx context.Context, id string, body BrowserCurlPa return res, err } +// HTTPClient returns an [http.Client] that performs HTTP requests through the +// browser VM's internal /curl/raw path using cached browser route data. +func (r *BrowserService) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) { + opts = slices.Concat(r.Options, opts) + cache := browserRouteCacheFromOptions(opts) + if cache == nil { + return nil, fmt.Errorf("kernel: browser route cache is not configured") + } + route, ok := cache.Load(id) + if !ok { + return nil, fmt.Errorf("kernel: browser route cache does not contain session %s", id) + } + cfg, err := requestconfig.PreRequestOptions(opts...) + if err != nil { + return browserscope.HTTPClient(route.BaseURL, route.JWT, nil), nil + } + return browserscope.HTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil +} + // Delete a browser session by ID func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) { opts = slices.Concat(r.Options, opts) @@ -157,6 +189,11 @@ func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...opti } path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...) + if err == nil { + if cache := browserRouteCacheFromOptions(opts); cache != nil { + cache.Delete(id) + } + } return err } diff --git a/browser_routing.go b/browser_routing.go new file mode 100644 index 0000000..3c30647 --- /dev/null +++ b/browser_routing.go @@ -0,0 +1,71 @@ +package kernel + +import ( + "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +// BrowserRoutingConfig controls which browser subresources route directly to the browser VM. +type BrowserRoutingConfig struct { + Enabled bool + DirectToVMSubresources []string +} + +type browserRoutingOption struct { + cache *browserscope.RouteCache + config BrowserRoutingConfig +} + +type browserRouteCacheOption struct { + cache *browserscope.RouteCache +} + +// WithBrowserRouting enables direct-to-VM routing for the configured browser subresources. +func WithBrowserRouting(config BrowserRoutingConfig) option.RequestOption { + return &browserRoutingOption{config: config} +} + +func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error { + if !o.config.Enabled { + return nil + } + r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.DirectToVMSubresources)) + return nil +} + +func (o *browserRoutingOption) browserRouteCache() *browserscope.RouteCache { + return o.cache +} + +func (o *browserRouteCacheOption) Apply(*requestconfig.RequestConfig) error { + return nil +} + +func (o *browserRouteCacheOption) browserRouteCache() *browserscope.RouteCache { + return o.cache +} + +func withBrowserRouteCache(cache *browserscope.RouteCache) option.RequestOption { + return &browserRouteCacheOption{cache: cache} +} + +func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.RouteCache { + for _, opt := range opts { + if carrier, ok := opt.(interface{ browserRouteCache() *browserscope.RouteCache }); ok { + if cache := carrier.browserRouteCache(); cache != nil { + return cache + } + } + } + return nil +} + +func primeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { + cache := browserRouteCacheFromOptions(opts) + for _, ref := range refs { + if cache != nil { + _ = cache.Prime(ref) + } + } +} diff --git a/browser_routing_test.go b/browser_routing_test.go new file mode 100644 index 0000000..4e0802b --- /dev/null +++ b/browser_routing_test.go @@ -0,0 +1,113 @@ +package kernel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/kernel-go-sdk/option" +) + +func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) { + var calls []struct { + Path string + Auth string + } + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, struct { + Path string + Auth string + }{Path: r.URL.Path + "?" + r.URL.RawQuery, Auth: r.Header.Get("Authorization")}) + + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/browsers": + _ = json.NewEncoder(w).Encode(map[string]any{ + "session_id": "sess-1", + "base_url": srv.URL + "/browser/kernel", + "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc", + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "duration_ms": 1, + "exit_code": 0, + "stderr_b64": "", + "stdout_b64": "", + }) + } + })) + defer srv.Close() + + client := NewClient( + option.WithBaseURL(srv.URL), + option.WithAPIKey("sk_test"), + option.WithHTTPClient(srv.Client()), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"process"}}), + ) + + if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { + t.Fatal(err) + } + if _, err := client.Browsers.Process.Exec(context.Background(), "sess-1", BrowserProcessExecParams{Command: "echo"}); err != nil { + t.Fatal(err) + } + + if route, ok := client.BrowserRouteCache.Load("sess-1"); !ok || route.JWT != "token-abc" { + t.Fatalf("expected warmed browser route cache, got %#v ok=%v", route, ok) + } + if len(calls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(calls)) + } + if calls[1].Path != "/browser/kernel/process/exec?jwt=token-abc" { + t.Fatalf("expected direct VM path, got %q", calls[1].Path) + } + if calls[1].Auth != "" { + t.Fatalf("expected authorization header removed, got %q", calls[1].Auth) + } +} + +func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) { + var paths []string + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/browsers": + _ = json.NewEncoder(w).Encode(map[string]any{ + "session_id": "sess-1", + "base_url": srv.URL + "/browser/kernel", + "cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc", + }) + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "duration_ms": 1, + "exit_code": 0, + "stderr_b64": "", + "stdout_b64": "", + }) + } + })) + defer srv.Close() + + client := NewClient( + option.WithBaseURL(srv.URL), + option.WithAPIKey("sk_test"), + option.WithHTTPClient(srv.Client()), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"computer"}}), + ) + + if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { + t.Fatal(err) + } + if _, err := client.Browsers.Process.Exec(context.Background(), "sess-1", BrowserProcessExecParams{Command: "echo"}); err != nil { + t.Fatal(err) + } + + if got := paths[len(paths)-1]; got != "/browsers/sess-1/process/exec" { + t.Fatalf("expected control-plane path, got %q", got) + } +} diff --git a/browser_session.go b/browser_session.go deleted file mode 100644 index c541f01..0000000 --- a/browser_session.go +++ /dev/null @@ -1,82 +0,0 @@ -package kernel - -//go:generate go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go - -import ( - "fmt" - "net/http" - "slices" - - "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" - "github.com/kernel/kernel-go-sdk/option" -) - -// SessionID returns the control-plane browser session id. -func (b *BrowserSessionClient) SessionID() string { return b.sessionID } - -// HTTPClient returns an [http.Client] that performs egress HTTP through the -// browser's Chrome network stack via the browser session /curl/raw proxy. Each -// request must use an absolute http(s) URL; it is not rewritten to expose -// /curl/raw in the public API. -func (b *BrowserSessionClient) HTTPClient() *http.Client { - cfg, err := requestconfig.PreRequestOptions(b.opts...) - if err != nil { - return browserscope.HTTPClient(b.kernelBase, b.jwt, nil) - } - return browserscope.HTTPClient(b.kernelBase, b.jwt, cfg.HTTPClient) -} - -// ForBrowser returns a [BrowserSessionClient] for the given browser value. -// Supported types are [browserscope.Ref], [*BrowserNewResponse], [*BrowserGetResponse], -// [*BrowserListResponse], and [*BrowserUpdateResponse]. -func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessionClient, error) { - ref, err := browserSessionRefFrom(v) - if err != nil { - return nil, err - } - norm, err := ref.Normalize() - if err != nil { - return nil, err - } - - scoped := slices.Concat( - c.Options, - []option.RequestOption{ - option.WithBaseURL(norm.BaseURL), - option.WithMiddleware(browserscope.BrowserSessionMiddleware(norm.SessionID, norm.JWT)), - }, - opts, - ) - - return newBrowserSessionClient(norm.SessionID, norm.BaseURL, norm.JWT, scoped), nil -} - -func browserSessionRefFrom(v any) (browserscope.Ref, error) { - switch t := v.(type) { - case browserscope.Ref: - return t, nil - case *BrowserNewResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserNewResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserGetResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserGetResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserListResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserListResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserUpdateResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserUpdateResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - default: - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: unsupported type %T", v) - } -} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index 99fb422..41ec505 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/kernel/kernel-go-sdk/lib/browserscope" "github.com/kernel/kernel-go-sdk/option" ) @@ -28,16 +29,16 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - sess, err := c.ForBrowser(&BrowserGetResponse{ + primeBrowserRouteCache(c.Options, browserscope.Ref{ SessionID: "sid", BaseURL: srv.URL + "/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", }) + + hc, err := c.Browsers.HTTPClient("sid") if err != nil { t.Fatal(err) } - - hc := sess.HTTPClient() req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/get", nil) if err != nil { t.Fatal(err) @@ -55,3 +56,22 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { t.Fatal("expected raw query on curl/raw") } } + +func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { + c := NewClient( + option.WithBaseURL("https://api.example/"), + option.WithAPIKey("sk"), + ) + + primeBrowserRouteCache(c.Options, browserscope.Ref{ + SessionID: "sid", + BaseURL: "https://browser-session.test/browser/kernel", + CdpWsURL: "wss://x/browser/cdp?jwt=j1", + }) + c.BrowserRouteCache.Delete("sid") + + _, err := c.Browsers.HTTPClient("sid") + if err == nil { + t.Fatal("expected cached route lookup failure") + } +} diff --git a/browser_session_services_gen.go b/browser_session_services_gen.go deleted file mode 100644 index be7053b..0000000 --- a/browser_session_services_gen.go +++ /dev/null @@ -1,261 +0,0 @@ -// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. - -package kernel - -import ( - "context" - "io" - "net/http" - - "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/packages/ssestream" - "github.com/kernel/kernel-go-sdk/shared" -) - -// BrowserSessionComputerService exposes browser session APIs without passing browser id. -type BrowserSessionComputerService struct { - inner BrowserComputerService - id string -} - -func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) (err error) { - return s.inner.Batch(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.ClickMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.DragMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerGetMousePositionResponse, err error) { - return s.inner.GetMousePosition(ctx, s.id, opts...) -} - -func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.MoveMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) { - return s.inner.PressKey(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerReadClipboardResponse, err error) { - return s.inner.ReadClipboard(ctx, s.id, opts...) -} - -func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) (err error) { - return s.inner.Scroll(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *BrowserComputerSetCursorVisibilityResponse, err error) { - return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) { - return s.inner.TypeText(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) { - return s.inner.WriteClipboard(ctx, s.id, body, opts...) -} - -// BrowserSessionFService exposes browser session APIs without passing browser id. -type BrowserSessionFService struct { - inner BrowserFService - id string - Watch BrowserSessionFWatchService -} - -func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) { - return s.inner.DeleteDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) { - return s.inner.DeleteFile(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.DownloadDirZip(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (res *BrowserFFileInfoResponse, err error) { - return s.inner.FileInfo(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (res *[]BrowserFListFilesResponse, err error) { - return s.inner.ListFiles(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) (err error) { - return s.inner.Move(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) { - return s.inner.NewDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.ReadFile(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) { - return s.inner.SetFilePermissions(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) (err error) { - return s.inner.Upload(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) (err error) { - return s.inner.UploadZip(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) (err error) { - return s.inner.WriteFile(ctx, s.id, contents, params, opts...) -} - -// BrowserSessionFWatchService exposes browser session APIs without passing browser id. -type BrowserSessionFWatchService struct { - inner BrowserFWatchService - id string -} - -func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserFWatchEventsResponse]) { - query.ID = s.id - return s.inner.EventsStreaming(ctx, watchID, query, opts...) -} - -func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (res *BrowserFWatchStartResponse, err error) { - return s.inner.Start(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) (err error) { - body.ID = s.id - return s.inner.Stop(ctx, watchID, body, opts...) -} - -// BrowserSessionLogService exposes browser session APIs without passing browser id. -type BrowserSessionLogService struct { - inner BrowserLogService - id string -} - -func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) { - return s.inner.StreamStreaming(ctx, s.id, query, opts...) -} - -// BrowserSessionPlaywrightService exposes browser session APIs without passing browser id. -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string -} - -func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *BrowserPlaywrightExecuteResponse, err error) { - return s.inner.Execute(ctx, s.id, body, opts...) -} - -// BrowserSessionProcessService exposes browser session APIs without passing browser id. -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} - -func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (res *BrowserProcessExecResponse, err error) { - return s.inner.Exec(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (res *BrowserProcessKillResponse, err error) { - params.ID = s.id - return s.inner.Kill(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (res *BrowserProcessResizeResponse, err error) { - params.ID = s.id - return s.inner.Resize(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (res *BrowserProcessSpawnResponse, err error) { - return s.inner.Spawn(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (res *BrowserProcessStatusResponse, err error) { - query.ID = s.id - return s.inner.Status(ctx, processID, query, opts...) -} - -func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (res *BrowserProcessStdinResponse, err error) { - params.ID = s.id - return s.inner.Stdin(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserProcessStdoutStreamResponse]) { - query.ID = s.id - return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) -} - -// BrowserSessionReplayService exposes browser session APIs without passing browser id. -type BrowserSessionReplayService struct { - inner BrowserReplayService - id string -} - -func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) { - query.ID = s.id - return s.inner.Download(ctx, replayID, query, opts...) -} - -func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (res *[]BrowserReplayListResponse, err error) { - return s.inner.List(ctx, s.id, opts...) -} - -func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (res *BrowserReplayStartResponse, err error) { - return s.inner.Start(ctx, s.id, body, opts...) -} - -func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) (err error) { - body.ID = s.id - return s.inner.Stop(ctx, replayID, body, opts...) -} - -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string - Replays BrowserSessionReplayService - Fs BrowserSessionFService - Process BrowserSessionProcessService - Logs BrowserSessionLogService - Computer BrowserSessionComputerService - Playwright BrowserSessionPlaywrightService -} - -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { - innerFs := NewBrowserFService(scoped...) - return &BrowserSessionClient{ - sessionID: sessionID, - opts: scoped, - kernelBase: kernelBase, - jwt: jwt, - Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: sessionID}, - Fs: BrowserSessionFService{ - inner: innerFs, - id: sessionID, - Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID}, - }, - Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: sessionID}, - Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: sessionID}, - Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: sessionID}, - Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: sessionID}, - } -} diff --git a/browser_session_test.go b/browser_session_test.go deleted file mode 100644 index 5ca48d0..0000000 --- a/browser_session_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package kernel - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/kernel/kernel-go-sdk/lib/browserscope" - "github.com/kernel/kernel-go-sdk/option" -) - -func TestForBrowserRewritesToKernelPaths(t *testing.T) { - var gotPath string - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - if r.URL.Query().Get("jwt") == "" { - http.Error(w, "jwt", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "duration_ms": 1, - "exit_code": 0, - "stderr_b64": "", - "stdout_b64": "", - }) - })) - defer srv.Close() - - c := NewClient( - option.WithBaseURL("https://api.example/"), - option.WithAPIKey("sk_test"), - option.WithHTTPClient(srv.Client()), - ) - - b := &BrowserGetResponse{ - SessionID: "sid-1", - BaseURL: srv.URL + "/browser/kernel", - CdpWsURL: "wss://x/browser/cdp?jwt=session-jwt", - } - - sess, err := c.ForBrowser(b) - if err != nil { - t.Fatal(err) - } - if sess.SessionID() != "sid-1" { - t.Fatalf("session id: %s", sess.SessionID()) - } - - _, err = sess.Process.Exec(context.Background(), BrowserProcessExecParams{Command: "true"}) - if err != nil { - t.Fatal(err) - } - - if gotPath != "/browser/kernel/process/exec" { - t.Fatalf("path: got %q", gotPath) - } - if gotAuth != "" { - t.Fatalf("authorization should be empty, got %q", gotAuth) - } -} - -func TestForBrowserRefNormalize(t *testing.T) { - ref := browserscope.Ref{ - SessionID: "s", - BaseURL: "https://x/browser/kernel", - JWT: "direct", - } - norm, err := ref.Normalize() - if err != nil { - t.Fatal(err) - } - if norm.JWT != "direct" { - t.Fatalf("jwt: %q", norm.JWT) - } -} diff --git a/client.go b/client.go index 880a657..9604f0c 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "slices" "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" "github.com/kernel/kernel-go-sdk/option" ) @@ -17,6 +18,8 @@ import ( // directly, and instead use the [NewClient] method instead. type Client struct { Options []option.RequestOption + // BrowserRouteCache stores cached base_url and jwt data for direct-to-VM routing. + BrowserRouteCache *browserscope.RouteCache // Create and manage app deployments and stream deployment events. Deployments DeploymentService // List applications and versions. @@ -61,8 +64,15 @@ func DefaultClientOptions() []option.RequestOption { // the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) + cache := browserscope.NewRouteCache() + for _, opt := range opts { + if routing, ok := opt.(*browserRoutingOption); ok { + routing.cache = cache + } + } + opts = append(opts, withBrowserRouteCache(cache)) - r = Client{Options: opts} + r = Client{Options: opts, BrowserRouteCache: cache} r.Deployments = NewDeploymentService(opts...) r.Apps = NewAppService(opts...) diff --git a/examples/browser-scoped/main.go b/examples/browser-scoped/main.go index 9f34f1e..8369943 100644 --- a/examples/browser-scoped/main.go +++ b/examples/browser-scoped/main.go @@ -11,7 +11,12 @@ import ( func main() { ctx := context.Background() - client := kernel.NewClient() + client := kernel.NewClient( + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + DirectToVMSubresources: []string{"process"}, + }), + ) browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ Headless: kernel.Bool(true), @@ -23,12 +28,10 @@ func main() { _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) }() - scoped, err := client.ForBrowser(browser) + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) if err != nil { panic(err) } - - httpClient := scoped.HTTPClient() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) if err != nil { panic(err) diff --git a/internal/genbrowsersessionservices/generate_test.go b/internal/genbrowsersessionservices/generate_test.go deleted file mode 100644 index 9499f40..0000000 --- a/internal/genbrowsersessionservices/generate_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "bytes" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestGenerateDeterministic(t *testing.T) { - root := moduleRoot(t) - a, err := Generate(root) - if err != nil { - t.Fatal(err) - } - b, err := Generate(root) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(a, b) { - t.Fatal("Generate is not deterministic for identical inputs") - } -} - -func TestGenerateIncludesExpectedServices(t *testing.T) { - root := moduleRoot(t) - src, err := Generate(root) - if err != nil { - t.Fatal(err) - } - text := string(src) - for _, want := range []string{ - "type BrowserSessionClient struct", - "func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient", - "\tReplays BrowserSessionReplayService", - "\tinnerFs := NewBrowserFService(scoped...)", - "type BrowserSessionComputerService struct", - "type BrowserSessionFService struct", - "type BrowserSessionFWatchService struct", - "type BrowserSessionLogService struct", - "type BrowserSessionPlaywrightService struct", - "type BrowserSessionProcessService struct", - "type BrowserSessionReplayService struct", - "func (s BrowserSessionProcessService) Kill(", - "params.ID = s.id", - "func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption)", - } { - if !strings.Contains(text, want) { - t.Fatalf("generated source missing %q", want) - } - } -} - -func moduleRoot(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller") - } - // internal/genbrowsersessionservices/generate_test.go -> repo root - return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) -} diff --git a/internal/genbrowsersessionservices/main.go b/internal/genbrowsersessionservices/main.go deleted file mode 100644 index 796fcbf..0000000 --- a/internal/genbrowsersessionservices/main.go +++ /dev/null @@ -1,623 +0,0 @@ -// Command genbrowsersessionservices emits browser_session_services_gen.go by -// analyzing BrowserService and nested *Browser*Service types in this module. -package main - -import ( - "bytes" - "flag" - "fmt" - "go/format" - "go/types" - "os" - "path/filepath" - "sort" - "strings" - - "golang.org/x/tools/go/packages" -) - -// browserSessionGenLoadStub provides placeholder BrowserSession* service types so -// the kernel package type-checks while this file is regenerated (see Overlay in Generate). -const browserSessionGenLoadStub = `package kernel - -import "github.com/kernel/kernel-go-sdk/option" - -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string -} - -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { - return &BrowserSessionClient{sessionID: sessionID, opts: scoped, kernelBase: kernelBase, jwt: jwt} -} - -type BrowserSessionReplayService struct { - inner BrowserReplayService - id string -} -type BrowserSessionFService struct { - inner BrowserFService - id string - Watch BrowserSessionFWatchService -} -type BrowserSessionFWatchService struct { - inner BrowserFWatchService - id string -} -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} -type BrowserSessionLogService struct { - inner BrowserLogService - id string -} -type BrowserSessionComputerService struct { - inner BrowserComputerService - id string -} -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string -} -` - -func main() { - outPath := flag.String("output", "browser_session_services_gen.go", "path to write generated file (relative to -dir)") - dir := flag.String("dir", "", "module root (default: working directory)") - flag.Parse() - - root := *dir - if root == "" { - wd, err := os.Getwd() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - root = wd - } - - src, err := Generate(root) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - path := *outPath - if !filepath.IsAbs(path) { - path = filepath.Join(root, path) - } - if err := os.WriteFile(path, src, 0o644); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -// Generate returns formatted Go source for browser session service wrappers. -func Generate(moduleRoot string) ([]byte, error) { - genPath := filepath.Join(moduleRoot, "browser_session_services_gen.go") - genAbs, err := filepath.Abs(genPath) - if err != nil { - return nil, err - } - cfg := &packages.Config{ - Mode: packages.NeedTypes | packages.NeedImports | packages.NeedName | packages.NeedModule, - Dir: moduleRoot, - Overlay: map[string][]byte{ - // Placeholder types so browser_session.go type-checks while the real file is regenerated. - genAbs: []byte(browserSessionGenLoadStub), - }, - } - pkgs, err := packages.Load(cfg, ".") - if err != nil { - return nil, err - } - if len(pkgs) != 1 { - return nil, fmt.Errorf("expected 1 package in %s, got %d", moduleRoot, len(pkgs)) - } - pkg := pkgs[0] - if len(pkg.Errors) > 0 { - return nil, fmt.Errorf("package load errors: %v", pkg.Errors) - } - if pkg.Name != "kernel" { - return nil, fmt.Errorf("expected package kernel, got %s", pkg.Name) - } - - services, err := discoverBrowserSessionServices(pkg) - if err != nil { - return nil, err - } - - var buf bytes.Buffer - buf.WriteString(`// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. - -package kernel - -import ( - "context" - "io" - "net/http" - - "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/packages/ssestream" - "github.com/kernel/kernel-go-sdk/shared" -) -`) - - for _, s := range services { - emitService(&buf, pkg, s) - } - - topFields, err := discoverBrowserSessionClientFields(pkg) - if err != nil { - return nil, err - } - emitBrowserSessionClient(&buf, topFields) - - out := buf.Bytes() - formatted, err := format.Source(out) - if err != nil { - return nil, fmt.Errorf("format: %w\n%s", err, string(out)) - } - return formatted, nil -} - -type serviceInfo struct { - innerName string - sessionName string - named *types.Named - nested []nestedFieldInfo -} - -type nestedFieldInfo struct { - fieldName string - inner *types.Named - session string -} - -// clientTopField is one exported subresource field on [BrowserSessionClient], -// aligned with a [BrowserService] service field (same name and inner type). -type clientTopField struct { - fieldName string // e.g. Replays, Fs - innerName string // e.g. BrowserReplayService -} - -func discoverBrowserSessionClientFields(pkg *packages.Package) ([]clientTopField, error) { - obj := pkg.Types.Scope().Lookup("BrowserService") - if obj == nil { - return nil, fmt.Errorf("BrowserService not found") - } - named, ok := obj.Type().(*types.Named) - if !ok { - return nil, fmt.Errorf("BrowserService is not a named type") - } - st, ok := named.Underlying().(*types.Struct) - if !ok { - return nil, fmt.Errorf("BrowserService underlying is not a struct") - } - var out []clientTopField - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - name := nn.Obj().Name() - if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { - continue - } - out = append(out, clientTopField{fieldName: f.Name(), innerName: name}) - } - if len(out) == 0 { - return nil, fmt.Errorf("no browser session subresource fields found on BrowserService") - } - return out, nil -} - -func emitBrowserSessionClient(buf *bytes.Buffer, top []clientTopField) { - buf.WriteString(` - -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string -`) - for _, f := range top { - buf.WriteByte('\t') - buf.WriteString(f.fieldName) - buf.WriteByte(' ') - buf.WriteString(sessionWrapperName(f.innerName)) - buf.WriteByte('\n') - } - buf.WriteString("}\n") - - buf.WriteString(` -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { -`) - - hasFS := false - for _, f := range top { - if f.innerName == "BrowserFService" { - hasFS = true - break - } - } - if hasFS { - buf.WriteString("\tinnerFs := NewBrowserFService(scoped...)\n") - } - - buf.WriteString("\treturn &BrowserSessionClient{\n") - buf.WriteString("\t\tsessionID: sessionID,\n") - buf.WriteString("\t\topts: scoped,\n") - buf.WriteString("\t\tkernelBase: kernelBase,\n") - buf.WriteString("\t\tjwt: jwt,\n") - - for _, f := range top { - if f.innerName == "BrowserFService" { - buf.WriteString("\t\tFs: BrowserSessionFService{\n") - buf.WriteString("\t\t\tinner: innerFs,\n") - buf.WriteString("\t\t\tid: sessionID,\n") - buf.WriteString("\t\t\tWatch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID},\n") - buf.WriteString("\t\t},\n") - continue - } - wrap := sessionWrapperName(f.innerName) - newFn := "New" + f.innerName - buf.WriteByte('\t') - buf.WriteByte('\t') - buf.WriteString(f.fieldName) - buf.WriteString(": ") - buf.WriteString(wrap) - buf.WriteString("{inner: ") - buf.WriteString(newFn) - buf.WriteString("(scoped...), id: sessionID},\n") - } - buf.WriteString("\t}\n}\n") -} - -func discoverBrowserSessionServices(pkg *packages.Package) ([]serviceInfo, error) { - obj := pkg.Types.Scope().Lookup("BrowserService") - if obj == nil { - return nil, fmt.Errorf("BrowserService not found") - } - named, ok := obj.Type().(*types.Named) - if !ok { - return nil, fmt.Errorf("BrowserService is not a named type") - } - - seen := map[string]*types.Named{} - var walk func(*types.Named) - walk = func(n *types.Named) { - name := n.Obj().Name() - if name == "BrowserService" { - // descend without recording - } else { - if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { - return - } - if _, dup := seen[name]; dup { - return - } - seen[name] = n - } - st, ok := n.Underlying().(*types.Struct) - if !ok { - return - } - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Embedded() { - continue - } - if f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - walk(nn) - } - } - walk(named) - - var names []string - for n := range seen { - names = append(names, n) - } - sort.Strings(names) - - var out []serviceInfo - for _, name := range names { - n := seen[name] - st := n.Underlying().(*types.Struct) - var nested []nestedFieldInfo - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Embedded() || f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - fn := nn.Obj().Name() - if !strings.HasPrefix(fn, "Browser") || !strings.HasSuffix(fn, "Service") { - continue - } - if _, ok := seen[fn]; !ok { - continue - } - nested = append(nested, nestedFieldInfo{ - fieldName: f.Name(), - inner: nn, - session: sessionWrapperName(fn), - }) - } - sort.Slice(nested, func(i, j int) bool { return nested[i].fieldName < nested[j].fieldName }) - out = append(out, serviceInfo{ - innerName: name, - sessionName: sessionWrapperName(name), - named: n, - nested: nested, - }) - } - return out, nil -} - -func sessionWrapperName(innerService string) string { - mid := strings.TrimSuffix(strings.TrimPrefix(innerService, "Browser"), "Service") - return "BrowserSession" + mid + "Service" -} - -func derefNamed(t types.Type) *types.Named { - for { - switch u := t.(type) { - case *types.Pointer: - t = u.Elem() - default: - n, ok := t.(*types.Named) - if !ok { - return nil - } - return n - } - } -} - -func emitService(buf *bytes.Buffer, pkg *packages.Package, s serviceInfo) { - buf.WriteString("\n// ") - buf.WriteString(s.sessionName) - buf.WriteString(" exposes browser session APIs without passing browser id.\n") - buf.WriteString("type ") - buf.WriteString(s.sessionName) - buf.WriteString(" struct {\n") - buf.WriteString("\tinner ") - buf.WriteString(s.innerName) - buf.WriteString("\n") - buf.WriteString("\tid string\n") - for _, nf := range s.nested { - buf.WriteString("\t") - buf.WriteString(nf.fieldName) - buf.WriteString(" ") - buf.WriteString(nf.session) - buf.WriteString("\n") - } - buf.WriteString("}\n") - - ms := types.NewMethodSet(types.NewPointer(s.named)) - var methods []*types.Func - for i := 0; i < ms.Len(); i++ { - sel := ms.At(i) - fn, ok := sel.Obj().(*types.Func) - if !ok || !fn.Exported() { - continue - } - methods = append(methods, fn) - } - sort.Slice(methods, func(i, j int) bool { return methods[i].Name() < methods[j].Name() }) - - for _, fn := range methods { - sig := fn.Type().(*types.Signature) - emitMethod(buf, pkg, s.sessionName, sig, fn.Name()) - } -} - -// writeWrapperSignature writes "(params) results" for a browser session wrapper method. -// go/types.WriteSignature encodes final variadic parameters as slices; callers expect -// "opts ...option.RequestOption" like the underlying Stainless services. -func writeWrapperSignature(buf *bytes.Buffer, sig *types.Signature, q types.Qualifier) { - buf.WriteByte('(') - params := sig.Params() - for i := 0; i < params.Len(); i++ { - if i > 0 { - buf.WriteString(", ") - } - p := params.At(i) - if p.Name() != "" { - buf.WriteString(p.Name()) - buf.WriteByte(' ') - } - if sig.Variadic() && i == params.Len()-1 { - slice := p.Type().(*types.Slice) - buf.WriteString("...") - buf.WriteString(types.TypeString(slice.Elem(), q)) - continue - } - buf.WriteString(types.TypeString(p.Type(), q)) - } - buf.WriteByte(')') - writeResults(buf, sig.Results(), q) -} - -func writeResults(buf *bytes.Buffer, results *types.Tuple, q types.Qualifier) { - switch results.Len() { - case 0: - return - case 1: - r := results.At(0) - if r.Name() == "" { - buf.WriteByte(' ') - buf.WriteString(types.TypeString(r.Type(), q)) - return - } - fallthrough - default: - buf.WriteString(" (") - for i := 0; i < results.Len(); i++ { - if i > 0 { - buf.WriteString(", ") - } - r := results.At(i) - if r.Name() != "" { - buf.WriteString(r.Name()) - buf.WriteByte(' ') - } - buf.WriteString(types.TypeString(r.Type(), q)) - } - buf.WriteByte(')') - } -} - -func emitMethod(buf *bytes.Buffer, pkg *packages.Package, sessionName string, innerSig *types.Signature, methodName string) { - params := innerSig.Params() - if params.Len() < 1 || !isContext(params.At(0).Type()) { - return - } - - qual := qualifier(pkg.Types) - wrapperSig := types.NewSignature(nil, wrapperParamTuple(innerSig), innerSig.Results(), innerSig.Variadic()) - - var preStmts []string - var callArgs []string - callArgs = append(callArgs, "ctx") - - for i := 1; i < params.Len(); i++ { - p := params.At(i) - pname := paramName(p, i) - if isVariadicOpts(innerSig, i) { - callArgs = append(callArgs, pname+"...") - break - } - if p.Name() == "id" && isStringType(p.Type()) { - callArgs = append(callArgs, "s.id") - continue - } - if hasStringIDField(p.Type()) { - preStmts = append(preStmts, fmt.Sprintf("\t%s.ID = s.id", pname)) - callArgs = append(callArgs, pname) - continue - } - callArgs = append(callArgs, pname) - } - - fmt.Fprintf(buf, "\nfunc (s %s) %s", sessionName, methodName) - writeWrapperSignature(buf, wrapperSig, qual) - buf.WriteString(" {\n") - for _, ln := range preStmts { - buf.WriteString(ln) - buf.WriteByte('\n') - } - innerCall := fmt.Sprintf("s.inner.%s(%s)", methodName, strings.Join(callArgs, ", ")) - if innerSig.Results().Len() == 0 { - fmt.Fprintf(buf, "\t%s\n", innerCall) - } else { - fmt.Fprintf(buf, "\treturn %s\n", innerCall) - } - buf.WriteString("}\n") -} - -// wrapperParamTuple is the inner API signature with the browser id path parameter removed. -func wrapperParamTuple(sig *types.Signature) *types.Tuple { - params := sig.Params() - var vars []*types.Var - vars = append(vars, params.At(0)) - last := params.Len() - 1 - for i := 1; i < params.Len(); i++ { - p := params.At(i) - if sig.Variadic() && i == last { - vars = append(vars, p) - break - } - if p.Name() == "id" && isStringType(p.Type()) { - continue - } - vars = append(vars, p) - } - return types.NewTuple(vars...) -} - -func paramName(p *types.Var, idx int) string { - if p.Name() != "" { - return p.Name() - } - return fmt.Sprintf("arg%d", idx) -} - -func isVariadicOpts(sig *types.Signature, idx int) bool { - if !sig.Variadic() || idx != sig.Params().Len()-1 { - return false - } - _, ok := sig.Params().At(idx).Type().(*types.Slice) - return ok -} - -func isContext(t types.Type) bool { - named, ok := t.(*types.Named) - if !ok { - return false - } - return named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "context" && named.Obj().Name() == "Context" -} - -func isStringType(t types.Type) bool { - b, ok := t.Underlying().(*types.Basic) - return ok && b.Kind() == types.String -} - -func hasStringIDField(t types.Type) bool { - str, ok := derefStruct(t) - if !ok { - return false - } - for i := 0; i < str.NumFields(); i++ { - f := str.Field(i) - if f.Name() == "ID" && isStringType(f.Type()) { - return true - } - } - return false -} - -func derefStruct(t types.Type) (*types.Struct, bool) { - if p, ok := t.(*types.Pointer); ok { - t = p.Elem() - } - switch u := t.(type) { - case *types.Named: - s, ok := u.Underlying().(*types.Struct) - return s, ok - case *types.Struct: - return u, true - default: - return nil, false - } -} - -func qualifier(pkg *types.Package) types.Qualifier { - return func(other *types.Package) string { - if other == pkg { - return "" - } - return other.Name() - } -} diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index 6feef68..7c4e5eb 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -13,7 +13,7 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -func TestIntegrationBrowserSessionClient(t *testing.T) { +func TestIntegrationBrowserRouting(t *testing.T) { apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) if apiKey == "" || baseURL == "" { @@ -26,6 +26,10 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { client := kernel.NewClient( option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + DirectToVMSubresources: []string{"process"}, + }), ) browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ @@ -43,12 +47,7 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { t.Fatal("expected browser base_url to be set") } - scoped, err := client.ForBrowser(browser) - if err != nil { - t.Fatalf("for browser: %v", err) - } - - execRes, err := scoped.Process.Exec(ctx, kernel.BrowserProcessExecParams{ + execRes, err := client.Browsers.Process.Exec(ctx, browser.SessionID, kernel.BrowserProcessExecParams{ Command: "echo", Args: []string{"hello"}, }) @@ -63,7 +62,11 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { if err != nil { t.Fatalf("new request: %v", err) } - resp, err := scoped.HTTPClient().Do(req) + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + resp, err := httpClient.Do(req) if err != nil { t.Fatalf("browser http client: %v", err) } diff --git a/lib/browserscope/route_cache.go b/lib/browserscope/route_cache.go new file mode 100644 index 0000000..b0d8d40 --- /dev/null +++ b/lib/browserscope/route_cache.go @@ -0,0 +1,153 @@ +package browserscope + +import ( + "net/http" + "net/url" + "strings" + "sync" + + "github.com/kernel/kernel-go-sdk/option" +) + +// Route identifies a cached direct-to-VM transport for one browser session. +type Route struct { + SessionID string + BaseURL string + JWT string +} + +// RouteCache stores browser session transport details keyed by session_id. +type RouteCache struct { + mu sync.RWMutex + routes map[string]Route +} + +// NewRouteCache returns an empty browser route cache. +func NewRouteCache() *RouteCache { + return &RouteCache{routes: map[string]Route{}} +} + +// Load returns the cached route for the given session id. +func (c *RouteCache) Load(sessionID string) (Route, bool) { + if c == nil { + return Route{}, false + } + c.mu.RLock() + defer c.mu.RUnlock() + route, ok := c.routes[sessionID] + return route, ok +} + +// Store normalizes and caches the given route. +func (c *RouteCache) Store(route Route) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.routes[strings.TrimSpace(route.SessionID)] = Route{ + SessionID: strings.TrimSpace(route.SessionID), + BaseURL: strings.TrimSpace(route.BaseURL), + JWT: strings.TrimSpace(route.JWT), + } +} + +// Delete removes a cached route. +func (c *RouteCache) Delete(sessionID string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.routes, sessionID) +} + +// Prime validates the browser reference and stores it in the cache. +func (c *RouteCache) Prime(ref Ref) error { + norm, err := ref.Normalize() + if err != nil { + return err + } + c.Store(Route{ + SessionID: norm.SessionID, + BaseURL: norm.BaseURL, + JWT: norm.JWT, + }) + return nil +} + +// DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to +// the browser VM using cached base_url and jwt data. +func DirectVMRoutingMiddleware(cache *RouteCache, directToVMSubresources []string) option.Middleware { + allowed := map[string]struct{}{} + for _, subresource := range directToVMSubresources { + if trimmed := strings.TrimSpace(subresource); trimmed != "" { + allowed[trimmed] = struct{}{} + } + } + + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) + if !ok { + return next(req) + } + if _, ok := allowed[subresource]; !ok { + return next(req) + } + route, ok := cache.Load(sessionID) + if !ok { + return next(req) + } + + base, err := url.Parse(route.BaseURL) + if err != nil { + return nil, err + } + req.Header.Del("Authorization") + if route.JWT != "" { + q := req.URL.Query() + if q.Get("jwt") == "" { + q.Set("jwt", route.JWT) + req.URL.RawQuery = q.Encode() + } + } + + req.URL.Scheme = base.Scheme + req.URL.Host = base.Host + req.Host = base.Host + req.URL.Path = joinURLPath(base.Path, subresource, suffix) + + return next(req) + } +} + +func parseDirectVMPath(path string) (sessionID, subresource, suffix string, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i+2 < len(parts); i++ { + if parts[i] != "browsers" { + continue + } + sessionID = parts[i+1] + subresource = parts[i+2] + if sessionID == "" || subresource == "" { + return "", "", "", false + } + if i+3 < len(parts) { + suffix = "/" + strings.Join(parts[i+3:], "/") + } + return sessionID, subresource, suffix, true + } + return "", "", "", false +} + +func joinURLPath(basePath, subresource, suffix string) string { + base := "/" + strings.Trim(strings.TrimSpace(basePath), "/") + if base == "/" { + base = "" + } + out := base + "/" + strings.TrimPrefix(subresource, "/") + if suffix != "" { + out += "/" + strings.TrimPrefix(suffix, "/") + } + return out +} diff --git a/scripts/generate-browser-session b/scripts/generate-browser-session deleted file mode 100755 index 5d5479a..0000000 --- a/scripts/generate-browser-session +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go diff --git a/scripts/lint b/scripts/lint index e577c0d..7e03a7b 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,12 +4,6 @@ set -e cd "$(dirname "$0")/.." -echo "==> Regenerating browser-scoped bindings" -./scripts/generate-browser-session - -echo "==> Verifying generated browser-scoped bindings are committed" -git diff --exit-code -- browser_session_services_gen.go - echo "==> Running Go build" go build ./... From 6bdf25f058d2e0bb552bd47ba4478222d97aa254 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 11:43:34 -0400 Subject: [PATCH 08/11] refactor: simplify direct-to-vm route caching Drop the extra cache priming helper, remove metro wording, and rename the example so the go diff stays focused on direct-to-VM routing. Made-with: Cursor --- browser.go | 16 ++++++++-------- browser_routing.go | 19 ++++++++++++++++--- browser_session_httpclient_test.go | 4 ++-- browserpool.go | 2 +- .../main.go | 0 invocation.go | 2 +- lib/browserscope/ref.go | 6 +++--- lib/browserscope/route_cache.go | 14 -------------- 8 files changed, 31 insertions(+), 32 deletions(-) rename examples/{browser-scoped => browser-routing}/main.go (100%) diff --git a/browser.go b/browser.go index 1423df6..867d233 100644 --- a/browser.go +++ b/browser.go @@ -71,7 +71,7 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts .. path := "browsers" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) if err == nil && res != nil { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -86,7 +86,7 @@ func (r *BrowserService) Get(ctx context.Context, id string, query BrowserGetPar path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) if err == nil && res != nil { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -101,7 +101,7 @@ func (r *BrowserService) Update(ctx context.Context, id string, body BrowserUpda path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) if err == nil && res != nil { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -123,7 +123,7 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts } res.SetPageConfig(cfg, raw) for _, item := range res.Items { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) } return res, nil } @@ -343,7 +343,7 @@ type BrowserNewResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -426,7 +426,7 @@ type BrowserGetResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -509,7 +509,7 @@ type BrowserUpdateResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -592,7 +592,7 @@ type BrowserListResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/browser_routing.go b/browser_routing.go index 3c30647..3378f57 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -61,11 +61,24 @@ func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.Rou return nil } -func primeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { +func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { cache := browserRouteCacheFromOptions(opts) for _, ref := range refs { - if cache != nil { - _ = cache.Prime(ref) + route, ok := browserRouteFromRef(ref) + if cache != nil && ok { + cache.Store(route) } } } + +func browserRouteFromRef(ref browserscope.Ref) (browserscope.Route, bool) { + norm, err := ref.Normalize() + if err != nil { + return browserscope.Route{}, false + } + return browserscope.Route{ + SessionID: norm.SessionID, + BaseURL: norm.BaseURL, + JWT: norm.JWT, + }, true +} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index 41ec505..d846cb2 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -29,7 +29,7 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - primeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserscope.Ref{ SessionID: "sid", BaseURL: srv.URL + "/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", @@ -63,7 +63,7 @@ func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { option.WithAPIKey("sk"), ) - primeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserscope.Ref{ SessionID: "sid", BaseURL: "https://browser-session.test/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", diff --git a/browserpool.go b/browserpool.go index 3069e49..009bce2 100644 --- a/browserpool.go +++ b/browserpool.go @@ -255,7 +255,7 @@ type BrowserPoolAcquireResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/examples/browser-scoped/main.go b/examples/browser-routing/main.go similarity index 100% rename from examples/browser-scoped/main.go rename to examples/browser-routing/main.go diff --git a/invocation.go b/invocation.go index 872a775..23346ca 100644 --- a/invocation.go +++ b/invocation.go @@ -552,7 +552,7 @@ type InvocationListBrowsersResponseBrowser struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 603a94b..5c384dd 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -5,9 +5,9 @@ import ( "strings" ) -// Ref identifies a browser session for browser-scoped HTTP calls. SessionID is -// reserved for future client-side routing; browser-scoped requests rewrite -// the /browsers/{SessionID}/ path segment against the returned base_url. +// Ref identifies a browser session for direct-to-VM HTTP calls. SessionID is +// reserved for future client-side routing; allowlisted requests rewrite the +// /browsers/{SessionID}/ path segment against the returned base_url. type Ref struct { SessionID string BaseURL string diff --git a/lib/browserscope/route_cache.go b/lib/browserscope/route_cache.go index b0d8d40..dc3cd5d 100644 --- a/lib/browserscope/route_cache.go +++ b/lib/browserscope/route_cache.go @@ -62,20 +62,6 @@ func (c *RouteCache) Delete(sessionID string) { delete(c.routes, sessionID) } -// Prime validates the browser reference and stores it in the cache. -func (c *RouteCache) Prime(ref Ref) error { - norm, err := ref.Normalize() - if err != nil { - return err - } - c.Store(Route{ - SessionID: norm.SessionID, - BaseURL: norm.BaseURL, - JWT: norm.JWT, - }) - return nil -} - // DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to // the browser VM using cached base_url and jwt data. func DirectVMRoutingMiddleware(cache *RouteCache, directToVMSubresources []string) option.Middleware { From 909c377e21adddbaede526ba2a356352652e947b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 12:57:08 -0400 Subject: [PATCH 09/11] refactor: rename browser routing subresources config Rename the browser routing allowlist field to Subresources so the direct-to-VM configuration is shorter and easier to read. Made-with: Cursor --- browser_routing.go | 6 +++--- browser_routing_test.go | 4 ++-- examples/browser-routing/main.go | 4 ++-- lib/browserscope/integration_test.go | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/browser_routing.go b/browser_routing.go index 3378f57..5e42fdb 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -8,8 +8,8 @@ import ( // BrowserRoutingConfig controls which browser subresources route directly to the browser VM. type BrowserRoutingConfig struct { - Enabled bool - DirectToVMSubresources []string + Enabled bool + Subresources []string } type browserRoutingOption struct { @@ -30,7 +30,7 @@ func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error { if !o.config.Enabled { return nil } - r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.DirectToVMSubresources)) + r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) return nil } diff --git a/browser_routing_test.go b/browser_routing_test.go index 4e0802b..fede55c 100644 --- a/browser_routing_test.go +++ b/browser_routing_test.go @@ -45,7 +45,7 @@ func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) option.WithBaseURL(srv.URL), option.WithAPIKey("sk_test"), option.WithHTTPClient(srv.Client()), - WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"process"}}), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"process"}}), ) if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { @@ -97,7 +97,7 @@ func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) option.WithBaseURL(srv.URL), option.WithAPIKey("sk_test"), option.WithHTTPClient(srv.Client()), - WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"computer"}}), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"computer"}}), ) if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { diff --git a/examples/browser-routing/main.go b/examples/browser-routing/main.go index 8369943..3e3591c 100644 --- a/examples/browser-routing/main.go +++ b/examples/browser-routing/main.go @@ -13,8 +13,8 @@ func main() { ctx := context.Background() client := kernel.NewClient( kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - DirectToVMSubresources: []string{"process"}, + Enabled: true, + Subresources: []string{"process"}, }), ) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index 7c4e5eb..f2bef82 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -27,8 +27,8 @@ func TestIntegrationBrowserRouting(t *testing.T) { option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - DirectToVMSubresources: []string{"process"}, + Enabled: true, + Subresources: []string{"process"}, }), ) From 77bda33323c48a60ab652bf42a39cc2bc1f909a0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 15:22:11 -0400 Subject: [PATCH 10/11] fix: clean up go browser routing follow-ups Rename the handwritten routing helpers to browserrouting, fix the shared-cache and RawPath issues, and revert the generated/module churn that should not stay in the PR. Made-with: Cursor --- browser.go | 37 ++----- browser_http_client.go | 34 ++++++ browser_routing.go | 26 ++--- browser_session_httpclient_test.go | 6 +- browserpool.go | 2 +- client.go | 15 ++- go.mod | 5 +- go.sum | 8 -- invocation.go | 2 +- lib/browserrouting/integration_test.go | 87 +++++++++++++++ lib/browserrouting/jwt.go | 25 +++++ lib/browserrouting/jwt_test.go | 21 ++++ lib/browserrouting/rawcurl.go | 81 ++++++++++++++ lib/browserrouting/rawcurl_test.go | 54 ++++++++++ lib/browserrouting/ref.go | 43 ++++++++ lib/browserrouting/route_cache.go | 140 +++++++++++++++++++++++++ lib/browserrouting/route_cache_test.go | 52 +++++++++ option/requestoption.go | 2 +- 18 files changed, 576 insertions(+), 64 deletions(-) create mode 100644 browser_http_client.go create mode 100644 lib/browserrouting/integration_test.go create mode 100644 lib/browserrouting/jwt.go create mode 100644 lib/browserrouting/jwt_test.go create mode 100644 lib/browserrouting/rawcurl.go create mode 100644 lib/browserrouting/rawcurl_test.go create mode 100644 lib/browserrouting/ref.go create mode 100644 lib/browserrouting/route_cache.go create mode 100644 lib/browserrouting/route_cache_test.go diff --git a/browser.go b/browser.go index 867d233..21ef07d 100644 --- a/browser.go +++ b/browser.go @@ -19,7 +19,7 @@ import ( "github.com/kernel/kernel-go-sdk/internal/apijson" "github.com/kernel/kernel-go-sdk/internal/apiquery" "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/param" @@ -71,7 +71,7 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts .. path := "browsers" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) if err == nil && res != nil { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -86,7 +86,7 @@ func (r *BrowserService) Get(ctx context.Context, id string, query BrowserGetPar path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...) if err == nil && res != nil { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -101,7 +101,7 @@ func (r *BrowserService) Update(ctx context.Context, id string, body BrowserUpda path := fmt.Sprintf("browsers/%s", id) err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...) if err == nil && res != nil { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -123,7 +123,7 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts } res.SetPageConfig(cfg, raw) for _, item := range res.Items { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) } return res, nil } @@ -160,25 +160,6 @@ func (r *BrowserService) Curl(ctx context.Context, id string, body BrowserCurlPa return res, err } -// HTTPClient returns an [http.Client] that performs HTTP requests through the -// browser VM's internal /curl/raw path using cached browser route data. -func (r *BrowserService) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) { - opts = slices.Concat(r.Options, opts) - cache := browserRouteCacheFromOptions(opts) - if cache == nil { - return nil, fmt.Errorf("kernel: browser route cache is not configured") - } - route, ok := cache.Load(id) - if !ok { - return nil, fmt.Errorf("kernel: browser route cache does not contain session %s", id) - } - cfg, err := requestconfig.PreRequestOptions(opts...) - if err != nil { - return browserscope.HTTPClient(route.BaseURL, route.JWT, nil), nil - } - return browserscope.HTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil -} - // Delete a browser session by ID func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) { opts = slices.Concat(r.Options, opts) @@ -343,7 +324,7 @@ type BrowserNewResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -426,7 +407,7 @@ type BrowserGetResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -509,7 +490,7 @@ type BrowserUpdateResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -592,7 +573,7 @@ type BrowserListResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/browser_http_client.go b/browser_http_client.go new file mode 100644 index 0000000..d128d0c --- /dev/null +++ b/browser_http_client.go @@ -0,0 +1,34 @@ +package kernel + +import ( + "context" + "fmt" + "net/http" + "slices" + + "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" + "github.com/kernel/kernel-go-sdk/option" +) + +// HTTPClient returns an [http.Client] that performs HTTP requests through the +// browser VM's internal /curl/raw path using cached browser route data. +func (r *BrowserService) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) { + opts = slices.Concat(r.Options, opts) + cache := browserRouteCacheFromOptions(opts) + if cache == nil { + return nil, fmt.Errorf("kernel: browser route cache is not configured") + } + + route, ok := cache.Load(id) + if !ok { + return nil, fmt.Errorf("kernel: browser route cache does not contain session %s", id) + } + + cfg, err := requestconfig.NewRequestConfig(context.Background(), http.MethodGet, "https://example.com", nil, nil, opts...) + if err != nil { + return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, nil), nil + } + + return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil +} diff --git a/browser_routing.go b/browser_routing.go index 5e42fdb..330e951 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -2,7 +2,7 @@ package kernel import ( "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -13,12 +13,12 @@ type BrowserRoutingConfig struct { } type browserRoutingOption struct { - cache *browserscope.RouteCache + cache *browserrouting.RouteCache config BrowserRoutingConfig } type browserRouteCacheOption struct { - cache *browserscope.RouteCache + cache *browserrouting.RouteCache } // WithBrowserRouting enables direct-to-VM routing for the configured browser subresources. @@ -30,11 +30,11 @@ func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error { if !o.config.Enabled { return nil } - r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) + r.Middlewares = append(r.Middlewares, browserrouting.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) return nil } -func (o *browserRoutingOption) browserRouteCache() *browserscope.RouteCache { +func (o *browserRoutingOption) browserRouteCache() *browserrouting.RouteCache { return o.cache } @@ -42,17 +42,17 @@ func (o *browserRouteCacheOption) Apply(*requestconfig.RequestConfig) error { return nil } -func (o *browserRouteCacheOption) browserRouteCache() *browserscope.RouteCache { +func (o *browserRouteCacheOption) browserRouteCache() *browserrouting.RouteCache { return o.cache } -func withBrowserRouteCache(cache *browserscope.RouteCache) option.RequestOption { +func withBrowserRouteCache(cache *browserrouting.RouteCache) option.RequestOption { return &browserRouteCacheOption{cache: cache} } -func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.RouteCache { +func browserRouteCacheFromOptions(opts []option.RequestOption) *browserrouting.RouteCache { for _, opt := range opts { - if carrier, ok := opt.(interface{ browserRouteCache() *browserscope.RouteCache }); ok { + if carrier, ok := opt.(interface{ browserRouteCache() *browserrouting.RouteCache }); ok { if cache := carrier.browserRouteCache(); cache != nil { return cache } @@ -61,7 +61,7 @@ func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.Rou return nil } -func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { +func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserrouting.Ref) { cache := browserRouteCacheFromOptions(opts) for _, ref := range refs { route, ok := browserRouteFromRef(ref) @@ -71,12 +71,12 @@ func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Re } } -func browserRouteFromRef(ref browserscope.Ref) (browserscope.Route, bool) { +func browserRouteFromRef(ref browserrouting.Ref) (browserrouting.Route, bool) { norm, err := ref.Normalize() if err != nil { - return browserscope.Route{}, false + return browserrouting.Route{}, false } - return browserscope.Route{ + return browserrouting.Route{ SessionID: norm.SessionID, BaseURL: norm.BaseURL, JWT: norm.JWT, diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index d846cb2..63d0f99 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -29,7 +29,7 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - storeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserrouting.Ref{ SessionID: "sid", BaseURL: srv.URL + "/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", @@ -63,7 +63,7 @@ func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { option.WithAPIKey("sk"), ) - storeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserrouting.Ref{ SessionID: "sid", BaseURL: "https://browser-session.test/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", diff --git a/browserpool.go b/browserpool.go index 009bce2..3069e49 100644 --- a/browserpool.go +++ b/browserpool.go @@ -255,7 +255,7 @@ type BrowserPoolAcquireResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/client.go b/client.go index 9604f0c..1b719cf 100644 --- a/client.go +++ b/client.go @@ -9,7 +9,7 @@ import ( "slices" "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -19,7 +19,7 @@ import ( type Client struct { Options []option.RequestOption // BrowserRouteCache stores cached base_url and jwt data for direct-to-VM routing. - BrowserRouteCache *browserscope.RouteCache + BrowserRouteCache *browserrouting.RouteCache // Create and manage app deployments and stream deployment events. Deployments DeploymentService // List applications and versions. @@ -64,13 +64,18 @@ func DefaultClientOptions() []option.RequestOption { // the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) - cache := browserscope.NewRouteCache() + cache := browserrouting.NewRouteCache() + nextOpts := make([]option.RequestOption, 0, len(opts)+1) for _, opt := range opts { if routing, ok := opt.(*browserRoutingOption); ok { - routing.cache = cache + cloned := *routing + cloned.cache = cache + nextOpts = append(nextOpts, &cloned) + continue } + nextOpts = append(nextOpts, opt) } - opts = append(opts, withBrowserRouteCache(cache)) + opts = append(nextOpts, withBrowserRouteCache(cache)) r = Client{Options: opts, BrowserRouteCache: cache} diff --git a/go.mod b/go.mod index cd5b1e5..e5d1e20 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,13 @@ module github.com/kernel/kernel-go-sdk -go 1.23.0 +go 1.22 require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - golang.org/x/tools v0.31.0 ) require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index abdea0d..32ba293 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -10,9 +8,3 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= diff --git a/invocation.go b/invocation.go index 23346ca..872a775 100644 --- a/invocation.go +++ b/invocation.go @@ -552,7 +552,7 @@ type InvocationListBrowsersResponseBrowser struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/lib/browserrouting/integration_test.go b/lib/browserrouting/integration_test.go new file mode 100644 index 0000000..f1d29db --- /dev/null +++ b/lib/browserrouting/integration_test.go @@ -0,0 +1,87 @@ +package browserrouting_test + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestIntegrationBrowserRouting(t *testing.T) { + apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) + baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) + if apiKey == "" || baseURL == "" { + t.Skip("set KERNEL_API_KEY and KERNEL_BASE_URL to run integration test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := kernel.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + Subresources: []string{"process"}, + }), + ) + + browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ + Headless: kernel.Bool(true), + TimeoutSeconds: kernel.Int(60), + }) + if err != nil { + t.Fatalf("create browser: %v", err) + } + t.Cleanup(func() { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + }) + + if browser.BaseURL == "" { + t.Fatal("expected browser base_url to be set") + } + + execRes, err := client.Browsers.Process.Exec(ctx, browser.SessionID, kernel.BrowserProcessExecParams{ + Command: "echo", + Args: []string{"hello"}, + }) + if err != nil { + t.Fatalf("process exec: %v", err) + } + if execRes.ExitCode != 0 { + t.Fatalf("expected process exit code 0, got %d", execRes.ExitCode) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + t.Fatalf("expected successful browser http response, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got == "" { + t.Fatal("expected raw browser response to include content type") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read raw browser response: %v", err) + } + if len(body) == 0 { + t.Fatal("expected raw browser response body to be non-empty") + } +} diff --git a/lib/browserrouting/jwt.go b/lib/browserrouting/jwt.go new file mode 100644 index 0000000..98f676e --- /dev/null +++ b/lib/browserrouting/jwt.go @@ -0,0 +1,25 @@ +package browserrouting + +import ( + "fmt" + "net/url" + "strings" +) + +// jwtFromWebSocketURL extracts the session jwt query parameter from a browser +// websocket URL (for example cdp_ws_url or webdriver_ws_url). +func jwtFromWebSocketURL(wsURL string) (string, error) { + wsURL = strings.TrimSpace(wsURL) + if wsURL == "" { + return "", fmt.Errorf("browserrouting: empty websocket url") + } + u, err := url.Parse(wsURL) + if err != nil { + return "", fmt.Errorf("browserrouting: parse websocket url: %w", err) + } + jwt := u.Query().Get("jwt") + if jwt == "" { + return "", fmt.Errorf("browserrouting: missing jwt query parameter") + } + return jwt, nil +} diff --git a/lib/browserrouting/jwt_test.go b/lib/browserrouting/jwt_test.go new file mode 100644 index 0000000..c206784 --- /dev/null +++ b/lib/browserrouting/jwt_test.go @@ -0,0 +1,21 @@ +package browserrouting + +import "testing" + +func TestJWTFromWebSocketURL(t *testing.T) { + const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" + j, err := jwtFromWebSocketURL(u) + if err != nil { + t.Fatal(err) + } + if j != "abc123" { + t.Fatalf("jwt: got %q want abc123", j) + } +} + +func TestJWTFromWebSocketURLMissing(t *testing.T) { + _, err := jwtFromWebSocketURL("wss://browser.example/browser/cdp") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/lib/browserrouting/rawcurl.go b/lib/browserrouting/rawcurl.go new file mode 100644 index 0000000..ce4d6eb --- /dev/null +++ b/lib/browserrouting/rawcurl.go @@ -0,0 +1,81 @@ +package browserrouting + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// RawCURLRoundTripper implements browser-egress HTTP by tunneling through the +// browser session base_url /curl/raw endpoint. +type RawCURLRoundTripper struct { + browserBaseURL string + jwt string + underlying http.RoundTripper +} + +var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) + +// NewHTTPClient returns an [http.Client] that performs browser egress HTTP via +// the browser session base_url and internal /curl/raw path. +func NewHTTPClient(browserBaseURL, jwt string, underlying *http.Client) *http.Client { + if underlying == nil { + underlying = http.DefaultClient + } + rt := underlying.Transport + if rt == nil { + rt = http.DefaultTransport + } + return &http.Client{ + Transport: newRawCURLRoundTripper(browserBaseURL, jwt, rt), + Timeout: underlying.Timeout, + } +} + +// newRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// to {base_url}/curl/raw?jwt=...&url=, preserving method, +// headers, and body. The caller's request URL must be an absolute http(s) URL. +func newRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { + if underlying == nil { + underlying = http.DefaultTransport + } + return &RawCURLRoundTripper{ + browserBaseURL: strings.TrimRight(strings.TrimSpace(browserBaseURL), "/"), + jwt: strings.TrimSpace(jwt), + underlying: underlying, + } +} + +// RoundTrip implements [http.RoundTripper]. +func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL == nil || !req.URL.IsAbs() { + return nil, fmt.Errorf("browserrouting: raw curl requires an absolute request URL (got %q)", req.URL) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return nil, fmt.Errorf("browserrouting: raw curl requires http or https scheme") + } + if t.browserBaseURL == "" { + return nil, fmt.Errorf("browserrouting: browser base_url is required") + } + if t.jwt == "" { + return nil, fmt.Errorf("browserrouting: jwt is required for raw curl") + } + + target := req.URL.String() + proxyURL, err := url.Parse(t.browserBaseURL + "/curl/raw") + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("jwt", t.jwt) + q.Set("url", target) + proxyURL.RawQuery = q.Encode() + + out := req.Clone(req.Context()) + out.URL = proxyURL + out.Host = proxyURL.Host + out.RequestURI = "" + + return t.underlying.RoundTrip(out) +} diff --git a/lib/browserrouting/rawcurl_test.go b/lib/browserrouting/rawcurl_test.go new file mode 100644 index 0000000..57982b2 --- /dev/null +++ b/lib/browserrouting/rawcurl_test.go @@ -0,0 +1,54 @@ +package browserrouting + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRawCURLRoundTripper(t *testing.T) { + var sawPath, sawRaw string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawPath = r.URL.Path + sawRaw = r.URL.RawQuery + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("ok")) + })) + defer up.Close() + + rt := newRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) + + client := &http.Client{Transport: rt} + req, err := http.NewRequest(http.MethodGet, "https://example.org/foo?bar=1", nil) + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusTeapot { + t.Fatalf("status: %d", res.StatusCode) + } + b, _ := io.ReadAll(res.Body) + if string(b) != "ok" { + t.Fatalf("body: %q", b) + } + if sawPath != "/browser/kernel/curl/raw" { + t.Fatalf("path: %s", sawPath) + } + if sawRaw == "" { + t.Fatal("expected query") + } +} + +func TestRawCURLRoundTripperRelativeURL(t *testing.T) { + rt := newRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) + req, _ := http.NewRequest(http.MethodGet, "/relative", nil) + _, err := rt.RoundTrip(req) + if err == nil { + t.Fatal("expected error for relative url") + } +} diff --git a/lib/browserrouting/ref.go b/lib/browserrouting/ref.go new file mode 100644 index 0000000..d98e497 --- /dev/null +++ b/lib/browserrouting/ref.go @@ -0,0 +1,43 @@ +package browserrouting + +import ( + "fmt" + "strings" +) + +// Ref identifies a browser session for direct-to-VM HTTP calls. SessionID is +// reserved for future client-side routing; allowlisted requests rewrite the +// /browsers/{SessionID}/ path segment against the returned base_url. +type Ref struct { + SessionID string + BaseURL string + JWT string + CdpWsURL string +} + +// Normalize validates fields and fills JWT from CdpWsURL when JWT is empty. +func (r Ref) Normalize() (Ref, error) { + if strings.TrimSpace(r.BaseURL) == "" { + return Ref{}, fmt.Errorf("browserrouting: base_url is required") + } + if strings.TrimSpace(r.SessionID) == "" { + return Ref{}, fmt.Errorf("browserrouting: session_id is required") + } + out := r + out.BaseURL = strings.TrimSpace(r.BaseURL) + out.SessionID = strings.TrimSpace(r.SessionID) + out.JWT = strings.TrimSpace(r.JWT) + out.CdpWsURL = strings.TrimSpace(r.CdpWsURL) + if out.JWT == "" { + src := out.CdpWsURL + if src == "" { + return Ref{}, fmt.Errorf("browserrouting: jwt or cdp_ws_url is required") + } + jwt, err := jwtFromWebSocketURL(src) + if err != nil { + return Ref{}, err + } + out.JWT = jwt + } + return out, nil +} diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go new file mode 100644 index 0000000..98fca6e --- /dev/null +++ b/lib/browserrouting/route_cache.go @@ -0,0 +1,140 @@ +package browserrouting + +import ( + "net/http" + "net/url" + "strings" + "sync" + + "github.com/kernel/kernel-go-sdk/option" +) + +// Route identifies a cached direct-to-VM transport for one browser session. +type Route struct { + SessionID string + BaseURL string + JWT string +} + +// RouteCache stores browser session transport details keyed by session_id. +type RouteCache struct { + mu sync.RWMutex + routes map[string]Route +} + +// NewRouteCache returns an empty browser route cache. +func NewRouteCache() *RouteCache { + return &RouteCache{routes: map[string]Route{}} +} + +// Load returns the cached route for the given session id. +func (c *RouteCache) Load(sessionID string) (Route, bool) { + if c == nil { + return Route{}, false + } + c.mu.RLock() + defer c.mu.RUnlock() + route, ok := c.routes[sessionID] + return route, ok +} + +// Store normalizes and caches the given route. +func (c *RouteCache) Store(route Route) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.routes[strings.TrimSpace(route.SessionID)] = Route{ + SessionID: strings.TrimSpace(route.SessionID), + BaseURL: strings.TrimSpace(route.BaseURL), + JWT: strings.TrimSpace(route.JWT), + } +} + +// Delete removes a cached route. +func (c *RouteCache) Delete(sessionID string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.routes, sessionID) +} + +// DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to +// the browser VM using cached base_url and jwt data. +func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.Middleware { + allowed := map[string]struct{}{} + for _, subresource := range subresources { + if trimmed := strings.TrimSpace(subresource); trimmed != "" { + allowed[trimmed] = struct{}{} + } + } + + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) + if !ok { + return next(req) + } + if _, ok := allowed[subresource]; !ok { + return next(req) + } + route, ok := cache.Load(sessionID) + if !ok { + return next(req) + } + + base, err := url.Parse(route.BaseURL) + if err != nil { + return nil, err + } + req.Header.Del("Authorization") + if route.JWT != "" { + q := req.URL.Query() + if q.Get("jwt") == "" { + q.Set("jwt", route.JWT) + req.URL.RawQuery = q.Encode() + } + } + + req.URL.Scheme = base.Scheme + req.URL.Host = base.Host + req.Host = base.Host + req.URL.Path = joinURLPath(base.Path, subresource, suffix) + req.URL.RawPath = "" + + return next(req) + } +} + +func parseDirectVMPath(path string) (sessionID, subresource, suffix string, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i+2 < len(parts); i++ { + if parts[i] != "browsers" { + continue + } + sessionID = parts[i+1] + subresource = parts[i+2] + if sessionID == "" || subresource == "" { + return "", "", "", false + } + if i+3 < len(parts) { + suffix = "/" + strings.Join(parts[i+3:], "/") + } + return sessionID, subresource, suffix, true + } + return "", "", "", false +} + +func joinURLPath(basePath, subresource, suffix string) string { + base := "/" + strings.Trim(strings.TrimSpace(basePath), "/") + if base == "/" { + base = "" + } + out := base + "/" + strings.TrimPrefix(subresource, "/") + if suffix != "" { + out += "/" + strings.TrimPrefix(suffix, "/") + } + return out +} diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go new file mode 100644 index 0000000..aa84b58 --- /dev/null +++ b/lib/browserrouting/route_cache_test.go @@ -0,0 +1,52 @@ +package browserrouting + +import ( + "net/http" + "net/url" + "testing" +) + +func TestDirectVMRoutingMiddlewareClearsStaleRawPath(t *testing.T) { + cache := NewRouteCache() + cache.Store(Route{ + SessionID: "sess-1", + BaseURL: "https://browser.example/browser/kernel", + JWT: "jwt-123", + }) + + middleware := DirectVMRoutingMiddleware(cache, []string{"process"}) + reqURL, err := url.Parse("https://api.example/browsers/sess-1/process/exec") + if err != nil { + t.Fatal(err) + } + reqURL.RawPath = "/browsers/sess-1/process/%65xec" + + req := &http.Request{ + URL: reqURL, + Header: http.Header{"Authorization": []string{"Bearer sk_test"}}, + } + + var got *http.Request + _, err = middleware(req, func(next *http.Request) (*http.Response, error) { + got = next + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("expected middleware to invoke next handler") + } + if got.URL.Path != "/browser/kernel/process/exec" { + t.Fatalf("expected rewritten path, got %q", got.URL.Path) + } + if got.URL.RawPath != "" { + t.Fatalf("expected stale raw path to be cleared, got %q", got.URL.RawPath) + } + if got.URL.Query().Get("jwt") != "jwt-123" { + t.Fatalf("expected jwt query param, got %q", got.URL.Query().Get("jwt")) + } + if got.Header.Get("Authorization") != "" { + t.Fatalf("expected authorization to be stripped, got %q", got.Header.Get("Authorization")) + } +} diff --git a/option/requestoption.go b/option/requestoption.go index 82b0927..fd632db 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -56,7 +56,7 @@ type HTTPClient interface { // For custom uses cases, it is recommended to provide an [*http.Client] with a custom // [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. func WithHTTPClient(client HTTPClient) RequestOption { - return requestconfig.PreRequestOptionFunc(func(r *requestconfig.RequestConfig) error { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { if client == nil { return fmt.Errorf("requestoption: custom http client cannot be nil") } From d594f39a352e2ad5639807f222bb16535cce72c6 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 15:22:21 -0400 Subject: [PATCH 11/11] fix: remove old go browser scope package Drop the superseded lib/browserscope files now that the renamed browserrouting package owns the direct-to-VM helpers. Made-with: Cursor --- lib/browserscope/integration_test.go | 87 ----------------- lib/browserscope/jwt.go | 25 ----- lib/browserscope/jwt_test.go | 21 ---- lib/browserscope/middleware.go | 41 -------- lib/browserscope/middleware_test.go | 57 ----------- lib/browserscope/rawcurl.go | 81 ---------------- lib/browserscope/rawcurl_test.go | 54 ----------- lib/browserscope/ref.go | 43 --------- lib/browserscope/route_cache.go | 139 --------------------------- 9 files changed, 548 deletions(-) delete mode 100644 lib/browserscope/integration_test.go delete mode 100644 lib/browserscope/jwt.go delete mode 100644 lib/browserscope/jwt_test.go delete mode 100644 lib/browserscope/middleware.go delete mode 100644 lib/browserscope/middleware_test.go delete mode 100644 lib/browserscope/rawcurl.go delete mode 100644 lib/browserscope/rawcurl_test.go delete mode 100644 lib/browserscope/ref.go delete mode 100644 lib/browserscope/route_cache.go diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go deleted file mode 100644 index f2bef82..0000000 --- a/lib/browserscope/integration_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package browserscope_test - -import ( - "context" - "io" - "net/http" - "os" - "strings" - "testing" - "time" - - kernel "github.com/kernel/kernel-go-sdk" - "github.com/kernel/kernel-go-sdk/option" -) - -func TestIntegrationBrowserRouting(t *testing.T) { - apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) - baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) - if apiKey == "" || baseURL == "" { - t.Skip("set KERNEL_API_KEY and KERNEL_BASE_URL to run integration test") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - client := kernel.NewClient( - option.WithAPIKey(apiKey), - option.WithBaseURL(baseURL), - kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - Subresources: []string{"process"}, - }), - ) - - browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ - Headless: kernel.Bool(true), - TimeoutSeconds: kernel.Int(60), - }) - if err != nil { - t.Fatalf("create browser: %v", err) - } - t.Cleanup(func() { - _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) - }) - - if browser.BaseURL == "" { - t.Fatal("expected browser base_url to be set") - } - - execRes, err := client.Browsers.Process.Exec(ctx, browser.SessionID, kernel.BrowserProcessExecParams{ - Command: "echo", - Args: []string{"hello"}, - }) - if err != nil { - t.Fatalf("process exec: %v", err) - } - if execRes.ExitCode != 0 { - t.Fatalf("expected process exit code 0, got %d", execRes.ExitCode) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) - if err != nil { - t.Fatalf("new request: %v", err) - } - httpClient, err := client.Browsers.HTTPClient(browser.SessionID) - if err != nil { - t.Fatalf("browser http client: %v", err) - } - resp, err := httpClient.Do(req) - if err != nil { - t.Fatalf("browser http client: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 400 { - t.Fatalf("expected successful browser http response, got %d", resp.StatusCode) - } - if got := resp.Header.Get("Content-Type"); got == "" { - t.Fatal("expected raw browser response to include content type") - } - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("read raw browser response: %v", err) - } - if len(body) == 0 { - t.Fatal("expected raw browser response body to be non-empty") - } -} diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go deleted file mode 100644 index 5e1f857..0000000 --- a/lib/browserscope/jwt.go +++ /dev/null @@ -1,25 +0,0 @@ -package browserscope - -import ( - "fmt" - "net/url" - "strings" -) - -// jwtFromWebSocketURL extracts the session jwt query parameter from a browser -// websocket URL (for example cdp_ws_url or webdriver_ws_url). -func jwtFromWebSocketURL(wsURL string) (string, error) { - wsURL = strings.TrimSpace(wsURL) - if wsURL == "" { - return "", fmt.Errorf("browserscope: empty websocket url") - } - u, err := url.Parse(wsURL) - if err != nil { - return "", fmt.Errorf("browserscope: parse websocket url: %w", err) - } - jwt := u.Query().Get("jwt") - if jwt == "" { - return "", fmt.Errorf("browserscope: missing jwt query parameter") - } - return jwt, nil -} diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go deleted file mode 100644 index 220ce77..0000000 --- a/lib/browserscope/jwt_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package browserscope - -import "testing" - -func TestJWTFromWebSocketURL(t *testing.T) { - const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" - j, err := jwtFromWebSocketURL(u) - if err != nil { - t.Fatal(err) - } - if j != "abc123" { - t.Fatalf("jwt: got %q want abc123", j) - } -} - -func TestJWTFromWebSocketURLMissing(t *testing.T) { - _, err := jwtFromWebSocketURL("wss://browser.example/browser/cdp") - if err == nil { - t.Fatal("expected error") - } -} diff --git a/lib/browserscope/middleware.go b/lib/browserscope/middleware.go deleted file mode 100644 index 8f9f7bf..0000000 --- a/lib/browserscope/middleware.go +++ /dev/null @@ -1,41 +0,0 @@ -package browserscope - -import ( - "net/http" - "strings" - - "github.com/kernel/kernel-go-sdk/option" -) - -// BrowserSessionMiddleware prepares requests for a browser session base_url: -// it strips the control-plane browsers/{session_id} path prefix, attaches jwt, -// and removes Authorization so the Kernel API key is not forwarded to the browser. -func BrowserSessionMiddleware(sessionID, jwt string) option.Middleware { - marker := "/browsers/" + sessionID + "/" - return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - req.Header.Del("Authorization") - - if jwt != "" { - q := req.URL.Query() - if q.Get("jwt") == "" { - q.Set("jwt", jwt) - req.URL.RawQuery = q.Encode() - } - } - - if sessionID != "" { - if idx := strings.Index(req.URL.Path, marker); idx >= 0 { - prefix := strings.TrimRight(req.URL.Path[:idx], "/") - rest := strings.TrimPrefix(req.URL.Path[idx+len(marker):], "/") - if rest == "" { - rest = "/" - } else { - rest = "/" + rest - } - req.URL.Path = prefix + rest - } - } - - return next(req) - } -} diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go deleted file mode 100644 index afd1efe..0000000 --- a/lib/browserscope/middleware_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package browserscope - -import ( - "net/http" - "net/url" - "testing" - - "github.com/kernel/kernel-go-sdk/option" -) - -func TestBrowserSessionMiddleware(t *testing.T) { - mw := BrowserSessionMiddleware("sess1", "tok") - var final *http.Request - next := func(req *http.Request) (*http.Response, error) { - final = req - return nil, nil - } - - u, err := url.Parse("https://host/browser/kernel/browsers/sess1/process/exec?x=1") - if err != nil { - t.Fatal(err) - } - u.Path = "/browser/kernel/browsers/sess1/process/exec" - req := &http.Request{URL: u, Header: http.Header{"Authorization": {"Bearer sk"}}} - _, _ = mw(req, next) - - if final.Header.Get("Authorization") != "" { - t.Fatal("authorization should be stripped") - } - if final.URL.Query().Get("jwt") != "tok" { - t.Fatalf("jwt query: got %q", final.URL.Query().Get("jwt")) - } - if final.URL.Path != "/browser/kernel/process/exec" { - t.Fatalf("path rewrite: got %s", final.URL.Path) - } -} - -func TestBrowserSessionMiddlewarePreservesExistingJWT(t *testing.T) { - mw := BrowserSessionMiddleware("sess1", "tok") - var final *http.Request - next := func(req *http.Request) (*http.Response, error) { - final = req - return nil, nil - } - - u, _ := url.Parse("https://host/browser/kernel/browsers/sess1/fs/list_files?jwt=already") - u.Path = "/browser/kernel/browsers/sess1/fs/list_files" - req := &http.Request{URL: u} - _, _ = mw(req, next) - if final.URL.Query().Get("jwt") != "already" { - t.Fatalf("jwt: got %q want already", final.URL.Query().Get("jwt")) - } -} - -func TestBrowserSessionMiddlewareType(t *testing.T) { - var _ option.Middleware = BrowserSessionMiddleware("a", "b") -} diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go deleted file mode 100644 index a53fc04..0000000 --- a/lib/browserscope/rawcurl.go +++ /dev/null @@ -1,81 +0,0 @@ -package browserscope - -import ( - "fmt" - "net/http" - "net/url" - "strings" -) - -// RawCURLRoundTripper implements browser-egress HTTP by tunneling through the -// browser session base_url /curl/raw endpoint. -type RawCURLRoundTripper struct { - browserBaseURL string - jwt string - underlying http.RoundTripper -} - -var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) - -// HTTPClient returns an [http.Client] that performs browser egress HTTP via the -// browser session base_url and internal /curl/raw path. -func HTTPClient(browserBaseURL, jwt string, underlying *http.Client) *http.Client { - if underlying == nil { - underlying = http.DefaultClient - } - rt := underlying.Transport - if rt == nil { - rt = http.DefaultTransport - } - return &http.Client{ - Transport: newRawCURLRoundTripper(browserBaseURL, jwt, rt), - Timeout: underlying.Timeout, - } -} - -// newRawCURLRoundTripper returns an [http.RoundTripper] that maps each request -// to {base_url}/curl/raw?jwt=...&url=, preserving method, -// headers, and body. The caller's request URL must be an absolute http(s) URL. -func newRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { - if underlying == nil { - underlying = http.DefaultTransport - } - return &RawCURLRoundTripper{ - browserBaseURL: strings.TrimRight(strings.TrimSpace(browserBaseURL), "/"), - jwt: strings.TrimSpace(jwt), - underlying: underlying, - } -} - -// RoundTrip implements [http.RoundTripper]. -func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if req.URL == nil || !req.URL.IsAbs() { - return nil, fmt.Errorf("browserscope: raw curl requires an absolute request URL (got %q)", req.URL) - } - if req.URL.Scheme != "http" && req.URL.Scheme != "https" { - return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") - } - if t.browserBaseURL == "" { - return nil, fmt.Errorf("browserscope: browser base_url is required") - } - if t.jwt == "" { - return nil, fmt.Errorf("browserscope: jwt is required for raw curl") - } - - target := req.URL.String() - proxyURL, err := url.Parse(t.browserBaseURL + "/curl/raw") - if err != nil { - return nil, err - } - q := url.Values{} - q.Set("jwt", t.jwt) - q.Set("url", target) - proxyURL.RawQuery = q.Encode() - - out := req.Clone(req.Context()) - out.URL = proxyURL - out.Host = proxyURL.Host - out.RequestURI = "" - - return t.underlying.RoundTrip(out) -} diff --git a/lib/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go deleted file mode 100644 index f39a622..0000000 --- a/lib/browserscope/rawcurl_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package browserscope - -import ( - "io" - "net/http" - "net/http/httptest" - "testing" -) - -func TestRawCURLRoundTripper(t *testing.T) { - var sawPath, sawRaw string - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sawPath = r.URL.Path - sawRaw = r.URL.RawQuery - w.WriteHeader(http.StatusTeapot) - _, _ = w.Write([]byte("ok")) - })) - defer up.Close() - - rt := newRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) - - client := &http.Client{Transport: rt} - req, err := http.NewRequest(http.MethodGet, "https://example.org/foo?bar=1", nil) - if err != nil { - t.Fatal(err) - } - res, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - if res.StatusCode != http.StatusTeapot { - t.Fatalf("status: %d", res.StatusCode) - } - b, _ := io.ReadAll(res.Body) - if string(b) != "ok" { - t.Fatalf("body: %q", b) - } - if sawPath != "/browser/kernel/curl/raw" { - t.Fatalf("path: %s", sawPath) - } - if sawRaw == "" { - t.Fatal("expected query") - } -} - -func TestRawCURLRoundTripperRelativeURL(t *testing.T) { - rt := newRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) - req, _ := http.NewRequest(http.MethodGet, "/relative", nil) - _, err := rt.RoundTrip(req) - if err == nil { - t.Fatal("expected error for relative url") - } -} diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go deleted file mode 100644 index 5c384dd..0000000 --- a/lib/browserscope/ref.go +++ /dev/null @@ -1,43 +0,0 @@ -package browserscope - -import ( - "fmt" - "strings" -) - -// Ref identifies a browser session for direct-to-VM HTTP calls. SessionID is -// reserved for future client-side routing; allowlisted requests rewrite the -// /browsers/{SessionID}/ path segment against the returned base_url. -type Ref struct { - SessionID string - BaseURL string - JWT string - CdpWsURL string -} - -// Normalize validates fields and fills JWT from CdpWsURL when JWT is empty. -func (r Ref) Normalize() (Ref, error) { - if strings.TrimSpace(r.BaseURL) == "" { - return Ref{}, fmt.Errorf("browserscope: base_url is required") - } - if strings.TrimSpace(r.SessionID) == "" { - return Ref{}, fmt.Errorf("browserscope: session_id is required") - } - out := r - out.BaseURL = strings.TrimSpace(r.BaseURL) - out.SessionID = strings.TrimSpace(r.SessionID) - out.JWT = strings.TrimSpace(r.JWT) - out.CdpWsURL = strings.TrimSpace(r.CdpWsURL) - if out.JWT == "" { - src := out.CdpWsURL - if src == "" { - return Ref{}, fmt.Errorf("browserscope: jwt or cdp_ws_url is required") - } - jwt, err := jwtFromWebSocketURL(src) - if err != nil { - return Ref{}, err - } - out.JWT = jwt - } - return out, nil -} diff --git a/lib/browserscope/route_cache.go b/lib/browserscope/route_cache.go deleted file mode 100644 index dc3cd5d..0000000 --- a/lib/browserscope/route_cache.go +++ /dev/null @@ -1,139 +0,0 @@ -package browserscope - -import ( - "net/http" - "net/url" - "strings" - "sync" - - "github.com/kernel/kernel-go-sdk/option" -) - -// Route identifies a cached direct-to-VM transport for one browser session. -type Route struct { - SessionID string - BaseURL string - JWT string -} - -// RouteCache stores browser session transport details keyed by session_id. -type RouteCache struct { - mu sync.RWMutex - routes map[string]Route -} - -// NewRouteCache returns an empty browser route cache. -func NewRouteCache() *RouteCache { - return &RouteCache{routes: map[string]Route{}} -} - -// Load returns the cached route for the given session id. -func (c *RouteCache) Load(sessionID string) (Route, bool) { - if c == nil { - return Route{}, false - } - c.mu.RLock() - defer c.mu.RUnlock() - route, ok := c.routes[sessionID] - return route, ok -} - -// Store normalizes and caches the given route. -func (c *RouteCache) Store(route Route) { - if c == nil { - return - } - c.mu.Lock() - defer c.mu.Unlock() - c.routes[strings.TrimSpace(route.SessionID)] = Route{ - SessionID: strings.TrimSpace(route.SessionID), - BaseURL: strings.TrimSpace(route.BaseURL), - JWT: strings.TrimSpace(route.JWT), - } -} - -// Delete removes a cached route. -func (c *RouteCache) Delete(sessionID string) { - if c == nil { - return - } - c.mu.Lock() - defer c.mu.Unlock() - delete(c.routes, sessionID) -} - -// DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to -// the browser VM using cached base_url and jwt data. -func DirectVMRoutingMiddleware(cache *RouteCache, directToVMSubresources []string) option.Middleware { - allowed := map[string]struct{}{} - for _, subresource := range directToVMSubresources { - if trimmed := strings.TrimSpace(subresource); trimmed != "" { - allowed[trimmed] = struct{}{} - } - } - - return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) - if !ok { - return next(req) - } - if _, ok := allowed[subresource]; !ok { - return next(req) - } - route, ok := cache.Load(sessionID) - if !ok { - return next(req) - } - - base, err := url.Parse(route.BaseURL) - if err != nil { - return nil, err - } - req.Header.Del("Authorization") - if route.JWT != "" { - q := req.URL.Query() - if q.Get("jwt") == "" { - q.Set("jwt", route.JWT) - req.URL.RawQuery = q.Encode() - } - } - - req.URL.Scheme = base.Scheme - req.URL.Host = base.Host - req.Host = base.Host - req.URL.Path = joinURLPath(base.Path, subresource, suffix) - - return next(req) - } -} - -func parseDirectVMPath(path string) (sessionID, subresource, suffix string, ok bool) { - parts := strings.Split(strings.Trim(path, "/"), "/") - for i := 0; i+2 < len(parts); i++ { - if parts[i] != "browsers" { - continue - } - sessionID = parts[i+1] - subresource = parts[i+2] - if sessionID == "" || subresource == "" { - return "", "", "", false - } - if i+3 < len(parts) { - suffix = "/" + strings.Join(parts[i+3:], "/") - } - return sessionID, subresource, suffix, true - } - return "", "", "", false -} - -func joinURLPath(basePath, subresource, suffix string) string { - base := "/" + strings.Trim(strings.TrimSpace(basePath), "/") - if base == "/" { - base = "" - } - out := base + "/" + strings.TrimPrefix(subresource, "/") - if suffix != "" { - out += "/" + strings.TrimPrefix(suffix, "/") - } - return out -}